axn-ruby_llm 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,17 +3,22 @@
3
3
  module Axn
4
4
  module RubyLLM
5
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 }`
6
+ # adapter-specific mixin required — can declare `configure(:ruby_llm) { |c| c.present_as = :message }`
7
7
  # to set these per-class, alongside e.g. `configure(:mcp) { ... }` for a different adapter on the
8
8
  # same class, without the two colliding. `wrap` resolves them via `resolve_override_for`, which
9
9
  # falls back to this module's own global `config` (`Axn::RubyLLM.configure { |c| ... }`) and then
10
10
  # to each setting's default — the same class-override-then-global-then-default chain a flat
11
11
  # `overridable: true` accessor would give a single-adapter consumer.
12
12
  config_namespace :ruby_llm
13
- setting :halt_after, default: false, overridable: true
14
- setting :provider_params, default: {}, overridable: true
13
+ setting :provider_options, default: {}, overridable: true
15
14
  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
15
+ # `Axn::Tools::AdapterSerialization` (extended onto Axn::RubyLLM in ruby_llm.rb, which is required
16
+ # before this file reopens the module) owns this setting's declaration so the three adapters can't
17
+ # drift on it. `default:` is a required kwarg with no core-picked value on purpose: an LLM-facing
18
+ # adapter is better off shipping an ugly-but-honest rendering than failing the whole tool call, so
19
+ # ruby_llm (like axn-mcp) declares `false`, where axn-openapi's published output contract declares
20
+ # `true`. Must follow `config_namespace` above -- it's an `overridable:` setting.
21
+ declare_reject_opaque_exposed_values! default: false
17
22
 
18
23
  # Wraps any Axn as a ::RubyLLM::Tool: schema, name, and description are read straight off the
19
24
  # Axn's own declared contract (`input_schema` / `resolved_axn_name` / `description`, from axn's
@@ -29,15 +34,14 @@ module Axn
29
34
  ADAPTER_FAILURE_MESSAGE = "The tool could not produce a valid response"
30
35
 
31
36
  class << self
32
- def wrap(axn_class, halt_after: nil, provider_params: nil, present_as: nil, render_as: NOT_SET, ambient_context: NOT_SET)
37
+ def wrap(axn_class, provider_options: nil, present_as: nil, render_as: NOT_SET, provider_params: NOT_SET, ambient_context: NOT_SET)
33
38
  validate_present_as_kwargs!(present_as, render_as)
39
+ validate_provider_options_kwargs!(provider_params)
34
40
 
35
41
  tool_class = build_tool_class(
36
42
  axn_class,
37
- halt_after: halt_after.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :halt_after) : halt_after,
38
- provider_params: provider_params.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :provider_params) : provider_params,
43
+ provider_options: provider_options.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :provider_options) : provider_options,
39
44
  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
45
  ambient_context:,
42
46
  )
43
47
 
@@ -64,7 +68,50 @@ module Axn
64
68
  raise ArgumentError, "present_as must be one of :structured, :message; got #{present_as.inspect}#{hint}"
65
69
  end
66
70
 
67
- def build_tool_class(axn_class, halt_after:, provider_params:, present_as:, reject_opaque:, ambient_context:)
71
+ # `provider_params:` was renamed to `provider_options:` (PRO-3467) to match RubyLLM 2.0's own
72
+ # `Tool.provider_options`, which replaced `with_params` for tool-level provider metadata.
73
+ # Same hard-error treatment as `render_as:` above: pre-1.0, never silently shimmed.
74
+ def validate_provider_options_kwargs!(provider_params)
75
+ return if provider_params.equal?(NOT_SET)
76
+
77
+ raise ArgumentError,
78
+ "`provider_params:` was renamed to `provider_options:` " \
79
+ "(e.g. `Axn::RubyLLM.wrap(..., provider_options: { ... })`)."
80
+ end
81
+
82
+ # `guard_tool_response`'s `on_error`: the transport-native error response, plus the operator's
83
+ # only pointer to WHY (the tool-facing text stays generic -- see ADAPTER_FAILURE_MESSAGE).
84
+ # Mirrors axn-openapi's dispatcher hint / axn-mcp's Invocation guard: the config pointer lives
85
+ # HERE rather than in core's exception message, since core raises the same error for adapters
86
+ # with no such setting. Named as BOTH config levels, never just the gem-wide setter -- the
87
+ # value is resolved per-tool, so a `configure(:ruby_llm)` override beats `config`, and core
88
+ # exposes no way to ask which level supplied a resolved value. Non-committal ("if this is")
89
+ # because reject_opaque_exposed_values being on doesn't mean THIS failure is an opaque
90
+ # rejection -- it could equally be a colliding key, a non-finite Float, or a gem bug.
91
+ #
92
+ # The whole hint is built and logged INSIDE a best_effort: `axn_class` is caller code, and
93
+ # interpolating it (a hostile/buggy #to_s) must not raise out of `on_error` -- `guard_tool_response`
94
+ # reports and re-raises an on_error failure rather than substituting a response, so a raise
95
+ # here would cost the tool its error response entirely. Deliberately a SEPARATE best_effort
96
+ # from the guard's own on_exception report: a broken configured logger must not suppress that
97
+ # report, and a broken reporter must not suppress this diagnostic line -- each is the guard's
98
+ # only surviving signal when the OTHER one is what's broken.
99
+ def serialization_failure_response(axn_class, error)
100
+ Axn::Extensions.best_effort("logging a tool serialization failure hint") do
101
+ hint = if Axn::RubyLLM.resolve_override_for(axn_class, :reject_opaque_exposed_values)
102
+ " (if this is an opaque-value rejection: reject_opaque_exposed_values resolved true for " \
103
+ "#{axn_class} — unset it on the action via `configure(:ruby_llm)`, or gem-wide via " \
104
+ "`Axn::RubyLLM.config.reject_opaque_exposed_values = false`, whichever is set)"
105
+ else
106
+ ""
107
+ end
108
+ Axn.config.logger.error { "[axn-ruby_llm] failed to serialize successful result: #{error.class}: #{error.message}#{hint}" }
109
+ end
110
+
111
+ { error: ADAPTER_FAILURE_MESSAGE }
112
+ end
113
+
114
+ def build_tool_class(axn_class, provider_options:, present_as:, ambient_context:)
68
115
  # Core's canonical, provider-safe tool_name (PRO-2921): strips configured leading prefixes,
69
116
  # snake_cases with single underscores, restricts to [a-z0-9_], and is never blank (anonymous
70
117
  # -> "tool"). Pass the `:ruby_llm` adapter key so a per-adapter `tool ruby_llm: { name: }`
@@ -74,15 +121,25 @@ module Axn
74
121
  # different name, so provider tool calls / forced choices on the declared name wouldn't
75
122
  # match. Absent an override it's identical to the zero-arg name (Axn::MCP.wrap passes `:mcp`
76
123
  # the same way -- the author-once point).
124
+ #
125
+ # Passed through unmodified (PRO-3467): RubyLLM 2.0's Gemini protocol reads a tool's schema
126
+ # via `parametersJsonSchema` -- the wire form verbatim, with no whitelist converter in the
127
+ # way -- so the array-valued-`type` / additionalProperties / min-maxProperties workarounds
128
+ # 1.x needed here are gone along with the fixed-property Gemini schema converter they patched
129
+ # around.
77
130
  tool_name = axn_class.tool_name(:ruby_llm)
78
- input_schema = normalize_nullable_types(axn_class.input_schema)
131
+ input_schema = axn_class.input_schema
132
+ # Built HERE, not inside `define_method(:execute)`: `self` in the executed block is the
133
+ # ::RubyLLM::Tool instance, which has no access to this module's private helpers. Closing
134
+ # over the lambda from build_tool_class's scope binds it to ToolAdapter instead.
135
+ on_serialization_failure = ->(e) { serialization_failure_response(axn_class, e) }
79
136
 
80
137
  Class.new(::RubyLLM::Tool) do
81
138
  description(axn_class.description) if axn_class.description
82
- params(input_schema)
83
- with_params(**provider_params) if provider_params.any?
139
+ parameters(input_schema)
140
+ provider_options(provider_options) if provider_options.any?
84
141
 
85
- define_method(:name) { tool_name }
142
+ define_singleton_method(:tool_name) { tool_name }
86
143
 
87
144
  define_method(:execute) do |**args|
88
145
  # Run the Axn through axn core's tool Invoker (PRO-2943): input types are coerced from the
@@ -90,8 +147,10 @@ module Axn
90
147
  # (the injection guard) while the wrap's own trusted context is injected in its place.
91
148
  # Contract violations settle user-facing, so `input_invalid?` lets us hand the model a
92
149
  # 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)
150
+ # (which also keeps a bad tool call from paging on_exception). `adapter: :ruby_llm`
151
+ # (PRO-3332) stamps the invoked_via dimension around the call, so a Datadog dashboard can
152
+ # separate tool-driven traffic from ordinary direct `.call`s with no per-call work here.
153
+ invoker = ::Axn::Tools::Invoker.new(adapter: :ruby_llm, user_facing_input_errors: true, reject_undeclared_inputs: true)
95
154
  result = if ambient_context.equal?(NOT_SET)
96
155
  invoker.call(axn_class, args)
97
156
  else
@@ -110,71 +169,37 @@ module Axn
110
169
  # step that runs AFTER it (exposed-value serialization + JSON encoding) can raise
111
170
  # outside core's executor: a value core can't render (two Hash keys colliding on one
112
171
  # JSON property, a non-finite Float, non-UTF-8 bytes, an opaque value under
113
- # reject_opaque), a structure past the JSON encoder's max_nesting, or a gem bug.
114
- # RubyLLM has no rescue around a tool's #execute, so any of these would escape and
115
- # break the whole chat. Scope the guard to JUST that mapping step (NOT the Invoker call,
116
- # which already handles + reports its own exceptions -- double-guarding would
117
- # double-report on_exception): report through axn's global on_exception for
118
- # observability, then -- honoring core's best_effort_raises_in_dev so a real bug
119
- # surfaces loudly rather than being masked -- re-raise in dev, otherwise return a tool
120
- # error so #execute ALWAYS yields a value. Shaped to drop into the planned shared
121
- # Axn::Tools::Serialization.guard (PRO-2996 §2b) with no behavior change.
122
- begin
123
- # RubyLLM::Chat#handle_tool_calls only treats a Content/Content::Raw return as-is; any
124
- # other object (including a plain Hash) gets `#to_s`'d before being sent to the
125
- # provider -- which for a Hash produces Ruby's inspect syntax (`{"k"=>"v"}`), not
126
- # JSON. Serialize structured payloads ourselves so the wire form is always valid JSON.
127
- payload = if present_as == :message
128
- result.message
129
- else
130
- Axn::Extensions::Serialization.render(result, reject_opaque:).to_json
131
- end
132
- halt_after ? halt(payload) : payload
133
- rescue StandardError => e
134
- # Report through on_exception for observability -- but the reporter is app-configured
135
- # and CAN raise (a buggy hook, or one assuming `action` is a settled instance). Core
136
- # normally invokes on_exception INSIDE its own best_effort; we call it directly, so a
137
- # raising reporter would escape and defeat this guard's never-raises intent (aborting
138
- # chat.ask in production). Wrap it in best_effort ourselves -- it swallows + warn-logs
139
- # (and reraises in dev per best_effort_raises_in_dev), same as core.
140
- Axn::Extensions.best_effort("reporting a tool serialization failure via on_exception") do
141
- Axn.config.on_exception(e, action: axn_class, context: { source: "Axn::RubyLLM" })
172
+ # reject_opaque_exposed_values), a structure past the JSON encoder's max_nesting, or a
173
+ # gem bug. RubyLLM has no rescue around a tool's #execute, so any of these would escape
174
+ # and break the whole chat. `guard_tool_response` (PRO-2996, from
175
+ # Axn::Tools::AdapterSerialization) is core's shared version of exactly that guard --
176
+ # report through the global on_exception inside a best_effort, re-raise when
177
+ # raises_in_dev? so a real bug surfaces loudly, else hand `on_error` the exception so
178
+ # this adapter builds its own transport-native error response. It is scoped to JUST the
179
+ # mapping step (NOT the Invoker call, which already handles + reports its own
180
+ # exceptions -- double-guarding would double-report on_exception), and the block's
181
+ # return value is #execute's.
182
+ Axn::RubyLLM.guard_tool_response(axn_class, on_error: on_serialization_failure) do
183
+ # RubyLLM::Tool.split_result (called from Chat#add_tool_result_message) sends a String
184
+ # through as-is but `#to_json`'s a returned Hash/Array only via its OWN #to_json
185
+ # dispatch, not necessarily matching how axn would serialize it (Symbol keys/values,
186
+ # BigDecimal, Time, opaque-value rejection). Serialize structured payloads ourselves so
187
+ # the wire form always reflects axn's own serialization contract, not Ruby's default.
188
+ #
189
+ # `serialize_exposed` (not `Serialization.render` directly) resolves
190
+ # reject_opaque_exposed_values PER CALL off the result's own action class, so a
191
+ # per-tool `configure(:ruby_llm)` override is honored and a config change reaches
192
+ # already-wrapped tools. `present_as` stays a wrap-time kwarg: it's adapter-owned, not
193
+ # part of the shared mixin, and `wrap` accepts it as an explicit override.
194
+ if present_as == :message
195
+ result.message
196
+ else
197
+ Axn::RubyLLM.serialize_exposed(result).to_json
142
198
  end
