0.0
The project is in a healthy, maintained state
Call LLMs from Axn actions using RubyLLM, with structured error handling, schema-based structured output, and cost/token tracking.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0.1.0.pre.alpha.6.1, < 0.2.0
>= 1.10.0
>= 1.3, < 2.0
>= 2.0, < 3.0
 Project Readme

axn-ruby_llm

Call LLMs from Axn actions using RubyLLM, with declarative error handling, schema-based structured output, configurable defaults, and cost/token tracking — and wrap any Axn as a RubyLLM::Tool a chat can call.

RubyLLM 2.0 required. As of 0.3.0, this gem requires ruby_llm >= 2.0, < 3.0 and no longer supports RubyLLM 1.x.

Upgrading to 0.4.0? Two behaviour changes: a response cut off by the output token limit or blocked by a content filter now fails instead of succeeding, and the OpenTelemetry attribute gen_ai.usage.input_tokens now includes cached tokens. input_tokens / prompt_tokens are deprecated in favour of uncached_input_tokens / total_input_tokens. See CHANGELOG.md for the full list and what to check.

Part of the axn-* extension ecosystem — see also axn-mcp.

Why use this over calling RubyLLM directly?

Five things you'd otherwise hand-build:

  1. Structured error handling. The Axn error DSL declaratively maps RateLimitError, JSON::ParserError, truncated or content-filtered responses, and generic StandardError to clean failure messages. Callers check result.ok? instead of wrapping every call in begin/rescue.

  2. Production gating. A single c.enabled = -> { Rails.env.production? } in an initializer stubs every LLM call in non-prod environments — no per-callsite guards needed. The stub is typed (stubbed: true, total_input_tokens: 0, etc.) so downstream code doesn't need to branch on it either.

  3. Cost/token tracking, exposed automatically. Every call exposes total_input_tokens, uncached_input_tokens, cache_read_tokens, cache_write_tokens, output_tokens, thinking_tokens, server_tool_use, cost, and cost_breakdown, read straight off RubyLLM's own usage ledger (Chat#tokens / Chat#cost) — no manual model lookup, and a tool-call loop's retries and multiple round-trips are already aggregated for you. If your app uses OpenTelemetry, these values are also set as attributes on the existing axn.call span — no configuration required.

  4. Author-once tools. Axn::RubyLLM.wrap turns any Axn into a RubyLLM::Tool your chat can call — reuse the same Axn classes you already expose through axn-mcp, or plain Axns, with no rewrite. The tool's name, JSON Schema, and argument validation all come from the Axn's own contract.

  5. Remote MCP tools. Axn::RubyLLM.remote_mcp_tools wraps a remote MCP server's tools so they sit next to your Axn tools, with an allowlist, a call budget, timeouts, and bearer-token or OAuth auth. Or let the provider connect to the server itself with provider_tools:.

Scope note: ask runs one prompt through to a final answer and covers every RubyLLM::Chat#with_* setting, plus attachments, seeded history, streaming, and a tool-call cap (a spec fails CI when a RubyLLM release adds a method that isn't covered or explicitly skipped). For anything beyond one prompt-to-answer run, such as keeping a chat alive across turns, stepping the loop by hand, or RubyLLM's lifecycle callbacks, drive RubyLLM::Chat directly; see "Using wrapped tools with RubyLLM directly" below. RubyLLM's non-chat capabilities (embeddings, images, transcription, and so on) aren't wrapped. Feedback and pull requests are very welcome.


Installation

gem "axn-ruby_llm"

Configure RubyLLM as normal (e.g. in config/initializers/ruby_llm.rb). The default model is gpt-4o-mini, but any RubyLLM-supported provider works — just configure the appropriate API key and pass model: to override:

RubyLLM.configure do |c|
  c.openai_api_key  = ENV["OPENAI_API_KEY"]   # OpenAI
  c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] # or Anthropic, Gemini, etc.
end

Optionally configure gem-level defaults:

Axn::RubyLLM.configure do |c|
  c.default_model = "gpt-4o-mini"          # default; override with any RubyLLM model ID
  c.error_headline = "LLM request failed"  # default; prefixes every result.error (see Errors below)
end

Usage

result = Axn::RubyLLM.ask(
  prompt: "Summarize this Slack thread: #{thread_text}"
)
result.response  # => "The team decided to..."

# With system prompt and model override
result = Axn::RubyLLM.ask(
  prompt: user_message,
  system_prompt: "You are a concise financial analyst.",
  model: "gpt-4o",
  temperature: 0.2
)

Chat options

Every other RubyLLM::Chat#with_* setting has a matching ask input. Each is forwarded only when given, so omitting it leaves RubyLLM's default in place:

