axn-ruby_llm 0.1.2 → 0.2.0

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: c33a22550fbc081c47ad09e370ce05aa400640cfe70f2e06488d93b1507a42a6
4
+ data.tar.gz: 1d65bcd8eeee475aee68c7a92e380601f8c3e333629fe6664609c0e1174e2f64
5
5
  SHA512:
6
- metadata.gz: 75587b0c6a29875e2de143459b0b2ac1280581cb9e3b2a080777737ed803fda05008f0703a31522a4d5bcb47ea10998ef6236e1917bc9157f10fcaf278f97f3f
7
- data.tar.gz: 21d44dcc5fc6bc41615cc472648737b2c6491d05dfc1d469b7c884bed2d7a260da574f83b5f14d8b0ff97694fdf60f2428d4db72a22123fe0764b47f0606b7c4
6
+ metadata.gz: c9b764c927824c06c5f14d8b186ede7c63182cda2259991e444e6226f45a0277b51433f8ea5246f0438623a59e9e4827d7ee393391db53f028e00178f154de1d
7
+ data.tar.gz: 5cc0b5fcda8f30cf1961cc44839c08d5447787262247f6ebd460d53c4ff9478143a9b4e1b7414082917d8052b4757770113e2e3451c206283e6ad49ff5cfb754
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-08-05
4
+
5
+ > **Upgrade notes (behavior changes):**
6
+ > - **`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.
7
+ > - **Requires a newer `axn`** — this release depends on core reflection, the tool registry + `tool_name`, and namespaced `configure(...)` (see Requires below).
8
+ > - No public API was removed, and no config or `wrap` option changed name or default.
9
+
10
+ ### Added
11
+
12
+ - **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.
13
+ - **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).
14
+ - **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 = [...] }`.
15
+ - **`error_headline` config** (default `"LLM request failed"`) overrides the prefix on every `Ask` failure without subclassing.
16
+ - **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.
17
+ - **`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).
18
+
19
+ ### Changed
20
+
21
+ - **`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.
22
+ - **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.
23
+ - **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.
24
+
25
+ ### Requires
26
+
27
+ - 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`).
28
+
29
+ ## [0.1.3] - 2026-06-26
30
+
31
+ Adopts Axn's `Configurable` DSL for gem configuration (requires the axn version that ships `Axn::Configurable`).
32
+
33
+ - Replace the hand-rolled `Configuration` class with `extend Axn::Configurable`, declaring `default_model` (default `"gpt-4o-mini"`) and `enabled` (default `true`, callable) as settings.
34
+ - **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.
35
+ - 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).
36
+
37
+ 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.
38
+
39
+ - **`result.error` now carries a consistent `"LLM request failed: <reason>"` headline for every failure mode** (previously only unhandled exceptions were prefixed). Affected strings:
40
+ - Rate limit: `"LLM request failed: Rate limit reached: <message>"`
41
+ - Schema parse: `"LLM request failed: Schema response was not valid JSON"`
42
+ - 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)
43
+ - Unhandled exceptions: `"LLM request failed: <exception message>"` (unchanged)
44
+ - **`result.success` now carries a meaningful headline** instead of axn's generic `"Action completed successfully"`:
45
+ - Normal calls: `"LLM request completed"`.
46
+ - Production-gated (disabled) calls: `"LLM request completed (using stubbed values - actual LLM request disabled)"`.
47
+
3
48
  ## [0.1.2] - 2026-06-11
4
49
 
5
50
  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,208 @@ 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.
161
+
162
+ On success, `execute` returns the exposed values (via `Axn::Extensions::Serialization.render`) 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
+ - **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
292
 
118
293
  ## Testing
119
294
 
120
- In your specs, require the helpers and use `stub_axn_ruby_llm`:
295
+ 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
296
 
122
297
  ```ruby
123
298
  require "axn/ruby_llm/rspec"
124
299
 
125
300
  it "summarizes the thread" do
126
- stub_axn_ruby_llm(response: "The team agreed to ship on Friday.")
301
+ stub_axn_ruby_llm("The team agreed to ship on Friday.")
127
302
  result = Axn::RubyLLM.ask(prompt: "...")
128
303
  expect(result.response).to include("ship on Friday")
129
304
  end
130
305
  ```
131
306
 
307
+ 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:
308
+
309
+ ```ruby
310
+ stub_axn_ruby_llm({ "company_id" => 42 }, schema: CompanyMatch)
311
+ stub_axn_ruby_llm("...", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 0.0023)
312
+ stub_axn_ruby_llm("...", cache_read_tokens: 500, cache_write_tokens: 200)
313
+ ```
314
+
315
+ 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.
316
+
132
317
  ## OpenTelemetry
133
318
 
134
319
  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:
@@ -146,7 +331,7 @@ For LLM-level tracing (individual `RubyLLM.chat` calls, tool calls, embeddings,
146
331
 
147
332
  ## Production gating
148
333
 
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):
334
+ 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
335
 
151
336
  ```ruby
152
337
  Axn::RubyLLM.configure do |c|
@@ -156,6 +341,8 @@ Axn::RubyLLM.configure do |c|
156
341
  end
