ruby_llm 2.2.0 → 2.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/README.md +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +2 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +2 -1
- data/lib/ruby_llm/active_record/chat_approvals.rb +3 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +14 -36
- data/lib/ruby_llm/active_record/chat_queue.rb +14 -1
- data/lib/ruby_llm/active_record/run_claims.rb +31 -3
- data/lib/ruby_llm/agent.rb +1 -0
- data/lib/ruby_llm/aliases.json +9 -0
- data/lib/ruby_llm/chat/file_references.rb +1 -4
- data/lib/ruby_llm/chat/tool_call_state.rb +11 -0
- data/lib/ruby_llm/chat.rb +86 -11
- data/lib/ruby_llm/configuration.rb +1 -1
- data/lib/ruby_llm/file_store/tools/read.rb +9 -3
- data/lib/ruby_llm/file_store.rb +42 -8
- data/lib/ruby_llm/models.json +3459 -1978
- data/lib/ruby_llm/models.rb +24 -1
- data/lib/ruby_llm/protocols/gemini/batches.rb +74 -5
- data/lib/ruby_llm/protocols/openai/decisions.rb +4 -2
- data/lib/ruby_llm/protocols/system_one/responses.rb +5 -5
- data/lib/ruby_llm/version.rb +1 -1
- data/skills/rubyllm/references/tools-and-agents.md +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: 55db20760e71db4c87a6f71b21ac7eba6b1651838dc6d897397d7d61e8283e14
|
|
4
|
+
data.tar.gz: d624ca6c55d349a74876d2ac12609484b7e8a7d28c08f7133e0c827550fd98cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ee13ff16e87c7ef85d5c9bea076b7c01dcaf6d8e7d422a632b403e31284c5c4400bd0c7d255fde5ad609e6e42cf57b5e15db514adba86d18bbcd07a41fbb7dc
|
|
7
|
+
data.tar.gz: 9988f48c57b5c8603dce67d9ddc098a3be387a9fd478756ac32037c0b8a4a3d7d5929aa60a7c1eba761677e93df980abc0fc6dfc279da9ee1e8a1f4018fa6bfa
|
data/README.md
CHANGED
|
@@ -179,7 +179,7 @@ Start your Rails server and visit `http://localhost:3000/chats` to try the chat
|
|
|
179
179
|
|
|
180
180
|
## Start with one line. Add files, tools, and agents
|
|
181
181
|
|
|
182
|
-
These examples use **2.2.
|
|
182
|
+
These examples use **2.2.1**. Follow [Getting Started](https://rubyllm.com/getting-started/) to install it and configure the providers you want to try. For 1.x, use the [1.x docs](https://rubyllm.com/v1/).
|
|
183
183
|
|
|
184
184
|
```ruby
|
|
185
185
|
# Just ask
|
|
@@ -309,7 +309,7 @@ response.parsed
|
|
|
309
309
|
Install RubyLLM 2.2:
|
|
310
310
|
|
|
311
311
|
```bash
|
|
312
|
-
bundle add ruby_llm --version 2.2.
|
|
312
|
+
bundle add ruby_llm --version 2.2.1
|
|
313
313
|
```
|
|
314
314
|
|
|
315
315
|
Configure a provider in your script, or in `config/initializers/ruby_llm.rb` in Rails:
|
data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt
CHANGED
|
@@ -66,7 +66,8 @@
|
|
|
66
66
|
<%%= form.select "answer[#{field.name}]", field.choices, { selected: field.default, include_blank: !field.required? && !list },
|
|
67
67
|
id: field_id, multiple: list, required: list && field.required?, class: "block shadow-sm rounded-md border border-gray-400 px-3 py-2 mt-1 w-full" %>
|
|
68
68
|
<%% else %>
|
|
69
|
-
<%%= form.text_field "answer[#{field.name}]", value: field.default
|
|
69
|
+
<%%= form.text_field "answer[#{field.name}]#{"[]" if list}", value: (field.default unless list),
|
|
70
|
+
required: field.required? && !(list && field.default), id: field_id,
|
|
70
71
|
type: steps.key?(field.type) ? "number" : "text", step: steps[field.type],
|
|
71
72
|
class: "block shadow-sm rounded-md border border-gray-400 px-3 py-2 mt-1 w-full" %>
|
|
72
73
|
<%% end %>
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
<%%= form.select "answer[#{field.name}]", field.choices, { selected: field.default, include_blank: !field.required? && !list },
|
|
51
51
|
id: field_id, multiple: list, required: list && field.required? %>
|
|
52
52
|
<%% else %>
|
|
53
|
-
<%%= form.text_field "answer[#{field.name}]", value: field.default
|
|
53
|
+
<%%= form.text_field "answer[#{field.name}]#{"[]" if list}", value: (field.default unless list),
|
|
54
|
+
required: field.required? && !(list && field.default), id: field_id,
|
|
54
55
|
type: steps.key?(field.type) ? "number" : "text", step: steps[field.type] %>
|
|
55
56
|
<%% end %>
|
|
56
57
|
<%% end %>
|
|
@@ -54,6 +54,9 @@ module RubyLLM
|
|
|
54
54
|
|
|
55
55
|
def record_tool_call_decision(tool_call, decision)
|
|
56
56
|
record = tool_call_record(tool_call)
|
|
57
|
+
if decision == 'remembered' && record.result_id.nil?
|
|
58
|
+
RubyLLM::Chat::ToolCallState.remembered_identity(record.to_llm)
|
|
59
|
+
end
|
|
57
60
|
decide_persisted_tool_call(record, decision)
|
|
58
61
|
record
|
|
59
62
|
end
|
|
@@ -129,7 +129,7 @@ module RubyLLM
|
|
|
129
129
|
#
|
|
130
130
|
def approve(tool_call, remember: false)
|
|
131
131
|
record = tool_call_record(RubyLLM::Question.decided_call(tool_call, 'answer'))
|
|
132
|
-
|
|
132
|
+
@chat&.approve(record.to_llm, remember:) if record.result_id.nil?
|
|
133
133
|
record_tool_call_decision(record, remember ? 'remembered' : 'approved')
|
|
134
134
|
self
|
|
135
135
|
end
|
|
@@ -839,15 +839,11 @@ module RubyLLM
|
|
|
839
839
|
# Raises PendingToolCallsError while the last response has tool calls
|
|
840
840
|
# waiting for their results and no run holds the chat.
|
|
841
841
|
def ask_later(message = nil, with: nil)
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if message.is_a?(RubyLLM::MCP::Prompt)
|
|
848
|
-
message.messages.each { |prompt_message| add_message(prompt_message) }
|
|
849
|
-
else
|
|
850
|
-
add_message(role: :user, content: message, attachments: with)
|
|
842
|
+
staging = ChatQueue.new(self)
|
|
843
|
+
if !run_claims? || message.is_a?(RubyLLM::MCP::Prompt)
|
|
844
|
+
staging.stage(message, attachments: with)
|
|
845
|
+
elsif running? || !stage_while_idle { staging.stage(message, attachments: with) }
|
|
846
|
+
queue(message, with:)
|
|
851
847
|
end
|
|
852
848
|
self
|
|
853
849
|
end
|
|
@@ -898,11 +894,13 @@ module RubyLLM
|
|
|
898
894
|
|
|
899
895
|
# Runs the completion loop on the underlying chat, persisting each
|
|
900
896
|
# message, and returns the latest RubyLLM::Message. Pauses while a call
|
|
901
|
-
# waits on a person (see #pending_questions). When the API call
|
|
902
|
-
# destroys the empty assistant message
|
|
903
|
-
#
|
|
904
|
-
#
|
|
905
|
-
#
|
|
897
|
+
# waits on a person (see #pending_questions). When the API call or a
|
|
898
|
+
# tool fails, destroys the empty assistant message, closes the tool
|
|
899
|
+
# calls of the round left without a result as unfinished, and
|
|
900
|
+
# re-raises the error. A tool call that finished keeps its result.
|
|
901
|
+
# When the block raises, destroys the empty assistant message and
|
|
902
|
+
# re-raises. Tool calls paused on a task or a question stay, so the
|
|
903
|
+
# next #complete picks them up.
|
|
906
904
|
#
|
|
907
905
|
# Only one process runs a chat at a time. While another process is
|
|
908
906
|
# #running? it, returns the latest message without calling the model
|
|
@@ -1125,7 +1123,7 @@ module RubyLLM
|
|
|
1125
1123
|
def cleanup_after_failure(error)
|
|
1126
1124
|
reason = error.is_a?(RubyLLM::CancelledError) ? 'chat cancelled' : 'API call failed'
|
|
1127
1125
|
cleanup_failed_messages(reason:) if blank_placeholder?
|
|
1128
|
-
|
|
1126
|
+
@chat&.close_unfinished_tool_calls
|
|
1129
1127
|
resync_messages if @chat
|
|
1130
1128
|
end
|
|
1131
1129
|
|
|
@@ -1138,26 +1136,6 @@ module RubyLLM
|
|
|
1138
1136
|
@message.destroy
|
|
1139
1137
|
end
|
|
1140
1138
|
|
|
1141
|
-
def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
|
|
1142
|
-
messages_association.reload
|
|
1143
|
-
last = eager_load_messages.last
|
|
1144
|
-
|
|
1145
|
-
return unless last&.tool_call? || last&.tool_result?
|
|
1146
|
-
|
|
1147
|
-
if last.tool_call?
|
|
1148
|
-
last.destroy unless last.ruby_llm_tool_calls.any?(&:paused?)
|
|
1149
|
-
else
|
|
1150
|
-
parent = last.ruby_llm_parent_tool_call
|
|
1151
|
-
tool_call_message = parent.message
|
|
1152
|
-
calls = tool_call_message.ruby_llm_tool_calls
|
|
1153
|
-
|
|
1154
|
-
if calls.any? { |call| call.result.nil? } && calls.none?(&:paused?)
|
|
1155
|
-
calls.filter_map(&:result).each(&:destroy)
|
|
1156
|
-
tool_call_message.destroy
|
|
1157
|
-
end
|
|
1158
|
-
end
|
|
1159
|
-
end
|
|
1160
|
-
|
|
1161
1139
|
def eager_load_messages
|
|
1162
1140
|
assoc = messages_association
|
|
1163
1141
|
messages = assoc.to_a
|
|
@@ -18,6 +18,18 @@ module RubyLLM
|
|
|
18
18
|
@chat = chat
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
def stage(message, attachments:)
|
|
22
|
+
chat = @chat.to_llm
|
|
23
|
+
chat.raise_if_pending_tool_calls!
|
|
24
|
+
chat.raise_if_prompt_attachments!(message, attachments)
|
|
25
|
+
chat.take_queued_messages
|
|
26
|
+
if message.is_a?(RubyLLM::MCP::Prompt)
|
|
27
|
+
message.messages.each { |prompt_message| @chat.add_message(prompt_message) }
|
|
28
|
+
else
|
|
29
|
+
@chat.add_message(role: :user, content: message, attachments:)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
21
33
|
def push(content:, attachments:, steer:, sender:)
|
|
22
34
|
QueuedMessage.transaction do
|
|
23
35
|
@chat.forget_stale_cancellation
|
|
@@ -54,7 +66,8 @@ module RubyLLM
|
|
|
54
66
|
|
|
55
67
|
def take_one(queued)
|
|
56
68
|
taken = nil
|
|
57
|
-
QueuedMessage.transaction do
|
|
69
|
+
QueuedMessage.transaction(requires_new: true) do
|
|
70
|
+
@chat.forget_stale_cancellation
|
|
58
71
|
next unless QueuedMessage.where(id: queued.id).delete_all == 1
|
|
59
72
|
|
|
60
73
|
taken = [@chat.to_llm.run_around_join(queued) { |**attributes| join(queued, attributes) }]
|
|
@@ -175,6 +175,21 @@ module RubyLLM
|
|
|
175
175
|
self[:cancelled] = false if has_attribute?(:cancelled)
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
+
# Hold the same row a run claims without replacing its token, so a
|
|
179
|
+
# failed submission leaves an interrupted run discoverable.
|
|
180
|
+
def stage_while_idle
|
|
181
|
+
transaction(requires_new: true) do
|
|
182
|
+
next false unless unclaimed(chat_row, Time.current).update_all('claim_token = claim_token') == 1
|
|
183
|
+
|
|
184
|
+
resync_messages if @chat && run_finished_elsewhere?
|
|
185
|
+
yield
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
rescue StandardError
|
|
189
|
+
resync_messages if @chat
|
|
190
|
+
raise
|
|
191
|
+
end
|
|
192
|
+
|
|
178
193
|
def exclusively(busy, &)
|
|
179
194
|
return yield if @run_claim_token
|
|
180
195
|
|
|
@@ -539,17 +554,30 @@ module RubyLLM
|
|
|
539
554
|
end
|
|
540
555
|
|
|
541
556
|
# Renewing inside the transaction holds the chat row until the result
|
|
542
|
-
# commits, so no other process can take the chat over in between.
|
|
557
|
+
# commits, so no other process can take the chat over in between. A
|
|
558
|
+
# message that touches its chat touches it after the commit: on
|
|
559
|
+
# Postgres, a second write to the row the claim renewed rechecks every
|
|
560
|
+
# foreign key on it and locks each row the chat references until the
|
|
561
|
+
# commit, which can deadlock with the application's own locks.
|
|
543
562
|
def fenced_by_run_claim(&)
|
|
544
|
-
|
|
563
|
+
reflection, columns = message_chat_touch if @run_claim_token
|
|
564
|
+
committed = transaction do
|
|
545
565
|
renew_run_claim(force: true)
|
|
546
|
-
yield
|
|
566
|
+
reflection ? reflection.klass.no_touching(&) : yield
|
|
547
567
|
end
|
|
568
|
+
@message.public_send(reflection.name)&.touch(*columns) if reflection && committed
|
|
569
|
+
committed
|
|
548
570
|
rescue ClaimLost
|
|
549
571
|
@message.destroy
|
|
550
572
|
raise
|
|
551
573
|
end
|
|
552
574
|
|
|
575
|
+
def message_chat_touch
|
|
576
|
+
reflection = @message.class.reflect_on_association(@message.class.chat_association_name)
|
|
577
|
+
touch = reflection&.options&.dig(:touch)
|
|
578
|
+
[reflection, touch == true ? [] : Array(touch)] if touch
|
|
579
|
+
end
|
|
580
|
+
|
|
553
581
|
def tool_call_rows(tool_call_ids)
|
|
554
582
|
RubyLLM::ActiveRecord::ToolCall.where(
|
|
555
583
|
tool_call_id: tool_call_ids,
|
data/lib/ruby_llm/agent.rb
CHANGED
|
@@ -198,6 +198,7 @@ module RubyLLM
|
|
|
198
198
|
# Called with no arguments, returns the configured options.
|
|
199
199
|
#
|
|
200
200
|
# tool_options choice: :required, calls: :one
|
|
201
|
+
# tool_options rounds: 25, calls: 50
|
|
201
202
|
#
|
|
202
203
|
def tool_options(**options, &block)
|
|
203
204
|
return @tool_options || {} if options.empty? && !block_given?
|
data/lib/ruby_llm/aliases.json
CHANGED
|
@@ -279,6 +279,15 @@
|
|
|
279
279
|
"openrouter": "google/gemini-4-carbon",
|
|
280
280
|
"vertexai": "gemini-4-carbon"
|
|
281
281
|
},
|
|
282
|
+
"gemini-4-hydrogen": {
|
|
283
|
+
"gemini": "gemini-4-hydrogen",
|
|
284
|
+
"openrouter": "google/gemini-4-hydrogen",
|
|
285
|
+
"vertexai": "gemini-4-hydrogen"
|
|
286
|
+
},
|
|
287
|
+
"gemini-decision-1": {
|
|
288
|
+
"gemini": "gemini-decision-1",
|
|
289
|
+
"openrouter": "google/gemini-decision-1"
|
|
290
|
+
},
|
|
282
291
|
"gemini-embedding-001": {
|
|
283
292
|
"gemini": "gemini-embedding-001",
|
|
284
293
|
"openrouter": "google/gemini-embedding-001",
|
|
@@ -156,10 +156,7 @@ module RubyLLM
|
|
|
156
156
|
end
|
|
157
157
|
|
|
158
158
|
def extract(file)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
text = FileStore::Converters.convert(store.attachment(file.path, file:), config: @config.call, local: true)
|
|
162
|
-
store.cache_text(file, text) if text
|
|
159
|
+
store.text_with_digest(file, config: @config.call, local: true) unless file.text?
|
|
163
160
|
rescue StandardError => e
|
|
164
161
|
RubyLLM.logger.warn { "Could not extract the text of #{file.path}: #{e.class}" }
|
|
165
162
|
end
|
|
@@ -72,6 +72,17 @@ module RubyLLM
|
|
|
72
72
|
decision(tool_call) == false
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Returns the identity a remembered approval of +call+ applies to:
|
|
76
|
+
# later calls to the same tool. A run_code call outside a namespace
|
|
77
|
+
# is approved one call at a time.
|
|
78
|
+
def self.remembered_identity(call)
|
|
79
|
+
if call.name == CodeExecution.tool_name && call.namespace.nil?
|
|
80
|
+
raise ArgumentError, "#{call.name} calls are approved one at a time and cannot be remembered"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
[call.namespace, call.name.to_s]
|
|
84
|
+
end
|
|
85
|
+
|
|
75
86
|
def remember(identity)
|
|
76
87
|
@remembered |= [identity]
|
|
77
88
|
end
|
data/lib/ruby_llm/chat.rb
CHANGED
|
@@ -141,7 +141,8 @@ module RubyLLM
|
|
|
141
141
|
# Returns the +choice+, +calls+, +concurrency+, and +rounds+ set with
|
|
142
142
|
# #with_tool_options, with +nil+ for anything left at the default.
|
|
143
143
|
def tool_options
|
|
144
|
-
{ choice: tool_prefs[:choice], calls: tool_prefs[:calls], concurrency: concurrency,
|
|
144
|
+
{ choice: tool_prefs[:choice], calls: tool_prefs[:calls] || @tool_call_cap, concurrency: concurrency,
|
|
145
|
+
rounds: @tool_rounds }
|
|
145
146
|
end
|
|
146
147
|
|
|
147
148
|
# Creates a chat with +model:+, or with the configured default model
|
|
@@ -625,7 +626,7 @@ module RubyLLM
|
|
|
625
626
|
# Returns whether +tool_call+ requires approval and has no recorded
|
|
626
627
|
# decision.
|
|
627
628
|
def approval_pending?(tool_call) # :nodoc:
|
|
628
|
-
return false if tool_call.malformed_arguments? || skipped_result(tool_call)
|
|
629
|
+
return false if tool_call.malformed_arguments? || skipped_result(tool_call) || capped_tool_call?(tool_call)
|
|
629
630
|
return approval_decision(nil, tool_call).nil? if tool_call.remote?
|
|
630
631
|
|
|
631
632
|
tool = tool_for(tool_call)
|
|
@@ -847,7 +848,12 @@ module RubyLLM
|
|
|
847
848
|
# Configures how the model uses the registered tools. +choice:+
|
|
848
849
|
# constrains tool use to +:auto+, +:none+, +:required+, a tool name, or
|
|
849
850
|
# a Tool class. +calls:+ limits how many tool calls one response may
|
|
850
|
-
# contain
|
|
851
|
+
# contain: +:one+ (or +1+) and +:many+ ask the provider, and a larger
|
|
852
|
+
# Integer runs at most that many of a response's calls to the chat's
|
|
853
|
+
# tools, in the response's order. Each call past it gets an error
|
|
854
|
+
# result straight away, without running or asking for approval, and
|
|
855
|
+
# emits one +tool_calls_capped.ruby_llm+ event per response. It is off
|
|
856
|
+
# by default. +concurrency:+ runs tool calls
|
|
851
857
|
# concurrently: +true+ or +:threads+ for threads, +:fibers+ for fibers.
|
|
852
858
|
# +rounds:+ limits how many model responses that ask for tools one
|
|
853
859
|
# #complete may run, however many calls each holds. It is off by
|
|
@@ -860,14 +866,14 @@ module RubyLLM
|
|
|
860
866
|
#
|
|
861
867
|
# chat.with_tools(Weather, Search).with_tool_options(choice: :required)
|
|
862
868
|
# chat.with_tool_options(calls: :one, concurrency: :threads)
|
|
863
|
-
# chat.with_tool_options(rounds: 25)
|
|
869
|
+
# chat.with_tool_options(rounds: 25, calls: 50)
|
|
864
870
|
# chat.with_tool_options(choice: nil)
|
|
865
871
|
#
|
|
866
872
|
def with_tool_options(**options)
|
|
867
873
|
options.each do |option, value|
|
|
868
874
|
case option
|
|
869
875
|
when :choice then apply_tool_choice(value)
|
|
870
|
-
when :calls then
|
|
876
|
+
when :calls then apply_tool_calls(value)
|
|
871
877
|
when :concurrency then apply_tool_concurrency(value)
|
|
872
878
|
when :rounds then @tool_rounds = normalize_rounds(value)
|
|
873
879
|
else raise ArgumentError, "Unknown tool option: #{option}. Valid options are: #{TOOL_OPTIONS.join(', ')}"
|
|
@@ -1757,6 +1763,27 @@ module RubyLLM
|
|
|
1757
1763
|
'their tasks, before asking again.'
|
|
1758
1764
|
end
|
|
1759
1765
|
|
|
1766
|
+
# Closes the calls of the latest round that a raising tool left without
|
|
1767
|
+
# a result, as unfinished, the way #cancel does, so the next request is
|
|
1768
|
+
# valid and no finished call runs again. Unlike a cancel, the turn goes
|
|
1769
|
+
# on: the next #complete asks the model, without forcing another tool
|
|
1770
|
+
# call. A call that waits on a person or a task, or that another
|
|
1771
|
+
# process runs, stays pending. A call that raised once its questions
|
|
1772
|
+
# were answered is closed too, so the answers don't run it again.
|
|
1773
|
+
def close_unfinished_tool_calls # :nodoc:
|
|
1774
|
+
response = pending_tool_response
|
|
1775
|
+
return unless response
|
|
1776
|
+
|
|
1777
|
+
@tool_prefs[:choice] = nil if forced_tool_choice?
|
|
1778
|
+
pending_tool_calls(response).each do |tool_call|
|
|
1779
|
+
next if running_elsewhere?(tool_call) || paused?(tool_call) || approval_pending?(tool_call)
|
|
1780
|
+
|
|
1781
|
+
record_tool_call_input(tool_call, nil)
|
|
1782
|
+
run_callbacks(:before_message)
|
|
1783
|
+
run_callbacks(:after_message, add_message(unfinished_result(tool_call)))
|
|
1784
|
+
end
|
|
1785
|
+
end
|
|
1786
|
+
|
|
1760
1787
|
private
|
|
1761
1788
|
|
|
1762
1789
|
def agent_run_chat = self
|
|
@@ -2513,15 +2540,58 @@ module RubyLLM
|
|
|
2513
2540
|
def execute_pending_tool_calls(response)
|
|
2514
2541
|
raise_if_cancelled!
|
|
2515
2542
|
|
|
2516
|
-
|
|
2543
|
+
pending = pending_tool_calls(response).except(*capped_tool_call_ids(response))
|
|
2544
|
+
server_calls, local_calls = pending.to_h.partition { |_, call| call.remote? }.map(&:to_h)
|
|
2517
2545
|
decisions = server_calls.transform_values { |tool_call| approval_decision(nil, tool_call) }.compact
|
|
2518
2546
|
executable, denied = partition_pending_tool_calls(local_calls)
|
|
2519
2547
|
executable = executable.reject { |_id, tool_call| exclusive_blocked?(tool_call, executable) }
|
|
2520
2548
|
claimed = claim_tool_calls(executable, server_calls.slice(*decisions.keys).merge(denied))
|
|
2521
2549
|
answer_claimed_tool_calls(claimed, server_calls, decisions, executable, denied)
|
|
2550
|
+
answer_capped_tool_calls(response)
|
|
2522
2551
|
@tool_prefs[:choice] = nil if forced_tool_choice?
|
|
2523
2552
|
end
|
|
2524
2553
|
|
|
2554
|
+
def answer_capped_tool_calls(response)
|
|
2555
|
+
capped = unanswered_capped_tool_calls(response)
|
|
2556
|
+
capped.each_value do |tool_call|
|
|
2557
|
+
raise_if_cancelled!
|
|
2558
|
+
run_callbacks(:before_message)
|
|
2559
|
+
add_tool_result_message(tool_call, { error: capped_tool_call_error })
|
|
2560
|
+
end
|
|
2561
|
+
ensure
|
|
2562
|
+
instrument_capped_tool_calls(response) if capped&.any? && unanswered_capped_tool_calls(response).empty?
|
|
2563
|
+
end
|
|
2564
|
+
|
|
2565
|
+
def unanswered_capped_tool_calls(response)
|
|
2566
|
+
pending_tool_calls(response).to_h.slice(*capped_tool_call_ids(response))
|
|
2567
|
+
end
|
|
2568
|
+
|
|
2569
|
+
def capped_tool_call_ids(response)
|
|
2570
|
+
return [] unless @tool_call_cap && response
|
|
2571
|
+
|
|
2572
|
+
response.tool_calls.reject(&:remote?).drop(@tool_call_cap).map(&:id)
|
|
2573
|
+
end
|
|
2574
|
+
|
|
2575
|
+
def capped_tool_call?(tool_call)
|
|
2576
|
+
return false unless @tool_call_cap
|
|
2577
|
+
|
|
2578
|
+
response = messages.reverse_each.find { |message| message.tool_calls.key?(tool_call.id) }
|
|
2579
|
+
capped_tool_call_ids(response).include?(tool_call.id)
|
|
2580
|
+
end
|
|
2581
|
+
|
|
2582
|
+
def capped_tool_call_error
|
|
2583
|
+
"This call didn't run: too many tool calls in one answer (#{@tool_call_cap} at most). " \
|
|
2584
|
+
'Continue step by step, a few calls at a time.'
|
|
2585
|
+
end
|
|
2586
|
+
|
|
2587
|
+
def instrument_capped_tool_calls(response)
|
|
2588
|
+
requested = response.tool_calls.count { |tool_call| !tool_call.remote? }
|
|
2589
|
+
payload = { chat: self, provider: response.provider || @provider.slug, model: response.model_id || @model.id,
|
|
2590
|
+
requested:, allowed: @tool_call_cap, capped: requested - @tool_call_cap, chat_id: @run.chat_id,
|
|
2591
|
+
**agent_fields }
|
|
2592
|
+
RubyLLM.instrument('tool_calls_capped.ruby_llm', payload.compact, config: @config)
|
|
2593
|
+
end
|
|
2594
|
+
|
|
2525
2595
|
def answer_claimed_tool_calls(claimed, server_calls, decisions, executable, denied)
|
|
2526
2596
|
respond_to_tool_approvals(server_calls.slice(*claimed), decisions)
|
|
2527
2597
|
deny_tool_calls(denied.slice(*claimed))
|
|
@@ -2647,11 +2717,8 @@ module RubyLLM
|
|
|
2647
2717
|
id = tool_call.respond_to?(:id) ? tool_call.id : tool_call
|
|
2648
2718
|
call = tool_call.respond_to?(:name) ? tool_call : find_tool_call(id)
|
|
2649
2719
|
raise ArgumentError, "Unknown tool call: #{id.inspect}" unless call
|
|
2650
|
-
if call.name == CodeExecution.tool_name && call.namespace.nil?
|
|
2651
|
-
raise ArgumentError, "#{call.name} calls are approved one at a time and cannot be remembered"
|
|
2652
|
-
end
|
|
2653
2720
|
|
|
2654
|
-
|
|
2721
|
+
ToolCallState.remembered_identity(call)
|
|
2655
2722
|
end
|
|
2656
2723
|
|
|
2657
2724
|
def tool_call_answered?(tool_call)
|
|
@@ -3031,6 +3098,13 @@ module RubyLLM
|
|
|
3031
3098
|
"Available modes: #{ToolConcurrency::MODES.join(', ')}"
|
|
3032
3099
|
end
|
|
3033
3100
|
|
|
3101
|
+
def apply_tool_calls(value)
|
|
3102
|
+
cap = value if value.is_a?(Integer) && value > 1
|
|
3103
|
+
calls = normalize_calls(value) unless value.nil? || cap
|
|
3104
|
+
@tool_call_cap = cap
|
|
3105
|
+
@tool_prefs[:calls] = calls
|
|
3106
|
+
end
|
|
3107
|
+
|
|
3034
3108
|
def normalize_calls(calls)
|
|
3035
3109
|
case calls
|
|
3036
3110
|
when :many, 'many'
|
|
@@ -3038,7 +3112,8 @@ module RubyLLM
|
|
|
3038
3112
|
when :one, 'one', 1
|
|
3039
3113
|
:one
|
|
3040
3114
|
else
|
|
3041
|
-
raise ArgumentError,
|
|
3115
|
+
raise ArgumentError,
|
|
3116
|
+
"Invalid calls value: #{calls.inspect}. Valid values are: :many, :one, or a positive integer"
|
|
3042
3117
|
end
|
|
3043
3118
|
end
|
|
3044
3119
|
|
|
@@ -17,7 +17,7 @@ module RubyLLM
|
|
|
17
17
|
# overrides.
|
|
18
18
|
#
|
|
19
19
|
# Provider credentials such as +openai_api_key+ are declared by each
|
|
20
|
-
# provider. See https://rubyllm.com/
|
|
20
|
+
# provider. See https://rubyllm.com/configuration-providers/ for
|
|
21
21
|
# provider credentials and endpoint settings.
|
|
22
22
|
#
|
|
23
23
|
# Assigning an empty or whitespace-only string to any option stores
|
|
@@ -44,13 +44,13 @@ module RubyLLM
|
|
|
44
44
|
seen = nil
|
|
45
45
|
result = guarded do
|
|
46
46
|
file = file_at(path)
|
|
47
|
-
seen = [file.path, digest_of(file)]
|
|
48
47
|
unit = (unit || 'lines').to_s
|
|
49
48
|
raise ToolError, "unit is lines or characters, not #{unit}." unless UNITS.include?(unit)
|
|
50
|
-
next "#{file.path} is empty." if file.byte_size.zero?
|
|
51
49
|
|
|
52
|
-
text =
|
|
50
|
+
text, digest = contents(file)
|
|
51
|
+
seen = [file.path, digest]
|
|
53
52
|
next unreadable(file) if text.nil?
|
|
53
|
+
next "#{file.path} is empty." if text.empty?
|
|
54
54
|
|
|
55
55
|
unit == 'characters' ? characters(file, text, offset, limit) : lines(file, text, offset, limit)
|
|
56
56
|
end
|
|
@@ -60,6 +60,12 @@ module RubyLLM
|
|
|
60
60
|
|
|
61
61
|
private
|
|
62
62
|
|
|
63
|
+
def contents(file)
|
|
64
|
+
return ['', file.digest || FileStore.digest('')] if file.byte_size.zero?
|
|
65
|
+
|
|
66
|
+
store.text_with_digest(file, config:)
|
|
67
|
+
end
|
|
68
|
+
|
|
63
69
|
def lines(file, text, offset, limit)
|
|
64
70
|
all = text.lines
|
|
65
71
|
start = [offset.to_i, 1].max
|
data/lib/ruby_llm/file_store.rb
CHANGED
|
@@ -252,32 +252,52 @@ module RubyLLM
|
|
|
252
252
|
#
|
|
253
253
|
# See Configuration#file_converters for adding converters.
|
|
254
254
|
def text(path, config: nil)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return cached if cached
|
|
255
|
+
text_with_digest(find(path), config:).first
|
|
256
|
+
end
|
|
258
257
|
|
|
259
|
-
|
|
258
|
+
# Returns the converted text of +file+ and the digest of the bytes it
|
|
259
|
+
# came from. With <tt>local: true</tt>, only converters that run on
|
|
260
|
+
# this machine for free are tried.
|
|
261
|
+
def text_with_digest(file, config:, local: false) # :nodoc:
|
|
262
|
+
text = cached_text(file) if file.digest
|
|
263
|
+
return [text, file.digest] if text
|
|
264
|
+
|
|
265
|
+
# Convert the bytes read here, and key the conversion by their own
|
|
266
|
+
# digest, so text never pairs with a version it didn't come from.
|
|
267
|
+
content = read(file.path)
|
|
268
|
+
file = StoredFile.new(**file.to_h, digest: FileStore.digest(content))
|
|
269
|
+
text = cached_text(file)
|
|
270
|
+
unless text
|
|
271
|
+
text = Converters.convert(attachment_of(file, content), config: config || RubyLLM.config, local:)
|
|
260
272
|
cache_text(file, text) if text
|
|
261
273
|
end
|
|
274
|
+
[text, file.digest]
|
|
262
275
|
end
|
|
263
276
|
|
|
264
277
|
# Returns the file at +path+ as an Attachment, to send it to a model.
|
|
265
278
|
# Raises FileNotFoundError when there is none.
|
|
266
279
|
def attachment(path, file: nil)
|
|
267
280
|
file ||= find(path)
|
|
268
|
-
|
|
281
|
+
attachment_of(file, read(file.path))
|
|
269
282
|
end
|
|
270
283
|
|
|
271
284
|
# Returns the converted text kept for +file+, or +nil+, from memory.
|
|
285
|
+
# A file that can no longer be read has none.
|
|
272
286
|
def cached_text(file) # :nodoc:
|
|
273
|
-
|
|
287
|
+
return unless file.digest || cached_path?(file)
|
|
288
|
+
|
|
289
|
+
key = text_key(file)
|
|
290
|
+
@texts_lock.synchronize { @texts[key] }
|
|
291
|
+
rescue FileNotFoundError
|
|
292
|
+
nil
|
|
274
293
|
end
|
|
275
294
|
|
|
276
295
|
# Keeps +text+ as the conversion of +file+, in memory.
|
|
277
296
|
def cache_text(file, text) # :nodoc:
|
|
297
|
+
key = text_key(file)
|
|
278
298
|
@texts_lock.synchronize do
|
|
279
299
|
@texts.delete(@texts.keys.first) if @texts.size >= CACHED_TEXTS
|
|
280
|
-
@texts[
|
|
300
|
+
@texts[key] = text
|
|
281
301
|
end
|
|
282
302
|
end
|
|
283
303
|
|
|
@@ -331,8 +351,22 @@ module RubyLLM
|
|
|
331
351
|
file.digest ? file.digest == digest : read(file.path).b == content
|
|
332
352
|
end
|
|
333
353
|
|
|
354
|
+
# A stored type is kept, unless it is the generic
|
|
355
|
+
# application/octet-stream, which leaves detection to the bytes.
|
|
356
|
+
def attachment_of(file, content)
|
|
357
|
+
generic = file.content_type.to_s.split(';').first.to_s.strip.casecmp?('application/octet-stream')
|
|
358
|
+
mime_type = file.content_type unless generic
|
|
359
|
+
Attachment.new(StringIO.new(content), filename: file.filename, mime_type:)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def cached_path?(file)
|
|
363
|
+
@texts_lock.synchronize do
|
|
364
|
+
@texts.each_key.any? { |path, type| path == file.path && type == file.content_type }
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
334
368
|
def text_key(file)
|
|
335
|
-
|
|
369
|
+
[file.path, file.content_type, file.digest || FileStore.digest(read(file.path))]
|
|
336
370
|
end
|
|
337
371
|
end
|
|
338
372
|
end
|