ask input Forwarded to Notes
provider:, protocol:, assume_model_exists: RubyLLM.chat (alongside model:) Choose the provider or wire protocol yourself instead of relying on autodetection. assume_model_exists: true skips the registry lookup and requires provider:.
context: RubyLLM.chat(context:) A RubyLLM.context { |c| ... } whose API keys and base URLs replace the global config for this call.
fallbacks:, fallback_on: with_fallbacks(*fallbacks, on: fallback_on) Models to try in order when generation fails. fallback_on: defaults to RubyLLM's transient provider and network errors.
cache_system_prompt: with_instructions(system_prompt, cache_until_here: true) Marks the system prompt as an explicit prompt-cache boundary. Worth it for a long system prompt you reuse.
max_output_tokens: with_max_output_tokens
thinking: with_thinking true, false, or { effort:, budget:, display: }.
citations: with_citations Use with attachments: to get raw_message.citations back.
caching: with_caching true, false, or { ttl:, id: }.
compaction: with_compaction true, false, or { at:, instructions:, pause_after: }.
end_user: with_end_user An opaque id. RubyLLM sends it as given, so never pass PII.
provider_options: with_provider_options Raw keys merged into the request payload, e.g. { service_tier: "flex" }.
headers: with_headers Extra HTTP headers, e.g. a provider beta flag.

thinking:, citations:, caching:, and compaction: also forward an explicit false, which you'd use, for example, to turn off thinking on a model that enables it by default. context:, headers:, and provider_tools: are marked sensitive, so axn keeps them out of logs.

Attachments, history, and streaming

Axn::RubyLLM.ask(
  prompt: "What changed since last quarter?",
  attachments: ["q3.pdf", "https://example.com/q2.pdf"],  # Chat#ask's with:
  history: prior_turns,                                  # seeded before the prompt
  on_chunk: ->(chunk) { print chunk.content },           # streamed as it arrives
)
  • attachments: is passed to Chat#ask as with:. RubyLLM reads a String from disk or fetches it over HTTP, so never pass an unvalidated, user-supplied path or URL.
  • history: is anything Chat#messages= accepts: RubyLLM::Messages, { role:, content: } Hashes, or records that respond to #to_llm. system_prompt: replaces any system message in it. transcript includes only the turns this call exchanged, not the seeded ones.
  • on_chunk: receives each RubyLLM::Chunk. response and raw_message are still the complete message, so schema: works as usual. The stubbed (disabled) path never calls it.

Structured output via schema

Pass schema: to enable provider-enforced structured output (e.g. OpenAI strict mode) via RubyLLM::Chat#with_schema. The result's response is the parsed Hash — read via RubyLLM::Message#parsed under the hood, not #content (which is now always the raw JSON text).

schema: accepts any of three forms:

A Schematist::Schema class or instance — anything RubyLLM::Chat#with_schema itself accepts:

class CompanyMatch < Schematist::Schema
  integer :company_id, description: "ID of the matched company, or null"
  number :confidence, description: "0.0–1.0"
  string :reasoning
end

result = Axn::RubyLLM.ask(
  prompt: "Which company is this thread about?\n\n#{thread_text}",
  schema: CompanyMatch,
)
result.response # => { "company_id" => 42, "confidence" => 0.92, "reasoning" => "..." }

The schematist gem (installed automatically by RubyLLM 2.0) is recommended but not required; declare it in your own Gemfile if you want the DSL.

A raw JSON Schema Hash, passed straight through unchanged:

Axn::RubyLLM.ask(prompt: "...", schema: { type: "object", properties: { answer: { type: "string" } } })

An Axn class, via its own output_schema reflection — the same contract you'd already write with exposes, no separate schema to maintain:

class CompanyMatch
  include Axn
  exposes :company_id, type: Integer, allow_nil: true
  exposes :confidence, type: Float
  exposes :reasoning, type: String
end

result = Axn::RubyLLM.ask(prompt: "...", schema: CompanyMatch)
result.response # => { "company_id" => 42, "confidence" => 0.92, "reasoning" => "..." }

A few adjustments happen automatically here, confirmed against real provider calls (Anthropic live; OpenAI by its documented contract):

  • additionalProperties: false is injected on every fixed-shape object node — required unconditionally by Anthropic's structured output and by OpenAI's strict mode, and axn's exposes contract has no reason to emit it on its own.
  • minProperties/maxProperties are stripped — axn emits minProperties: 1 by default on a nested fixed-shape Hash field, and Anthropic's schema validator rejects it outright (confirmed live).
  • strict: false is always sent, rather than left to RubyLLM's own strict-inference (which would otherwise turn on for the common case of every property being required).

You get the declared shape, required keys, and "no extra keys" enforcement; you don't get OpenAI's full strict-mode guarantee, which additionally requires every property to appear in required — even conceptually optional ones, via a nullable type — which axn's reflection doesn't promise. Pass a Schematist::Schema instead if you need that.