157
342
  ```
158
343
 
344
+ 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.)
345
+
159
346
  When disabled, `Axn::RubyLLM.ask` returns a **success** result with obvious stub content, so callers don't need per-callsite branching:
160
347
 
161
348
  | Field | Stubbed value |
@@ -167,4 +354,4 @@ When disabled, `Axn::RubyLLM.ask` returns a **success** result with obvious stub
167
354
  | `cost_breakdown` | `nil` |
168
355
  | `stubbed` | `true` |
169
356
 
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.
357
+ 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"`.
@@ -11,6 +11,7 @@ module Axn
11
11
  expects :model, optional: true
12
12
  expects :system_prompt, optional: true
13
13
  expects :temperature, optional: true
14
+ expects :tools, optional: true
14
15
 
15
16
  exposes :response
16
17
  exposes :raw_message
@@ -25,8 +26,39 @@ module Axn
25
26
 
26
27
  StubMessage = Data.define(:content, :input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens, :model_id)
27
28
 
28
- error prefix: "LLM request failed: "
29
- error "Failed to parse JSON from LLM response", if: JSON::ParserError
29
+ # RubyLLM wraps HTTP-response-level provider errors (4xx/5xx) under RubyLLM::Error, but its
30
+ # non-HTTP errors (bad config, missing model/prompt/role, unsupported attachment) subclass
31
+ # StandardError directly -- so RubyLLM::Error alone misses them. Connection-level failures
32
+ # (timeout, DNS, refused) never reach RubyLLM at all and surface as raw Faraday errors. All
33
+ # three are "known" failure shapes safe to surface verbatim; anything outside this is a bug
34
+ # and must not leak its message into a user-facing result.
35
+ KNOWN_ERROR_CLASSES = [
36
+ ::RubyLLM::Error,
37
+ ::Faraday::Error,
38
+ ::RubyLLM::ConfigurationError,
39
+ ::RubyLLM::ModelNotFoundError,
40
+ ::RubyLLM::PromptNotFoundError,
41
+ ::RubyLLM::InvalidRoleError,
42
+ ::RubyLLM::InvalidToolChoiceError,
43
+ ::RubyLLM::UnsupportedAttachmentError,
44
+ ].freeze
45
+ KNOWN_ERROR = ->(exception:) { KNOWN_ERROR_CLASSES.any? { |k| exception.is_a?(k) } }
46
+ RETRYABLE_ERROR = lambda { |exception:|
47
+ [::RubyLLM::OverloadedError, ::RubyLLM::ServiceUnavailableError, ::RubyLLM::ServerError].any? { |k| exception.is_a?(k) }
48
+ }
49
+
50
+ # Base headlines for a consistent result.error / result.success surface: failures read
51
+ # "<error_headline>: <reason>" (configurable via Axn::RubyLLM.configure); successes read
52
+ # "LLM request completed", with any detail attached parenthetically via join: (e.g. the
53
+ # stubbed-values note on the disabled path below).
54
+ # Reason entries are ordered most-specific-last (axn checks most-recently-declared first), so a
55
+ # narrower match (retryable, context length, JSON parse) wins over the generic KNOWN_ERROR catch-all.
56
+ error { Axn::RubyLLM.config.error_headline }
57
+ error(if: KNOWN_ERROR, &:message)
58
+ error(if: RETRYABLE_ERROR) { |e| "Provider temporarily unavailable, try again later: #{e.message}" }
59
+ error(if: ::RubyLLM::ContextLengthExceededError) { |e| "Prompt exceeds the model's context window: #{e.message}" }
60
+ error "Response was not valid JSON", if: JSON::ParserError
61
+ success "LLM request completed", join: ->(base, reason) { "#{base} (#{reason})" }
30
62
 
31
63
  before do
32
64
  if disabled?
@@ -38,7 +70,8 @@ module Axn
38
70
  response_model: nil,
39
71
  stubbed: true,
40
72
  )
41
- done!("disabled - returning stubbed values", **exposures)
73
+ # Reason attaches to the "LLM request completed" base via the parenthetical join: above.
74
+ done!("using stubbed values - actual LLM request disabled", **exposures)
42
75
  end
43
76
  end
44
77
 
@@ -46,20 +79,20 @@ module Axn
46
79
  expose(
47
80
  response: parsed_response,
48
81
  raw_message: llm_response,
49
- input_tokens: llm_response.input_tokens,
50
- output_tokens: llm_response.output_tokens,
51
- cache_read_tokens: llm_response.cache_read_tokens,
52
- cache_write_tokens: llm_response.cache_write_tokens,
82
+ input_tokens: sum_across(:input_tokens),
83
+ output_tokens: sum_across(:output_tokens),
84
+ cache_read_tokens: sum_across(:cache_read_tokens),
85
+ cache_write_tokens: sum_across(:cache_write_tokens),
53
86
  prompt_tokens: total_input_tokens,
54
87
  cost_breakdown:,
55
88
  cost: cost_breakdown&.total,
56
89
  stubbed: false,
57
90
  )
58
91
  record_otel_attributes!(
59
- input_tokens: llm_response.input_tokens,
60
- output_tokens: llm_response.output_tokens,
92
+ input_tokens: sum_across(:input_tokens),
93
+ output_tokens: sum_across(:output_tokens),
61
94
  cost: cost_breakdown&.total,
62
- response_model: llm_response.model_id,
95
+ response_model: response_message&.model_id,
63
96
  stubbed: false,
64
97
  )
65
98
  rescue ::RubyLLM::RateLimitError => e
