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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e784b5ab43c18ac17675ffa3e46c802934a9f8eaa4ef733bd1e7c450c462fff5
4
- data.tar.gz: 69d10ad8492781735ce7b50d56476bd39d3cf405e9218adb819e735712144761
3
+ metadata.gz: 55db20760e71db4c87a6f71b21ac7eba6b1651838dc6d897397d7d61e8283e14
4
+ data.tar.gz: d624ca6c55d349a74876d2ac12609484b7e8a7d28c08f7133e0c827550fd98cd
5
5
  SHA512:
6
- metadata.gz: 38f20b77e3e1553f8b3962fe2da6a11c39ea339363447b652387a832637ccf0b8316d848a895296e157d869d22cc661ce231a891369aa1aee3f33cc06fb863f0
7
- data.tar.gz: 1403357da777eacf6fce2d449de634e16fd848f93f8e36b9c06632fe60d1b638afe7856456ccfb1e948b5d443980c8a442f586e6f21af7cf5775dd485cdabe47
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.0**. 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/).
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.0
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:
@@ -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, required: field.required?, id: field_id,
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, required: field.required?, id: field_id,
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
- to_llm.approve(record.to_llm, remember:) if record.result_id.nil?
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
- return tap { queue(message, with:) } if running? && !message.is_a?(RubyLLM::MCP::Prompt)
843
-
844
- to_llm.raise_if_pending_tool_calls!
845
- to_llm.raise_if_prompt_attachments!(message, with)
846
- to_llm.take_queued_messages
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 fails,
902
- # destroys the empty assistant message and any orphaned tool results,
903
- # then re-raises the error. When the block raises, destroys the empty
904
- # assistant message and re-raises. Tool calls paused on a task or a
905
- # question stay, so the next #complete picks them up.
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
- cleanup_orphaned_tool_results
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
- transaction do
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,
@@ -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?
@@ -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
- return if file.text? || store.cached_text(file)
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, rounds: @tool_rounds }
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 (+:many+ or +:one+). +concurrency:+ runs tool calls
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 @tool_prefs[:calls] = value.nil? ? nil : normalize_calls(value)
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
- server_calls, local_calls = pending_tool_calls(response).to_h.partition { |_, call| call.remote? }.map(&:to_h)
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
- [call.namespace, call.name.to_s]
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, "Invalid calls value: #{calls.inspect}. Valid values are: :many, :one, or 1"
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/next/configuration-providers/ for
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 = store.text(file.path, config:)
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
@@ -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
- file = find(path)
256
- cached = cached_text(file)
257
- return cached if cached
255
+ text_with_digest(find(path), config:).first
256
+ end
258
257
 
259
- Converters.convert(attachment(file.path, file:), config: config || RubyLLM.config).tap do |text|
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
- Attachment.new(StringIO.new(read(file.path)), filename: file.filename)
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
- @texts_lock.synchronize { @texts[text_key(file)] }
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[text_key(file)] = text
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
- file.digest || [file.path, file.byte_size, file.updated_at]
369
+ [file.path, file.content_type, file.digest || FileStore.digest(read(file.path))]
336
370
  end
337
371
  end
338
372
  end