A Hash map field (type: Hash, of: {...}) doesn't survive this path against every provider. Confirmed live: Anthropic's structured output rejects additionalProperties set to anything but the literal false ("additionalProperties: object' is not supported. Please set 'additionalProperties' to false"), and OpenAI's strict mode has the same restriction by design — neither provider's structured-output feature represents dynamic/arbitrary keys, only a fixed shape. This isn't something the adapter can paper over (there's no schema-legal way to say "arbitrary keys, but still typed" in either provider's strict mode), so a map field is left as-is and the provider's own rejection surfaces as the request's error. Use a fixed-shape Hash (shape:) instead, or pass your own schema Hash / Schematist::Schema if you specifically need a map with that provider.

Token counts and cost

Every successful result exposes token usage and cost, read off RubyLLM's own usage ledger (Chat#tokens / Chat#cost) — which already sums every provider attempt for the call, including a tool loop's multiple round-trips and any retries:

result = Axn::RubyLLM.ask(prompt: "...")

result.total_input_tokens    # => 512  (every input token: uncached + cache reads + cache writes — use this for prompt size)
result.uncached_input_tokens # => 412  (standard-rate input only — RubyLLM's Tokens#input)
result.cache_read_tokens     # => 80   (served from the provider's prompt cache)
result.cache_write_tokens    # => 20   (written to the provider's prompt cache)
result.output_tokens         # => 78
result.thinking_tokens       # => 40   (reasoning tokens, where the provider reports them separately)
result.server_tool_use       # => { "web_search_requests" => 2 }  (per-use counters for provider-hosted tools)
result.cost               # => 0.00056 (Float USD total; nil if RubyLLM has no pricing for the model)

# Full breakdown — RubyLLM::Cost, RubyLLM's own aggregated-cost object
result.cost_breakdown  # => #<Cost input: 0.0004, output: 0.00016, cache_read: 0.0, ..., total: 0.00056>

# Raw RubyLLM::Message for thinking tokens, raw provider data, etc.
result.raw_message     # => #<RubyLLM::Message ...>