@@ -68,7 +101,7 @@ module Axn
68
101
 
69
102
  private
70
103
 
71
- def disabled? = !Axn::RubyLLM.configuration.enabled?
104
+ def disabled? = !Axn::RubyLLM.enabled?
72
105
 
73
106
  def stubbed_exposures
74
107
  content = schema || json ? { "stubbed" => true } : "stubbed response value"
@@ -87,6 +120,8 @@ module Axn
87
120
  end
88
121
 
89
122
  def parsed_response
123
+ return halted_response if halted?
124
+
90
125
  if schema
91
126
  # with_schema makes RubyLLM parse the response into a Hash on success
92
127
  return llm_response.content if llm_response.content.is_a?(Hash)
@@ -96,52 +131,128 @@ module Axn
96
131
  json ? JSON.parse(llm_response.content) : llm_response.content
97
132
  end
98
133
 
134
+ # A halted tool (halt_after:) short-circuits the model's final turn, so with_schema/json never
135
+ # parsed a model response — the "response" is the tool's own payload (Halt#content). For a
136
+ # :structured tool that's JSON text, so parse it to honor the Hash contract a schema:/json:
137
+ # caller expects; fall back to the raw string for a :message tool or an unparseable payload.
138
+ def halted_response
139
+ content = llm_response.content
140
+ return content unless (schema || json) && content.is_a?(String)
141
+
142
+ JSON.parse(content)
143
+ rescue JSON::ParserError
144
+ content
145
+ end
146
+
147
+ # A tool call makes multiple model round-trips inside one `ask`; every assistant turn is
148
+ # accumulated on the chat and reports its OWN usage, so sum across them for the true per-call
149
+ # totals rather than just the final turn's. Non-response messages (the user prompt, tool
150
+ # results) carry no tokens — they contribute 0 to the token sums, and RubyLLM::Cost.aggregate
151
+ # ignores them (no `tokens?`) — so summing over every message is correct, and a plain (no-tool)
152
+ # ask (one assistant turn) is a no-op.
153
+ def usage_messages
154
+ chat.messages
155
+ end
156
+
157
+ # nil only when NO turn reported the field (preserving the "nil if the provider didn't return it"
158
+ # contract); otherwise the summed count, treating a missing turn as 0.
159
+ def sum_across(field)
160
+ values = usage_messages.map(&field)
161
+ values.all?(&:nil?) ? nil : values.sum(&:to_i)
162
+ end
163
+
99
164
  def total_input_tokens
100
- vals = [llm_response.input_tokens, llm_response.cache_read_tokens, llm_response.cache_write_tokens]
165
+ vals = usage_messages.flat_map { |m| [m.input_tokens, m.cache_read_tokens, m.cache_write_tokens] }
101
166
  vals.all?(&:nil?) ? nil : vals.sum(&:to_i)
102
167
  end
103
168
 
104
- def cost_breakdown
169
+ memo def cost_breakdown
105
170
  return nil unless model_info
106
171
 
107
- llm_response.cost(model: model_info)
172
+ # chat.messages always includes the user prompt (chat.ask appends it before completing) and,
173
+ # in a tool loop, the tool-result messages -- none of which carry token usage. Keep only the
174
+ # token-bearing (billable) costs BEFORE the one?-vs-aggregate decision: a normal single-turn
175
+ # call then preserves the response's OWN Cost (with its tokens/model) via costs.one?, instead
176
+ # of being forced through aggregate -- which returns a Cost with nil tokens/model -- by the
177
+ # ever-present user message. `select(&:tokens?)` mirrors Cost.aggregate's own billable filter,
178
+ # so the multi-turn total is unchanged.
179
+ costs = usage_messages.map { |message| message.cost(model: model_info) }.select(&:tokens?)
180
+ return nil if costs.empty?
181
+
182
+ # One billable turn → its own Cost (identical to the pre-tool-loop non-tool call). Multiple →
183
+ # RubyLLM::Cost.aggregate sums the per-tier costs into a single breakdown.
184
+ costs.one? ? costs.first : ::RubyLLM::Cost.aggregate(costs)
108
185
  end
109
186
 
110
187
  memo def model_info
111
- ::RubyLLM.models.find(llm_response.model_id)
188
+ return nil unless response_message&.model_id
189
+
190
+ ::RubyLLM.models.find(response_message.model_id)
112
191
  rescue ::RubyLLM::ModelNotFoundError
113
192
  nil
114
193
  end
115
194
 
116
195
  memo def llm_response = chat.ask(prompt)
117
196
 
197
+ # When a wrapped tool halts the loop (halt_after:), chat.ask returns a ::RubyLLM::Tool::Halt
198
+ # carrying the tool payload as #content, not a Message — and a Halt has no #model_id. Read the
199
+ # model (for cost lookup + OTel) from the last assistant turn accumulated on the chat in that
200
+ # case; for a normal response, llm_response IS that final message. Token/cost SUMS already read
201
+ # chat.messages, so only the model-id reads needed this indirection.
202
+ def response_message
203
+ return llm_response unless halted?
204
+
205
+ chat.messages.reverse.find { |message| message.role == :assistant }
206
+ end
207
+
208
+ def halted? = llm_response.is_a?(::RubyLLM::Tool::Halt)
209
+
118
210
  memo def chat
119
211
  ::RubyLLM.chat(model: resolved_model).tap do |c|
