raix 2.0.5 → 2.0.6

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: 3f9cda9662a819d937b2be909563e7bbd145111b2f70006a596b6fc30cf25fe6
4
- data.tar.gz: 280b9bee2cc6b3f47ded7b0ae88b406842184c48597efc4c9aa085458bff4cab
3
+ metadata.gz: 238b5d66fbe10f61f2264758964451d8e3a45da5b7ea0056e50a2cb87674d608
4
+ data.tar.gz: f80fc111967055c7d62d3facecb6e10190efaa6d9ad0e8df32034aae837bf2d2
5
5
  SHA512:
6
- metadata.gz: dc7ee985af7a5b832803e7aa9300986f83fd520315e4d3668e68fa1d9084be08782517054dac91e9a187e190de2876215e0dfa71bb2f09ffb462d9f275b22e71
7
- data.tar.gz: 49380b2201f8563dd7e69c30a76866b16b89d2085c6bc3e953afd6f233343d4f9c93ba891c02122228d2579341b9c64c423f16751d588d97e5105c6443d2da07
6
+ metadata.gz: b45d5cf20fda5333e00b563af114e670d69cb22fb32b386753b05bc85cfc5e2c88a7a627f9d759697ba0c507ab93c63b0a242099bfce47f4ea8b82b34c9267a2
7
+ data.tar.gz: cabedce14ff63b6b7227aa8e2457541769170a4b1a0c8a52a0eeef73a0e62c53ff567cbfdfbf15d9045dca02582dccd9daf0155878d7b59d01b55a718b4bd185
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [2.0.6] - 2026-07-23
4
+
5
+ ### Fixed
6
+ - `max_tool_calls` is now enforced under the RubyLLM backend. RubyLLM drives the tool-call loop internally (`complete` → `handle_tool_calls` → `complete`) and only returns once the model stops calling tools, so the counting loop in `Raix::ChatCompletion` never saw a tool round — `max_tool_calls` was dead code and agentic loops ran unbounded. The budget is now enforced inside the generated `FunctionToolAdapter` tool wrapper, which increments a per-completion counter on each invocation and returns a `RubyLLM::Tool::Halt` (refusing to run the underlying function) once the count exceeds the budget. Enforcement is per call because a single model response can pack several parallel tool calls, all of which RubyLLM executes in one round even after one halts. On a halt, `chat_completion` issues one final completion with no tools (and drops `tool_choice`) and returns its text, appending the existing `"Maximum tool calls (N) exceeded…"` system message.
7
+ - `stop_tool_calls_and_respond!` works again. It set a flag that only the (now unreachable) counting loop read, so under RubyLLM it did nothing. The tool wrapper now checks the flag after a successful tool execution and halts the loop, forcing a final text response.
8
+ - `Raix::ChatCompletion` no longer crashes when RubyLLM's tool loop is halted. `chat.complete`/`#ask` return a `RubyLLM::Tool::Halt` (not a `Message`) in that case, and the response conversion called `#raw` / `#input_tokens` on it. `ruby_llm_request` now detects the `Halt` and hands it back for final-response handling.
9
+ - Assistant tool-call messages replayed from the transcript into a fresh `RubyLLM::Chat` are now translated from Raix's OpenAI-style array-of-hashes shape into the `{ id => RubyLLM::ToolCall }` shape RubyLLM's providers expect. Previously this path (reachable via the forced final completion above) raised `NoMethodError: undefined method 'id' for nil` while rendering the request payload.
10
+
3
11
  ## [2.0.5] - 2026-06-04
4
12
 
5
13
  ### Fixed
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- raix (2.0.5)
4
+ raix (2.0.6)
5
5
  activesupport (>= 6.0)
6
6
  faraday-retry (~> 2.0)
7
7
  ostruct
@@ -127,6 +127,11 @@ module Raix
127
127
  # Track tool call count
128
128
  tool_call_count = 0
129
129
 
130
+ # Reset the per-completion tool-call counter that FunctionToolAdapter's
131
+ # generated wrappers use to enforce max_tool_calls under the RubyLLM
132
+ # backend (see #increment_tool_call_count).
133
+ @tool_call_count = 0
134
+
130
135
  # set the model to the default if not provided
131
136
  self.model ||= configuration.model
132
137
 
@@ -144,7 +149,27 @@ module Raix
144
149
  run_before_completion_hooks(params, messages)
145
150
 
146
151
  begin
152
+ # Snapshot the transcript length before the request. If a generated tool
153
+ # halts RubyLLM's loop, force_final_response_after_halt uses this to
154
+ # recover exactly the assistant/tool messages appended while executing
155
+ # tools and replay them after the original messages — which may not live
156
+ # in the transcript at all when `messages:` was passed explicitly. Only
157
+ # tools can trigger a halt, so skip the transcript read entirely when
158
+ # none are registered.
159
+ transcript_size_before = params[:tools].present? ? transcript.flatten.compact.size : 0
160
+
147
161
  response = ruby_llm_request(params:, model: openai || model, messages:, openai_override: openai)