143
- raise if Axn::Extensions.raises_in_dev?
144
-
145
- { error: ADAPTER_FAILURE_MESSAGE }
146
199
  end
147
200
  end
148
201
  end
149
202
  end
150
-
151
- # axn reflects a nullable/optional field as a JSON Schema array-valued `type`
152
- # (e.g. `["integer", "null"]`). That's valid JSON Schema and OpenAI/Anthropic consume it
153
- # fine, but RubyLLM's Gemini converter only recognizes anyOf-form nullability: it does
154
- # `param_type_for_gemini(type)` with `type.to_s.downcase`, so an array `type` matches no
155
- # case and falls through to STRING -- silently dropping both the declared type and the
156
- # nullability. Rewrite every array-valued `type` into the equivalent `anyOf: [{type: ...}]`,
157
- # which Gemini's `normalize_any_of_schema` collapses back to the real type + nullable, and
158
- # which the other providers accept unchanged. Purely a wire-shape change: the admitted value
159
- # set is identical, and the adapter's own validator (json_types_for) already reads anyOf.
160
- #
161
- # Builds new Hashes/Arrays throughout rather than mutating -- axn may hand back a memoized
162
- # input_schema, and mutating it would corrupt every other reader.
163
- def normalize_nullable_types(node)
164
- case node
165
- when Hash
166
- rebuilt = node.to_h { |key, value| [key, normalize_nullable_types(value)] }
167
- if rebuilt[:type].is_a?(Array)
168
- types = rebuilt.delete(:type)
169
- rebuilt[:anyOf] = types.map { |type| { type: } }
170
- end
171
- rebuilt
172
- when Array
173
- node.map { |value| normalize_nullable_types(value) }
174
- else
175
- node
176
- end
177
- end
178
203
  end