120
212
  c.with_instructions(system_prompt) if system_prompt
121
213
  c.with_schema(schema) if schema
122
214
  c.with_params(response_format: { type: "json_object" }) if json && !schema
123
215
  c.with_params(temperature:) if temperature
216
+ c.with_tools(*resolved_tools) if resolved_tools.any?
124
217
  end
125
218
  end
126
219
 
127
220
  def resolved_model
128
- model || Axn::RubyLLM.configuration.default_model
221
+ model || Axn::RubyLLM.config.default_model
222
+ end
223
+
224
+ # `tools:` accepts a mix of bare Axn classes (wrapped here, so callers can pass their own Axns
225
+ # straight in) and already-wrapped `::RubyLLM::Tool`s -- a class or an instance, the latter being
226
+ # how you pass a tool that closed over explicit context via `Axn::RubyLLM.wrap(axn, ambient_context:)`.
227
+ # RubyLLM's `with_tools` accepts either a class or an instance, so wrapped classes register as-is.
228
+ def resolved_tools
229
+ Array(tools).map { |tool| _as_ruby_llm_tool(tool) }
230
+ end
231
+
232
+ def _as_ruby_llm_tool(tool)
233
+ return tool if tool.is_a?(::RubyLLM::Tool)
234
+ return tool if tool.is_a?(::Class) && tool < ::RubyLLM::Tool
235
+
236
+ Axn::RubyLLM.wrap(tool)
129
237
  end
130
238
 
131
239
  def record_otel_attributes!(input_tokens:, output_tokens:, cost:, response_model:, stubbed:)
132
- return unless defined?(::OpenTelemetry::Trace)
133
-
134
- span = ::OpenTelemetry::Trace.current_span
135
- return unless span&.context&.valid?
136
-
137
- span.set_attribute("gen_ai.request.model", resolved_model) if resolved_model
138
- span.set_attribute("gen_ai.response.model", response_model) if response_model
139
- span.set_attribute("gen_ai.usage.input_tokens", input_tokens) if input_tokens
140
- span.set_attribute("gen_ai.usage.output_tokens", output_tokens) if output_tokens
141
- span.set_attribute("gen_ai.usage.cost", cost) if cost
142
- span.set_attribute("axn.ruby_llm.stubbed", stubbed) unless stubbed.nil?
143
- rescue StandardError
144
- # never let telemetry break the action
240
+ # Telemetry is a best-effort side effect: it must never break the LLM call. Route it through
241
+ # axn core's guard (PRO-2950) rather than a bare rescue — it swallows + warn-logs on failure
242
+ # (and fails loud in dev when best_effort_raises_in_dev is set) instead of silently vanishing.
243
+ Axn::Extensions.best_effort("recording OpenTelemetry attributes on the axn.call span") do
244
+ next unless defined?(::OpenTelemetry::Trace)
245
+
246
+ span = ::OpenTelemetry::Trace.current_span
247
+ next unless span&.context&.valid?
248
+
249
+ span.set_attribute("gen_ai.request.model", resolved_model) if resolved_model
250
+ span.set_attribute("gen_ai.response.model", response_model) if response_model
251
+ span.set_attribute("gen_ai.usage.input_tokens", input_tokens) if input_tokens
252
+ span.set_attribute("gen_ai.usage.output_tokens", output_tokens) if output_tokens
253
+ span.set_attribute("gen_ai.usage.cost", cost) if cost
254
+ span.set_attribute("axn.ruby_llm.stubbed", stubbed) unless stubbed.nil?
255
+ end
145
256
  end
146
257
  end
147
258
  end
@@ -6,19 +6,28 @@ module Axn
6
6
  module RubyLLM
7
7
  module RSpec
8
8
  module Helpers
9
- # Stubs RubyLLM so that Ask returns a canned response.
9
+ UNSET = Object.new
10
+ private_constant :UNSET
11
+
12
+ # Stubs RubyLLM so that Ask returns a canned response. The response can be given
13
+ # positionally (the common case) or as `response:` — both are equivalent.
10
14
  #
11
15
  # Usage in a spec:
12
- # stub_axn_ruby_llm(response: "Here is a summary.")
13
- # stub_axn_ruby_llm(response: { "key" => "value" }) # auto-JSON-serialized for json: true calls
14
- # stub_axn_ruby_llm(response: { "k" => "v" }, schema: MySchema) # Hash passed through unparsed
15
- # stub_axn_ruby_llm(response: "...", input_tokens: 100, output_tokens: 50, cost: 0.0023)
16
- # stub_axn_ruby_llm(response: "...", cache_read_tokens: 500, cache_write_tokens: 200)
16
+ # stub_axn_ruby_llm("Here is a summary.")
17
+ # stub_axn_ruby_llm({ "key" => "value" }) # auto-JSON-serialized for json: true calls
18
+ # stub_axn_ruby_llm({ "k" => "v" }, schema: MySchema) # Hash passed through unparsed
19
+ # stub_axn_ruby_llm("...", input_tokens: 100, output_tokens: 50, cost: 0.0023)
20
+ # stub_axn_ruby_llm("...", cache_read_tokens: 500, cache_write_tokens: 200)
21
+ # stub_axn_ruby_llm(response: "...") # keyword form still works
17
22
  #
18
23
  # Returns the chat instance double for further assertions if needed.
