axn-ruby_llm 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,379 @@
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
+ # `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
23
+
24
+ # Wraps any Axn as a ::RubyLLM::Tool: schema, name, and description are read straight off the
25
+ # Axn's own declared contract (`input_schema` / `resolved_axn_name` / `description`, from axn's
26
+ # core reflection), so a tool needs no adapter-specific mixin to be wrapped.
27
+ module ToolAdapter
28
+ NOT_SET = Object.new.freeze
29
+
30
+ # Client-facing tool-error text when the transport step raises while turning a *successful*
31
+ # result into a response (see the guard in build_tool_class's #execute). Deliberately generic:
32
+ # the actionable detail (class, path) is a gem/tool bug, so it rides on the reported exception
33
+ # (on_exception / logs), not the tool's response — mirroring how axn keeps a failure's detail
34
+ # off the user-facing message, and axn-mcp's Serializer::ADAPTER_FAILURE_MESSAGE.
35
+ ADAPTER_FAILURE_MESSAGE = "The tool could not produce a valid response"
36
+
37
+ class << self
38
+ def wrap(axn_class, halt_after: nil, provider_params: nil, present_as: nil, render_as: NOT_SET, ambient_context: NOT_SET)
39
+ validate_present_as_kwargs!(present_as, render_as)
40
+
41
+ tool_class = build_tool_class(
42
+ axn_class,
43
+ halt_after: halt_after.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :halt_after) : halt_after,
44
+ provider_params: provider_params.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :provider_params) : provider_params,
45
+ present_as: present_as.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :present_as) : present_as,
46
+ ambient_context:,
47
+ )
48
+
49
+ ambient_context.equal?(NOT_SET) ? tool_class : tool_class.new
50
+ end
51
+
52
+ private
53
+
54
+ # `render_as:` (values :structured/:text) was renamed to `present_as:` (:structured/:message)
55
+ # to unify the knob with axn-mcp's `present_as` (see DEPRECATIONS.md). Pre-1.0, so a leftover
56
+ # `render_as:` is a hard error with a pointer, not a silent shim (an ignored kwarg would quietly
57
+ # revert a caller to :structured). `one_of:` on the setting only guards the config-set path, so
58
+ # validate the `present_as` kwarg here too, pointing render_as's old `:text` value at its rename.
59
+ def validate_present_as_kwargs!(present_as, render_as)
60
+ unless render_as.equal?(NOT_SET)
61
+ raise ArgumentError,
62
+ "`render_as:` was renamed to `present_as:` and its `:text` value to `:message` " \
63
+ "(e.g. `Axn::RubyLLM.wrap(..., present_as: :message)`)."
64
+ end
65
+
66
+ return if present_as.nil? || %i[structured message].include?(present_as)
67
+
68
+ hint = present_as == :text ? " (the `:text` value was renamed to `:message`)" : ""
69
+ raise ArgumentError, "present_as must be one of :structured, :message; got #{present_as.inspect}#{hint}"
70
+ end
71
+
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:)
105
+ # Core's canonical, provider-safe tool_name (PRO-2921): strips configured leading prefixes,
106
+ # snake_cases with single underscores, restricts to [a-z0-9_], and is never blank (anonymous
107
+ # -> "tool"). Pass the `:ruby_llm` adapter key so a per-adapter `tool ruby_llm: { name: }`
108
+ # override wins -- this is the SAME name `Axn::Tools.for(:ruby_llm)` keys membership,
109
+ # version-collapsing, and sort order on (registry.rb), so `.tools` publishes the exact name
110
+ # the registry selected; the zero-arg form would ignore the override and advertise a
111
+ # different name, so provider tool calls / forced choices on the declared name wouldn't
112
+ # match. Absent an override it's identical to the zero-arg name (Axn::MCP.wrap passes `:mcp`
113
+ # the same way -- the author-once point).
114
+ tool_name = axn_class.tool_name(:ruby_llm)
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) }
120
+
121
+ Class.new(::RubyLLM::Tool) do
122
+ description(axn_class.description) if axn_class.description
123
+ params(input_schema)
124
+ with_params(**provider_params) if provider_params.any?
125
+
126
+ define_method(:name) { tool_name }
127
+
128
+ define_method(:execute) do |**args|
129
+ # Run the Axn through axn core's tool Invoker (PRO-2943): input types are coerced from the
130
+ # wire, undeclared args are rejected, and a model-supplied `ambient_context` is stripped
131
+ # (the injection guard) while the wrap's own trusted context is injected in its place.
132
+ # Contract violations settle user-facing, so `input_invalid?` lets us hand the model a
133
+ # clean, correctable "Invalid tool arguments" error instead of leaking a dev-facing bug
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)
138
+ result = if ambient_context.equal?(NOT_SET)
139
+ invoker.call(axn_class, args)
140
+ else
141
+ invoker.call(axn_class, args, ambient_context:)
142
+ end
143
+
144
+ unless result.ok?
145
+ next({ error: "Invalid tool arguments: #{result.error}" }) if ::Axn::Tools::Invoker.input_invalid?(result)
146
+
147
+ next({ error: result.error })
148
+ end
149
+
150
+ # Uphold axn's non-bang "never raises" contract at the adapter boundary. The wrapped
151
+ # Axn's own `.call` (run via the Invoker above) never raises -- core catches action
152
+ # exceptions into a failed Result and pages on_exception itself -- but the TRANSPORT
153
+ # step that runs AFTER it (exposed-value serialization + JSON encoding) can raise
154
+ # outside core's executor: a value core can't render (two Hash keys colliding on one
155
+ # JSON property, a non-finite Float, non-UTF-8 bytes, an opaque value under
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
167
+ # RubyLLM::Chat#handle_tool_calls only treats a Content/Content::Raw return as-is; any
168
+ # other object (including a plain Hash) gets `#to_s`'d before being sent to the
169
+ # provider -- which for a Hash produces Ruby's inspect syntax (`{"k"=>"v"}`), not
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.
177
+ payload = if present_as == :message
178
+ result.message
179
+ else
180
+ Axn::RubyLLM.serialize_exposed(result).to_json
181
+ end
182
+ halt_after ? halt(payload) : payload
183
+ end
184
+ end
185
+ end
186
+ end
187
+
188
+ # axn reflects a nullable/optional field as a JSON Schema array-valued `type`
189
+ # (e.g. `["integer", "null"]`). That's valid JSON Schema and OpenAI/Anthropic consume it
190
+ # fine, but RubyLLM's Gemini converter only recognizes anyOf-form nullability: it does
191
+ # `param_type_for_gemini(type)` with `type.to_s.downcase`, so an array `type` matches no
192
+ # case and falls through to STRING -- silently dropping both the declared type and the
193
+ # nullability. Rewrite every array-valued `type` into the equivalent `anyOf: [{type: ...}]`,
194
+ # which Gemini's `normalize_any_of_schema` collapses back to the real type + nullable, and
195
+ # which the other providers accept unchanged. Purely a wire-shape change: the admitted value
196
+ # set is identical, and the adapter's own validator (json_types_for) already reads anyOf.
197
+ #
198
+ # Builds new Hashes/Arrays throughout rather than mutating -- axn may hand back a memoized
199
+ # input_schema, and mutating it would corrupt every other reader.
200
+ def normalize_nullable_types(node)
201
+ case node
202
+ when Hash
203
+ rebuilt = node.to_h { |key, value| [key, normalize_nullable_types(value)] }
204
+ if rebuilt[:type].is_a?(Array)
205
+ types = rebuilt.delete(:type)
206
+ rebuilt[:anyOf] = types.map { |type| { type: } }
207
+ end
208
+ rebuilt
209
+ when Array
210
+ node.map { |value| normalize_nullable_types(value) }
211
+ else
212
+ node
213
+ end
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
361
+ end
362
+ end
363
+
364
+ class << self
365
+ def wrap(...)
366
+ ToolAdapter.wrap(...)
367
+ end
368
+
369
+ # Every Axn registered as a :ruby_llm tool -- via `tool`/`tool :ruby_llm`, residency under one of
370
+ # the configured `tool_roots`, or a `configure(:ruby_llm)` bag (see Axn::Tools::Registry#member?)
371
+ # -- each already wrapped as a ::RubyLLM::Tool, so a consumer builds its whole chat tool list in
372
+ # one call: `chat.with_tools(*Axn::RubyLLM.tools)`. Mirrors the shared GemName.tools contract
373
+ # with Axn::MCP.tools; the same Axn class resolves to the same tool_name across both surfaces.
374
+ def tools
375
+ Axn::Tools.for(:ruby_llm).map { |axn| wrap(axn) }
376
+ end
377
+ end
378
+ end
379
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Axn
4
4
  module RubyLLM
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
data/lib/axn/ruby_llm.rb CHANGED
@@ -1,30 +1,85 @@
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
+ extend Axn::Tools::AdapterSerialization
16
+
17
+ setting :default_model, default: "gpt-4o-mini"
18
+ setting :enabled, default: true
19
+ setting :error_headline, default: "LLM request failed"
20
+
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
23
+ # living under `app/agent_tools` is exposed as a `:ruby_llm` tool out of the box. It's the same dir
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]
29
+
30
+ # Register this module as the `:ruby_llm` adapter AND its config source (PRO-2948): the registry
31
+ # reads `Axn::RubyLLM.config.tool_roots` off the source to grant directory-based membership.
32
+ Axn::Tools.register_adapter(:ruby_llm, self)
13
33
 
