ruby_llm 1.12.0 → 1.14.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.
Files changed (141) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -5
  3. data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
  4. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
  5. data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
  10. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +2 -2
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
  37. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
  49. data/lib/generators/ruby_llm/generator_helpers.rb +37 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +22 -18
  51. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  52. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -1
  53. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +4 -10
  54. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +2 -2
  55. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -2
  56. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  57. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  59. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  60. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  61. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +1 -1
  63. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  66. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  67. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  68. data/lib/ruby_llm/active_record/acts_as_legacy.rb +87 -20
  69. data/lib/ruby_llm/active_record/chat_methods.rb +80 -22
  70. data/lib/ruby_llm/active_record/message_methods.rb +17 -0
  71. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  72. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  73. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  74. data/lib/ruby_llm/agent.rb +50 -8
  75. data/lib/ruby_llm/aliases.json +60 -21
  76. data/lib/ruby_llm/attachment.rb +4 -1
  77. data/lib/ruby_llm/chat.rb +113 -12
  78. data/lib/ruby_llm/configuration.rb +65 -66
  79. data/lib/ruby_llm/connection.rb +11 -7
  80. data/lib/ruby_llm/content.rb +6 -2
  81. data/lib/ruby_llm/error.rb +37 -1
  82. data/lib/ruby_llm/message.rb +5 -3
  83. data/lib/ruby_llm/model/info.rb +15 -13
  84. data/lib/ruby_llm/models.json +12279 -13517
  85. data/lib/ruby_llm/models.rb +16 -6
  86. data/lib/ruby_llm/provider.rb +10 -1
  87. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  88. data/lib/ruby_llm/providers/anthropic/chat.rb +22 -5
  89. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  90. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  91. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  92. data/lib/ruby_llm/providers/azure/chat.rb +1 -1
  93. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -1
  94. data/lib/ruby_llm/providers/azure/models.rb +1 -1
  95. data/lib/ruby_llm/providers/azure.rb +92 -0
  96. data/lib/ruby_llm/providers/bedrock/chat.rb +50 -5
  97. data/lib/ruby_llm/providers/bedrock/models.rb +17 -1
  98. data/lib/ruby_llm/providers/bedrock/streaming.rb +8 -4
  99. data/lib/ruby_llm/providers/bedrock.rb +9 -1
  100. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  101. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  102. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  103. data/lib/ruby_llm/providers/gemini/chat.rb +20 -4
  104. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  105. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  106. data/lib/ruby_llm/providers/gemini/streaming.rb +2 -1
  107. data/lib/ruby_llm/providers/gemini/tools.rb +19 -0
  108. data/lib/ruby_llm/providers/gemini.rb +4 -0
  109. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  110. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  111. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  112. data/lib/ruby_llm/providers/mistral/chat.rb +2 -1
  113. data/lib/ruby_llm/providers/mistral.rb +4 -0
  114. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  115. data/lib/ruby_llm/providers/ollama.rb +11 -1
  116. data/lib/ruby_llm/providers/openai/capabilities.rb +95 -195
  117. data/lib/ruby_llm/providers/openai/chat.rb +15 -5
  118. data/lib/ruby_llm/providers/openai/media.rb +4 -1
  119. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  120. data/lib/ruby_llm/providers/openai/temperature.rb +2 -2
  121. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  122. data/lib/ruby_llm/providers/openai.rb +10 -0
  123. data/lib/ruby_llm/providers/openrouter/chat.rb +19 -5
  124. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  125. data/lib/ruby_llm/providers/openrouter.rb +35 -1
  126. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  127. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  128. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  129. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  130. data/lib/ruby_llm/providers/vertexai.rb +18 -6
  131. data/lib/ruby_llm/providers/xai.rb +4 -0
  132. data/lib/ruby_llm/stream_accumulator.rb +10 -5
  133. data/lib/ruby_llm/streaming.rb +7 -7
  134. data/lib/ruby_llm/tool.rb +48 -3
  135. data/lib/ruby_llm/version.rb +1 -1
  136. data/lib/tasks/models.rake +33 -7
  137. data/lib/tasks/release.rake +1 -1
  138. data/lib/tasks/ruby_llm.rake +9 -1
  139. data/lib/tasks/vcr.rake +1 -1
  140. metadata +56 -15
  141. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
@@ -18,8 +18,6 @@ module RubyLLM
18
18
  class_name: @message_class,
19
19
  inverse_of: :chat,
20
20
  dependent: :destroy