19
- def stub_axn_ruby_llm(response:, model: nil, schema: nil, input_tokens: nil, output_tokens: nil,
20
- cache_read_tokens: nil, cache_write_tokens: nil, cost: nil)
21
- resolved_model_id = model || Axn::RubyLLM.configuration.default_model
24
+ def stub_axn_ruby_llm(positional_response = UNSET, response: UNSET, model: nil, schema: nil,
25
+ input_tokens: nil, output_tokens: nil, cache_read_tokens: nil,
26
+ cache_write_tokens: nil, cost: nil)
27
+ response = positional_response unless positional_response.equal?(UNSET)
28
+ raise ArgumentError, "stub_axn_ruby_llm requires a response (positionally or as `response:`)" if response.equal?(UNSET)
29
+
30
+ resolved_model_id = model || Axn::RubyLLM.config.default_model
22
31
  llm_message = _stub_axn_ruby_llm_message(response, resolved_model_id, input_tokens, output_tokens,
23
32
  cache_read_tokens:, cache_write_tokens:, schema:)
24
33
  chat_instance = _stub_axn_ruby_llm_chat(model, llm_message, schema:)
@@ -49,8 +58,9 @@ module Axn
49
58
  else
50
59
  allow(::RubyLLM).to receive(:chat).and_return(chat_instance)
51
60
  end
52
- allow(chat_instance).to receive(:with_instructions).and_return(chat_instance)
53
- allow(chat_instance).to receive(:with_params).and_return(chat_instance)
61
+ %i[with_instructions with_params with_tools].each do |method|
62
+ allow(chat_instance).to receive(method).and_return(chat_instance)
63
+ end
54
64
  # Always stub with_schema so specs don't blow up if production code passes schema:
55
65
  # even when the helper is called without schema:. Use a tight matcher when schema
56
66
  # is known so the stub still validates the correct class is passed.
@@ -60,6 +70,8 @@ module Axn
60
70
  allow(chat_instance).to receive(:with_schema).and_return(chat_instance)
61
71
  end
62
72
  allow(chat_instance).to receive(:ask).and_return(llm_message)
73
+ # Ask sums usage across the chat's assistant turns; a stubbed call is single-turn.
74
+ allow(chat_instance).to receive(:messages).and_return([llm_message])
63
75
  chat_instance
64
76
  end
65
77
 
@@ -69,7 +81,9 @@ module Axn
69
81
  # Default to zero cost so specs exercise the "model found, cost computed" path.
70
82
  # Pass cost: explicitly to assert a specific value.
71
83
  cost_total = cost || 0.0
72
- cost_struct = instance_double(::RubyLLM::Cost, total: cost_total)
84
+ # tokens?: true — the stubbed message is a billable assistant turn, and Ask's cost_breakdown
85
+ # keeps only token-bearing (tokens?) costs before its one?-vs-aggregate decision.
86
+ cost_struct = instance_double(::RubyLLM::Cost, total: cost_total, tokens?: true)
73
87
  allow(llm_message).to receive(:cost).with(model: model_info).and_return(cost_struct)
74
88
  end
75
89
  end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module RubyLLM
