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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +118 -0
- data/README.md +202 -13
- data/lib/axn/ruby_llm/ask.rb +132 -29
- data/lib/axn/ruby_llm/rspec.rb +26 -12
- data/lib/axn/ruby_llm/tool_adapter.rb +379 -0
- data/lib/axn/ruby_llm/version.rb +1 -1
- data/lib/axn/ruby_llm.rb +61 -6
- metadata +4 -5
- data/Rakefile +0 -13
- data/lib/axn/ruby_llm/configuration.rb +0 -20
|
@@ -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
|
data/lib/axn/ruby_llm/version.rb
CHANGED
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|