14
34
  mount_axn :ask, Ask
15
35
 
36
+ # Backward-compatible view of `config` returned by the deprecated `configuration` alias. The
37
+ # pre-DSL `Configuration#enabled?` invoked a callable gate (`enabled = -> { ... }`); the
38
+ # DSL-generated `config.enabled?` returns an assigned Proc as-is (always truthy). Delegate
39
+ # everything to `config`, but restore the callable-resolving `enabled?` (via the module-level
40
+ # `enabled?`) so a compatibility caller's production gate still resolves correctly during the
41
+ # deprecation window instead of silently reading as enabled. Removed with the alias in 0.3.0.
42
+ class DeprecatedConfigProxy < SimpleDelegator
43
+ def enabled? = Axn::RubyLLM.enabled?
44
+ end
45
+
16
46
  class << self
17
- def configuration
18
- @configuration ||= Configuration.new
47
+ # `enabled` accepts a Boolean OR a callable — the documented production-gating idiom is
48
+ # `c.enabled = -> { Rails.env.production? }`. axn's Configurable used to invoke an assigned
49
+ # callable on read via `callable: true`; that kwarg was removed upstream (PRO-3017) and an
50
+ # assigned Proc is now returned as-is, so resolve it here. Without this the DSL-generated
51
+ # `config.enabled?` is `!!some_proc` — always true — and production gating dies silently.
52
+ # This (`Axn::RubyLLM.enabled?`), NOT `config.enabled?`, is the supported reader.
53
+ def enabled?
54
+ value = config.enabled
55
+ value.respond_to?(:call) ? !!value.call : !!value
19
56
  end