5
+ # Namespaced per-class config (axn's `Axn::Configurable`, PRO-2880): any Axn — with no
6
+ # adapter-specific mixin required — can declare `configure(:ruby_llm) { |c| c.halt_after = true }`
7
+ # to set these per-class, alongside e.g. `configure(:mcp) { ... }` for a different adapter on the
8
+ # same class, without the two colliding. `wrap` resolves them via `resolve_override_for`, which
9
+ # falls back to this module's own global `config` (`Axn::RubyLLM.configure { |c| ... }`) and then
10
+ # to each setting's default — the same class-override-then-global-then-default chain a flat
11
+ # `overridable: true` accessor would give a single-adapter consumer.
12
+ config_namespace :ruby_llm
13
+ setting :halt_after, default: false, overridable: true
14
+ setting :provider_params, default: {}, overridable: true
15
+ setting :present_as, default: :structured, one_of: %i[structured message], overridable: true
16
+ setting :reject_opaque_exposed_values, default: false, one_of: [true, false], overridable: true
17
+
18
+ # Wraps any Axn as a ::RubyLLM::Tool: schema, name, and description are read straight off the
19
+ # Axn's own declared contract (`input_schema` / `resolved_axn_name` / `description`, from axn's
20
+ # core reflection), so a tool needs no adapter-specific mixin to be wrapped.
21
+ module ToolAdapter
22
+ NOT_SET = Object.new.freeze
23
+
24
+ # Client-facing tool-error text when the transport step raises while turning a *successful*
25
+ # result into a response (see the guard in build_tool_class's #execute). Deliberately generic:
26
+ # the actionable detail (class, path) is a gem/tool bug, so it rides on the reported exception
27
+ # (on_exception / logs), not the tool's response — mirroring how axn keeps a failure's detail
28
+ # off the user-facing message, and axn-mcp's Serializer::ADAPTER_FAILURE_MESSAGE.
29
+ ADAPTER_FAILURE_MESSAGE = "The tool could not produce a valid response"
30
+
31
+ class << self
32
+ def wrap(axn_class, halt_after: nil, provider_params: nil, present_as: nil, render_as: NOT_SET, ambient_context: NOT_SET)
33
+ validate_present_as_kwargs!(present_as, render_as)
34
+
35
+ tool_class = build_tool_class(
36
+ axn_class,
37
+ halt_after: halt_after.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :halt_after) : halt_after,
38
+ provider_params: provider_params.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :provider_params) : provider_params,
39
+ present_as: present_as.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :present_as) : present_as,
40
+ reject_opaque: Axn::RubyLLM.resolve_override_for(axn_class, :reject_opaque_exposed_values),
41
+ ambient_context:,
42
+ )
43
+
44
+ ambient_context.equal?(NOT_SET) ? tool_class : tool_class.new
45
+ end
46
+
47
+ private
48
+
49
+ # `render_as:` (values :structured/:text) was renamed to `present_as:` (:structured/:message)
50
+ # to unify the knob with axn-mcp's `present_as` (see DEPRECATIONS.md). Pre-1.0, so a leftover
51
+ # `render_as:` is a hard error with a pointer, not a silent shim (an ignored kwarg would quietly
52
+ # revert a caller to :structured). `one_of:` on the setting only guards the config-set path, so
53
+ # validate the `present_as` kwarg here too, pointing render_as's old `:text` value at its rename.
54
+ def validate_present_as_kwargs!(present_as, render_as)
55
+ unless render_as.equal?(NOT_SET)
56
+ raise ArgumentError,
57
+ "`render_as:` was renamed to `present_as:` and its `:text` value to `:message` " \
58
+ "(e.g. `Axn::RubyLLM.wrap(..., present_as: :message)`)."
59
+ end
60
+
61
+ return if present_as.nil? || %i[structured message].include?(present_as)
62
+
63
+ hint = present_as == :text ? " (the `:text` value was renamed to `:message`)" : ""
64
+ raise ArgumentError, "present_as must be one of :structured, :message; got #{present_as.inspect}#{hint}"
65
+ end
66
+
67
+ def build_tool_class(axn_class, halt_after:, provider_params:, present_as:, reject_opaque:, ambient_context:)
68
+ # Core's canonical, provider-safe tool_name (PRO-2921): strips configured leading prefixes,
69
+ # snake_cases with single underscores, restricts to [a-z0-9_], and is never blank (anonymous
70
+ # -> "tool"). Pass the `:ruby_llm` adapter key so a per-adapter `tool ruby_llm: { name: }`
71
+ # override wins -- this is the SAME name `Axn::Tools.for(:ruby_llm)` keys membership,
72
+ # version-collapsing, and sort order on (registry.rb), so `.tools` publishes the exact name
73
+ # the registry selected; the zero-arg form would ignore the override and advertise a
74
+ # different name, so provider tool calls / forced choices on the declared name wouldn't
75
+ # match. Absent an override it's identical to the zero-arg name (Axn::MCP.wrap passes `:mcp`
76
+ # the same way -- the author-once point).
77
+ tool_name = axn_class.tool_name(:ruby_llm)
78
+ input_schema = normalize_nullable_types(axn_class.input_schema)
79
+
80
+ Class.new(::RubyLLM::Tool) do
81
+ description(axn_class.description) if axn_class.description
82
+ params(input_schema)
83
+ with_params(**provider_params) if provider_params.any?
84
+
85
+ define_method(:name) { tool_name }
86
+
87
+ define_method(:execute) do |**args|
88
+ # Run the Axn through axn core's tool Invoker (PRO-2943): input types are coerced from the
89
+ # wire, undeclared args are rejected, and a model-supplied `ambient_context` is stripped
90
+ # (the injection guard) while the wrap's own trusted context is injected in its place.
91
+ # Contract violations settle user-facing, so `input_invalid?` lets us hand the model a
92
+ # clean, correctable "Invalid tool arguments" error instead of leaking a dev-facing bug
93
+ # (which also keeps a bad tool call from paging on_exception).
94
+ invoker = ::Axn::Tools::Invoker.new(user_facing_input_errors: true, reject_undeclared_inputs: true)
95
+ result = if ambient_context.equal?(NOT_SET)
96
+ invoker.call(axn_class, args)
97
+ else
98
+ invoker.call(axn_class, args, ambient_context:)
99
+ end
100
+
101
+ unless result.ok?
102
+ next({ error: "Invalid tool arguments: #{result.error}" }) if ::Axn::Tools::Invoker.input_invalid?(result)
103
+
104
+ next({ error: result.error })
105
+ end
106
+
107
+ # Uphold axn's non-bang "never raises" contract at the adapter boundary. The wrapped
108
+ # Axn's own `.call` (run via the Invoker above) never raises -- core catches action
109
+ # exceptions into a failed Result and pages on_exception itself -- but the TRANSPORT
110
+ # step that runs AFTER it (exposed-value serialization + JSON encoding) can raise
111
+ # outside core's executor: a value core can't render (two Hash keys colliding on one
112
+ # JSON property, a non-finite Float, non-UTF-8 bytes, an opaque value under
113
+ # reject_opaque), a structure past the JSON encoder's max_nesting, or a gem bug.
114
+ # RubyLLM has no rescue around a tool's #execute, so any of these would escape and
115
+ # break the whole chat. Scope the guard to JUST that mapping step (NOT the Invoker call,
116
+ # which already handles + reports its own exceptions -- double-guarding would
117
+ # double-report on_exception): report through axn's global on_exception for
118
+ # observability, then -- honoring core's best_effort_raises_in_dev so a real bug
119
+ # surfaces loudly rather than being masked -- re-raise in dev, otherwise return a tool
120
+ # error so #execute ALWAYS yields a value. Shaped to drop into the planned shared
121
+ # Axn::Tools::Serialization.guard (PRO-2996 §2b) with no behavior change.
122
+ begin
123
+ # RubyLLM::Chat#handle_tool_calls only treats a Content/Content::Raw return as-is; any
124
+ # other object (including a plain Hash) gets `#to_s`'d before being sent to the
125
+ # provider -- which for a Hash produces Ruby's inspect syntax (`{"k"=>"v"}`), not
126
+ # JSON. Serialize structured payloads ourselves so the wire form is always valid JSON.
127
+ payload = if present_as == :message
128
+ result.message
129
+ else
130
+ Axn::Extensions::Serialization.render(result, reject_opaque:).to_json
131
+ end
132
+ halt_after ? halt(payload) : payload
133
+ rescue StandardError => e
134
+ # Report through on_exception for observability -- but the reporter is app-configured
135
+ # and CAN raise (a buggy hook, or one assuming `action` is a settled instance). Core
136
+ # normally invokes on_exception INSIDE its own best_effort; we call it directly, so a
137
+ # raising reporter would escape and defeat this guard's never-raises intent (aborting
138
+ # chat.ask in production). Wrap it in best_effort ourselves -- it swallows + warn-logs
139
+ # (and reraises in dev per best_effort_raises_in_dev), same as core.
140
+ Axn::Extensions.best_effort("reporting a tool serialization failure via on_exception") do
141
+ Axn.config.on_exception(e, action: axn_class, context: { source: "Axn::RubyLLM" })
142
+ end
143
+ raise if Axn::Extensions.raises_in_dev?
144
+
145
+ { error: ADAPTER_FAILURE_MESSAGE }
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ # axn reflects a nullable/optional field as a JSON Schema array-valued `type`
152
+ # (e.g. `["integer", "null"]`). That's valid JSON Schema and OpenAI/Anthropic consume it
153
+ # fine, but RubyLLM's Gemini converter only recognizes anyOf-form nullability: it does
154
+ # `param_type_for_gemini(type)` with `type.to_s.downcase`, so an array `type` matches no
155
+ # case and falls through to STRING -- silently dropping both the declared type and the
156
+ # nullability. Rewrite every array-valued `type` into the equivalent `anyOf: [{type: ...}]`,
157
+ # which Gemini's `normalize_any_of_schema` collapses back to the real type + nullable, and
158
+ # which the other providers accept unchanged. Purely a wire-shape change: the admitted value
159
+ # set is identical, and the adapter's own validator (json_types_for) already reads anyOf.
160
+ #
161
+ # Builds new Hashes/Arrays throughout rather than mutating -- axn may hand back a memoized
162
+ # input_schema, and mutating it would corrupt every other reader.
163
+ def normalize_nullable_types(node)
164
+ case node
165
+ when Hash
166
+ rebuilt = node.to_h { |key, value| [key, normalize_nullable_types(value)] }
167
+ if rebuilt[:type].is_a?(Array)
168
+ types = rebuilt.delete(:type)
169
+ rebuilt[:anyOf] = types.map { |type| { type: } }
170
+ end
171
+ rebuilt
172
+ when Array
173
+ node.map { |value| normalize_nullable_types(value) }
174
+ else
175
+ node
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ class << self
182
+ def wrap(...)
183
+ ToolAdapter.wrap(...)
184
+ end
185
+
186
+ # Every Axn registered as a :ruby_llm tool -- via `tool`/`tool :ruby_llm`, residency under one of
187
+ # the configured `tool_roots`, or a `configure(:ruby_llm)` bag (see Axn::Tools::Registry#member?)
188
+ # -- each already wrapped as a ::RubyLLM::Tool, so a consumer builds its whole chat tool list in
189
+ # one call: `chat.with_tools(*Axn::RubyLLM.tools)`. Mirrors the shared GemName.tools contract
190
+ # with Axn::MCP.tools; the same Axn class resolves to the same tool_name across both surfaces.
191
+ def tools
192
+ Axn::Tools.for(:ruby_llm).map { |axn| wrap(axn) }
193
+ end
194
+ end
195
+ end
196
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Axn
4
4
  module RubyLLM
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/axn/ruby_llm.rb CHANGED
@@ -1,30 +1,82 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "delegate"
3
4
  require "ruby_llm"
