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 +4 -4
- data/CHANGELOG.md +8 -0
- data/Gemfile.lock +1 -1
- data/lib/raix/chat_completion.rb +92 -2
- data/lib/raix/function_tool_adapter.rb +32 -2
- data/lib/raix/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 238b5d66fbe10f61f2264758964451d8e3a45da5b7ea0056e50a2cb87674d608
|
|
4
|
+
data.tar.gz: f80fc111967055c7d62d3facecb6e10190efaa6d9ad0e8df32034aae837bf2d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/lib/raix/chat_completion.rb
CHANGED
|
@@ -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:
|
|
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.
|
|
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
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.
|
|
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-
|
|
10
|
+
date: 2026-07-23 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activesupport
|