162
+
163
+ # RubyLLM runs the entire tool-call loop inside chat.complete/#ask. When
164
+ # a generated tool halts that loop — because max_tool_calls was exceeded
165
+ # or stop_tool_calls_and_respond! was called — the return value is a
166
+ # RubyLLM::Tool::Halt rather than a Message. Re-issue one final
167
+ # completion with no tools; the shared handling below then returns its
168
+ # text/JSON as usual.
169
+ if response.is_a?(RubyLLM::Tool::Halt)
170
+ response = force_final_response_after_halt(response, params:, openai:, messages:, transcript_size_before:)
171
+ end
172
+
148
173
  retry_count = 0
149
174
  content = nil
150
175
 
@@ -298,8 +323,47 @@ module Raix
298
323
  public_send(function_name, arguments, cache)
299
324
  end
300
325
 
326
+ # Increments and returns the per-completion tool-call counter. Called by the
327
+ # generated FunctionToolAdapter wrappers to enforce max_tool_calls: RubyLLM
328
+ # runs the whole tool loop inside a single chat.complete, so Raix never sees
329
+ # the individual rounds and has to count from inside the tool. Internal API.
330
+ def increment_tool_call_count
331
+ @tool_call_count = @tool_call_count.to_i + 1
332
+ end
333
+
301
334
  private
302
335
 
336
+ # Issues the single final completion after RubyLLM's tool loop was halted,
337
+ # then completes once with no tools and returns the OpenAI-compatible hash.
338
+ #
339
+ # The final request is rebuilt from the original `messages` (the transcript
340
+ # or the caller-supplied `messages:` argument that drove the first request)
341
+ # plus the assistant/tool exchange FunctionDispatch appended while executing
342
+ # tools before the halt. `transcript_size_before` marks where that exchange
343
+ # begins, so the caller's system/user prompt is preserved even when it was
344
+ # never written into the transcript — otherwise the forced final call would
345
+ # answer from tool results (or stale transcript) alone.
346
+ def force_final_response_after_halt(halt, params:, openai:, messages:, transcript_size_before:)
347
+ adapter = MessageAdapters::Base.new(self)
348
+ tool_exchange = transcript.flatten.compact.drop(transcript_size_before).map { |msg| adapter.transform(msg) }
349
+ messages += tool_exchange
350
+
351
+ reason = halt.content
352
+ if reason.is_a?(FunctionToolAdapter::ToolCallsCapReached)
353
+ messages << { role: "system",
354
+ content: "Maximum tool calls (#{reason.max_tool_calls}) exceeded. Please provide a final response to the user without calling any more tools." }
355
+ end
356
+
357
+ # Force a final response without tools. Drop tool_choice as well: a
358
+ # lingering tool_choice that forces tool use with no tools registered is a
359
+ # provider error.
360
+ final_params = params.dup
361
+ final_params[:tools] = nil
362
+ final_params.delete(:tool_choice)
363
+
364
+ ruby_llm_request(params: final_params, model: openai || model, messages:, openai_override: openai)
365
+ end
366
+
303
367
  def filtered_tools(tool_names)
304
368
  return nil if tool_names.blank?
305
369
 
@@ -357,8 +421,8 @@ module Raix
357
421
  has_user_message = true
358
422
  chat.add_message(role: :user, content: MultimodalContentAdapter.translate(content))
359
423
  when "assistant"
360
- if msg[:tool_calls] || msg["tool_calls"]
361
- chat.add_message(role: :assistant, content:, tool_calls: msg[:tool_calls] || msg["tool_calls"])
424
+ if (tool_calls = msg[:tool_calls] || msg["tool_calls"])
425
+ chat.add_message(role: :assistant, content:, tool_calls: normalize_tool_calls_for_ruby_llm(tool_calls))
362
426
  else
363
427
  chat.add_message(role: :assistant, content:)
364
428
  end
@@ -397,6 +461,13 @@ module Raix
397
461
  # Non-streaming mode - return OpenAI-compatible response format
398
462
  response_message = has_user_message ? chat.complete : chat.ask
399
463
 
464
+ # A generated tool can halt RubyLLM's internal tool loop (max_tool_calls
465
+ # exceeded or stop_tool_calls_and_respond!). When that happens
466
+ # chat.complete/#ask returns a RubyLLM::Tool::Halt, not a Message, so it
467
+ # has no #raw / #input_tokens. Hand it straight back to chat_completion,
468
+ # which forces a final tool-less completion.
469
+ return response_message if response_message.is_a?(RubyLLM::Tool::Halt)
470
+
400
471
  # Pull through the raw provider payload when available. OpenRouter's