21
-
22
- delegate :add_message, to: :to_llm
23
21
  end
24
22
 
25
23
  def acts_as_message(chat_class: 'Chat',
@@ -60,6 +58,8 @@ module RubyLLM
60
58
  end
61
59
 
62
60
  def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil)
61
+ include RubyLLM::ActiveRecord::ToolCallMethods
62
+
63
63
  @message_class = message_class.to_s
64
64
  @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class)
65
65
  @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name)
@@ -99,6 +99,7 @@ module RubyLLM
99
99
  ordered_messages.each do |msg|
100
100
  @chat.add_message(msg.to_llm)
101
101
  end
102
+ reapply_runtime_instructions(@chat)
102
103
 
103
104
  setup_persistence_callbacks
104
105
  end
@@ -111,6 +112,14 @@ module RubyLLM
111
112
  self
112
113
  end
113
114
 
115
+ def with_runtime_instructions(instructions, append: false, replace: nil)
116
+ append = append_instructions?(append:, replace:)
117
+ store_runtime_instruction(instructions, append:)
118
+
119
+ to_llm.with_instructions(instructions, append:, replace:)
120
+ self
121
+ end
122
+
114
123
  def with_tool(...)
115
124
  to_llm.with_tool(...)
116
125
  self
@@ -185,14 +194,34 @@ module RubyLLM
185
194
  self
186
195
  end
187
196
 
188
- def create_user_message(content, with: nil)
189
- message_record = messages.create!(role: :user, content: content)
190
- persist_content(message_record, with) if with.present?
197
+ def add_message(message_or_attributes)
198
+ llm_message = message_or_attributes.is_a?(RubyLLM::Message) ? message_or_attributes : RubyLLM::Message.new(message_or_attributes)
199
+ content, attachments_to_persist = prepare_content_for_storage(llm_message.content)
200
+
201
+ attrs = { role: llm_message.role, content: }
202
+ tool_call_foreign_key = messages.klass.tool_call_foreign_key
203
+ if llm_message.tool_call_id && tool_call_foreign_key
204
+ tool_call_id = find_tool_call_id(llm_message.tool_call_id)
205
+ attrs[tool_call_foreign_key] = tool_call_id if tool_call_id
206
+ end
207
+
208
+ message_record = messages.create!(attrs)
209
+ persist_content(message_record, attachments_to_persist) if attachments_to_persist.present?
210
+ persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
211
+
191
212
  message_record
192
213
  end
193
214
 
194
- def ask(message, with: nil, &)
195
- create_user_message(message, with:)
215
+ def create_user_message(content, with: nil)
216
+ RubyLLM.logger.warn(
217
+ '`create_user_message` is deprecated and will be removed in RubyLLM 2.0. ' \
218
+ 'Use `add_message(role: :user, content: ...)` instead.'
219
+ )
220
+ add_message(role: :user, content: build_content(content, with))
221
+ end
222
+
223
+ def ask(message = nil, with: nil, &)
224
+ add_message(role: :user, content: build_content(message, with))
196
225
  complete(&)
197
226
  end
198
227
 
@@ -267,6 +296,26 @@ module RubyLLM
267
296
  system_messages + non_system_messages
268
297
  end
269
298
 
299
+ def runtime_instructions
300
+ @runtime_instructions ||= []
301
+ end
302
+
303
+ def store_runtime_instruction(instructions, append:)
304
+ if append
305
+ runtime_instructions << instructions
306
+ else
307
+ @runtime_instructions = [instructions]
308
+ end
309
+ end
310
+
311
+ def reapply_runtime_instructions(chat)
312
+ return if runtime_instructions.empty?
313
+
314
+ first, *rest = runtime_instructions
315
+ chat.with_instructions(first)
316
+ rest.each { |instruction| chat.with_instructions(instruction, append: true) }
317
+ end
318
+
270
319
  def setup_persistence_callbacks
271
320
  return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
272
321
 
@@ -281,21 +330,13 @@ module RubyLLM
281
330
  @message = messages.create!(role: :assistant, content: '')
282
331
  end
283
332
 
284
- def persist_message_completion(message) # rubocop:disable Metrics/PerceivedComplexity
333
+ def persist_message_completion(message)
285
334
  return unless message
286
335
 
287
336
  tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
288
337
 
289
338
  transaction do
