axn-ruby_llm 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +4 -2
- data/lib/axn/ruby_llm/ask.rb +8 -16
- data/lib/axn/ruby_llm/tool_adapter.rb +213 -30
- data/lib/axn/ruby_llm/version.rb +1 -1
- data/lib/axn/ruby_llm.rb +8 -5
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 567165990e6185d68aab836344f8aaf5f1b2891c0c6fd19442aab5908c1fa335
|
|
4
|
+
data.tar.gz: 326731c8da66d1ac1ca92bcc5bf8b4c43132aaa42580f10ba97d7d98218911ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b0ba66b042f93f747523524bbf2eb33c00f8a2ad2d21f81525c2429c211896512aa96cfd2e749aec9682de68497b7d6a27e609e5bb0b2a4e5b0aeec6385a78e
|
|
7
|
+
data.tar.gz: d2d928acbe79f0c3c25757d0db23135cfa5abaad6e3c5b4d5602f2074a70ce8a6331a6583c296e96c7191b151f783448b646b6091d0dc6288ebba88a7ef158e6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-09-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **[FEAT] Every tool call is now stamped `invoked_via: :ruby_llm`** (PRO-3332), via
|
|
8
|
+
`Axn::Tools::Invoker.new(adapter: :ruby_llm)`. No adapter-side work required — the stamp applies to
|
|
9
|
+
the wrapped Axn and any nested sub-axn or enqueued Sidekiq job for the life of the call tree, so a
|
|
10
|
+
Datadog dashboard (or any `Axn.config.on_exception`/tracing consumer reading the resolved
|
|
11
|
+
`invoked_via` dimension) can separate tool-driven traffic from an ordinary direct `.call`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **`record_otel_attributes!` now uses `Axn::Extensions::Tracing.annotate_span`** instead of the
|
|
16
|
+
unreliable ambient `OpenTelemetry::Trace.current_span` lookup, which could disagree with the span
|
|
17
|
+
axn's own tracer actually opened (PRO-3278) and silently drop every `gen_ai.*` attribute. Requires
|
|
18
|
+
axn `>= 0.1.0-alpha.6`.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **[BUGFIX] `reject_opaque_exposed_values` is now resolved per tool call instead of once at wrap
|
|
23
|
+
time.** The setting is `overridable:`, so a per-tool `configure(:ruby_llm) { |c| ... }` /
|
|
24
|
+
`tool ruby_llm: { ... }` bag or a gem-wide `Axn::RubyLLM.configure` assignment is meant to be what
|
|
25
|
+
a tool honors — but `wrap` resolved the value eagerly and the built tool class closed over that
|
|
26
|
+
Boolean forever. Since the normal way to build tools is once at boot (`chat.with_tools(*Axn::RubyLLM.tools)`),
|
|
27
|
+
any change to the setting *after* that point silently did nothing to a live tool: the wrapped class
|
|
28
|
+
kept whatever the flag happened to resolve to at wrap time, with no warning and no way to tell from
|
|
29
|
+
the outside. Resolution now happens inside `#execute`, at the moment the result is rendered, so a
|
|
30
|
+
tool always reflects the currently-configured value — the same per-call semantics `axn-mcp` already
|
|
31
|
+
had. **Old vs new:** a tool wrapped while the flag was `false` and later switched to `true` used to
|
|
32
|
+
keep shipping opaque renderings; it now fails those calls with the generic tool error, as configured.
|
|
33
|
+
Only the *timing* changed — the resolution order (per-class override, then gem-wide config, then the
|
|
34
|
+
`false` default) is unchanged, so a setup that configures before wrapping (the overwhelmingly common
|
|
35
|
+
case, and every documented example) behaves exactly as before.
|
|
36
|
+
|
|
37
|
+
- **A map's `additionalProperties` (and a Hash's `minProperties`/`maxProperties`) are no longer silently
|
|
38
|
+
lost at Gemini.** RubyLLM's Gemini converter rebuilds each property from a fixed whitelist that omits all
|
|
39
|
+
three, so `expects :scores, type: Hash, of: { keys: String, values: Integer }` reached the model as an empty
|
|
40
|
+
`{type: OBJECT}` — with no error raised, leaving the model to guess the value type and the call to be rejected
|
|
41
|
+
at runtime instead (PRO-3172). The adapter now restates these constraints as prose in the same node's
|
|
42
|
+
`description`, which Gemini does forward, appending after any `description:` you supplied; a structured value
|
|
43
|
+
type carries its compact JSON Schema too. The enforceable keys are still advertised unchanged, so OpenAI and
|
|
44
|
+
Anthropic are unaffected apart from the redundant sentence.
|
|
45
|
+
|
|
46
|
+
- **The transport-failure guard now logs an operator hint when `reject_opaque_exposed_values` may be the
|
|
47
|
+
cause.** The tool-facing error stays generic (`"The tool could not produce a valid response"`), but the
|
|
48
|
+
logged line now names the offending tool and both places the setting could be set
|
|
49
|
+
(`configure(:ruby_llm)` / `Axn::RubyLLM.config.reject_opaque_exposed_values`) whenever the resolved
|
|
50
|
+
value is `true` — matching `axn-openapi`'s dispatcher hint and `axn-mcp`'s guard. Previously an operator
|
|
51
|
+
had to guess which knob caused a rejection since the setting is per-tool overridable.
|
|
52
|
+
|
|
53
|
+
### Internal
|
|
54
|
+
|
|
55
|
+
- **[INTERNAL] Adopted axn's `Axn::Tools::AdapterSerialization` mixin (PRO-2996)** in place of this
|
|
56
|
+
gem's hand-rolled copies of the same three things. `Axn::RubyLLM` now `extend`s the mixin alongside
|
|
57
|
+
`Axn::Tools::AdapterRoots` and uses `declare_reject_opaque_exposed_values! default: false` for the
|
|
58
|
+
setting, `Axn::RubyLLM.serialize_exposed(result)` for the render (which resolves the per-tool flag
|
|
59
|
+
itself — see the Fixed entry above), and `Axn::RubyLLM.guard_tool_response(axn_class, on_error:)`
|
|
60
|
+
for the transport-mapping guard. The default `tool_roots` moved to `tool_roots_default %w[agent_tools]`,
|
|
61
|
+
which drops this gem's hand-copied `AdapterRoots.validate!` lambda and validates the default eagerly
|
|
62
|
+
at gem load rather than at the registry's first read. No public API, config name, default, or
|
|
63
|
+
user-facing string changed. Two second-order effects worth knowing: the mixin's guard runs its
|
|
64
|
+
dev-mode re-raise *before* reporting (so under `best_effort_raises_in_dev` a mapping failure now
|
|
65
|
+
raises without first emitting the `reject_opaque_exposed_values` log hint — production behavior is
|
|
66
|
+
unchanged), and it rescues `SystemStackError`/`ScriptError` in addition to `StandardError`, so a
|
|
67
|
+
runaway `as_json`/`to_h` on an exposed value now becomes a tool error rather than escaping into the
|
|
68
|
+
chat.
|
|
69
|
+
|
|
70
|
+
### Requires
|
|
71
|
+
|
|
72
|
+
- **axn `>= 0.1.0-alpha.6`** (released), for `Axn::Tools::AdapterSerialization` (PRO-2996) and
|
|
73
|
+
`Axn::Extensions::Tracing.annotate_span` (PRO-3278). No Gemfile override needed — resolves from
|
|
74
|
+
RubyGems.
|
|
75
|
+
|
|
3
76
|
## [0.2.0] - 2026-08-05
|
|
4
77
|
|
|
5
78
|
> **Upgrade notes (behavior changes):**
|
data/README.md
CHANGED
|
@@ -157,9 +157,9 @@ result.response # => "Created widget Sprocket (id: 42)."
|
|
|
157
157
|
|
|
158
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
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.
|
|
160
|
+
The tool's name, description, and JSON Schema parameters come straight from the Axn's own contract — the same `description`/`expects`/`exposes` you'd write for any Axn — so a minimal class just works (the tool name defaults from the class name: `CreateWidget` → `create_widget`). Arguments the model supplies are run through axn core's tool `Invoker`: wire types are coerced, and any contract violation — a missing required field, an out-of-schema argument, a wrong type, or a value outside an `inclusion` set (validated at **full depth**, not just the top-level type) — comes back to the model as a clean, correctable `{ error: "Invalid tool arguments: <reason>" }`, and does **not** page `on_exception` as though it were a bug. A model-supplied `ambient_context` is stripped before the Axn runs, so a prompt-injected context can never override the caller's — the wrap's own `ambient_context:` (below) is injected instead. The `Invoker` also stamps every call (including any nested sub-Axn) with the `invoked_via: :ruby_llm` dimension, so a Datadog dashboard can query tool-driven traffic separately from ordinary direct `.call`s — see [OpenTelemetry](#opentelemetry) below.
|
|
161
161
|
|
|
162
|
-
On success, `execute` returns the exposed values (via `Axn::
|
|
162
|
+
On success, `execute` returns the exposed values (via `Axn::RubyLLM.serialize_exposed`, honoring `reject_opaque_exposed_values` — see below) as a JSON **string**, not a Hash — `RubyLLM::Chat#handle_tool_calls` only passes a `Content`/`Content::Raw` return through as-is, and otherwise sends `tool_payload.to_s`, which for a Hash produces Ruby's inspect syntax rather than JSON; on failure, `{ error: result.error }`. The same `CreateWidget` class can be wrapped for other transports (e.g. `Axn::MCP.wrap`) with no changes — the contract is declared once.
|
|
163
163
|
|
|
164
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
165
|
|
|
@@ -288,6 +288,7 @@ The advertised tool schema is axn's reflected `input_schema`. A few things worth
|
|
|
288
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
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
290
|
- **Enums are advertised — use the top-level `inclusion:` key.** `expects :color, type: String, inclusion: %w[red green blue]` (a bare Array, or the long form `inclusion: { in: %w[red green blue] }`) reflects to `{ type: "string", enum: ["red", "green", "blue"] }`, so the model is told the allowed set (an `optional:` field keeps `null` in the enum; a dynamic `in: -> { ... }` is correctly skipped rather than guessed). Note this is the **top-level `inclusion:` option**, not `validate: { inclusion: ... }` — `validate:` is axn's custom-callable hook (it needs `with:`), so that spelling neither enforces nor reflects the set.
|
|
291
|
+
- **Hash maps and entry counts** — handled. A map (`expects :scores, type: Hash, of: { keys: String, values: Integer }`) reflects to `additionalProperties`, and a Hash's entry-count bounds to `minProperties`/`maxProperties`. Gemini's converter carries none of the three, so a map would otherwise arrive as an empty `{type: OBJECT}` — no error, but the model never learns what the values must be, so its guess is rejected at runtime instead. The adapter restates the constraint in that node's `description` (“An object mapping arbitrary keys to integer values. This object must not be empty.”), which Gemini does forward; a structured value type carries its compact JSON Schema alongside. The real keys are left in place for OpenAI/Anthropic, which enforce them, so the prose is redundant there rather than load-bearing. Your own `description:` is kept and the generated sentences appended after it. No action needed on your part.
|
|
291
292
|
- **Conditional expectations** (`expects :token, if: :use_token`) reflect to a JSON Schema `allOf`/`if`/`then` clause. Gemini's converter ignores it — the field degrades to plain-optional (safe: a valid call is never wrongly rejected, but the conditional isn't conveyed to the model). This gem doesn't set OpenAI's `strict` mode, so OpenAI tolerates `allOf` by default; if you opt into strict via `provider_params`, OpenAI will reject `allOf`. Either way, encoding the rule in `description:` is the portable option.
|
|
292
293
|
|
|
293
294
|
## Testing
|
|
@@ -326,6 +327,7 @@ If your app uses OpenTelemetry, `axn` already wraps every action in an `axn.call
|
|
|
326
327
|
| `gen_ai.usage.output_tokens` | Completion token count |
|
|
327
328
|
| `gen_ai.usage.cost` | USD total (non-standard; useful for spend filtering) |
|
|
328
329
|
| `axn.ruby_llm.stubbed` | `true` when production gating returned a stub |
|
|
330
|
+
| `axn.dimension.invoked_via` | `"ruby_llm"` — set by axn core on every tool call (including nested sub-Axns), not by this gem; lets you separate tool-driven traffic from ordinary direct `.call`s in the same span schema |
|
|
329
331
|
|
|
330
332
|
For LLM-level tracing (individual `RubyLLM.chat` calls, tool calls, embeddings, prompt content), add [`opentelemetry-instrumentation-ruby_llm`](https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm) to your own Gemfile and configure it per its README. It is not a dependency of this gem.
|
|
331
333
|
|
data/lib/axn/ruby_llm/ask.rb
CHANGED
|
@@ -237,22 +237,14 @@ module Axn
|
|
|
237
237
|
end
|
|
238
238
|
|
|
239
239
|
def record_otel_attributes!(input_tokens:, output_tokens:, cost:, response_model:, stubbed:)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
240
|
+
Axn::Extensions::Tracing.annotate_span(
|
|
241
|
+
"gen_ai.request.model" => resolved_model,
|
|
242
|
+
"gen_ai.response.model" => response_model,
|
|
243
|
+
"gen_ai.usage.input_tokens" => input_tokens,
|
|
244
|
+
"gen_ai.usage.output_tokens" => output_tokens,
|
|
245
|
+
"gen_ai.usage.cost" => cost,
|
|
246
|
+
"axn.ruby_llm.stubbed" => stubbed,
|
|
247
|
+
)
|
|
256
248
|
end
|
|
257
249
|
end
|
|
258
250
|
end
|
|
@@ -13,7 +13,13 @@ module Axn
|
|
|
13
13
|
setting :halt_after, default: false, overridable: true
|
|
14
14
|
setting :provider_params, default: {}, overridable: true
|
|
15
15
|
setting :present_as, default: :structured, one_of: %i[structured message], overridable: true
|
|
16
|
-
|
|
16
|
+
# `Axn::Tools::AdapterSerialization` (extended onto Axn::RubyLLM in ruby_llm.rb, which is required
|
|
17
|
+
# before this file reopens the module) owns this setting's declaration so the three adapters can't
|
|
18
|
+
# drift on it. `default:` is a required kwarg with no core-picked value on purpose: an LLM-facing
|
|
19
|
+
# adapter is better off shipping an ugly-but-honest rendering than failing the whole tool call, so
|
|
20
|
+
# ruby_llm (like axn-mcp) declares `false`, where axn-openapi's published output contract declares
|
|
21
|
+
# `true`. Must follow `config_namespace` above -- it's an `overridable:` setting.
|
|
22
|
+
declare_reject_opaque_exposed_values! default: false
|
|
17
23
|
|
|
18
24
|
# Wraps any Axn as a ::RubyLLM::Tool: schema, name, and description are read straight off the
|
|
19
25
|
# Axn's own declared contract (`input_schema` / `resolved_axn_name` / `description`, from axn's
|
|
@@ -37,7 +43,6 @@ module Axn
|
|
|
37
43
|
halt_after: halt_after.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :halt_after) : halt_after,
|
|
38
44
|
provider_params: provider_params.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :provider_params) : provider_params,
|
|
39
45
|
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
46
|
ambient_context:,
|
|
42
47
|
)
|
|
43
48
|
|
|
@@ -64,7 +69,39 @@ module Axn
|
|
|
64
69
|
raise ArgumentError, "present_as must be one of :structured, :message; got #{present_as.inspect}#{hint}"
|
|
65
70
|
end
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
# `guard_tool_response`'s `on_error`: the transport-native error response, plus the operator's
|
|
73
|
+
# only pointer to WHY (the tool-facing text stays generic -- see ADAPTER_FAILURE_MESSAGE).
|
|
74
|
+
# Mirrors axn-openapi's dispatcher hint / axn-mcp's Invocation guard: the config pointer lives
|
|
75
|
+
# HERE rather than in core's exception message, since core raises the same error for adapters
|
|
76
|
+
# with no such setting. Named as BOTH config levels, never just the gem-wide setter -- the
|
|
77
|
+
# value is resolved per-tool, so a `configure(:ruby_llm)` override beats `config`, and core
|
|
78
|
+
# exposes no way to ask which level supplied a resolved value. Non-committal ("if this is")
|
|
79
|
+
# because reject_opaque_exposed_values being on doesn't mean THIS failure is an opaque
|
|
80
|
+
# rejection -- it could equally be a colliding key, a non-finite Float, or a gem bug.
|
|
81
|
+
#
|
|
82
|
+
# The whole hint is built and logged INSIDE a best_effort: `axn_class` is caller code, and
|
|
83
|
+
# interpolating it (a hostile/buggy #to_s) must not raise out of `on_error` -- `guard_tool_response`
|
|
84
|
+
# reports and re-raises an on_error failure rather than substituting a response, so a raise
|
|
85
|
+
# here would cost the tool its error response entirely. Deliberately a SEPARATE best_effort
|
|
86
|
+
# from the guard's own on_exception report: a broken configured logger must not suppress that
|
|
87
|
+
# report, and a broken reporter must not suppress this diagnostic line -- each is the guard's
|
|
88
|
+
# only surviving signal when the OTHER one is what's broken.
|
|
89
|
+
def serialization_failure_response(axn_class, error)
|
|
90
|
+
Axn::Extensions.best_effort("logging a tool serialization failure hint") do
|
|
91
|
+
hint = if Axn::RubyLLM.resolve_override_for(axn_class, :reject_opaque_exposed_values)
|
|
92
|
+
" (if this is an opaque-value rejection: reject_opaque_exposed_values resolved true for " \
|
|
93
|
+
"#{axn_class} — unset it on the action via `configure(:ruby_llm)`, or gem-wide via " \
|
|
94
|
+
"`Axn::RubyLLM.config.reject_opaque_exposed_values = false`, whichever is set)"
|
|
95
|
+
else
|
|
96
|
+
""
|
|
97
|
+
end
|
|
98
|
+
Axn.config.logger.error { "[axn-ruby_llm] failed to serialize successful result: #{error.class}: #{error.message}#{hint}" }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
{ error: ADAPTER_FAILURE_MESSAGE }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_tool_class(axn_class, halt_after:, provider_params:, present_as:, ambient_context:)
|
|
68
105
|
# Core's canonical, provider-safe tool_name (PRO-2921): strips configured leading prefixes,
|
|
69
106
|
# snake_cases with single underscores, restricts to [a-z0-9_], and is never blank (anonymous
|
|
70
107
|
# -> "tool"). Pass the `:ruby_llm` adapter key so a per-adapter `tool ruby_llm: { name: }`
|
|
@@ -75,7 +112,11 @@ module Axn
|
|
|
75
112
|
# match. Absent an override it's identical to the zero-arg name (Axn::MCP.wrap passes `:mcp`
|
|
76
113
|
# the same way -- the author-once point).
|
|
77
114
|
tool_name = axn_class.tool_name(:ruby_llm)
|
|
78
|
-
input_schema = normalize_nullable_types(axn_class.input_schema)
|
|
115
|
+
input_schema = normalize_nullable_types(annotate_object_constraints(axn_class.input_schema))
|
|
116
|
+
# Built HERE, not inside `define_method(:execute)`: `self` in the executed block is the
|
|
117
|
+
# ::RubyLLM::Tool instance, which has no access to this module's private helpers. Closing
|
|
118
|
+
# over the lambda from build_tool_class's scope binds it to ToolAdapter instead.
|
|
119
|
+
on_serialization_failure = ->(e) { serialization_failure_response(axn_class, e) }
|
|
79
120
|
|
|
80
121
|
Class.new(::RubyLLM::Tool) do
|
|
81
122
|
description(axn_class.description) if axn_class.description
|
|
@@ -90,8 +131,10 @@ module Axn
|
|
|
90
131
|
# (the injection guard) while the wrap's own trusted context is injected in its place.
|
|
91
132
|
# Contract violations settle user-facing, so `input_invalid?` lets us hand the model a
|
|
92
133
|
# 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
|
-
|
|
134
|
+
# (which also keeps a bad tool call from paging on_exception). `adapter: :ruby_llm`
|
|
135
|
+
# (PRO-3332) stamps the invoked_via dimension around the call, so a Datadog dashboard can
|
|
136
|
+
# separate tool-driven traffic from ordinary direct `.call`s with no per-call work here.
|
|
137
|
+
invoker = ::Axn::Tools::Invoker.new(adapter: :ruby_llm, user_facing_input_errors: true, reject_undeclared_inputs: true)
|
|
95
138
|
result = if ambient_context.equal?(NOT_SET)
|
|
96
139
|
invoker.call(axn_class, args)
|
|
97
140
|
else
|
|
@@ -110,39 +153,33 @@ module Axn
|
|
|
110
153
|
# step that runs AFTER it (exposed-value serialization + JSON encoding) can raise
|
|
111
154
|
# outside core's executor: a value core can't render (two Hash keys colliding on one
|
|
112
155
|
# JSON property, a non-finite Float, non-UTF-8 bytes, an opaque value under
|
|
113
|
-
#
|
|
114
|
-
# RubyLLM has no rescue around a tool's #execute, so any of these would escape
|
|
115
|
-
# break the whole chat.
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
|
|
156
|
+
# reject_opaque_exposed_values), a structure past the JSON encoder's max_nesting, or a
|
|
157
|
+
# gem bug. RubyLLM has no rescue around a tool's #execute, so any of these would escape
|
|
158
|
+
# and break the whole chat. `guard_tool_response` (PRO-2996, from
|
|
159
|
+
# Axn::Tools::AdapterSerialization) is core's shared version of exactly that guard --
|
|
160
|
+
# report through the global on_exception inside a best_effort, re-raise when
|
|
161
|
+
# raises_in_dev? so a real bug surfaces loudly, else hand `on_error` the exception so
|
|
162
|
+
# this adapter builds its own transport-native error response. It is scoped to JUST the
|
|
163
|
+
# mapping step (NOT the Invoker call, which already handles + reports its own
|
|
164
|
+
# exceptions -- double-guarding would double-report on_exception), and the block's
|
|
165
|
+
# return value is #execute's.
|
|
166
|
+
Axn::RubyLLM.guard_tool_response(axn_class, on_error: on_serialization_failure) do
|
|
123
167
|
# RubyLLM::Chat#handle_tool_calls only treats a Content/Content::Raw return as-is; any
|
|
124
168
|
# other object (including a plain Hash) gets `#to_s`'d before being sent to the
|
|
125
169
|
# provider -- which for a Hash produces Ruby's inspect syntax (`{"k"=>"v"}`), not
|
|
126
170
|
# JSON. Serialize structured payloads ourselves so the wire form is always valid JSON.
|
|
171
|
+
#
|
|
172
|
+
# `serialize_exposed` (not `Serialization.render` directly) resolves
|
|
173
|
+
# reject_opaque_exposed_values PER CALL off the result's own action class, so a
|
|
174
|
+
# per-tool `configure(:ruby_llm)` override is honored and a config change reaches
|
|
175
|
+
# already-wrapped tools. `present_as` stays a wrap-time kwarg: it's adapter-owned, not
|
|
176
|
+
# part of the shared mixin, and `wrap` accepts it as an explicit override.
|
|
127
177
|
payload = if present_as == :message
|
|
128
178
|
result.message
|
|
129
179
|
else
|
|
130
|
-
Axn::
|
|
180
|
+
Axn::RubyLLM.serialize_exposed(result).to_json
|
|
131
181
|
end
|
|
132
182
|
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
183
|
end
|
|
147
184
|
end
|
|
148
185
|
end
|
|
@@ -175,6 +212,152 @@ module Axn
|
|
|
175
212
|
node
|
|
176
213
|
end
|
|
177
214
|
end
|
|
215
|
+
|
|
216
|
+
# PRO-3172. RubyLLM's Gemini converter rebuilds every property from a fixed whitelist
|
|
217
|
+
# (`convert_property`: description/enum/format/nullable/maximum/minimum/multipleOf, plus
|
|
218
|
+
# properties/required and, for an array, items/minItems/maxItems). Three keys axn emits for a
|
|
219
|
+
# Hash fall outside it: `additionalProperties` -- a map's value contract, from
|
|
220
|
+
# `of: { keys:, values: }` -- and `minProperties`/`maxProperties`, its entry-count bounds.
|
|
221
|
+
# All three are dropped with no error raised, so a map reaches Gemini as a bare
|
|
222
|
+
# `{type: OBJECT, properties: {}}`: the model never learns what the values must be, sends
|
|
223
|
+
# whatever it likes, and the Invoker rejects the call. The constraint degrades from
|
|
224
|
+
# schema-enforced to runtime-rejected, costing a wasted round trip plus a recovery the model
|
|
225
|
+
# has to work out for itself.
|
|
226
|
+
#
|
|
227
|
+
# Gemini's Schema proto has no equivalent to translate any of them to -- but `description` IS
|
|
228
|
+
# copied through, so restate them as prose there. Applied unconditionally rather than only for
|
|
229
|
+
# Gemini: the adapter has no provider to branch on (a wrapped tool class outlives the choice
|
|
230
|
+
# of chat), and on OpenAI/Anthropic -- which take `params_schema` verbatim and so still get
|
|
231
|
+
# the enforceable keys themselves -- the extra sentence is merely redundant, never wrong.
|
|
232
|
+
#
|
|
233
|
+
# Same non-mutation rule as normalize_nullable_types, for the same reason: axn may hand back
|
|
234
|
+
# a memoized input_schema, so build new Hashes/Arrays throughout.
|
|
235
|
+
def annotate_object_constraints(node)
|
|
236
|
+
case node
|
|
237
|
+
when Hash
|
|
238
|
+
rebuilt = node.to_h { |key, value| [key, annotate_object_constraints(value)] }
|
|
239
|
+
# Read the sentences off the ORIGINAL node, not `rebuilt`: map_sentence may dump the value
|
|
240
|
+
# subschema as JSON, and the original is the copy that has no generated prose in it yet.
|
|
241
|
+
# The JSON clause goes LAST: it ends in a brace rather than a period, so anything appended
|
|
242
|
+
# after it would read as a run-on (and a period placed right after `}` risks being read as
|
|
243
|
+
# part of the JSON itself).
|
|
244
|
+
return rebuilt unless object_node?(node)
|
|
245
|
+
|
|
246
|
+
sentences = [map_sentence(node), entry_count_sentence(node), value_schema_clause(node)].compact
|
|
247
|
+
return rebuilt if sentences.empty?
|
|
248
|
+
|
|
249
|
+
# merge (rather than assignment into a fresh Hash) so an author-supplied description keeps
|
|
250
|
+
# its original position in the node; the generated sentences follow the author's text.
|
|
251
|
+
rebuilt.merge(description: [node[:description], *sentences].compact.join(" "))
|
|
252
|
+
when Array
|
|
253
|
+
node.map { |value| annotate_object_constraints(value) }
|
|
254
|
+
else
|
|
255
|
+
node
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A map's value contract as prose. A bare type reads as a plain word -- "integer", "string or
|
|
260
|
+
# integer" -- which says everything the schema does; anything structured is named by its
|
|
261
|
+
# top-level type here and spelled out exactly by value_schema_clause below.
|
|
262
|
+
def map_sentence(node)
|
|
263
|
+
values = map_values(node)
|
|
264
|
+
return nil unless values
|
|
265
|
+
|
|
266
|
+
# `additionalProperties` governs only the keys `properties` does NOT match, so a map that
|
|
267
|
+
# also declares a `shape:` carries both on one node -- and Gemini keeps `properties`, which
|
|
268
|
+
# makes "arbitrary keys" actively wrong in that case.
|
|
269
|
+
lead = if node[:properties].is_a?(Hash) && node[:properties].any?
|
|
270
|
+
"Keys other than those listed map to"
|
|
271
|
+
else
|
|
272
|
+
"An object mapping arbitrary keys to"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
phrase = bare_type_phrase(values)
|
|
276
|
+
phrase ? "#{lead} #{phrase} values." : "#{lead} values."
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# A structured value type -- an array's `items`, a nested map, a constrained scalar -- would
|
|
280
|
+
# need hand-written English grammar to render as prose, which degrades fast with nesting
|
|
281
|
+
# depth, so carry it as compact JSON Schema instead: exact at any depth, and a form models
|
|
282
|
+
# read natively. Skipped when the type word alone already said everything.
|
|
283
|
+
def value_schema_clause(node)
|
|
284
|
+
values = map_values(node)
|
|
285
|
+
return nil if values.nil? || bare_type?(values)
|
|
286
|
+
|
|
287
|
+
"Each value must match this JSON Schema: #{JSON.generate(values)}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The recursion above walks every Hash in the schema, but not every Hash IS a schema node --
|
|
291
|
+
# `properties` is a name-to-schema map, so an Axn with a field named `additionalProperties` or
|
|
292
|
+
# `minProperties` puts a Hash (or an Integer) at exactly the key this pass reads. Without this
|
|
293
|
+
# gate, such a container was itself annotated, injecting a `description` key into `properties`
|
|
294
|
+
# and thereby advertising a phantom parameter named "description" -- which the model might then
|
|
295
|
+
# send and the Invoker would reject as undeclared. Requiring a declared object type also keeps
|
|
296
|
+
# object prose off a string/array node that carries these keys for any other reason.
|
|
297
|
+
def object_node?(node)
|
|
298
|
+
type = node[:type]
|
|
299
|
+
type == "object" || (type.is_a?(Array) && type.include?("object"))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A map's value schema, or nil if this node isn't a map. axn omits `additionalProperties`
|
|
303
|
+
# entirely rather than emitting an empty one, and never emits the boolean form.
|
|
304
|
+
def map_values(node)
|
|
305
|
+
values = node[:additionalProperties]
|
|
306
|
+
values if values.is_a?(Hash) && values.any?
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# True when a schema constrains nothing beyond the type itself -- exactly the case a type word
|
|
310
|
+
# conveys in full, with no JSON clause needed.
|
|
311
|
+
def bare_type?(schema)
|
|
312
|
+
case schema.keys
|
|
313
|
+
when [:type] then true
|
|
314
|
+
when [:anyOf] then schema[:anyOf].all? { |entry| entry.is_a?(Hash) && entry.keys == [:type] }
|
|
315
|
+
else false
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# "integer"; "integer or null" (axn's array-valued nullable type -- annotation runs BEFORE
|
|
320
|
+
# normalize_nullable_types rewrites it to anyOf); "string or integer" (a union's anyOf). nil
|
|
321
|
+
# when no type is declared at all, which sends the caller to the JSON-only phrasing.
|
|
322
|
+
def bare_type_phrase(schema)
|
|
323
|
+
types = if schema[:type].is_a?(String)
|
|
324
|
+
[schema[:type]]
|
|
325
|
+
elsif schema[:type].is_a?(Array)
|
|
326
|
+
schema[:type]
|
|
327
|
+
elsif schema[:anyOf].is_a?(Array)
|
|
328
|
+
schema[:anyOf].filter_map { |entry| entry[:type] if entry.is_a?(Hash) }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
return nil unless types.is_a?(Array) && types.any? && types.all?(String)
|
|
332
|
+
|
|
333
|
+
types.uniq.join(" or ")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# minProperties/maxProperties. Gemini forwards an ARRAY's minItems/maxItems but has no OBJECT
|
|
337
|
+
# equivalent, so an entry-count bound is lost whether or not the node is also a map -- a plain
|
|
338
|
+
# `expects :meta, type: Hash` already reflects `minProperties: 1` from axn's non-blank default.
|
|
339
|
+
def entry_count_sentence(node)
|
|
340
|
+
min = node[:minProperties]
|
|
341
|
+
max = node[:maxProperties]
|
|
342
|
+
# A zero minimum admits the empty object, i.e. constrains nothing -- reporting it as
|
|
343
|
+
# "must not be empty" below would state the opposite of what the schema allows.
|
|
344
|
+
min = nil unless min.is_a?(Integer) && min.positive?
|
|
345
|
+
max = nil unless max.is_a?(Integer)
|
|
346
|
+
return nil unless min || max
|
|
347
|
+
|
|
348
|
+
bound = if min && max && min == max then "exactly #{entry_count(min)}"
|
|
349
|
+
elsif min && max then "between #{min} and #{entry_count(max)}"
|
|
350
|
+
elsif max then "at most #{entry_count(max)}"
|
|
351
|
+
elsif min > 1 then "at least #{entry_count(min)}"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# A bare `minProperties: 1` is just non-emptiness, and reads far better said that way.
|
|
355
|
+
bound ? "This object must have #{bound}." : "This object must not be empty."
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def entry_count(count)
|
|
359
|
+
"#{count} #{count == 1 ? "entry" : "entries"}"
|
|
360
|
+
end
|
|
178
361
|
end
|
|
179
362
|
end
|
|
180
363
|
|
data/lib/axn/ruby_llm/version.rb
CHANGED
data/lib/axn/ruby_llm.rb
CHANGED
|
@@ -12,17 +12,20 @@ module Axn
|
|
|
12
12
|
include Axn::Mountable
|
|
13
13
|
extend Axn::Configurable
|
|
14
14
|
extend Axn::Tools::AdapterRoots
|
|
15
|
+
extend Axn::Tools::AdapterSerialization
|
|
15
16
|
|
|
16
17
|
setting :default_model, default: "gpt-4o-mini"
|
|
17
18
|
setting :enabled, default: true
|
|
18
19
|
setting :error_headline, default: "LLM request failed"
|
|
19
20
|
|
|
20
|
-
# `Axn::Tools::AdapterRoots` (extended above) declares `tool_roots` with
|
|
21
|
-
#
|
|
21
|
+
# `Axn::Tools::AdapterRoots` (extended above) declares `tool_roots` with core's conservative
|
|
22
|
+
# `default: []`; `tool_roots_default` re-declares it to ship the shared agent-tools dir, so any Axn
|
|
22
23
|
# 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.
|
|
24
|
-
# keeps AdapterRoots' broad-path
|
|
25
|
-
|
|
24
|
+
# axn-mcp defaults to, so one Axn there is authored once and surfaces on both. Going through
|
|
25
|
+
# `tool_roots_default` rather than a hand-written `setting` keeps AdapterRoots' broad-path
|
|
26
|
+
# validation (no widening a root to `app/`/`actions`/`.`/`..`) without hand-copying its lambda, and
|
|
27
|
+
# validates the default EAGERLY at gem load instead of at the registry's first read.
|
|
28
|
+
tool_roots_default %w[agent_tools]
|
|
26
29
|
|
|
27
30
|
# Register this module as the `:ruby_llm` adapter AND its config source (PRO-2948): the registry
|
|
28
31
|
# reads `Axn::RubyLLM.config.tool_roots` off the source to grant directory-based membership.
|
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.2.
|
|
4
|
+
version: 0.2.1
|
|
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.6
|
|
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.6
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: 0.2.0
|