179
204
  end
180
205
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Axn
4
4
  module RubyLLM
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/axn/ruby_llm.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "delegate"
4
3
  require "ruby_llm"
5
4
  require "axn"
6
5
 
@@ -12,17 +11,20 @@ module Axn
12
11
  include Axn::Mountable
13
12
  extend Axn::Configurable
14
13
  extend Axn::Tools::AdapterRoots
14
+ extend Axn::Tools::AdapterSerialization
15
15
 
16
16
  setting :default_model, default: "gpt-4o-mini"
17
17
  setting :enabled, default: true
18
18
  setting :error_headline, default: "LLM request failed"
19
19
 
20
- # `Axn::Tools::AdapterRoots` (extended above) declares `tool_roots` with `default: []`; re-declare
21
- # it (core's `setting` is last-wins) to ship the shared agent-tools dir as the default, so any Axn
20
+ # `Axn::Tools::AdapterRoots` (extended above) declares `tool_roots` with core's conservative
21
+ # `default: []`; `tool_roots_default` re-declares it to ship the shared agent-tools dir, so any Axn
22
22
  # living under `app/agent_tools` is exposed as a `:ruby_llm` tool out of the box. It's the same dir
23
- # axn-mcp defaults to, so one Axn there is authored once and surfaces on both. The re-declaration
24
- # keeps AdapterRoots' broad-path validation (no widening a root to `app/`/`actions`/`.`/`..`).
25
- setting :tool_roots, default: ["agent_tools"], validate: ->(value) { Axn::Tools::AdapterRoots.validate!(value) }
23
+ # axn-mcp defaults to, so one Axn there is authored once and surfaces on both. Going through
24
+ # `tool_roots_default` rather than a hand-written `setting` keeps AdapterRoots' broad-path
25
+ # validation (no widening a root to `app/`/`actions`/`.`/`..`) without hand-copying its lambda, and
26
+ # validates the default EAGERLY at gem load instead of at the registry's first read.
27
+ tool_roots_default %w[agent_tools]
26
28
 