290
- content = message.content
291
- attachments_to_persist = nil
292
-
293
- if content.is_a?(RubyLLM::Content)
294
- attachments_to_persist = content.attachments if content.attachments.any?
295
- content = content.text
296
- elsif content.is_a?(Hash) || content.is_a?(Array)
297
- content = content.to_json
298
- end
339
+ content, attachments_to_persist = prepare_content_for_storage(message.content)
299
340
 
300
341
  @message.update!(
301
342
  role: message.role,
@@ -312,14 +353,17 @@ module RubyLLM
312
353
  end
313
354
  end
314
355
 
315
- def persist_tool_calls(tool_calls)
316
- supports_thought_signature = tool_calls.klass.column_names.include?('thought_signature')
356
+ def persist_tool_calls(tool_calls, message_record: @message)
357
+ tool_call_assoc = message_record.respond_to?(:tool_calls) ? message_record.tool_calls : nil
358
+ return unless tool_call_assoc
359
+
360
+ supports_thought_signature = tool_call_assoc.klass.column_names.include?('thought_signature')
317
361
 
318
362
  tool_calls.each_value do |tool_call|
319
363
  attributes = tool_call.to_h
320
364
  attributes.delete(:thought_signature) unless supports_thought_signature
321
365
  attributes[:tool_call_id] = attributes.delete(:id)
322
- @message.tool_calls.create!(**attributes)
366
+ tool_call_assoc.create!(**attributes)
323
367
  end
324
368
  end
325
369
 
@@ -363,6 +407,29 @@ module RubyLLM
363
407
  RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
364
408
  nil
365
409
  end
410
+
411
+ def build_content(message, attachments)
412
+ return message if content_like?(message)
413
+
414
+ RubyLLM::Content.new(message, attachments)
415
+ end
416
+
417
+ def content_like?(object)
418
+ object.is_a?(RubyLLM::Content) || object.is_a?(RubyLLM::Content::Raw)
419
+ end
420
+
421
+ def prepare_content_for_storage(content)
422
+ attachments = nil
423
+
424
+ if content.is_a?(RubyLLM::Content)
425
+ attachments = content.attachments if content.attachments.any?
426
+ [content.text, attachments]
427
+ elsif content.is_a?(Hash) || content.is_a?(Array)
428
+ [content.to_json, attachments]
429
+ else
430
+ [content, attachments]
431
+ end
432
+ end
366
433
  end
367
434
 
368
435
  # Methods mixed into message models.
@@ -79,7 +79,8 @@ module RubyLLM
79
79
  model_record = model_association
80
80
  @chat ||= (context || RubyLLM).chat(
81
81
  model: model_record.model_id,
82
- provider: model_record.provider.to_sym
82
+ provider: model_record.provider.to_sym,
83
+ assume_model_exists: assume_model_exists || false
83
84
  )
84
85
  @chat.reset_messages!
85
86
 
@@ -87,6 +88,7 @@ module RubyLLM
87
88
  ordered_messages.each do |msg|
88
89
  @chat.add_message(msg.to_llm)
89
90
  end
91
+ reapply_runtime_instructions(@chat)
90
92
 
91
93
  setup_persistence_callbacks
92
94
  end
@@ -99,6 +101,14 @@ module RubyLLM
99
101
  self
100
102
  end
101
103
 
104
+ def with_runtime_instructions(instructions, append: false, replace: nil)
105
+ append = append_instructions?(append:, replace:)
106
+ store_runtime_instruction(instructions, append:)
107
+
108
+ to_llm.with_instructions(instructions, append:, replace:)
109
+ self
110
+ end
111
+
102
112
  def with_tool(...)
103
113
  to_llm.with_tool(...)
104
114
  self
@@ -115,7 +125,7 @@ module RubyLLM
115
125
  self.assume_model_exists = assume_exists
116
126
  resolve_model_from_strings
117
127
  save!
118
- to_llm.with_model(model.model_id, provider: model.provider.to_sym, assume_exists:)
128
+ to_llm.with_model(model_association.model_id, provider: model_association.provider.to_sym, assume_exists:)
119
129
  self
120
130
  end
121
131
 
@@ -178,22 +188,32 @@ module RubyLLM
178
188
  self
179
189
  end
180
190
 
181
- def create_user_message(content, with: nil)
182
- content_text, attachments, content_raw = prepare_content_for_storage(content)
191
+ def add_message(message_or_attributes)
192
+ llm_message = message_or_attributes.is_a?(RubyLLM::Message) ? message_or_attributes : RubyLLM::Message.new(message_or_attributes)
193
+ content_text, attachments, content_raw = prepare_content_for_storage(llm_message.content)
183
194
 
184
- message_record = messages_association.build(role: :user)
185
- message_record.content = content_text
186
- message_record.content_raw = content_raw if message_record.respond_to?(:content_raw=)
187
- message_record.save!
195
+ attrs = { role: llm_message.role, content: content_text }
196
+ parent_tool_call_assoc = messages_association.klass.reflect_on_association(:parent_tool_call)
197
+ if parent_tool_call_assoc && llm_message.tool_call_id
198
+ tool_call_id = find_tool_call_id(llm_message.tool_call_id)
199
+ attrs[parent_tool_call_assoc.foreign_key] = tool_call_id if tool_call_id
200
+ end
201
+
202
+ message_record = messages_association.create!(attrs)
203
+ message_record.update!(content_raw:) if message_record.respond_to?(:content_raw=)
188
204
 
189
- persist_content(message_record, with) if with.present?
190
205
  persist_content(message_record, attachments) if attachments.present?
206
+ persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
191
207
 
192
208
  message_record
193
209
  end
194
210
 
195
- def ask(message, with: nil, &)
196
- create_user_message(message, with:)
211
+ def create_user_message(content, with: nil)
212
+ add_message(role: :user, content: build_content(content, with))
213
+ end
214
+
215
+ def ask(message = nil, with: nil, &)
216
+ add_message(role: :user, content: build_content(message, with))
197
217
  complete(&)
198
218
  end
199
219
 
@@ -223,9 +243,10 @@ module RubyLLM
223
243
  if last.tool_call?
224
244
  last.destroy
225
245
  elsif last.tool_result?
226
- tool_call_message = last.parent_tool_call.message
227
- expected_results = tool_call_message.tool_calls.pluck(:id)
228
- actual_results = tool_call_message.tool_results.pluck(:tool_call_id)
246
+ tool_call_message = last.parent_tool_call.message_association
247
+ expected_results = tool_call_message.tool_calls_association.pluck(:id)
248
+ fk_column = tool_call_message.class.reflections['tool_results'].foreign_key
249
+ actual_results = tool_call_message.tool_results.pluck(fk_column)
229
250
 
230
251
  if expected_results.sort != actual_results.sort
231
252
  tool_call_message.tool_results.each(&:destroy)
@@ -278,6 +299,26 @@ module RubyLLM
278
299
  system_messages + non_system_messages
279
300
  end
280
301
 
302
+ def runtime_instructions
303
+ @runtime_instructions ||= []
304
+ end
305
+
306
+ def store_runtime_instruction(instructions, append:)
307
+ if append
308
+ runtime_instructions << instructions
309
+ else
310
+ @runtime_instructions = [instructions]
311
+ end
312
+ end
313
+
314
+ def reapply_runtime_instructions(chat)
315
+ return if runtime_instructions.empty?
316
+
317
+ first, *rest = runtime_instructions
318
+ chat.with_instructions(first)
319
+ rest.each { |instruction| chat.with_instructions(instruction, append: true) }
320
+ end
321
+
281
322
  def persist_new_message
282
323
  @message = messages_association.create!(role: :assistant, content: '')
283
324
  end
@@ -323,15 +364,15 @@ module RubyLLM
323
364
  end
324
365
  # rubocop:enable Metrics/PerceivedComplexity
325
366
 
326
- def persist_tool_calls(tool_calls)
327
- tool_call_klass = @message.tool_calls_association.klass
367
+ def persist_tool_calls(tool_calls, message_record: @message)
368
+ tool_call_klass = message_record.tool_calls_association.klass
328
369
  supports_thought_signature = tool_call_klass.column_names.include?('thought_signature')
329
370
 
330
371
  tool_calls.each_value do |tool_call|
331
372
  attributes = tool_call.to_h
332
373
  attributes.delete(:thought_signature) unless supports_thought_signature
333
374
  attributes[:tool_call_id] = attributes.delete(:id)
334
- @message.tool_calls_association.create!(**attributes)
375
+ message_record.tool_calls_association.create!(**attributes)
335
376
  end
336
377
  end
337
378
 
@@ -376,16 +417,33 @@ module RubyLLM
376
417
 
377
418
  attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
378
419
 
379
- {
380
- io: StringIO.new(attachment.content),
381
- filename: attachment.filename,
382
- content_type: attachment.mime_type
383
- }
420
+ if attachment.active_storage?
421
+ case attachment.source
422
+ when ActiveStorage::Blob then attachment.source
423
+ when ActiveStorage::Attached::One, ActiveStorage::Attached::Many then attachment.source.blobs
424
+ end
425
+ else
426
+ {
427
+ io: StringIO.new(attachment.content),
428
+ filename: attachment.filename,
429
+ content_type: attachment.mime_type
430
+ }
431
+ end
384
432
  rescue StandardError => e
385
433
  RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
386
434
  nil
387
435
  end
388
436
 
437
+ def build_content(message, attachments)
438
+ return message if content_like?(message)
439
+
440
+ RubyLLM::Content.new(message, attachments)
441
+ end
442
+
443
+ def content_like?(object)
444
+ object.is_a?(RubyLLM::Content) || object.is_a?(RubyLLM::Content::Raw)
445
+ end
446
+
389
447
  def prepare_content_for_storage(content)
390
448
  attachments = nil
391
449
  content_raw = nil
@@ -5,6 +5,7 @@ module RubyLLM
5
5
  # Methods mixed into message models.
6
6
  module MessageMethods
7
7
  extend ActiveSupport::Concern
8
+ include PayloadHelpers
8
9
 
9
10
  class_methods do
10
11
  attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
@@ -39,6 +40,22 @@ module RubyLLM
39
40
  )