4
5
  require "axn"
5
6
 
6
7
  require_relative "ruby_llm/version"
7
- require_relative "ruby_llm/configuration"
8
8
  require_relative "ruby_llm/ask"
9
9
 
10
10
  module Axn
11
11
  module RubyLLM
12
12
  include Axn::Mountable
13
+ extend Axn::Configurable
14
+ extend Axn::Tools::AdapterRoots
15
+
16
+ setting :default_model, default: "gpt-4o-mini"
17
+ setting :enabled, default: true
18
+ setting :error_headline, default: "LLM request failed"
19
+
20
+ # `Axn::Tools::AdapterRoots` (extended above) declares `tool_roots` with `default: []`; re-declare
21
+ # it (core's `setting` is last-wins) to ship the shared agent-tools dir as the default, so any Axn
22
+ # living under `app/agent_tools` is exposed as a `:ruby_llm` tool out of the box. It's the same dir
23
+ # axn-mcp defaults to, so one Axn there is authored once and surfaces on both. The re-declaration
24
+ # keeps AdapterRoots' broad-path validation (no widening a root to `app/`/`actions`/`.`/`..`).
25
+ setting :tool_roots, default: ["agent_tools"], validate: ->(value) { Axn::Tools::AdapterRoots.validate!(value) }
26
+
27
+ # Register this module as the `:ruby_llm` adapter AND its config source (PRO-2948): the registry
28
+ # reads `Axn::RubyLLM.config.tool_roots` off the source to grant directory-based membership.
29
+ Axn::Tools.register_adapter(:ruby_llm, self)
13
30
 