27
29
  # Register this module as the `:ruby_llm` adapter AND its config source (PRO-2948): the registry
28
30
  # reads `Axn::RubyLLM.config.tool_roots` off the source to grant directory-based membership.
@@ -30,16 +32,6 @@ module Axn
30
32
 
31
33
  mount_axn :ask, Ask
32
34
 
33
- # Backward-compatible view of `config` returned by the deprecated `configuration` alias. The
34
- # pre-DSL `Configuration#enabled?` invoked a callable gate (`enabled = -> { ... }`); the
35
- # DSL-generated `config.enabled?` returns an assigned Proc as-is (always truthy). Delegate
36
- # everything to `config`, but restore the callable-resolving `enabled?` (via the module-level
37
- # `enabled?`) so a compatibility caller's production gate still resolves correctly during the
38
- # deprecation window instead of silently reading as enabled. Removed with the alias in 0.3.0.
39
- class DeprecatedConfigProxy < SimpleDelegator
40
- def enabled? = Axn::RubyLLM.enabled?
41
- end
42
-
43
35
  class << self
44
36
  # `enabled` accepts a Boolean OR a callable — the documented production-gating idiom is
45
37
  # `c.enabled = -> { Rails.env.production? }`. axn's Configurable used to invoke an assigned
@@ -51,30 +43,6 @@ module Axn
51
43
  value = config.enabled