40
41
  end
41
42
 
43
+ def to_partial_path
44
+ partial_prefix = self.class.name.underscore.pluralize
45
+ role_partial = if to_llm.tool_call?
46
+ 'tool_calls'
47
+ elsif role.to_s == 'tool'
48
+ 'tool'
49
+ else
50
+ role.to_s.presence || 'assistant'
51
+ end
52
+ "#{partial_prefix}/#{role_partial}"
53
+ end
54
+
55
+ def tool_error_message
56
+ payload_error_message(content)
57
+ end
58
+
42
59
  private
43
60
 
44
61
  def thinking_text_value
@@ -77,7 +77,7 @@ module RubyLLM
77
77
  delegate :supports?, :supports_vision?, :supports_functions?, :type,
78
78
  :input_price_per_million, :output_price_per_million,
79
79
  :function_calling?, :structured_output?, :batch?,
80
- :reasoning?, :citations?, :streaming?, :provider_class,
80
+ :reasoning?, :citations?, :streaming?, :provider_class, :label,
81
81
  to: :to_llm
82
82
  end
83
83
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Shared helpers for parsing serialized payloads on ActiveRecord-backed models.
6
+ module PayloadHelpers
7
+ private
8
+
9
+ def payload_error_message(value)
10
+ payload = parse_payload(value)
11
+ return unless payload.is_a?(Hash)
12
+
13
+ payload['error'] || payload[:error]
14
+ end
15
+
16
+ def parse_payload(value)
17
+ return value if value.is_a?(Hash) || value.is_a?(Array)
18
+ return if value.blank?
19
+
20
+ JSON.parse(value)
21
+ rescue JSON::ParserError
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Methods mixed into tool call models.
6
+ module ToolCallMethods
7
+ extend ActiveSupport::Concern
8
+ include PayloadHelpers
9
+
10
+ def tool_error_message
11
+ payload_error_message(arguments)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'erb'
4
+ require 'forwardable'
4
5
  require 'pathname'