14
31
  mount_axn :ask, Ask
15
32
 
33
+ # Backward-compatible view of `config` returned by the deprecated `configuration` alias. The
34
+ # pre-DSL `Configuration#enabled?` invoked a callable gate (`enabled = -> { ... }`); the
35
+ # DSL-generated `config.enabled?` returns an assigned Proc as-is (always truthy). Delegate
36
+ # everything to `config`, but restore the callable-resolving `enabled?` (via the module-level
37
+ # `enabled?`) so a compatibility caller's production gate still resolves correctly during the
38
+ # deprecation window instead of silently reading as enabled. Removed with the alias in 0.3.0.
39
+ class DeprecatedConfigProxy < SimpleDelegator
40
+ def enabled? = Axn::RubyLLM.enabled?
41
+ end
42
+
16
43
  class << self
17
- def configuration
18
- @configuration ||= Configuration.new
44
+ # `enabled` accepts a Boolean OR a callable — the documented production-gating idiom is
45
+ # `c.enabled = -> { Rails.env.production? }`. axn's Configurable used to invoke an assigned
46
+ # callable on read via `callable: true`; that kwarg was removed upstream (PRO-3017) and an
47
+ # assigned Proc is now returned as-is, so resolve it here. Without this the DSL-generated
48
+ # `config.enabled?` is `!!some_proc` — always true — and production gating dies silently.
49
+ # This (`Axn::RubyLLM.enabled?`), NOT `config.enabled?`, is the supported reader.
50
+ def enabled?
51
+ value = config.enabled
52
+ value.respond_to?(:call) ? !!value.call : !!value
19
53
  end
20
54
 
21
- def configure
22
- yield configuration
55
+ # DEPRECATED backward-compatible aliases for the pre-DSL API. The
56
+ # Axn::Configurable DSL standardizes on `.config` / `reset_config!`.
57
+ # These keep older callers working but emit a deprecation warning and
58
+ # are scheduled for removal in the next minor version (see DEPRECATIONS.md).
59
+ def configuration
60
+ _warn_deprecated_alias("Axn::RubyLLM.configuration", "Axn::RubyLLM.config")
61
+ DeprecatedConfigProxy.new(config)
23
62
  end
24
63
 
25
64
  def reset_configuration!
26
- @configuration = nil
65
+ _warn_deprecated_alias("Axn::RubyLLM.reset_configuration!", "Axn::RubyLLM.reset_config!")
66
+ reset_config!
67
+ end
68
+
69
+ private
70
+
71
+ def _warn_deprecated_alias(old, new)
72
+ warn(
73
+ "[axn-ruby_llm] DEPRECATION: #{old} is deprecated and will be removed in the next minor version; use #{new} instead.",
74
+ category: :deprecated,
75
+ uplevel: 2,
76
+ )
27
77
  end
28
78
  end
29
79
  end
30
80
  end
81
+
82
+ require_relative "ruby_llm/tool_adapter"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axn-ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0.pre.alpha.4.3
18
+ version: 0.1.0.pre.alpha.5
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: 0.2.0
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: 0.1.0.pre.alpha.4.3
28
+ version: 0.1.0.pre.alpha.5
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: 0.2.0
@@ -60,12 +60,11 @@ files:
60
60
  - CHANGELOG.md
61
61
  - LICENSE
62
62
  - README.md
63
- - Rakefile
64
63
  - lib/axn-ruby_llm.rb
65
64
  - lib/axn/ruby_llm.rb
66
65
  - lib/axn/ruby_llm/ask.rb
67
- - lib/axn/ruby_llm/configuration.rb
68
66
  - lib/axn/ruby_llm/rspec.rb
67
+ - lib/axn/ruby_llm/tool_adapter.rb
69
68
  - lib/axn/ruby_llm/version.rb
70
69
  homepage: https://github.com/teamshares/axn-ruby_llm
71
70
  licenses:
data/Rakefile DELETED
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
5
- require "rubocop/rake_task"
6
-
7
- RSpec::Core::RakeTask.new(:spec)
8
-
9
- RuboCop::RakeTask.new
10
-
11
- task default: %i[spec rubocop]
12
-
13
- Rake::Task["build"].enhance([:default])
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Axn
4
- module RubyLLM
5
- class Configuration
6
- DEFAULT_MODEL = "gpt-4o-mini"
7
-
8
- attr_accessor :default_model, :enabled
9
-
10
- def initialize
11
- @default_model = DEFAULT_MODEL
12
- @enabled = true
13
- end
14
-
15
- def enabled?
16
- enabled.respond_to?(:call) ? !!enabled.call : !!enabled
17
- end
18
- end
19
- end
20
- end