52
44
  value.respond_to?(:call) ? !!value.call : !!value
53
45
  end
54
-
55
- # DEPRECATED backward-compatible aliases for the pre-DSL API. The
56
- # Axn::Configurable DSL standardizes on `.config` / `reset_config!`.
57
- # These keep older callers working but emit a deprecation warning and
58
- # are scheduled for removal in the next minor version (see DEPRECATIONS.md).
59
- def configuration
60
- _warn_deprecated_alias("Axn::RubyLLM.configuration", "Axn::RubyLLM.config")
61
- DeprecatedConfigProxy.new(config)
62
- end
63
-
64
- def reset_configuration!
65
- _warn_deprecated_alias("Axn::RubyLLM.reset_configuration!", "Axn::RubyLLM.reset_config!")
66
- reset_config!
67
- end
68
-
69
- private
70
-
71
- def _warn_deprecated_alias(old, new)
72
- warn(
73
- "[axn-ruby_llm] DEPRECATION: #{old} is deprecated and will be removed in the next minor version; use #{new} instead.",
74
- category: :deprecated,
75
- uplevel: 2,
76
- )
77
- end
78
46
  end
79
47
  end
80
48
  end
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0.pre.alpha.5
18
+ version: 0.1.0.pre.alpha.6.1
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.1
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: 0.2.0
@@ -35,22 +35,36 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '1.15'
38
+ version: '2.0'
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
- version: '2.0'
41
+ version: '3.0'
42
42
  type: :runtime
43
43
  prerelease: false
44
44
  version_requirements: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '1.15'
48
+ version: '2.0'
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
- version: '2.0'
51
+ version: '3.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: faraday
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 1.10.0
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 1.10.0
52
66
  description: Call LLMs from Axn actions using RubyLLM, with structured error handling,
53
- optional JSON mode, and cost/token tracking.
67
+ schema-based structured output, and cost/token tracking.
54
68
  email:
55
69
  - kali@teamshares.com
56
70
  executables: []