6
+ require 'ruby_llm/schema'
5
7
 
6
8
  module RubyLLM
7
9
  # Base class for simple, class-configured agents.
8
10
  class Agent
11
+ extend Forwardable
9
12
  include Enumerable
10
13
 
11
14
  class << self
@@ -118,6 +121,7 @@ module RubyLLM
118
121
  input_values, = partition_inputs(kwargs)
119
122
  record = resolved_chat_model.find(id)
120
123
  apply_configuration(record, input_values:, persist_instructions: false)
124
+
121
125
  record
122
126
  end
123
127
 
@@ -126,6 +130,7 @@ module RubyLLM
126
130
 
127
131
  input_values, = partition_inputs(kwargs)
128
132
  record = chat_or_id.is_a?(resolved_chat_model) ? chat_or_id : resolved_chat_model.find(chat_or_id)
133
+ apply_assume_model_exists(record)
129
134
  runtime = runtime_context(chat: record, inputs: input_values)
130
135
  instructions_value = resolved_instructions_value(record, runtime, inputs: input_values)
131
136
  return record if instructions_value.nil?
@@ -136,7 +141,10 @@ module RubyLLM
136
141
 
137
142
  def render_prompt(name, chat:, inputs:, locals:)
138
143
  path = prompt_path_for(name)