401
472
  # `id` is the only handle we have to look up authoritative billing
402
473
  # cost via /api/v1/generation, and callers that watch the response
@@ -450,5 +521,24 @@ module Raix
450
521
  # Default to openrouter for model IDs with provider prefix
451
522
  :openrouter
452
523
  end
524
+
525
+ # Raix's transcript stores assistant tool calls in OpenAI's array-of-hashes
526
+ # shape (`[{ id:, type:, function: { name:, arguments: } }]`), but RubyLLM's
527
+ # providers format tool calls from a Hash keyed by call id whose values
528
+ # respond to #id/#name/#arguments (RubyLLM::ToolCall). Translate so a
529
+ # transcript that already contains tool exchanges can be replayed back into
530
+ # a fresh RubyLLM chat — notably for the forced final completion after a
531
+ # max_tool_calls / stop_tool_calls_and_respond! halt.
532
+ def normalize_tool_calls_for_ruby_llm(tool_calls)
533
+ return tool_calls if tool_calls.is_a?(Hash) && tool_calls.values.all?(RubyLLM::ToolCall)
534
+
535
+ Array(tool_calls).each_with_object({}) do |raw, acc|
536
+ tc = raw.respond_to?(:with_indifferent_access) ? raw.with_indifferent_access : raw
537
+ function = tc[:function] || {}
538
+ arguments = function[:arguments]
539
+ arguments = JSON.parse(arguments) if arguments.is_a?(String) && arguments.present?
540
+ acc[tc[:id]] = RubyLLM::ToolCall.new(id: tc[:id], name: function[:name], arguments: arguments || {})
541
+ end
542
+ end
453
543
  end
454
544
  end
@@ -3,6 +3,12 @@
3
3
  module Raix
4
4
  # Adapter to convert Raix function declarations to RubyLLM::Tool instances
5
5
  class FunctionToolAdapter
6
+ # Sentinels wrapped in a RubyLLM::Tool::Halt so that ChatCompletion can tell
7
+ # *why* RubyLLM's internal tool loop was halted from inside a generated tool
8
+ # wrapper. Internal API — not intended for use outside Raix.
9
+ ToolCallsCapReached = Struct.new(:max_tool_calls)
10
+ StopToolCallsRequested = Struct.new(:result)
11
+
6
12
  def self.create_tool_from_function(function_def, instance)
7
13
  tool_class = Class.new(RubyLLM::Tool) do
8
14
  description function_def[:description] if function_def[:description]
@@ -25,9 +31,33 @@ module Raix
25
31
  define_method(:raix_instance) { instance }
26
32
  define_method(:raix_function_name) { function_def[:name] }
27
33
 
28
- # Override execute to call the Raix function
34
+ # Override execute to call the Raix function.
35
+ #
36
+ # The max_tool_calls budget is enforced here, inside the generated
37
+ # wrapper, because RubyLLM drives the tool loop internally
38
+ # (complete -> handle_tool_calls -> complete) and never yields control
39
+ # back to Raix between rounds. Counting must therefore happen per tool
40
+ # invocation: a single model response can pack several parallel tool
41
+ # calls, all of which RubyLLM executes in one round even after one has
42
+ # halted, so each wrapper checks the shared counter independently.
29
43
  define_method(:execute) do |**args|
30
- raix_instance.public_send(raix_function_name, args.with_indifferent_access, nil)
44
+ if raix_instance.increment_tool_call_count > raix_instance.max_tool_calls
45
+ # Refuse to run the underlying function and halt the loop. RubyLLM
46
+ # returns this Halt from chat.complete; ChatCompletion turns it into
47
+ # a final, tool-less completion.
48
+ halt(ToolCallsCapReached.new(raix_instance.max_tool_calls))
49
+ else
50
+ result = raix_instance.public_send(raix_function_name, args.with_indifferent_access, nil)
51
+
52
+ # stop_tool_calls_and_respond! sets a flag that only the old (now
53
+ # unreachable) counting loop used to read. Honor it here so it still
54
+ # forces a final text response under the RubyLLM backend.
55
+ if raix_instance.stop_tool_calls_and_respond
56
+ halt(StopToolCallsRequested.new(result))
57
+ else
58
+ result
59
+ end
60
+ end
31
61
  end
32
62
  end
33
63
 
data/lib/raix/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raix
4
- VERSION = "2.0.5"
4
+ VERSION = "2.0.6"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raix
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.5
4
+ version: 2.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-04 00:00:00.000000000 Z
10
+ date: 2026-07-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport