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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +200 -13
- data/lib/axn/ruby_llm/ask.rb +140 -29
- data/lib/axn/ruby_llm/rspec.rb +26 -12
- data/lib/axn/ruby_llm/tool_adapter.rb +196 -0
- data/lib/axn/ruby_llm/version.rb +1 -1
- data/lib/axn/ruby_llm.rb +58 -6
- metadata +4 -5
- data/Rakefile +0 -13
- data/lib/axn/ruby_llm/configuration.rb +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c33a22550fbc081c47ad09e370ce05aa400640cfe70f2e06488d93b1507a42a6
|
|
4
|
+
data.tar.gz: 1d65bcd8eeee475aee68c7a92e380601f8c3e333629fe6664609c0e1174e2f64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
- `
|
|
116
|
-
-
|
|
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(
|
|
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 `
|
|
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).
|
|
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"`.
|
data/lib/axn/ruby_llm/ask.rb
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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:
|
|
50
|
-
output_tokens:
|
|
51
|
-
cache_read_tokens:
|
|
52
|
-
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:
|
|
60
|
-
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:
|
|
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.
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
data/lib/axn/ruby_llm/rspec.rb
CHANGED
|
@@ -6,19 +6,28 @@ module Axn
|
|
|
6
6
|
module RubyLLM
|
|
7
7
|
module RSpec
|
|
8
8
|
module Helpers
|
|
9
|
-
|
|
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(
|
|
13
|
-
# stub_axn_ruby_llm(
|
|
14
|
-
# stub_axn_ruby_llm(
|
|
15
|
-
# stub_axn_ruby_llm(
|
|
16
|
-
# stub_axn_ruby_llm(
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
data/lib/axn/ruby_llm/version.rb
CHANGED
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|