139
- return nil unless File.exist?(path)
144
+ unless File.exist?(path)
145
+ raise RubyLLM::PromptNotFoundError,
146
+ "Prompt file not found for #{self}: #{path}. Create the file or use inline instructions."
147
+ end
140
148
 
141
149
  resolved_locals = resolve_prompt_locals(locals, runtime: runtime_context(chat:, inputs:), chat:, inputs:)
142
150
  ERB.new(File.read(path)).result_with_hash(resolved_locals)
@@ -175,7 +183,10 @@ module RubyLLM
175
183
  value = resolved_instructions_value(chat_object, runtime, inputs:)
176
184
  return if value.nil?
177
185
 
178
- instruction_target(chat_object, persist:).with_instructions(value)
186
+ target = instruction_target(chat_object, persist:)
187
+ return target.with_runtime_instructions(value) if use_runtime_instructions?(target, persist:)
188
+
189
+ target.with_instructions(value)
179
190
  end
180
191
 
181
192
  def apply_tools(llm_chat, runtime)
@@ -202,20 +213,40 @@ module RubyLLM
202
213
  end
203
214
 
204
215
  def apply_schema(llm_chat, runtime)
205
- value = evaluate(schema, runtime)
216
+ value = resolved_schema_value(runtime)
206
217
  llm_chat.with_schema(value) if value
207
218
  end
208
219
 
220
+ def resolved_schema_value(runtime)
221
+ value = schema
222
+ return value unless value.is_a?(Proc)
223
+
224
+ evaluate(value, runtime)
225
+ rescue NoMethodError => e
226
+ raise unless e.receiver.equal?(runtime)
227
+
228
+ RubyLLM::Schema.create(&value)
229
+ end
230
+
209
231
  def llm_chat_for(chat_object)
232
+ apply_assume_model_exists(chat_object)
210
233
  chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
211
234
  end
212
235
 
236
+ def apply_assume_model_exists(chat_object)
237
+ return unless chat_kwargs.key?(:assume_model_exists) &&
238
+ resolved_chat_model &&
239
+ chat_object.is_a?(resolved_chat_model)
240
+
241
+ chat_object.assume_model_exists = chat_kwargs[:assume_model_exists]
242
+ end
243
+
213
244
  def evaluate(value, runtime)
214
245
  value.is_a?(Proc) ? runtime.instance_exec(&value) : value
215
246
  end
216
247
 
217
248
  def resolved_instructions_value(chat_object, runtime, inputs:)
218
- value = evaluate(instructions, runtime)
249
+ value = evaluate(@instructions, runtime)
219
250
  return value unless prompt_instruction?(value)
220
251
 
221
252
  runtime.prompt(
@@ -232,10 +263,20 @@ module RubyLLM
232
263
  if persist || !chat_object.respond_to?(:to_llm)
233
264
  chat_object
234
265
  else
235
- chat_object.to_llm
266
+ runtime_instruction_target(chat_object)
236
267
  end
237
268
  end
238
269
 
270
+ def runtime_instruction_target(chat_object)
271
+ return chat_object if chat_object.respond_to?(:with_runtime_instructions)
272
+
273
+ chat_object.to_llm
274
+ end
275
+
276
+ def use_runtime_instructions?(target, persist:)
277
+ !persist && target.respond_to?(:with_runtime_instructions)
278
+ end
279
+
239
280
  def resolve_prompt_locals(locals, runtime:, chat:, inputs:)
240
281
  base = { chat: chat }.merge(inputs)
241
282
  evaluated = locals.each_with_object({}) do |(key, value), acc|
@@ -316,8 +357,9 @@ module RubyLLM
316
357
 
317
358
  attr_reader :chat
318
359
 
319
- delegate :ask, :say, :complete, :add_message, :messages,
320
- :on_new_message, :on_end_message, :on_tool_call, :on_tool_result, :each,
321
- to: :chat
360
+ def_delegators :chat, :model, :messages, :tools, :params, :headers, :schema, :ask, :say, :with_tool, :with_tools,
361
+ :with_model, :with_temperature, :with_thinking, :with_context, :with_params, :with_headers,
362
+ :with_schema, :on_new_message, :on_end_message, :on_tool_call, :on_tool_result, :each, :complete,
363
+ :add_message, :reset_messages!
322
364
  end
323
365
  end