axn-ruby_llm 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5339909ae113dc742efe969fb25ae90294abef8090629c5fc9f452acdb98757d
4
- data.tar.gz: 8d73239e08a8ab270fc6edb980557e6b1932e63e9fdbdcd6f26bfb431b2a5c47
3
+ metadata.gz: 567165990e6185d68aab836344f8aaf5f1b2891c0c6fd19442aab5908c1fa335
4
+ data.tar.gz: 326731c8da66d1ac1ca92bcc5bf8b4c43132aaa42580f10ba97d7d98218911ee
5
5
  SHA512:
6
- metadata.gz: 75587b0c6a29875e2de143459b0b2ac1280581cb9e3b2a080777737ed803fda05008f0703a31522a4d5bcb47ea10998ef6236e1917bc9157f10fcaf278f97f3f
7
- data.tar.gz: 21d44dcc5fc6bc41615cc472648737b2c6491d05dfc1d469b7c884bed2d7a260da574f83b5f14d8b0ff97694fdf60f2428d4db72a22123fe0764b47f0606b7c4
6
+ metadata.gz: 6b0ba66b042f93f747523524bbf2eb33c00f8a2ad2d21f81525c2429c211896512aa96cfd2e749aec9682de68497b7d6a27e609e5bb0b2a4e5b0aeec6385a78e
7
+ data.tar.gz: d2d928acbe79f0c3c25757d0db23135cfa5abaad6e3c5b4d5602f2074a70ce8a6331a6583c296e96c7191b151f783448b646b6091d0dc6288ebba88a7ef158e6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,123 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1] - 2026-09-03
4
+
5
+ ### Added
6
+
7
+ - **[FEAT] Every tool call is now stamped `invoked_via: :ruby_llm`** (PRO-3332), via
8
+ `Axn::Tools::Invoker.new(adapter: :ruby_llm)`. No adapter-side work required — the stamp applies to
9
+ the wrapped Axn and any nested sub-axn or enqueued Sidekiq job for the life of the call tree, so a
10
+ Datadog dashboard (or any `Axn.config.on_exception`/tracing consumer reading the resolved
11
+ `invoked_via` dimension) can separate tool-driven traffic from an ordinary direct `.call`.
12
+
13
+ ### Changed
14
+
15
+ - **`record_otel_attributes!` now uses `Axn::Extensions::Tracing.annotate_span`** instead of the
16
+ unreliable ambient `OpenTelemetry::Trace.current_span` lookup, which could disagree with the span
17
+ axn's own tracer actually opened (PRO-3278) and silently drop every `gen_ai.*` attribute. Requires
18
+ axn `>= 0.1.0-alpha.6`.
19
+
20
+ ### Fixed
21
+
22
+ - **[BUGFIX] `reject_opaque_exposed_values` is now resolved per tool call instead of once at wrap
23
+ time.** The setting is `overridable:`, so a per-tool `configure(:ruby_llm) { |c| ... }` /
24
+ `tool ruby_llm: { ... }` bag or a gem-wide `Axn::RubyLLM.configure` assignment is meant to be what
25
+ a tool honors — but `wrap` resolved the value eagerly and the built tool class closed over that
26
+ Boolean forever. Since the normal way to build tools is once at boot (`chat.with_tools(*Axn::RubyLLM.tools)`),
27
+ any change to the setting *after* that point silently did nothing to a live tool: the wrapped class
28
+ kept whatever the flag happened to resolve to at wrap time, with no warning and no way to tell from
29
+ the outside. Resolution now happens inside `#execute`, at the moment the result is rendered, so a
30
+ tool always reflects the currently-configured value — the same per-call semantics `axn-mcp` already
31
+ had. **Old vs new:** a tool wrapped while the flag was `false` and later switched to `true` used to
32
+ keep shipping opaque renderings; it now fails those calls with the generic tool error, as configured.
33
+ Only the *timing* changed — the resolution order (per-class override, then gem-wide config, then the
34
+ `false` default) is unchanged, so a setup that configures before wrapping (the overwhelmingly common
35
+ case, and every documented example) behaves exactly as before.
36
+
37
+ - **A map's `additionalProperties` (and a Hash's `minProperties`/`maxProperties`) are no longer silently
38
+ lost at Gemini.** RubyLLM's Gemini converter rebuilds each property from a fixed whitelist that omits all
39
+ three, so `expects :scores, type: Hash, of: { keys: String, values: Integer }` reached the model as an empty
40
+ `{type: OBJECT}` — with no error raised, leaving the model to guess the value type and the call to be rejected
41
+ at runtime instead (PRO-3172). The adapter now restates these constraints as prose in the same node's
42
+ `description`, which Gemini does forward, appending after any `description:` you supplied; a structured value
43
+ type carries its compact JSON Schema too. The enforceable keys are still advertised unchanged, so OpenAI and
44
+ Anthropic are unaffected apart from the redundant sentence.
45
+
46
+ - **The transport-failure guard now logs an operator hint when `reject_opaque_exposed_values` may be the
47
+ cause.** The tool-facing error stays generic (`"The tool could not produce a valid response"`), but the
48
+ logged line now names the offending tool and both places the setting could be set
49
+ (`configure(:ruby_llm)` / `Axn::RubyLLM.config.reject_opaque_exposed_values`) whenever the resolved
50
+ value is `true` — matching `axn-openapi`'s dispatcher hint and `axn-mcp`'s guard. Previously an operator
51
+ had to guess which knob caused a rejection since the setting is per-tool overridable.
52
+
53
+ ### Internal
54
+
55
+ - **[INTERNAL] Adopted axn's `Axn::Tools::AdapterSerialization` mixin (PRO-2996)** in place of this
56
+ gem's hand-rolled copies of the same three things. `Axn::RubyLLM` now `extend`s the mixin alongside
57
+ `Axn::Tools::AdapterRoots` and uses `declare_reject_opaque_exposed_values! default: false` for the
58
+ setting, `Axn::RubyLLM.serialize_exposed(result)` for the render (which resolves the per-tool flag
59
+ itself — see the Fixed entry above), and `Axn::RubyLLM.guard_tool_response(axn_class, on_error:)`
60
+ for the transport-mapping guard. The default `tool_roots` moved to `tool_roots_default %w[agent_tools]`,
61
+ which drops this gem's hand-copied `AdapterRoots.validate!` lambda and validates the default eagerly
62
+ at gem load rather than at the registry's first read. No public API, config name, default, or
63
+ user-facing string changed. Two second-order effects worth knowing: the mixin's guard runs its
64
+ dev-mode re-raise *before* reporting (so under `best_effort_raises_in_dev` a mapping failure now
65
+ raises without first emitting the `reject_opaque_exposed_values` log hint — production behavior is
66
+ unchanged), and it rescues `SystemStackError`/`ScriptError` in addition to `StandardError`, so a
67
+ runaway `as_json`/`to_h` on an exposed value now becomes a tool error rather than escaping into the
68
+ chat.
69
+
70
+ ### Requires
71
+
72
+ - **axn `>= 0.1.0-alpha.6`** (released), for `Axn::Tools::AdapterSerialization` (PRO-2996) and
73
+ `Axn::Extensions::Tracing.annotate_span` (PRO-3278). No Gemfile override needed — resolves from
74
+ RubyGems.
75
+
76
+ ## [0.2.0] - 2026-08-05
77
+
78
+ > **Upgrade notes (behavior changes):**
79
+ > - **`Ask` failure messages changed wording.** Code that pattern-matches on `result.error` strings may need updating: unrecognized exceptions no longer include the underlying exception message, and 5xx / context-length errors now have their own more specific text (see Changed below). `result.ok?`-based control flow is unaffected.
80
+ > - **Requires a newer `axn`** — this release depends on core reflection, the tool registry + `tool_name`, and namespaced `configure(...)` (see Requires below).
81
+ > - No public API was removed, and no config or `wrap` option changed name or default.
82
+
83
+ ### Added
84
+
85
+ - **Tool adapter — wrap any Axn as a `RubyLLM::Tool`.** `Axn::RubyLLM.wrap(any_axn)` turns any Axn into a `::RubyLLM::Tool` a chat can call, with no adapter-specific mixin: its name, description, and JSON Schema parameters come from the Axn's own `description`/`expects`/`exposes` contract. On success the tool returns the exposed values as JSON (or `result.message`, per `present_as:`); on failure, `{ error: <result.error> }`. Malformed tool calls — a missing/unknown/wrong-typed argument, a value outside an `inclusion` set (validated at full depth via axn's tool `Invoker`), or an injected `ambient_context:` — come back to the model as a clean `Invalid tool arguments` error and never page `on_exception`. A result core can't serialize (two Hash keys colliding on one JSON property, a non-finite Float, non-UTF-8 bytes) or one nested past the JSON encoder's `max_nesting` likewise surfaces as a tool error instead of breaking the chat. Per-tool options `halt_after:`, `provider_params:`, `present_as:` (`:structured` / `:message`), and `ambient_context:` are settable per-call, per-class via `configure(:ruby_llm) { |c| ... }` (or inline via `tool ruby_llm: { ... }`), or gem-wide via `Axn::RubyLLM.configure`. The same Axn advertises an identical tool name whether wrapped here or by `Axn::MCP.wrap`. See the README's "Tool adapter" section.
86
+ - **Tools in `ask`.** `Axn::RubyLLM.ask(prompt:, tools: [...])` registers wrapped Axns on the chat and runs RubyLLM's tool-call loop within the single call. `tools:` takes bare Axn classes (wrapped automatically) and already-wrapped tools alike, or pass `Axn::RubyLLM.tools` for everything registered. Token counts, `cost`, and `cost_breakdown` are summed across every turn of the loop (not just the final response; `raw_message` remains the final response).
87
+ - **Tool registry integration.** `Axn::RubyLLM.tools` returns every Axn registered under the `:ruby_llm` adapter — one whose file lives under the adapter's `tool_roots` (default `["agent_tools"]`), or that declares `tool` / `tool :ruby_llm`, or that carries a `configure(:ruby_llm)` bag, minus any `tool false` / `tool except: :ruby_llm` opt-outs — each wrapped and ready to register in stable, `tool_name`-sorted order: `chat.with_tools(*Axn::RubyLLM.tools)`. When tools declare `tool_version`, only the latest version per `tool_name` is returned. Configure the directories via `Axn::RubyLLM.configure { |c| c.tool_roots = [...] }`.
88
+ - **`error_headline` config** (default `"LLM request failed"`) overrides the prefix on every `Ask` failure without subclassing.
89
+ - **Adapter-boundary never-raises guard.** The wrapper's `execute`/`.call` upholds axn's non-bang "never raises" contract: if turning a *successful* result into a tool response raises in the transport step (an unserializable value, a structure past the JSON encoder's `max_nesting`, or a gem bug), the adapter reports it through `Axn.config.on_exception` and returns a generic tool error (`"The tool could not produce a valid response"`) instead of letting the exception escape and break the chat — re-raising in development (per core's `best_effort_raises_in_dev`) so real bugs surface loudly. The client-facing text is generic by design; the actionable detail rides on the reported exception. Mirrors axn-mcp's adapter-boundary guard.
90
+ - **`reject_opaque_exposed_values` config** (default `false`; per-tool via `configure(:ruby_llm)`, per-class wins). When `true`, a tool result holding a value with no author-declared JSON form — one that would otherwise ship as an opaque blob (`"#<User:0x…>"`, or an ActiveSupport instance-variable dump under Rails) — fails as a tool error instead. Output-side only; the always-on rejections (reference cycles, non-finite Floats, non-UTF-8 bytes, colliding JSON keys) are unaffected. Mirrors axn-mcp's knob; built on `serialize_exposed(reject_opaque:)` from axn [#206](https://github.com/teamshares/axn/pull/206) (PRO-2988).
91
+
92
+ ### Changed
93
+
94
+ - **`Ask` failures now carry a consistent `"LLM request failed: <reason>"` message, with more specific reasons.** Rate limits, transient provider errors (5xx → "Provider temporarily unavailable, try again later"), context-length-exceeded, and invalid-JSON responses each get their own wording; the provider's own message is preserved where useful. Unrecognized exceptions (likely bugs, not known RubyLLM/network failures) now fail with the bare headline and no leaked exception detail — error reporting via `Axn.config.on_exception` is unaffected. See the README's "Errors" section.
95
+ - **OpenTelemetry attribute recording is guarded by axn's `Extensions.best_effort` helper** — a telemetry failure now warn-logs (and fails loud in development) instead of vanishing silently, while still never breaking the LLM call.
96
+ - **Production gating: read the resolved gate via `Axn::RubyLLM.enabled?`.** axn's `Configurable` dropped its `callable:` kwarg (axn [#209](https://github.com/teamshares/axn/pull/209) / PRO-3017), so it no longer invokes an assigned `enabled` callable on read. Callable resolution moved into this gem — `Axn::RubyLLM.enabled?` invokes the callable and returns a Boolean, and is the supported reader. The DSL-generated `Axn::RubyLLM.config.enabled?` returns an assigned Proc as-is (always truthy) and must not be used for the gate.
97
+
98
+ ### Requires
99
+
100
+ - An `axn` version providing core contract reflection, the tool registry (per-adapter `tool_roots` + union membership) with canonical `tool_name`, the tool `Invoker` (input-validation surfacing), the extension-author surface (`Axn::Extensions.best_effort` and the `Axn::Extensions::Serialization.render` result serializer — axn [#207](https://github.com/teamshares/axn/pull/207) / PRO-2992, which made `Axn::Reflection::Values.serialize_exposed` private), and namespaced per-class `configure(...)`. Satisfied by the released `axn` `0.1.0-alpha.5`, resolved from RubyGems (the gemspec pins `>= 0.1.0-alpha.5, < 0.2.0`).
101
+
102
+ ## [0.1.3] - 2026-06-26
103
+
104
+ Adopts Axn's `Configurable` DSL for gem configuration (requires the axn version that ships `Axn::Configurable`).
105
+
106
+ - Replace the hand-rolled `Configuration` class with `extend Axn::Configurable`, declaring `default_model` (default `"gpt-4o-mini"`) and `enabled` (default `true`, callable) as settings.
107
+ - **Rename** `Axn::RubyLLM.configuration` → `Axn::RubyLLM.config` and `Axn::RubyLLM.reset_configuration!` → `Axn::RubyLLM.reset_config!`, matching the DSL's standard surface. `Axn::RubyLLM.configure { |c| ... }` is unchanged.
108
+ - The old names are kept as **deprecated aliases** — they still work but emit a deprecation warning (`category: :deprecated`) and are scheduled for removal in the next minor version. See [DEPRECATIONS.md](DEPRECATIONS.md).
109
+
110
+ Also migrates the `Ask` error-message DSL to axn's new message-presentation semantics ([axn#109](https://github.com/teamshares/axn/pull/109), [#132](https://github.com/teamshares/axn/pull/132), [#134](https://github.com/teamshares/axn/pull/134)), which replaced per-message `prefix:` with a base `error`/`success` headline plus a `standalone:` attach flag. Requires the axn version that ships these.
111
+
112
+ - **`result.error` now carries a consistent `"LLM request failed: <reason>"` headline for every failure mode** (previously only unhandled exceptions were prefixed). Affected strings:
113
+ - Rate limit: `"LLM request failed: Rate limit reached: <message>"`
114
+ - Schema parse: `"LLM request failed: Schema response was not valid JSON"`
115
+ - JSON parse: `"LLM request failed: Response was not valid JSON"` (reason reworded from `"Failed to parse JSON from LLM response"` so it joins the headline cleanly)
116
+ - Unhandled exceptions: `"LLM request failed: <exception message>"` (unchanged)
117
+ - **`result.success` now carries a meaningful headline** instead of axn's generic `"Action completed successfully"`:
118
+ - Normal calls: `"LLM request completed"`.
119
+ - Production-gated (disabled) calls: `"LLM request completed (using stubbed values - actual LLM request disabled)"`.
120
+
3
121
  ## [0.1.2] - 2026-06-11
4
122
 
5
123
  Requires RubyLLM >= 1.15 (minimum version bumped from 1.0).
data/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # axn-ruby_llm
2
2
 
3
- Call LLMs from [Axn](https://github.com/teamshares/axn) actions using [RubyLLM](https://github.com/crmne/ruby_llm), with declarative error handling, optional JSON mode, configurable defaults, and cost/token tracking.
3
+ Call LLMs from [Axn](https://github.com/teamshares/axn) actions using [RubyLLM](https://github.com/crmne/ruby_llm), with declarative error handling, optional JSON mode, configurable defaults, and cost/token tracking — and wrap any Axn as a `RubyLLM::Tool` a chat can call.
4
4
 
5
5
  Part of the `axn-*` extension ecosystem — see also [axn-mcp](https://github.com/teamshares/axn-mcp).
6
6
 
7
7
  ### Why use this over calling RubyLLM directly?
8
8
 
9
- Three things you'd otherwise build at every callsite:
9
+ Four things you'd otherwise hand-build:
10
10
 
11
11
  1. **Structured error handling.** The Axn error DSL declaratively maps `RateLimitError`, `JSON::ParserError`, and generic `StandardError` to clean failure messages. Callers check `result.ok?` instead of wrapping every call in `begin/rescue`.
12
12
 
@@ -14,7 +14,9 @@ Three things you'd otherwise build at every callsite:
14
14
 
15
15
  3. **Cost/token tracking, exposed automatically.** Every call exposes `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `prompt_tokens` (the total), `cost`, and `cost_breakdown` without you doing the `RubyLLM.models.find` lookup manually. If your app uses OpenTelemetry, these values are also set as attributes on the existing `axn.call` span — no configuration required.
16
16
 
17
- > **Scope note:** This gem covers the subset of RubyLLM functionality that [Teamshares](https://github.com/teamshares) uses internally — single-turn chat, structured output, and basic observability. It is intentionally minimal rather than a full-featured wrapper. Feedback and pull requests to extend it are very welcome.
17
+ 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](https://github.com/teamshares/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.
18
+
19
+ > **Scope note:** This gem covers the subset of RubyLLM functionality that [Teamshares](https://github.com/teamshares) uses internally — single-turn chat, structured output, basic observability, and wrapping Axns as tools. It is intentionally minimal rather than a full-featured wrapper. Feedback and pull requests to extend it are very welcome.
18
20
 
19
21
  ---
20
22
 
@@ -37,7 +39,8 @@ Optionally configure gem-level defaults:
37
39
 
38
40
  ```ruby
39
41
  Axn::RubyLLM.configure do |c|
40
- c.default_model = "gpt-4o-mini" # default; override with any RubyLLM model ID
42
+ c.default_model = "gpt-4o-mini" # default; override with any RubyLLM model ID
43
+ c.error_headline = "LLM request failed" # default; prefixes every result.error (see Errors below)
41
44
  end
42
45
  ```
43
46
 
@@ -109,26 +112,209 @@ result.raw_message # => #<RubyLLM::Message ...>
109
112
 
110
113
  `cost` and `cost_breakdown` are both `nil` when RubyLLM lacks pricing for the model (e.g. unknown/custom endpoints). Token counts are nil only if the provider did not return them. `prompt_tokens` is nil only if all three input token fields are nil.
111
114
 
112
- Errors are handled via Axn's declarative `error` DSL:
113
- - `JSON::ParserError` → result fails with `"Failed to parse JSON from LLM response"`
114
- - `RubyLLM::RateLimitError` (HTTP 429, provider-agnostic) result fails with `"Rate limit reached: <message>"`
115
- - `schema:` set but LLM returned non-JSON → result fails with `"Schema response was not valid JSON"`
116
- - Any other `StandardError` result fails with `"LLM request failed: <message>"`
115
+ ### Errors
116
+
117
+ 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):
118
+ - `JSON::ParserError``"LLM request failed: Response was not valid JSON"`
119
+ - `RubyLLM::RateLimitError` (HTTP 429, provider-agnostic) `"LLM request failed: Rate limit reached: <message>"`
120
+ - `RubyLLM::OverloadedError` / `ServiceUnavailableError` / `ServerError` (5xx, transient) → `"LLM request failed: Provider temporarily unavailable, try again later: <message>"`
121
+ - `RubyLLM::ContextLengthExceededError` → `"LLM request failed: Prompt exceeds the model's context window: <message>"` (message retains the provider's token counts)
122
+ - `schema:` set but LLM returned non-JSON → `"LLM request failed: Schema response was not valid JSON"`
123
+ - Any other known RubyLLM error — `RubyLLM::Error` (auth, bad request, payment, etc.), `RubyLLM::ConfigurationError`, `ModelNotFoundError`, `PromptNotFoundError`, `InvalidRoleError`, `InvalidToolChoiceError`, `UnsupportedAttachmentError` — or `Faraday::Error` (network/transport failure) → `"LLM request failed: <message>"`
124
+ - 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
125
+
126
+ ## Tool adapter — wrap any Axn as a RubyLLM::Tool
127
+
128
+ Any [Axn](https://github.com/teamshares/axn) can be exposed as a `::RubyLLM::Tool` — no adapter-specific mixin required, it's just a normal Axn:
129
+
130
+ ```ruby
131
+ class CreateWidget
132
+ include Axn
133
+
134
+ description "Creates a widget with the given name"
135
+ expects :name, type: String
136
+ exposes :widget_id
137
+
138
+ def call
139
+ expose widget_id: Widget.create!(name:).id
140
+ end
141
+ end
142
+ ```
143
+
144
+ 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:
145
+
146
+ ```ruby
147
+ result = Axn::RubyLLM.ask(
148
+ prompt: "Create a widget called Sprocket, then tell me its id.",
149
+ tools: [CreateWidget],
150
+ )
151
+ result.response # => "Created widget Sprocket (id: 42)."
152
+ ```
153
+
154
+ `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](#enumerating-tools-from-the-registry)). The same Axn classes you expose through [axn-mcp](https://github.com/teamshares/axn-mcp) work here unchanged.
155
+
156
+ > **Token/cost in a tool loop:** a tool call makes multiple model round-trips inside one `ask`. The token counts, `cost`, and `cost_breakdown` are **summed across every turn**, so they reflect the whole call — not just the final response. (`raw_message` is still the final response.)
157
+
158
+ `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](#using-wrapped-tools-with-rubyllm-directly).
159
+
160
+ 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 `.call`s — see [OpenTelemetry](#opentelemetry) below.
161
+
162
+ 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::Chat#handle_tool_calls` only passes a `Content`/`Content::Raw` return through as-is, and otherwise sends `tool_payload.to_s`, which for a Hash produces Ruby's inspect syntax rather than JSON; 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.
163
+
164
+ 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):
165
+
166
+ | Option | Effect |
167
+ |---|---|
168
+ | `halt_after:` | When `true`, wraps a successful payload in `RubyLLM::Tool::Halt` to stop the agent loop after this call. Default `false`. |
169
+ | `provider_params:` | Hash deep-merged into the tool definition sent to the provider (via RubyLLM's `with_params`) — 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 `{}`. |
170
+ | `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:`. |
171
+
172
+ Set a default once on the Axn with `configure(:ruby_llm)`, and still override per call:
173
+
174
+ ```ruby
175
+ class CreateWidget
176
+ include Axn
177
+ configure(:ruby_llm) { |c| c.halt_after = true } # default for this tool
178
+ # ...
179
+ end
180
+
181
+ Axn::RubyLLM.wrap(CreateWidget) # halts after running
182
+ Axn::RubyLLM.wrap(CreateWidget, halt_after: false) # per-call override
183
+ ```
184
+
185
+ `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`):
186
+
187
+ ```ruby
188
+ class CreateWidget
189
+ include Axn
190
+ # ...
191
+
192
+ configure(:ruby_llm) { |c| c.present_as = :message } # how the RubyLLM tool presents its result
193
+ configure(:mcp) { |c| c.present_as = :structured } # same setting name, different namespace — no collision
194
+ end
195
+ ```
196
+
197
+ 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:
198
+
199
+ ```ruby
200
+ Axn::RubyLLM.wrap(CreateWidget, ambient_context: { company_id: current_company.id })
201
+ ```
202
+
203
+ Passing `ambient_context:` returns a tool **instance** (closing over that context) rather than the tool class, since `chat.with_tool` accepts either.
204
+
205
+ ### Using wrapped tools with RubyLLM directly
206
+
207
+ `Axn::RubyLLM.ask(tools:)` covers the common single-call case. When you're driving `RubyLLM.chat` yourself — multi-turn conversations, streaming, or anything else beyond `ask` — register wrapped tools with RubyLLM's own `with_tool` / `with_tools`, which accept a `RubyLLM::Tool` class or instance:
208
+
209
+ ```ruby
210
+ chat = RubyLLM.chat.with_tool(Axn::RubyLLM.wrap(CreateWidget))
211
+ chat.ask("Create a widget called Sprocket")
212
+
213
+ # or register everything under the :ruby_llm adapter at once:
214
+ chat = RubyLLM.chat.with_tools(*Axn::RubyLLM.tools)
215
+ ```
216
+
217
+ ### Tool naming
218
+
219
+ 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.
220
+
221
+ ### Enumerating tools from the registry
222
+
223
+ 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:
224
+
225
+ ```ruby
226
+ chat = RubyLLM.chat.with_tools(*Axn::RubyLLM.tools)
227
+ ```
228
+
229
+ `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.
230
+
231
+ **Membership = (directory grant ∪ declaration grant) − exclusions.** An Axn is a `:ruby_llm` tool if either:
232
+
233
+ - **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).
234
+ - **Declaration grant** — it declares `tool` (every adapter), `tool :ruby_llm`, or carries a `configure(:ruby_llm)` bag.
235
+
236
+ …unless it opts out: `tool false` (no adapter) or `tool except: :ruby_llm` (keep the directory grant, drop this adapter).
237
+
238
+ ```ruby
239
+ class CreateWidget
240
+ include Axn
241
+ tool :ruby_llm # add :ruby_llm (on top of any directory grant)
242
+ tool ruby_llm: { present_as: :message } # …or add it AND set per-adapter options inline
243
+ # ...
244
+ end
245
+ ```
246
+
247
+ 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.
248
+
249
+ > **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.
250
+
251
+ ### Date/Time/Symbol/Integer/Float fields — declare `coerce:`
252
+
253
+ 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:
254
+
255
+ ```ruby
256
+ expects :scheduled_for, coerce: Date # sugar for type: { klass: Date, coerce: true }
257
+ expects :priority, type: { klass: Symbol, coerce: true } # explicit form, e.g. alongside other type: options
258
+ ```
259
+
260
+ 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.
261
+
262
+ ### Opaque exposed values — `reject_opaque_exposed_values`
263
+
264
+ 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.
265
+
266
+ 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:
267
+
268
+ ```ruby
269
+ CreateWidget.configure(:ruby_llm) { |c| c.reject_opaque_exposed_values = true } # per tool (wins)
270
+ Axn::RubyLLM.configure { |c| c.reject_opaque_exposed_values = true } # gem-wide default
271
+ ```
272
+
273
+ Scope:
274
+
275
+ - **Output-side only.** It governs `exposes` serialization, never inbound `coerce:` on `expects`.
276
+ - **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.
277
+
278
+ ### Transport-boundary never-raises guard
279
+
280
+ 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.
281
+
282
+ 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](https://github.com/teamshares/axn-mcp)'s adapter-boundary guard.
283
+
284
+ ### Schema reflection — provider notes
285
+
286
+ The advertised tool schema is axn's reflected `input_schema`. A few things worth knowing when you care how it lands at a specific provider (Gemini is the strictest — it runs a mandatory OpenAPI-subset converter; OpenAI and Anthropic pass the schema through as-is):
287
+
288
+ - **Nullable/optional fields** — handled. axn reflects a nullable field as an array-valued `type` (`["integer", "null"]`); the adapter rewrites that to the equivalent `anyOf` form, because Gemini's converter can't read array-valued types and would otherwise collapse the field to `STRING`. No action needed on your part.
289
+ - **Array fields — declare `of:`.** `expects :ids, type: Array` reflects to `{type: array}` with no `items`, so the element type isn't advertised (Gemini then assumes `string`; OpenAI *strict* mode requires `items`). Declare the element type — `expects :ids, type: Array, of: Integer` — to advertise `items` correctly.
290
+ - **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.
291
+ - **Hash maps and entry counts** — handled. A map (`expects :scores, type: Hash, of: { keys: String, values: Integer }`) reflects to `additionalProperties`, and a Hash's entry-count bounds to `minProperties`/`maxProperties`. Gemini's converter carries none of the three, so a map would otherwise arrive as an empty `{type: OBJECT}` — no error, but the model never learns what the values must be, so its guess is rejected at runtime instead. The adapter restates the constraint in that node's `description` (“An object mapping arbitrary keys to integer values. This object must not be empty.”), which Gemini does forward; a structured value type carries its compact JSON Schema alongside. The real keys are left in place for OpenAI/Anthropic, which enforce them, so the prose is redundant there rather than load-bearing. Your own `description:` is kept and the generated sentences appended after it. No action needed on your part.
292
+ - **Conditional expectations** (`expects :token, if: :use_token`) reflect to a JSON Schema `allOf`/`if`/`then` clause. Gemini's converter ignores it — the field degrades to plain-optional (safe: a valid call is never wrongly rejected, but the conditional isn't conveyed to the model). This gem doesn't set OpenAI's `strict` mode, so OpenAI tolerates `allOf` by default; if you opt into strict via `provider_params`, OpenAI will reject `allOf`. Either way, encoding the rule in `description:` is the portable option.
117
293
 
118
294
  ## Testing
119
295
 
120
- In your specs, require the helpers and use `stub_axn_ruby_llm`:
296
+ 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:
121
297
 
122
298
  ```ruby
123
299
  require "axn/ruby_llm/rspec"
124
300
 
125
301
  it "summarizes the thread" do
126
- stub_axn_ruby_llm(response: "The team agreed to ship on Friday.")
302
+ stub_axn_ruby_llm("The team agreed to ship on Friday.")
127
303
  result = Axn::RubyLLM.ask(prompt: "...")
128
304
  expect(result.response).to include("ship on Friday")
129
305
  end
130
306
  ```
131
307
 
308
+ The response is the only required argument — pass it positionally (as above) or as `response:`. A Hash response is auto-JSON-serialized for `json: true` calls; pass `schema:` to route a Hash through the schema path unparsed (and to assert the exact schema class). Token counts and cost default to zero and can be set explicitly to exercise cost/usage logic:
309
+
310
+ ```ruby
311
+ stub_axn_ruby_llm({ "company_id" => 42 }, schema: CompanyMatch)
312
+ stub_axn_ruby_llm("...", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 0.0023)
313
+ stub_axn_ruby_llm("...", cache_read_tokens: 500, cache_write_tokens: 200)
314
+ ```
315
+
316
+ Remaining keywords: `model:`, `schema:`, `input_tokens:`, `output_tokens:`, `cache_read_tokens:`, `cache_write_tokens:`, `cost:`. Returns the stubbed chat instance double for further assertions if you need it.
317
+
132
318
  ## OpenTelemetry
133
319
 
134
320
  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:
@@ -141,12 +327,13 @@ If your app uses OpenTelemetry, `axn` already wraps every action in an `axn.call
141
327
  | `gen_ai.usage.output_tokens` | Completion token count |
142
328
  | `gen_ai.usage.cost` | USD total (non-standard; useful for spend filtering) |
143
329
  | `axn.ruby_llm.stubbed` | `true` when production gating returned a stub |
330
+ | `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 `.call`s in the same span schema |
144
331
 
145
332
  For LLM-level tracing (individual `RubyLLM.chat` calls, tool calls, embeddings, prompt content), add [`opentelemetry-instrumentation-ruby_llm`](https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm) to your own Gemfile and configure it per its README. It is not a dependency of this gem.
146
333
 
147
334
  ## Production gating
148
335
 
149
- Set `Configuration#enabled` to gate LLM calls — useful for skipping spend in non-production environments. Accepts a Boolean or a callable (evaluated per call):
336
+ 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):
150
337
 
151
338
  ```ruby
152
339
  Axn::RubyLLM.configure do |c|
@@ -156,6 +343,8 @@ Axn::RubyLLM.configure do |c|
156
343
  end
157
344
  ```
158
345
 
346
+ 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.)
347
+
159
348
  When disabled, `Axn::RubyLLM.ask` returns a **success** result with obvious stub content, so callers don't need per-callsite branching:
160
349
 
161
350
  | Field | Stubbed value |
@@ -167,4 +356,4 @@ When disabled, `Axn::RubyLLM.ask` returns a **success** result with obvious stub
167
356
  | `cost_breakdown` | `nil` |
168
357
  | `stubbed` | `true` |
169
358
 
170
- Check `result.stubbed` if you need to branch on it (e.g. skip downstream writes that would otherwise persist stub LLM output). The Axn result's `message` is `"disabled - returning stubbed values"` for the same purpose.
359
+ 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"`.