20
57
 
21
- def configure
22
- yield configuration
58
+ # DEPRECATED backward-compatible aliases for the pre-DSL API. The
59
+ # Axn::Configurable DSL standardizes on `.config` / `reset_config!`.
60
+ # These keep older callers working but emit a deprecation warning and
61
+ # are scheduled for removal in the next minor version (see DEPRECATIONS.md).
62
+ def configuration
63
+ _warn_deprecated_alias("Axn::RubyLLM.configuration", "Axn::RubyLLM.config")
64
+ DeprecatedConfigProxy.new(config)
23
65
  end
24
66
 
25
67
  def reset_configuration!
26
- @configuration = nil
68
+ _warn_deprecated_alias("Axn::RubyLLM.reset_configuration!", "Axn::RubyLLM.reset_config!")
69
+ reset_config!
70
+ end
71
+
72
+ private
73
+
74
+ def _warn_deprecated_alias(old, new)
75
+ warn(
76
+ "[axn-ruby_llm] DEPRECATION: #{old} is deprecated and will be removed in the next minor version; use #{new} instead.",
77
+ category: :deprecated,
78
+ uplevel: 2,
79
+ )
27
80
  end
28
81
  end
29
82
  end
30
83
  end
84
+
85
+ require_relative "ruby_llm/tool_adapter"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axn-ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.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.4.3
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.4.3
28
+ version: 0.1.0.pre.alpha.6
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