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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c33a22550fbc081c47ad09e370ce05aa400640cfe70f2e06488d93b1507a42a6
4
- data.tar.gz: 1d65bcd8eeee475aee68c7a92e380601f8c3e333629fe6664609c0e1174e2f64
3
+ metadata.gz: 567165990e6185d68aab836344f8aaf5f1b2891c0c6fd19442aab5908c1fa335
4
+ data.tar.gz: 326731c8da66d1ac1ca92bcc5bf8b4c43132aaa42580f10ba97d7d98218911ee
5
5
  SHA512:
6
- metadata.gz: c9b764c927824c06c5f14d8b186ede7c63182cda2259991e444e6226f45a0277b51433f8ea5246f0438623a59e9e4827d7ee393391db53f028e00178f154de1d
7
- data.tar.gz: 5cc0b5fcda8f30cf1961cc44839c08d5447787262247f6ebd460d53c4ff9478143a9b4e1b7414082917d8052b4757770113e2e3451c206283e6ad49ff5cfb754
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::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.
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
 
@@ -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
- # 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
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
- setting :reject_opaque_exposed_values, default: false, one_of: [true, false], overridable: true
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
- def build_tool_class(axn_class, halt_after:, provider_params:, present_as:, reject_opaque:, ambient_context:)
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
- invoker = ::Axn::Tools::Invoker.new(user_facing_input_errors: true, reject_undeclared_inputs: true)
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
- # 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
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::Extensions::Serialization.render(result, reject_opaque:).to_json
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Axn
4
4
  module RubyLLM
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
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 `default: []`; re-declare
21
- # it (core's `setting` is last-wins) to ship the shared agent-tools dir as the default, so any Axn
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. 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) }
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.0
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.5
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.5
28
+ version: 0.1.0.pre.alpha.6
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: 0.2.0