Use total_input_tokens for prompt size. With prompt caching (explicit via caching:, or automatic on OpenAI's newer models), a tool loop's repeated prefix is billed as cache reads and new content as cache writes, so uncached_input_tokens can be single digits on a 50k-token prompt. cost prices each bucket at its own rate, so it's right either way.

There's deliberately no plain input_tokens here: RubyLLM's Tokens#input means uncached input (a billing bucket), while OpenTelemetry's gen_ai.usage.input_tokens means all input, and each field name says which one it is. The older input_tokens (= uncached_input_tokens) and prompt_tokens (= total_input_tokens) still work but are deprecated; see DEPRECATIONS.md.

cost is nil when RubyLLM lacks pricing for the model (e.g. unknown/custom endpoints); cost_breakdown itself is still a Cost object in that case (only its component readers are nil). Token counts are nil only if the provider did not return them. total_input_tokens is nil only if all three input token fields are nil.

Errors

Errors are handled via Axn's declarative error DSL. Every failure shares a consistent "LLM request failed: <reason>" headline (the headline itself is configurable via c.error_headline =, e.g. to "Something went wrong calling the LLM"; the reasons below are unaffected):

  • The response hit the output token limit (finish_reason: :max_tokens) → "LLM request failed: Response was cut off by the output token limit before it finished". This is checked before schema: parsing, so a truncated structured response reports this rather than a JSON error.
  • A provider content filter blocked the response (finish_reason: :content_filter) → "LLM request failed: Response was blocked by the provider's content filter"
  • JSON::ParserError → "LLM request failed: Response was not valid JSON"
  • RubyLLM::RateLimitError (HTTP 429, provider-agnostic) → "LLM request failed: Rate limit reached: <message>"
  • RubyLLM::OverloadedError / ServiceUnavailableError / ServerError (5xx, transient) → "LLM request failed: Provider temporarily unavailable, try again later: <message>"
  • RubyLLM::ContextLengthExceededError → "LLM request failed: Prompt exceeds the model's context window: <message>" (message retains the provider's token counts)
  • schema: set but LLM returned non-JSON, or valid JSON that isn't an object → "LLM request failed: Response was not valid JSON" (malformed JSON text) or "LLM request failed: Schema response was not valid JSON" (valid JSON, wrong shape)
  • Any other known RubyLLM error — RubyLLM::Error (auth, bad request, payment, etc.), RubyLLM::ConfigurationError, ModelNotFoundError, ModelRegistryError, PromptNotFoundError, InvalidRoleError, InvalidToolChoiceError, PendingToolCallsError, CancelledError, UnsupportedAttachmentError — or Faraday::Error (network/transport failure) → "LLM request failed: <message>"
  • Any other StandardError (i.e. not a recognized RubyLLM/network failure — most likely a bug) → "LLM request failed", with no exception detail leaked into the message

The truncation and content-filter failures still expose the usage fields, cost, raw_message, transcript and finish_reason, since the call was paid for. result.finish_reason is also set on success (normally :stop).

Tool adapter — wrap any Axn as a RubyLLM::Tool

Any Axn can be exposed as a ::RubyLLM::Tool — no adapter-specific mixin required, it's just a normal Axn:

class CreateWidget
  include Axn

  description "Creates a widget with the given name"
  expects :name, type: String
  exposes :widget_id

  def call
    expose widget_id: Widget.create!(name:).id
  end
end

Pass Axns to Axn::RubyLLM.ask via tools: and the model can call them as part of the request — RubyLLM runs the tool-call loop internally and result.response is the model's final reply:

result = Axn::RubyLLM.ask(
  prompt: "Create a widget called Sprocket, then tell me its id.",
  tools: [CreateWidget],
)
result.response # => "Created widget Sprocket (id: 42)."

tools: accepts a mix of bare Axn classes (wrapped automatically) and already-wrapped tools from Axn::RubyLLM.wrap (a class, or an instance that closed over ambient_context: — see below). Pass tools: Axn::RubyLLM.tools to expose everything registered under the :ruby_llm adapter (see Enumerating tools). The same Axn classes you expose through axn-mcp work here unchanged.

Token/cost in a tool loop: a tool call makes multiple model round-trips inside one ask. The token counts, cost, and cost_breakdown come from RubyLLM's own usage ledger (Chat#tokens / Chat#cost), which already aggregates every turn — so they reflect the whole call, not just the final response. (raw_message is still the final response.)

Axn::RubyLLM.wrap is also available directly if you're driving RubyLLM.chat yourself rather than going through ask — see Using wrapped tools with RubyLLM directly.

The tool's name, description, and JSON Schema parameters come straight from the Axn's own contract — the same description/expects/exposes you'd write for any Axn — so a minimal class just works (the tool name defaults from the class name: CreateWidget → create_widget). Arguments the model supplies are run through axn core's tool Invoker: wire types are coerced, and any contract violation — a missing required field, an out-of-schema argument, a wrong type, or a value outside an inclusion set (validated at full depth, not just the top-level type) — comes back to the model as a clean, correctable { error: "Invalid tool arguments: <reason>" }, and does not page on_exception as though it were a bug. A model-supplied ambient_context is stripped before the Axn runs, so a prompt-injected context can never override the caller's — the wrap's own ambient_context: (below) is injected instead. The Invoker also stamps every call (including any nested sub-Axn) with the invoked_via: :ruby_llm dimension, so a Datadog dashboard can query tool-driven traffic separately from ordinary direct .calls — see OpenTelemetry below.

On success, execute returns the exposed values (via Axn::RubyLLM.serialize_exposed, honoring reject_opaque_exposed_values — see below) as a JSON string, not a Hash — RubyLLM::Tool.split_result sends a returned String through as-is, but a returned Hash/Array is #to_json'd via Ruby's own dispatch, which may not match axn's own serialization contract (Symbol keys/values, BigDecimal, Time, opaque-value rejection); serializing ourselves keeps the wire form aligned with what output_schema advertises. On failure, { error: result.error }. The same CreateWidget class can be wrapped for other transports (e.g. Axn::MCP.wrap) with no changes — the contract is declared once.

Options, settable either per-call via wrap keywords or once on the Axn via axn's namespaced per-class configure(:ruby_llm) { |c| ... } (a wrap keyword wins when both are present, then the class-level configure(:ruby_llm) value, then this gem's own Axn::RubyLLM.configure { |c| ... } global, then the default below):

Option Effect
provider_options: Hash merged into the tool definition sent to the provider (via RubyLLM's Tool.provider_options) — an escape hatch for provider-specific tool fields RubyLLM doesn't model first-class (e.g. OpenAI strict function calling, Anthropic tool cache_control). Keys mirror that provider's tool shape. Default {}.
present_as: :structured (default) returns the exposed values as a JSON string; :message returns result.message instead. Same knob as axn-mcp's present_as:.

Set a default once on the Axn with configure(:ruby_llm), and still override per call:

class CreateWidget
  include Axn
  configure(:ruby_llm) { |c| c.present_as = :message }        # default for this tool
  # ...
end

Axn::RubyLLM.wrap(CreateWidget)                               # returns result.message
Axn::RubyLLM.wrap(CreateWidget, present_as: :structured)      # per-call override

No more halt_after:. RubyLLM 2.0 removed Tool::Halt along with the rest of the auto-halting machinery — the conversation loop is now caller-controlled. If you need a tool call to stop the loop, drive it yourself: loop { chat.step; break if chat.complete? || done_condition }. See RubyLLM's Agentic Workflows guide.

configure(:ruby_llm) needs no include beyond Axn — every Axn gets it for free (core's namespaced per-class config). It's usually written in the class body as above, but since it's a plain class method you can also call it from outside — e.g. SomeThirdPartyAxn.configure(:ruby_llm) { |c| ... } in an initializer, to configure an Axn you don't own. Namespacing is what lets one base Axn be configured for multiple adapters at once, each in its own namespace, without collision even when two adapters share a setting name (both this gem and axn-mcp expose present_as):

class CreateWidget
  include Axn
  # ...

  configure(:ruby_llm) { |c| c.present_as = :message }    # how the RubyLLM tool presents its result
  configure(:mcp)      { |c| c.present_as = :structured } # same setting name, different namespace — no collision
end

Pass ambient_context: to close over explicit caller context (e.g. current_user, company) at wrap time, instead of relying on axn's reflective Current-based default resolved when the tool runs. This matters when the tool executes somewhere Current isn't the right context — e.g. the chat (and therefore the tool call) runs in a background job or a different thread than the request that built the tools:

Axn::RubyLLM.wrap(CreateWidget, ambient_context: { company_id: current_company.id })

Passing ambient_context: returns a tool instance (closing over that context) rather than the tool class, since chat.with_tools accepts either.

Using wrapped tools with RubyLLM directly

Axn::RubyLLM.ask runs one prompt through to a final answer. Drive RubyLLM.chat yourself when you need more control than that: keeping one chat alive across turns, stepping the loop by hand (ask_later/step), or RubyLLM's lifecycle callbacks (before_tool_call, after_tool_result, after_message, before_request, before_fallback). Register wrapped tools with RubyLLM's own with_tools, which accepts one or many RubyLLM::Tool classes/instances:

chat = RubyLLM.chat.with_tools(Axn::RubyLLM.wrap(CreateWidget))
chat.ask("Create a widget called Sprocket")

# or register everything under the :ruby_llm adapter at once:
chat = RubyLLM.chat.with_tools(*Axn::RubyLLM.tools)

Provider-hosted tools and remote MCP (provider_tools:)

provider_tools: forwards verbatim to RubyLLM's Chat#with_provider_tools — a Hash of alias => options. This is how the provider (not your app) runs a tool, including a remote MCP server: the provider connects to the server directly, with whatever headers: you pass, and its own tool calls happen inside the provider's request rather than yours.

Axn::RubyLLM.ask(
  prompt: "Why did this company's margin drop?",
  provider_tools: {
    mcp: {
      name: "metabase", url: ENV.fetch("METABASE_MCP_URL"),
      headers: { "X-API-KEY" => ENV.fetch("METABASE_MCP_API_KEY") },
      allowed_tools: %w[search execute_sql],
      require_approval: "never",
    },
  },
)

See RubyLLM's Chat#with_provider_tools for the other aliases (:web_search, :code_execution, ...) and which providers support each one.

App-side remote MCP (Axn::RubyLLM.remote_mcp_tools)

remote_mcp_tools works the other way round: your app connects to the MCP server, using the official mcp gem, and wraps each of the server's tools as an ordinary RubyLLM::Tool. They sit next to your Axn tools in tools:, and every call is one your app makes, logs, times out, and can cap.

toolset = Axn::RubyLLM.remote_mcp_tools(
  url: ENV.fetch("METABASE_MCP_URL"),
  headers: { "X-API-KEY" => ENV.fetch("METABASE_MCP_API_KEY") },
  allowed_tools: %w[search execute_sql],  # pass it: a server's full list can include write/admin tools
  max_calls: 20,                          # shared across the whole toolset, for its lifetime (default 20)
)
Axn::RubyLLM.ask(prompt: "...", tools: [*Axn::RubyLLM.tools, *toolset.tools])
toolset.close

The max_calls budget never resets, so connect a fresh toolset per request (and close it) when you want a per-request cap; a toolset reused across requests shares one budget between them. timeout: (default 60s) and max_result_chars: (default 20,000) bound each call. When a server wants more than a static header for auth:

  • bearer_token: takes a String, or a callable that returns one, and sends it as Authorization: Bearer <token>. A callable runs on every request, so your code owns fetching, caching, and rotating the token, e.g. bearer_token: -> { MyOAuthStore.current_token }.
  • oauth: takes an MCP::Client::OAuth::ClientCredentialsProvider (machine-to-machine) or MCP::Client::OAuth::Provider (interactive authorization code + PKCE). It's passed straight to MCP::Client::HTTP, which handles discovery, token exchange, refresh, and retrying after a 401.

Pass one or the other, not both. Both refuse a URL that is neither https nor loopback http.

Pausing on approval before a provider-hosted call runs (on_remote_tool_approval:)

Set require_approval: "always" (or per-tool) on a provider_tools: entry and the provider pauses before running the call, asking your app to approve or deny it first. Without a decision, ask would return whatever the chat produced at that pause — not a final answer. Pass on_remote_tool_approval:, a callable given each pending RubyLLM::ToolCall (.name, .arguments, .remote?), and ask drives the chat past every approval until it's genuinely done:

Axn::RubyLLM.ask(
  prompt: "...",
  provider_tools: { mcp: { name: "metabase", url: ..., require_approval: "always" } },
  on_remote_tool_approval: ->(tool_call) {
    Rails.logger.info("[probe] #{tool_call.name}: #{tool_call.arguments}")
    tool_call.name == "execute_sql" # only allow the tools you actually want to approve
  },
)

The same callback also resolves a local tool declared with Tool.requires_approval — pending_approvals doesn't distinguish where the call runs, only whether a decision is still owed.

Tool concurrency and other chat options (tool_options:)

tool_options: forwards verbatim to Chat#with_tool_options (choice:, calls:, concurrency:). concurrency: :threads or :fibers runs one turn's local Axn tool calls in parallel — useful when a tool is pure I/O (an HTTP call, a remote MCP round-trip wrapped as a local tool) and each call doesn't share mutable state. It is not a good fit for a tool that checks out an ActiveRecord connection: parallel local calls check out one connection each, from a pool sized for the process's normal concurrency.

Axn::RubyLLM.ask(prompt: "...", tools: [...], tool_options: { concurrency: :threads, calls: :many })

Capping tool calls (max_tool_calls:)

ask has no limit of its own on how long the tool loop runs, because RubyLLM 2.0 removed halt_after. max_tool_calls: caps how many tool calls your app runs in one ask, counted across every tool in tools:. Once the cap is reached, each further call returns { error: "Tool call budget exhausted ... write your final answer with what you have so far." } and the model is left to finish. The cap is soft on purpose: raising would throw away everything the chat had gathered. Calls to remote_mcp_tools tools count here as well as against their own max_calls. Provider-hosted calls (provider_tools:) never reach your app, so they aren't counted. The caller's tool instances are copied, not modified, so reusing a wrap(axn, ambient_context:) instance across calls is safe.

Axn::RubyLLM.ask(prompt: "...", tools: Axn::RubyLLM.tools, max_tool_calls: 15)

Reading what the chat actually did (transcript)

ask always exposes transcript: every message the chat exchanged (the system prompt excluded), each shaped as { role:, content:, tool_calls:, tool_call_id:, server_tool_calls: }. server_tool_calls is where a provider-hosted MCP call's name, arguments, and result show up — your app never receives that request, so this is the only place to see what query actually ran.

result = Axn::RubyLLM.ask(prompt: "...", provider_tools: { mcp: { ... } })
result.transcript.flat_map { |m| m[:server_tool_calls]&.values || [] }.each { |c| puts "#{c.name}: #{c.arguments}" }

Tool naming

The name is axn core's canonical, provider-safe tool_name: lowercased to [a-z0-9_], leading configured prefixes stripped, snake_cased with single underscores, and never blank (Admin::CreateWidget → admin_create_widget; a truly anonymous Axn → "tool"). Declare axn_name "..." on the Axn to override the default. Because it's the same core derivation every adapter uses, a class wrapped by both Axn::RubyLLM.wrap and Axn::MCP.wrap advertises an identical name — the contract is declared once.

Enumerating tools from the registry

Rather than wiring each tool up by hand, let axn's tool registry find them and build the whole chat tool list in one call:

chat = RubyLLM.chat.with_tools(*Axn::RubyLLM.tools)

Axn::RubyLLM.tools returns every Axn registered under the :ruby_llm adapter, already wrapped as a ::RubyLLM::Tool — sugar for Axn::Tools.for(:ruby_llm).map { |axn| Axn::RubyLLM.wrap(axn) }, in a stable, tool_name-sorted order.

Membership = (directory grant ∪ declaration grant) − exclusions. An Axn is a :ruby_llm tool if either:

  • Directory grant — its file lives under one of the adapter's tool_roots. The default is ["agent_tools"] (→ app/agent_tools in a Rails app), so an Axn dropped in app/agent_tools is a tool with no declaration at all. Configure the roots with Axn::RubyLLM.configure { |c| c.tool_roots = ["agent_tools", "actions/tools"] }; each entry must be a narrow subdir (app/, actions, ., and .. are rejected, so you can't bulk-expose every action).
  • Declaration grant — it declares tool (every adapter), tool :ruby_llm, or carries a configure(:ruby_llm) bag.

…unless it opts out: tool false (no adapter) or tool except: :ruby_llm (keep the directory grant, drop this adapter).

class CreateWidget
  include Axn
  tool :ruby_llm                              # add :ruby_llm (on top of any directory grant)
  tool ruby_llm: { present_as: :message }     # …or add it AND set per-adapter options inline
  # ...
end

Because both adapters read the registry and the same canonical tool_name, the identical set of Axns exposed via Axn::MCP.tools advertises identical names — and since both default tool_roots to agent_tools, an Axn there is authored once and is a tool on both surfaces.

Upgrading from the pre-registry API: tool :ruby_llm now adds to the directory grant rather than replacing it (declare all adapters, name:, except:, and per-adapter options in a single tool call). And the old global Axn.config.tool_paths is gone — each adapter owns its own tool_roots, so point Axn::RubyLLM.config.tool_roots (and Axn::MCP's) at your tool dirs instead.

Date/Time/Symbol/Integer/Float fields — declare coerce:

A provider always sends tool-call arguments as JSON, so a Date/Time/DateTime/Symbol/Integer/Float-typed field arrives as a String (e.g. "2026-07-08"), which the plain type: validator rejects — it checks value.is_a?(klass), not a parse. Declare coerce: on that expects field so axn parses the wire string before validation runs:

expects :scheduled_for, coerce: Date          # sugar for type: { klass: Date, coerce: true }
expects :priority, type: { klass: Symbol, coerce: true } # explicit form, e.g. alongside other type: options

Opt-in per field — a field with no coerce: is unaffected. A non-String value (a direct Ruby caller's real Date, a JSON-native number) is left untouched either way.

Opaque exposed values — reject_opaque_exposed_values

A tool's result is the Axn's exposed values serialized to JSON. A value with no author-declared JSON form — no to_json/as_json of its own — has no honest representation and can only render as an opaque blob: "#<User:0x000…>" outside Rails, or ActiveSupport's generic instance-variable dump under it. By default that blob ships, because for an LLM tool result an ugly-but-honest string usually beats a failed call.

Set reject_opaque_exposed_values (default false) to reject it instead. Serialization then raises Axn::Extensions::Serialization::UnserializableValue (naming the path, e.g. records[3].owner), which the adapter's transport-boundary guard (below) turns into a generic tool error rather than shipping the blob:

CreateWidget.configure(:ruby_llm) { |c| c.reject_opaque_exposed_values = true }   # per tool (wins)
Axn::RubyLLM.configure           { |c| c.reject_opaque_exposed_values = true }    # gem-wide default

Scope:

  • Output-side only. It governs exposes serialization, never inbound coerce: on expects.
  • Narrow. Values with no honest JSON form at all — reference cycles, non-finite Floats, non-UTF-8 bytes, two Hash keys colliding onto one property — raise UnserializableValue regardless of this flag (and surface as a tool error). reject_opaque_exposed_values only adds the extra "was this rendering author-declared?" check on top.

Transport-boundary never-raises guard

A wrapped Axn's own .call never raises — core catches action exceptions into a failed Result and pages on_exception itself. But the transport step that runs after the Axn settles — serializing the exposed values, encoding them to JSON — happens outside core's executor and can raise (an unserializable value as above, a structure past the JSON encoder's max_nesting, or a plain gem bug). Since RubyLLM has no rescue around a tool's execute, an escaping exception there would break the whole chat.

So the adapter guards that mapping step (only — the Axn call already reports its own exceptions): any StandardError is reported through Axn.config.on_exception and the tool returns a generic error, "The tool could not produce a valid response". The message is deliberately generic — the actionable detail (exception class, path) rides on the reported exception, not the tool's response. In development (per core's best_effort_raises_in_dev) the exception is re-raised instead, so a real bug surfaces loudly rather than being masked. This mirrors axn-mcp's adapter-boundary guard.

Schema reflection — provider notes

The advertised tool schema is axn's reflected input_schema, passed through to RubyLLM unmodified — the adapter does no per-provider schema rewriting. RubyLLM 2.0's Gemini protocol reads a tool's schema via parametersJsonSchema, the wire form verbatim, rather than rebuilding each property from a fixed whitelist (as its 1.x converter did), so nullable fields, additionalProperties, and entry-count bounds all reach Gemini the same way they reach OpenAI and Anthropic.

A few things worth knowing about what axn itself reflects, independent of RubyLLM version:

  • Array fields — declare of:. expects :ids, type: Array reflects to {type: array} with no items, so the element type isn't advertised (some providers then assume string; OpenAI strict mode requires items). Declare the element type — expects :ids, type: Array, of: Integer — to advertise items correctly.
  • Enums are advertised — use the top-level inclusion: key. expects :color, type: String, inclusion: %w[red green blue] (a bare Array, or the long form inclusion: { in: %w[red green blue] }) reflects to { type: "string", enum: ["red", "green", "blue"] }, so the model is told the allowed set (an optional: field keeps null in the enum; a dynamic in: -> { ... } is correctly skipped rather than guessed). Note this is the top-level inclusion: option, not validate: { inclusion: ... } — validate: is axn's custom-callable hook (it needs with:), so that spelling neither enforces nor reflects the set.
  • Conditional expectations (expects :token, if: :use_token) reflect to a JSON Schema allOf/if/then clause. Whether a given provider's tool-schema handling honors that clause is between RubyLLM and the provider, not something this adapter controls — check the provider's own function-calling docs if it matters for your case. Encoding the rule in description: remains the portable option regardless.

Testing

In your specs, require the helpers and use stub_axn_ruby_llm to stub RubyLLM so Axn::RubyLLM.ask returns a canned response without a real API call:

require "axn/ruby_llm/rspec"

it "summarizes the thread" do
  stub_axn_ruby_llm("The team agreed to ship on Friday.")
  result = Axn::RubyLLM.ask(prompt: "...")
  expect(result.response).to include("ship on Friday")
end

The response is the only required argument — pass it positionally (as above) or as response:. Pass schema: to route a Hash response through the schema path (stubbing raw_message.parsed so result.response gets the Hash back unparsed, matching what a real schema: call returns). Token counts and cost default to zero and can be set explicitly to exercise cost/usage logic:

stub_axn_ruby_llm({ "company_id" => 42 }, schema: CompanyMatch)
stub_axn_ruby_llm("...", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 0.0023)
stub_axn_ruby_llm("...", cache_read_tokens: 500, cache_write_tokens: 200)
stub_axn_ruby_llm("...", thinking_tokens: 300, server_tool_use: { "web_search_requests" => 1 })
stub_axn_ruby_llm("partial", finish_reason: :max_tokens)  # exercises Ask's truncation failure

Remaining keywords: model:, schema:, input_tokens: (RubyLLM's uncached count, so it becomes uncached_input_tokens), output_tokens:, cache_read_tokens:, cache_write_tokens:, thinking_tokens:, server_tool_use:, cost:, and finish_reason: (default :stop). It stubs every RubyLLM::Chat#with_* method, so specs can pass any Ask option. Returns the stubbed chat instance double for further assertions if you need it.

OpenTelemetry

If your app uses OpenTelemetry, axn already wraps every action in an axn.call span. This gem enriches that span with LLM-specific attributes automatically — no configuration required:

Attribute Value
gen_ai.request.model The model requested
gen_ai.response.model The model that responded
gen_ai.usage.input_tokens total_input_tokens — all input, cached included, per the OTel GenAI conventions
gen_ai.usage.cache_read.input_tokens cache_read_tokens (omitted when the provider doesn't report it)
gen_ai.usage.cache_creation.input_tokens cache_write_tokens (omitted when the provider doesn't report it)
gen_ai.usage.output_tokens Completion token count
gen_ai.usage.cost USD total (non-standard; useful for spend filtering)
axn.ruby_llm.stubbed true when production gating returned a stub
axn.ruby_llm.version This gem's version — separates spans from before and after a change in an attribute's meaning
axn.dimension.invoked_via "ruby_llm" — set by axn core on every tool call (including nested sub-Axns), not by this gem; lets you separate tool-driven traffic from ordinary direct .calls in the same span schema

For LLM-level tracing (individual RubyLLM.chat calls, tool calls, embeddings, prompt content), add opentelemetry-instrumentation-ruby_llm to your own Gemfile and configure it per its README. It is not a dependency of this gem.

Production gating

Set the enabled config to gate LLM calls — useful for skipping spend in non-production environments. Accepts a Boolean or a callable (evaluated per call):

Axn::RubyLLM.configure do |c|
  c.enabled = -> { Rails.env.production? }
  # c.enabled = false  # always stub
  # c.enabled = true   # default; always run
end

To read the resolved gate, use Axn::RubyLLM.enabled? — it invokes an assigned callable and returns a Boolean. (Don't use the DSL-generated Axn::RubyLLM.config.enabled?: axn's Configurable returns an assigned Proc as-is rather than calling it, so for a callable that predicate is always truthy.)

When disabled, Axn::RubyLLM.ask returns a success result with obvious stub content, so callers don't need per-callsite branching:

Field Stubbed value
response "stubbed response value" (plain) / { "stubbed" => true } (schema:)
raw_message Stub struct with .content, .tokens (a real RubyLLM::Tokens, all zero), .model, .parsed
total_input_tokens / uncached_input_tokens / cache_read_tokens / cache_write_tokens / output_tokens 0 (the deprecated input_tokens / prompt_tokens too)
thinking_tokens / server_tool_use / finish_reason nil
transcript []
cost 0.0
cost_breakdown nil
stubbed true

Check result.stubbed if you need to branch on it (e.g. skip downstream writes that would otherwise persist stub LLM output). result.success is "LLM request completed (using stubbed values - actual LLM request disabled)" for the same purpose; a normal (non-stubbed) call succeeds with "LLM request completed".