ruby_llm 2.1.0 → 2.2.0

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 (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +522 -277
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1598 -332
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +67 -28
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +134 -109
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +23 -2
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +2 -9
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +8 -3
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +219 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -2,6 +2,18 @@
2
2
 
3
3
  require 'active_support/concern'
4
4
  require 'ruby_llm/active_record/attachment_helpers'
5
+ require 'ruby_llm/active_record/claim_heartbeat'
6
+ require 'ruby_llm/active_record/run_claims'
7
+ require 'ruby_llm/active_record/chat_approvals'
8
+ require 'ruby_llm/active_record/chat_questions'
9
+ require 'ruby_llm/active_record/chat_cancellation'
10
+ require 'ruby_llm/active_record/chat_placeholders'
11
+ require 'ruby_llm/active_record/chat_reply_error'
12
+ require 'ruby_llm/active_record/system_instructions'
13
+ require 'ruby_llm/active_record/chat_queue'
14
+ require 'ruby_llm/active_record/chat_tool_call_state'
15
+ require 'ruby_llm/active_record/chat_run'
16
+ require 'ruby_llm/active_record/chat_agent_hooks'
5
17
 
6
18
  module RubyLLM
7
19
  module ActiveRecord
@@ -13,22 +25,57 @@ module RubyLLM
13
25
  # acts_as_chat
14
26
  # end
15
27
  #
16
- # chat = Chat.create!(model: 'gpt-5.6-luna')
28
+ # chat = Chat.create!(model: 'gpt-6-luna')
17
29
  # chat.ask "What is the capital of France?"
18
30
  # chat.messages.count # => 2
19
31
  #
20
32
  module ChatMethods
21
33
  extend ActiveSupport::Concern
22
- include Enumerable
34
+ include RubyLLM::Support::DeprecatedEnumerable
23
35
  include AttachmentHelpers
36
+ include RunClaims
37
+ include ChatApprovals
38
+ include ChatQuestions
39
+ include ChatCancellation
40
+ include ChatPlaceholders
41
+ include ChatReplyError
42
+ include SystemInstructions
43
+ prepend ChatAgentHooks
24
44
 
25
45
  CANCELLATION_POLL_INTERVAL = 1.0 # :nodoc:
26
46
  COMPLETION_ERRORS = [ # :nodoc:
27
- RubyLLM::CancelledError, RubyLLM::Error, Faraday::Error, Timeout::Error, Errno::ETIMEDOUT
47
+ CancelledError, Error, ConfigurationError, Faraday::Error, Timeout::Error, Errno::ETIMEDOUT
28
48
  ].freeze
29
49
 
50
+ ##
51
+ # :singleton-method: interrupted
52
+ #
53
+ # Returns the chats whose run died holding its claim: a crash, or a
54
+ # deploy that stopped the process, left the claim behind, and its
55
+ # lease has expired. A run that ends any other way releases its
56
+ # claim, whether it completes, pauses on a question or a task, is
57
+ # cancelled, or fails, so none of those chats are returned, nor a
58
+ # chat whose job hasn't started yet, nor one someone stopped. No job
59
+ # backend runs such a reply again by itself, so a recurring job
60
+ # completes them:
61
+ #
62
+ # Chat.interrupted.find_each { |chat| ChatResponseJob.perform_later(chat.id) }
63
+ #
64
+ # Chains with other scopes. Returns none when the chats table has no
65
+ # claim columns, since then no process records that it runs a chat.
66
+
30
67
  included do
68
+ scope :interrupted, -> { RunClaims.interrupted(self) }
31
69
  before_save :resolve_model
70
+ after_commit :remember_model_registry_stored, if: -> { @model_registry_loaded_into }
71
+ after_rollback -> { @model_registry_loaded_into = nil }
72
+ end
73
+
74
+ # Returns the record's attributes as a Hash, accepting the same options
75
+ # as any Active Record model, rather than the message list Enumerable
76
+ # would serialize.
77
+ def as_json(options = nil)
78
+ ActiveModel::Serializers::JSON.instance_method(:as_json).bind_call(self, options)
32
79
  end
33
80
 
34
81
  # When +true+, skips the model registry lookup so unregistered model ids
@@ -38,24 +85,27 @@ module RubyLLM
38
85
  # Overrides the wire protocol the provider would pick for the model, such
39
86
  # as +:responses+ or +:chat_completions+ for OpenAI, or +nil+ for the
40
87
  # provider default. Not persisted; set it again after reloading the record.
41
- attr_accessor :protocol
88
+ attr_reader :protocol
42
89
 
43
- # An optional RubyLLM::Context supplying per-chat configuration, used
44
- # when building the underlying chat. Not persisted; set it again after
45
- # reloading the record.
46
- attr_accessor :context
90
+ # Sets the protocol override, switching the underlying chat to it when
91
+ # that is already built.
92
+ def protocol=(value)
93
+ @protocol = value
94
+ @chat_model_stale = true if @chat
95
+ end
47
96
 
48
- # Requests cancellation of the current in-flight chat operation. The
49
- # request is persisted so a background job can observe it from another
50
- # process.
51
- def cancel
52
- if persisted?
53
- update_column(:cancelled, true)
54
- else
55
- self[:cancelled] = true
56
- end
97
+ # An optional RubyLLM::Context supplying per-chat configuration. Not
98
+ # persisted; set it again after reloading the record. Assigning one
99
+ # rebinds the underlying chat, like #with_context.
100
+ attr_reader :context
57
101
 
58
- @chat&.cancel
102
+ # Requests cancellation of the current in-flight chat operation, or of
103
+ # the run that will answer a message still waiting for one. The request
104
+ # is persisted so a background job can observe it from another process.
105
+ # Does nothing when no run holds the chat and nothing waits to run.
106
+ # Returns +self+.
107
+ def cancel
108
+ request_cancellation
59
109
  self
60
110
  end
61
111
 
@@ -65,56 +115,84 @@ module RubyLLM
65
115
  @chat&.cancelled? || self[:cancelled]
66
116
  end
67
117
 
68
- # Records approval for +tool_call+ (a ToolCall, a tool call record, or
69
- # an id) on the persisted tool call, so the next #complete executes it
70
- # from any process. Returns +self+.
118
+ # Records approval for +tool_call+ (an approval RubyLLM::Question, a
119
+ # ToolCall, a tool call record, or an id) on the persisted tool call,
120
+ # so the next #complete executes it from any process. Approving a call
121
+ # that is running or answered changes nothing. With
122
+ # <tt>remember: true</tt>, later calls to the same tool run without
123
+ # asking for the rest of the chat, as RubyLLM::Chat#approve describes,
124
+ # in every process. Returns +self+.
71
125
  #
72
126
  # chat.approve(params[:tool_call_id])
127
+ # chat.approve(params[:tool_call_id], remember: true)
73
128
  # CompleteJob.perform_later(chat.id)
74
129
  #
75
- def approve(tool_call)
76
- record_tool_call_decision(tool_call, 'approved')
77
- end
78
-
79
- # Records denial for +tool_call+ (a ToolCall, a tool call record, or
80
- # an id) on the persisted tool call. The next #complete appends a
81
- # structured denial result instead of executing the tool. Returns
82
- # +self+.
83
- def deny(tool_call)
84
- record_tool_call_decision(tool_call, 'denied')
130
+ def approve(tool_call, remember: false)
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?
133
+ record_tool_call_decision(record, remember ? 'remembered' : 'approved')
134
+ self
85
135
  end
86
136
 
87
- # Returns whether the conversation is waiting on tool calls that
88
- # require approval and have no recorded decision. See
89
- # RubyLLM::Chat#awaiting_approval?.
90
- def awaiting_approval?
91
- to_llm.awaiting_approval?
137
+ def claiming_delivery(&) # :nodoc:
138
+ exclusively(-> { false }) { transaction(requires_new: true, &) }
92
139
  end
93
140
 
94
- # Returns the persisted tool call records that require approval and
95
- # have no recorded decision, ready to render as approval cards. Pass
96
- # a record (or its tool_call_id) to #approve or #deny.
97
- # A record's +remote?+ identifies a provider-executed call.
141
+ # Returns whether a process is running this chat's conversation loop:
142
+ # calling the model or running tools from #complete, #step, #generate,
143
+ # #run_tools, or #compact. Reads the database, so it sees runs in
144
+ # other processes. While another process runs the chat, those methods
145
+ # return without doing anything and #waiting? is +true+. A process
146
+ # that dies stops counting as running within a minute.
98
147
  #
99
- # chat.pending_approvals.each { |record| render record }
148
+ # retry_job wait: 1.minute if chat.running?
100
149
  #
101
- def pending_approvals
102
- ids = to_llm.pending_approvals.map(&:id)
103
- RubyLLM::ActiveRecord::ToolCall.where(
104
- tool_call_id: ids,
105
- message_type: self.class.message_class.constantize.polymorphic_name,
150
+ def running?
151
+ return false unless run_claims?
152
+
153
+ claimed_until = self.class.uncached { chat_row.pick(:claimed_until) }
154
+ claimed_until&.future? || false
155
+ end
156
+
157
+ # Returns whether the conversation can't continue until something
158
+ # outside it happens, as RubyLLM::Chat#waiting? describes, or until
159
+ # the process that is #running? it finishes. Approvals, answers, and
160
+ # tasks persist on their tool calls, so any process sees what the
161
+ # chat waits on.
162
+ def waiting?
163
+ to_llm.waiting? || running_elsewhere?
164
+ end
165
+
166
+ def messages_association # :nodoc:
167
+ association(messages_association_name).reader
168
+ end
169
+
170
+ def find_tool_call(tool_call_id) # :nodoc:
171
+ RubyLLM::ActiveRecord::ToolCall.find_by(
172
+ tool_call_id: tool_call_id,
173
+ message_type: messages_association.klass.polymorphic_name,
106
174
  message_id: messages_association.select(:id)
107
175
  )
108
176
  end
109
177
 
110
- def messages_association # :nodoc:
111
- send(messages_association_name)
178
+ def persist_usage_entry(entry) # :nodoc:
179
+ usage_records_by_entry[entry] = Usage.transaction(requires_new: true) do
180
+ ruby_llm_usages.create!(Usage.attributes_for(entry))
181
+ end
182
+ rescue StandardError => e
183
+ RubyLLM.logger.warn("RubyLLM could not record #{entry.operation} usage: #{e.class}: #{e.message}")
184
+ end
185
+
186
+ def refresh_messages # :nodoc:
187
+ messages_association.reset
188
+ reload_usages
189
+ sync_messages if @chat
112
190
  end
113
191
 
114
192
  # Sets the chat's model from an id, a RubyLLM::Model value, or the
115
193
  # associated internal model record.
116
194
  #
117
- # chat.model = 'gpt-5.6-luna'
195
+ # chat.model = 'gpt-6-luna'
118
196
  #
119
197
  def model=(value)
120
198
  if value.is_a?(RubyLLM::ActiveRecord::Model)
@@ -125,47 +203,59 @@ module RubyLLM
125
203
  @pending_model_id = value.respond_to?(:id) ? value.id : value
126
204
  @pending_provider = value.provider if value.respond_to?(:provider)
127
205
  end
206
+ @chat_model_stale = true if @chat
128
207
  end
129
208
 
130
209
  # Stores +value+ as the model id, resolved to a model record before save.
131
210
  def model_id=(value)
132
- @pending_model_id = value
211
+ self.model = value
133
212
  end
134
213
 
135
- # Returns the model id of the associated model record, or +nil+.
214
+ # Returns the model id assigned since the last save, or that of the
215
+ # associated model record, or +nil+.
136
216
  #
137
- # chat.model_id # => "gpt-5.6-luna"
217
+ # chat.model_id # => "gpt-6-luna"
138
218
  #
139
219
  def model_id
140
- model&.model_id || @pending_model_id
220
+ @pending_model_id || model&.model_id
141
221
  end
142
222
 
143
223
  # Stores +value+ as the provider used when resolving the model id
144
224
  # before save.
145
225
  def provider=(value)
146
226
  @pending_provider = value
227
+ @chat_model_stale = true if @chat
147
228
  end
148
229
 
149
- # Returns the provider of the associated model record, or +nil+.
230
+ # Returns the provider assigned since the last save, or that of the
231
+ # associated model record, or +nil+. A model id assigned without a
232
+ # provider has none until save resolves it.
150
233
  def provider
151
- model&.provider || @pending_provider
234
+ @pending_provider || (model&.provider unless @pending_model_id)
152
235
  end
153
236
 
154
237
  # Returns the underlying RubyLLM::Chat for this record, building it on
155
238
  # first call and memoizing it. The chat is loaded with the persisted
156
239
  # messages and wired to persist new ones. Subsequent calls return the
157
- # same chat without touching the database; use #reload to refresh its
158
- # message history from the record.
240
+ # same chat without touching the database, switched to any model
241
+ # assigned since; use #reload to refresh its message history from the
242
+ # record.
159
243
  def to_llm
160
- @chat ||= build_llm_chat # rubocop:disable Naming/MemoizedInstanceVariableName
244
+ @chat ||= build_llm_chat
245
+ @chat.with_model(model_id, provider: provider&.to_sym, protocol:, assume_model_exists:) if @chat_model_stale
246
+ @chat_model_stale = false
247
+ @chat
161
248
  end
162
249
 
163
250
  # Reloads the record from the database, Rails-style, and refreshes the
164
- # underlying chat's persisted message history to match. Runtime-only
165
- # configuration such as tools, temperature, and callbacks is preserved.
251
+ # underlying chat's model and persisted message history to match. Runtime-only
252
+ # configuration such as tools, temperature, and callbacks is preserved;
253
+ # a model or provider assigned since the last save is discarded.
166
254
  # Returns +self+.
167
255
  def reload(...)
168
256
  super
257
+ @pending_model_id = @pending_provider = nil
258
+ @chat_model_stale = true if @chat && chat_model_moved?
169
259
  sync_messages if @chat
170
260
  self
171
261
  end
@@ -175,10 +265,11 @@ module RubyLLM
175
265
  # configuration. The Context itself is runtime-only and is not
176
266
  # persisted.
177
267
  def with_context(value)
178
- self.context = value
268
+ @context = value
179
269
  @chat&.with_context(value)
180
270
  self
181
271
  end
272
+ alias context= with_context
182
273
 
183
274
  # Sets the system instructions, persisting them as a message with the
184
275
  # +:system+ role. Replaces any persisted system messages unless
@@ -213,13 +304,13 @@ module RubyLLM
213
304
  # RubyLLM::Chat. Each behaves exactly as documented on RubyLLM::Chat,
214
305
  # then returns the record so calls chain.
215
306
  CHAINABLE_CHAT_DELEGATES = %i[
216
- with_tools with_mcp with_tool_options with_provider_tools with_fallbacks with_temperature answer decline
307
+ with_tools with_mcp with_tool_options with_provider_tools with_fallbacks with_temperature
217
308
  with_max_output_tokens with_thinking with_citations with_caching
218
- with_end_user with_compaction
309
+ with_end_user with_owner with_compaction with_context_budget with_code_execution with_skills
219
310
  with_provider_options with_headers with_schema
220
311
  convert_unsupported_attachments
221
- before_request before_message after_message before_tool_call after_tool_result after_tool_progress
222
- before_fallback after_fallback
312
+ before_request before_message after_message before_tool_call around_tool_call after_tool_result
313
+ after_tool_progress before_fallback after_fallback around_join after_file_write
223
314
  ].freeze
224
315
 
225
316
  ##
@@ -234,19 +325,6 @@ module RubyLLM
234
325
  #
235
326
  # Applies Chat#with_mcp and returns this record.
236
327
 
237
- ##
238
- # :method: answer
239
- # :call-seq: answer(request, **values)
240
- #
241
- # Applies Chat#answer and returns this record. The answer persists
242
- # on the tool call, so any process can resume the call.
243
-
244
- ##
245
- # :method: decline
246
- # :call-seq: decline(request)
247
- #
248
- # Applies Chat#decline and returns this record.
249
-
250
328
  ##
251
329
  # :method: with_tool_options
252
330
  # :call-seq: with_tool_options(**options)
@@ -261,7 +339,7 @@ module RubyLLM
261
339
 
262
340
  ##
263
341
  # :method: with_fallbacks
264
- # :call-seq: with_fallbacks(*models, on: Fallback::DEFAULT_ERRORS)
342
+ # :call-seq: with_fallbacks(*models, on: Fallback::DEFAULT_ERRORS, drop_unsupported: false)
265
343
  #
266
344
  # Applies Chat#with_fallbacks and returns this record.
267
345
 
@@ -301,12 +379,48 @@ module RubyLLM
301
379
  #
302
380
  # Applies Chat#with_end_user and returns this record.
303
381
 
382
+ ##
383
+ # :method: with_owner
384
+ # :call-seq: with_owner(owner)
385
+ #
386
+ # Applies Chat#with_owner and returns this record. For this record
387
+ # instance, the owner wins over the association +acts_as_chat owner:+
388
+ # names, both for usage and for the files of the Active Storage
389
+ # store. It is not persisted, so a record loaded later works for that
390
+ # association again. Read it with <tt>to_llm.owner</tt>: the record
391
+ # defines no +owner+, so an association of yours by that name keeps
392
+ # working.
393
+
394
+ ##
395
+ # :method: with_context_budget
396
+ # :call-seq: with_context_budget(budget = {}, per_result: nil, per_request: nil, window: nil)
397
+ #
398
+ # Applies Chat#with_context_budget and returns this record.
399
+
304
400
  ##
305
401
  # :method: with_compaction
306
402
  # :call-seq: with_compaction(options = {})
307
403
  #
308
404
  # Applies Chat#with_compaction and returns this record.
309
405
 
406
+ ##
407
+ # :method: with_code_execution
408
+ # :call-seq: with_code_execution(enabled = true, **options)
409
+ #
410
+ # Applies Chat#with_code_execution and returns this record.
411
+
412
+ ##
413
+ # :method: with_skills
414
+ # :call-seq: with_skills(*skills)
415
+ #
416
+ # Applies Chat#with_skills and returns this record.
417
+
418
+ ##
419
+ # :method: after_file_write
420
+ # :call-seq: after_file_write(&block)
421
+ #
422
+ # Applies Chat#after_file_write and returns this record.
423
+
310
424
  ##
311
425
  # :method: with_provider_options
312
426
  # :call-seq: with_provider_options(provider_options)
@@ -361,6 +475,12 @@ module RubyLLM
361
475
  #
362
476
  # Applies Chat#after_tool_result and returns this record.
363
477
 
478
+ ##
479
+ # :method: around_tool_call
480
+ # :call-seq: around_tool_call(&block)
481
+ #
482
+ # Applies Chat#around_tool_call and returns this record.
483
+
364
484
  ##
365
485
  # :method: after_tool_progress
366
486
  # :call-seq: after_tool_progress(&block)
@@ -379,6 +499,25 @@ module RubyLLM
379
499
  #
380
500
  # Applies Chat#after_fallback and returns this record.
381
501
 
502
+ ##
503
+ # :method: around_join
504
+ # :call-seq: around_join(&block)
505
+ #
506
+ # Applies Chat#around_join and returns this record. The block
507
+ # receives the QueuedMessage record and +join+, in the process that
508
+ # runs the chat, inside the transaction that creates the message
509
+ # record and deletes the queued one. <tt>join.call</tt> takes
510
+ # attributes for the new message record and returns it, so the
511
+ # record is saved with your own columns set:
512
+ #
513
+ # chat.around_join do |queued, join|
514
+ # join.call(steered: queued.steer?)
515
+ # end
516
+ #
517
+ # When the block raises, the transaction rolls back and the message
518
+ # stays queued. When it never calls +join+, the queued record is
519
+ # deleted without joining.
520
+
382
521
  ##
383
522
  # :method: caching
384
523
  # :call-seq: caching
@@ -391,12 +530,36 @@ module RubyLLM
391
530
  #
392
531
  # Delegates to Chat#citations. See that method for arguments and return values.
393
532
 
533
+ ##
534
+ # :method: code_execution
535
+ # :call-seq: code_execution
536
+ #
537
+ # Delegates to Chat#code_execution. See that method for arguments and return values.
538
+
539
+ ##
540
+ # :method: skills
541
+ # :call-seq: skills
542
+ #
543
+ # Delegates to Chat#skills. See that method for arguments and return values.
544
+
545
+ ##
546
+ # :method: file_store
547
+ # :call-seq: file_store
548
+ #
549
+ # Delegates to Chat#file_store. See that method for arguments and return values.
550
+
394
551
  ##
395
552
  # :method: compaction
396
553
  # :call-seq: compaction
397
554
  #
398
555
  # Delegates to Chat#compaction. See that method for arguments and return values.
399
556
 
557
+ ##
558
+ # :method: context_budget
559
+ # :call-seq: context_budget
560
+ #
561
+ # Delegates to Chat#context_budget. See that method for arguments and return values.
562
+
400
563
  ##
401
564
  # :method: concurrency
402
565
  # :call-seq: concurrency
@@ -481,32 +644,6 @@ module RubyLLM
481
644
  #
482
645
  # Delegates to Chat#mcp. See that method for arguments and return values.
483
646
 
484
- ##
485
- # :method: waiting?
486
- # :call-seq: waiting?
487
- #
488
- # Delegates to Chat#waiting?. Approvals, answers, and tasks persist on
489
- # their tool calls, so any process sees what the chat waits on.
490
-
491
- ##
492
- # :method: awaiting_input?
493
- # :call-seq: awaiting_input?
494
- #
495
- # Delegates to Chat#awaiting_input?. See that method for arguments and return values.
496
-
497
- ##
498
- # :method: pending_inputs
499
- # :call-seq: pending_inputs
500
- #
501
- # Delegates to Chat#pending_inputs. Requests persist on their tool
502
- # calls, so they survive restarts.
503
-
504
- ##
505
- # :method: awaiting_tasks?
506
- # :call-seq: awaiting_tasks?
507
- #
508
- # Delegates to Chat#awaiting_tasks?. See that method for arguments and return values.
509
-
510
647
  ##
511
648
  # :method: pending_tasks
512
649
  # :call-seq: pending_tasks
@@ -526,9 +663,11 @@ module RubyLLM
526
663
  # The public methods behave as documented on RubyLLM::Chat.
527
664
 
528
665
  PASSTHROUGH_CHAT_DELEGATES = %i[
529
- caching citations compaction concurrency end_user fallbacks headers max_output_tokens provider_options
530
- schema provider_tools temperature thinking tool_options tools deferred_tools mcp waiting? awaiting_input?
531
- pending_inputs awaiting_tasks? pending_tasks add_completion count_tokens each render
666
+ caching citations code_execution compaction context_budget concurrency end_user fallbacks headers
667
+ max_output_tokens provider_options schema provider_tools temperature thinking tool_options tools deferred_tools
668
+ mcp skills file_store
669
+ pending_tasks add_completion count_tokens
670
+ render
532
671
  ].freeze
533
672
 
534
673
  ##
@@ -538,12 +677,6 @@ module RubyLLM
538
677
  # Returns the number of input tokens the next request would carry,
539
678
  # counted by the provider over the persisted conversation.
540
679
 
541
- ##
542
- # :method: each
543
- # :call-seq: each(&block)
544
- #
545
- # Yields each message in the conversation. Returns an Enumerator without a block.
546
-
547
680
  ##
548
681
  # :method: render
549
682
  # :call-seq: render
@@ -573,7 +706,7 @@ module RubyLLM
573
706
  self.assume_model_exists = assume_model_exists
574
707
  resolve_model
575
708
  save!
576
- to_llm.with_model(model_id, provider: provider&.to_sym, protocol:, assume_model_exists:)
709
+ to_llm
577
710
  self
578
711
  end
579
712
 
@@ -585,38 +718,52 @@ module RubyLLM
585
718
  # chat.add_message(role: :user, content: long_context)
586
719
  #
587
720
  def add_message(message_or_attributes)
588
- llm_message = message_or_attributes
589
- llm_message = llm_message.to_llm if llm_message.respond_to?(:to_llm)
590
- llm_message = RubyLLM::Message.new(llm_message) unless llm_message.is_a?(RubyLLM::Message)
721
+ persist_message(message_or_attributes)
722
+ end
591
723
 
592
- message_record = messages_association.create!(message_attributes(llm_message))
724
+ # Persists +message_or_attributes+ like #add_message, assigning
725
+ # +record_attributes+ to the new record. Returns the message record.
726
+ def persist_message(message_or_attributes, record_attributes = {}) # :nodoc:
727
+ llm_message = to_llm.prepare_message(message_or_attributes) do |id|
728
+ RubyLLM::ActiveRecord::ToolCall.exists?(tool_call_id: id)
729
+ end
730
+
731
+ message_record = create_message_record(llm_message, record_attributes)
732
+ @last_message_id = message_record.id
593
733
 
594
- if llm_message.tool_call_id && (tool_call = find_tool_call(llm_message.tool_call_id))
734
+ tool_call = find_tool_call(llm_message.tool_call_id) if llm_message.tool_call_id
735
+ if tool_call && tool_call.result_id.nil?
736
+ tool_call.mcp_result = llm_message.mcp_result&.dump if tool_call.has_attribute?(:mcp_result)
595
737
  tool_call.update!(result: message_record)
596
738
  end
597
739
 
598
740
  persist_content(message_record, llm_message.attachments) if llm_message.attachments.any?
599
741
  persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
600
742
 
601
- @chat&.add_message(llm_message)
743
+ @chat.add_message(llm_message)
602
744
 
603
- message_record
745
+ bind_message_chat(message_record)
604
746
  end
605
747
 
606
- # Marks the latest persisted message as a prompt cache boundary, or the
607
- # latest in-memory message when none is persisted yet. Pass +ttl:+ to
608
- # give the boundary its own cache lifetime. Returns +self+.
748
+ # Deprecated: mark the message itself, or pass +cache_until_here:+ to
749
+ # #with_instructions. Marks the latest persisted message as a prompt
750
+ # cache boundary, or the latest in-memory message when none is
751
+ # persisted yet. Warns through RubyLLM.deprecator. RubyLLM 3.0 removes
752
+ # it. Returns +self+.
609
753
  #
610
- # chat.with_instructions('Reusable analysis prompt').cache_until_here
611
- # chat.with_instructions('Reusable analysis prompt').cache_until_here(ttl: "1h")
754
+ # chat.with_instructions('Reusable analysis prompt', cache_until_here: { ttl: "1h" })
755
+ # chat.add_message(role: :user, content: long_context).cache_until_here
612
756
  #
613
757
  # Raises ArgumentError if the chat has no messages.
614
758
  def cache_until_here(ttl: nil)
759
+ RubyLLM.deprecator.deprecate("#{self.class}#cache_until_here",
760
+ replacement: 'Message#cache_until_here or with_instructions(cache_until_here:)',
761
+ removal: '3.0')
615
762
  message_record = messages_association.order(:id).last
616
763
  if message_record
617
- message_record.cache_until_here(ttl:)
764
+ bind_message_chat(message_record).cache_until_here(ttl:)
618
765
  elsif @chat&.messages&.any?
619
- @chat.cache_until_here(ttl:)
766
+ @chat.messages.last.cache_until_here(ttl:)
620
767
  else
621
768
  raise ArgumentError, 'No messages to cache'
622
769
  end
@@ -643,9 +790,16 @@ module RubyLLM
643
790
  RubyLLM::Cost.aggregate(records.map(&:cost), complete: records.all?(&:cost_available?))
644
791
  end
645
792
 
646
- # Persists +message+ as a user message, then runs the conversation loop
647
- # and returns the latest assistant RubyLLM::Message. The loop pauses
648
- # when #awaiting_approval? is true. Yields streaming chunks to a block.
793
+ def ruby_llm_usage_entries # :nodoc:
794
+ records = ruby_llm_usages.to_a
795
+ ::ActiveRecord::Associations::Preloader.new(records:, associations: [:message, *Usage.owner_preload]).call
796
+ records.map(&:to_entry)
797
+ end
798
+
799
+ # Persists +message+ as a user message, after every queued message
800
+ # (see #queue), then runs the conversation loop and returns the latest
801
+ # assistant RubyLLM::Message. The loop pauses while a call waits on a
802
+ # person (see #pending_questions). Yields streaming chunks to a block.
649
803
  #
650
804
  # chat.ask "What is the capital of France?"
651
805
  # chat.ask "What's in this file?", with: "diagram.png"
@@ -657,14 +811,39 @@ module RubyLLM
657
811
 
658
812
  alias say ask
659
813
 
814
+ # Deprecated: iterate <tt>to_llm.messages</tt> for the same objects, or
815
+ # the message association for your records. Yields each RubyLLM::Message
816
+ # in the conversation, or returns an Enumerator without a block. The
817
+ # Enumerable methods built on it, such as +map+ and +count+, are
818
+ # deprecated with it. Warns through RubyLLM.deprecator. RubyLLM 3.0
819
+ # removes them.
820
+ def each(&)
821
+ RubyLLM.deprecator.deprecate("Enumerable on #{self.class}", replacement: "#{self.class}#to_llm.messages",
822
+ removal: '3.0')
823
+ to_llm.messages.each(&)
824
+ end
825
+
660
826
  # Persists +message+ as a user message without calling the model, so
661
- # #complete can run later. Returns +self+.
827
+ # #complete can run later, usually in a job. Every queued message (see
828
+ # #queue) is persisted first, in order. Returns +self+.
662
829
  #
663
- # chat.ask_later "Summarize this document."
664
- # chat.complete
830
+ # chat.ask_later params[:content]
831
+ # ChatResponseJob.perform_later(chat.id)
665
832
  #
833
+ # While a run holds the chat, in this process or another, the message
834
+ # is queued instead, as #queue does, so the transcript that run writes
835
+ # stays whole. The run takes it at its next step, or the next
836
+ # #complete does. An MCP::Prompt, which the queue can't hold, is
837
+ # persisted either way.
838
+ #
839
+ # Raises PendingToolCallsError while the last response has tool calls
840
+ # waiting for their results and no run holds the chat.
666
841
  def ask_later(message = nil, with: nil)
842
+ return tap { queue(message, with:) } if running? && !message.is_a?(RubyLLM::MCP::Prompt)
843
+
667
844
  to_llm.raise_if_pending_tool_calls!
845
+ to_llm.raise_if_prompt_attachments!(message, with)
846
+ to_llm.take_queued_messages
668
847
  if message.is_a?(RubyLLM::MCP::Prompt)
669
848
  message.messages.each { |prompt_message| add_message(prompt_message) }
670
849
  else
@@ -675,41 +854,39 @@ module RubyLLM
675
854
 
676
855
  # Makes a single generation attempt, persists the response, and returns it as a
677
856
  # RubyLLM::Message. Tool calls in the response are not executed. See
678
- # RubyLLM::Chat#generate.
679
- def generate(...)
680
- to_llm.generate(...)
681
- rescue *COMPLETION_ERRORS => e
682
- cleanup_after_failure(e)
683
- raise
857
+ # RubyLLM::Chat#generate. Returns +nil+ without calling the model
858
+ # while another process is #running? the chat. A block receives the
859
+ # chunks as it does in #complete.
860
+ def generate(&block)
861
+ exclusively(-> {}) { completing(block) { |on_chunk| to_llm.generate(&on_chunk) } }
684
862
  end
685
863
 
686
864
  # Compacts the model context and persists its assistant Message without
687
- # deleting earlier messages. See RubyLLM::Chat#compact.
865
+ # deleting earlier messages. See RubyLLM::Chat#compact. Returns +nil+
866
+ # without calling the model while another process is #running? the
867
+ # chat.
688
868
  def compact
689
- to_llm.compact
690
- rescue *COMPLETION_ERRORS => e
691
- cleanup_after_failure(e)
692
- raise
869
+ exclusively(-> {}) { completing { to_llm.compact } }
693
870
  end
694
871
 
695
872
  # Executes the pending tool calls and persists their results without
696
- # calling the model. See RubyLLM::Chat#run_tools. Returns +self+.
873
+ # calling the model. See RubyLLM::Chat#run_tools. Does nothing while
874
+ # another process is #running? the chat. Returns +self+.
697
875
  def run_tools
698
- to_llm.run_tools
876
+ exclusively(-> {}) { to_llm.run_tools }
699
877
  self
700
878
  end
701
879
 
702
880
  # Advances the conversation by one move: runs the pending tool calls if
703
881
  # there are any, otherwise generates a response. Returns +nil+ once the
704
- # chat is complete or #waiting?. See RubyLLM::Chat#step.
882
+ # chat is complete or #waiting?, including while another process is
883
+ # #running? it. See RubyLLM::Chat#step. A block receives the chunks as
884
+ # it does in #complete.
705
885
  #
706
886
  # chat.step until chat.complete? || chat.waiting?
707
887
  #
708
- def step(...)
709
- to_llm.step(...)
710
- rescue *COMPLETION_ERRORS => e
711
- cleanup_after_failure(e)
712
- raise
888
+ def step(&block)
889
+ exclusively(-> {}) { completing(block) { |on_chunk| to_llm.step(&on_chunk) } }
713
890
  end
714
891
 
715
892
  # Returns whether the conversation has no pending work, neither a
@@ -720,31 +897,159 @@ module RubyLLM
720
897
  end
721
898
 
722
899
  # Runs the completion loop on the underlying chat, persisting each
723
- # message, and returns the latest RubyLLM::Message. Pauses when a tool
724
- # requires approval. When the API call fails, destroys the empty assistant
725
- # message and any orphaned tool results, then re-raises the error.
726
- def complete(...)
727
- to_llm.complete(...)
728
- rescue *COMPLETION_ERRORS => e
729
- cleanup_after_failure(e)
900
+ # 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.
906
+ #
907
+ # Only one process runs a chat at a time. While another process is
908
+ # #running? it, returns the latest message without calling the model
909
+ # or running tools, and #waiting? is +true+. Work recorded while this
910
+ # process ran, such as an approval, continues before it returns. A job
911
+ # that finds the chat running tries again later, which also covers a
912
+ # process that dies, since its claim expires within a minute:
913
+ #
914
+ # def perform(chat_id)
915
+ # chat = ChatAgent.find(chat_id)
916
+ # chat.complete(&:broadcast)
917
+ # retry_job wait: 1.minute if chat.running?
918
+ # end
919
+ #
920
+ # Each chunk passed to the block also answers +message+, the record
921
+ # it streams into, and +broadcast+ (see ChunkMethods). When the
922
+ # messages broadcast with Turbo, the chat's page refreshes once the
923
+ # run ends.
924
+ #
925
+ # Queued messages join as RubyLLM::Chat#complete describes, including
926
+ # those another process queues while this one runs. A message that
927
+ # steers, queued as the run ends, starts another loop before
928
+ # #complete returns. When #cancel stops the run while messages wait
929
+ # in the queue, the run goes on with them before #complete returns,
930
+ # as the next #complete would. It raises CancelledError when none
931
+ # wait, or when the stop came before the run began.
932
+ #
933
+ # When the chats table has a +reply_error+ column, a failed reply
934
+ # saves a short message for the person there before the error is
935
+ # raised, and the next run clears it, so the chat's page can show it
936
+ # whenever it loads. The message comes from I18n:
937
+ # +ruby_llm.reply_error.too_long+ for ContextLengthExceededError,
938
+ # +ruby_llm.reply_error.failed+ for any other error, with English
939
+ # defaults.
940
+ def complete(&block)
941
+ operation = :complete
942
+ loop do
943
+ busy = stopped_before_run = false
944
+ response = exclusively(-> { busy = true }) do
945
+ stopped_before_run = self[:cancelled]
946
+ clear_reply_error
947
+ completing(block) { |on_chunk| to_llm.public_send(operation, &on_chunk) }
948
+ end
949
+ return latest_message if busy
950
+ return response unless work_arrived_during_run?
951
+
952
+ operation = :continue_turn
953
+ rescue CancelledError
954
+ raise if stopped_before_run || ChatQueue.new(self).none?
955
+
956
+ operation = :complete
957
+ end
958
+ rescue StandardError => e
959
+ record_reply_error(e)
730
960
  raise
961
+ ensure
962
+ broadcast_refresh_to(self, request_id: nil) if broadcasts_messages?
963
+ end
964
+
965
+ # Queues +message+ in the chat's +ruby_llm_queued_messages+ table, as
966
+ # RubyLLM::Chat#queue describes, and returns the QueuedMessage record.
967
+ # Attachments given with +with:+ are stored with Active Storage and
968
+ # move to the message it becomes. +sender+ is any record, such as the
969
+ # user who asked. It is stored, and when your message model has a
970
+ # +sender+ association that can hold it, the message record is saved
971
+ # with it.
972
+ #
973
+ # Any process can queue a message. The process that runs the chat
974
+ # takes it at its next step; when none is running, run #complete,
975
+ # usually in a job. #ask_later queues for you while a run holds the
976
+ # chat, so reach for #queue when a message should wait for the turn
977
+ # to end (<tt>steer: false</tt>) or keep its +sender+.
978
+ #
979
+ # chat.queue params[:content], sender: Current.user
980
+ # ChatResponseJob.perform_later(chat.id)
981
+ #
982
+ def queue(message = nil, with: nil, steer: true, sender: nil)
983
+ to_llm.queue(message, with:, steer:, sender:)
984
+ end
985
+
986
+ # Returns the QueuedMessage records waiting in the queue, oldest
987
+ # first, ready to render.
988
+ def queued_messages = ruby_llm_queued_messages.scope.skip_query_cache!
989
+
990
+ # Takes +queued+, a QueuedMessage record or its id, out of the queue
991
+ # and deletes its attachments. Returns +true+, or +false+ once the
992
+ # loop has taken it into the conversation or it was withdrawn already.
993
+ # QueuedMessage#withdraw does the same from the record. See
994
+ # RubyLLM::Chat#withdraw.
995
+ def withdraw(queued)
996
+ to_llm.withdraw(queued)
997
+ end
998
+
999
+ # Persists every queued message as a user message record, without
1000
+ # calling the model, and returns the records, oldest first. Each joins
1001
+ # through #around_join and runs #after_message, as
1002
+ # RubyLLM::Chat#join_queued_messages describes.
1003
+ #
1004
+ # chat.join_queued_messages.map(&:content) # => ["Use the EU file"]
1005
+ #
1006
+ def join_queued_messages
1007
+ to_llm.join_queued_messages
731
1008
  end
732
1009
 
733
1010
  private
734
1011
 
735
- def persist_usage_entry(entry)
736
- usage_records_by_entry[entry] = Usage.transaction(requires_new: true) do
737
- ruby_llm_usages.create!(Usage.attributes_for(entry))
1012
+ def create_message_record(llm_message, record_attributes)
1013
+ transaction do
1014
+ forget_stale_cancellation if llm_message.role == :user
1015
+ messages_association.create!(message_attributes(llm_message).merge(record_attributes))
738
1016
  end
739
- rescue StandardError => e
740
- RubyLLM.logger.warn("RubyLLM could not record #{entry.operation} usage: #{e.class}: #{e.message}")
1017
+ end
1018
+
1019
+ def broadcasts_messages?
1020
+ message_class = messages_association.klass
1021
+ message_class.include?(MessageBroadcasting) && message_class.broadcasting?
1022
+ end
1023
+
1024
+ def completing(block = nil)
1025
+ yield ChunkMethods.streaming(block, on_error: method(:cleanup_after_block_failure)) { @message }
1026
+ rescue *COMPLETION_ERRORS => e
1027
+ cleanup_after_failure(e)
1028
+ raise
1029
+ end
1030
+
1031
+ def chat_model_moved?
1032
+ [@chat.model.id, @chat.provider.slug] != [model_id, provider.to_s]
1033
+ end
1034
+
1035
+ def latest_message
1036
+ to_llm.messages.reverse.find { |message| message.role != :system } || to_llm.messages.last
1037
+ end
1038
+
1039
+ def bind_message_chat(message)
1040
+ message.association(message.chat_association_name).target = self
1041
+ message
741
1042
  end
742
1043
 
743
1044
  def link_usage_entries(message)
744
- message.ruby_llm_usage_entries.each do |entry|
745
- record = usage_records_by_entry[entry]
746
- record&.update!(message: @message)
1045
+ Usage.transaction(requires_new: true) do
1046
+ message.ruby_llm_usage_entries.each do |entry|
1047
+ record = usage_records_by_entry[entry]
1048
+ record&.update!(message: @message)
1049
+ end
747
1050
  end
1051
+ rescue StandardError => e
1052
+ RubyLLM.logger.warn("RubyLLM could not link usage to message #{@message.id}: #{e.class}: #{e.message}")
748
1053
  end
749
1054
 
750
1055
  def usage_records_by_entry
@@ -754,7 +1059,7 @@ module RubyLLM
754
1059
  def resolve_model
755
1060
  config = context&.config || RubyLLM.config
756
1061
  @pending_model_id ||= config.default_model unless model
757
- return unless @pending_model_id
1062
+ return unless @pending_model_id || (@pending_provider && model)
758
1063
 
759
1064
  model_info = resolve_model_info
760
1065
 
@@ -771,25 +1076,34 @@ module RubyLLM
771
1076
  begin
772
1077
  find_registered_model
773
1078
  rescue RubyLLM::ModelNotFoundError
774
- RubyLLM::Model.default(@pending_model_id, @pending_provider)
1079
+ RubyLLM::Model.default(model_id, @pending_provider)
775
1080
  end
776
1081
  end
777
1082
 
778
1083
  def find_registered_model
779
- RubyLLM.models.find(@pending_model_id, provider: @pending_provider, config: context&.config)
1084
+ RubyLLM.models.find_configured(model_id, provider: @pending_provider, config: context&.config)
780
1085
  end
781
1086
 
782
1087
  # An empty store would otherwise hold only this chat's model on the next
783
1088
  # boot, since the registry reads whatever the store returns.
784
1089
  def load_model_registry_into_store
785
1090
  RubyLLM::ActiveRecord::Model.save_to_database(RubyLLM.models)
1091
+ @model_registry_loaded_into = RubyLLM::ActiveRecord::Model.connection_db_config
786
1092
  rescue ::ActiveRecord::RecordInvalid, ::ActiveRecord::RecordNotUnique
787
1093
  nil
788
1094
  end
789
1095
 
1096
+ def model_store_empty?
1097
+ !RubyLLM::ActiveRecord::Model.registry_stored? && RubyLLM::ActiveRecord::Model.none?
1098
+ end
1099
+
1100
+ def remember_model_registry_stored
1101
+ RubyLLM::ActiveRecord::Model.registry_stored!(@model_registry_loaded_into)
1102
+ end
1103
+
790
1104
  def find_or_create_model(model_info)
791
1105
  attributes = { model_id: model_info.id, provider: model_info.provider }
792
- load_model_registry_into_store if RubyLLM::ActiveRecord::Model.none?
1106
+ load_model_registry_into_store if model_store_empty?
793
1107
 
794
1108
  RubyLLM::ActiveRecord::Model.find_or_create_by!(attributes) do |record|
795
1109
  record.name = model_info.name || model_info.id
@@ -812,6 +1126,11 @@ module RubyLLM
812
1126
  reason = error.is_a?(RubyLLM::CancelledError) ? 'chat cancelled' : 'API call failed'
813
1127
  cleanup_failed_messages(reason:) if blank_placeholder?
814
1128
  cleanup_orphaned_tool_results
1129
+ resync_messages if @chat
1130
+ end
1131
+
1132
+ def cleanup_after_block_failure
1133
+ cleanup_failed_messages(reason: 'streaming block failed') if blank_placeholder?
815
1134
  end
816
1135
 
817
1136
  def cleanup_failed_messages(reason:)
@@ -826,13 +1145,13 @@ module RubyLLM
826
1145
  return unless last&.tool_call? || last&.tool_result?
827
1146
 
828
1147
  if last.tool_call?
829
- last.destroy
830
- elsif last.tool_result?
1148
+ last.destroy unless last.ruby_llm_tool_calls.any?(&:paused?)
1149
+ else
831
1150
  parent = last.ruby_llm_parent_tool_call
832
1151
  tool_call_message = parent.message
833
1152
  calls = tool_call_message.ruby_llm_tool_calls
834
1153
 
835
- if calls.any? { |call| call.result.nil? }
1154
+ if calls.any? { |call| call.result.nil? } && calls.none?(&:paused?)
836
1155
  calls.filter_map(&:result).each(&:destroy)
837
1156
  tool_call_message.destroy
838
1157
  end
@@ -844,7 +1163,7 @@ module RubyLLM
844
1163
  messages = assoc.to_a
845
1164
  return messages unless assoc.respond_to?(:klass)
846
1165
 
847
- associations = %i[ruby_llm_tool_calls ruby_llm_parent_tool_call ruby_llm_usages]
1166
+ associations = [:ruby_llm_tool_calls, :ruby_llm_parent_tool_call, { ruby_llm_usages: Usage.owner_preload }]
848
1167
  associations << { attachments_attachments: :blob } if attachment_association?(assoc.klass)
849
1168
  rich_text_reflection = assoc.klass.reflect_on_association(:rich_text_content)
850
1169
  associations << :rich_text_content if rich_text_reflection
@@ -866,50 +1185,23 @@ module RubyLLM
866
1185
  assume_model_exists: assume_model_exists || false
867
1186
  )
868
1187
  sync_messages(chat)
869
- chat.cancellation_checker = proc { consume_persisted_cancellation_request }
870
- chat.approval_checker = proc { |tool_call| persisted_tool_call_approval(tool_call) }
871
- chat.input_checker = proc { |tool_call| persisted_tool_call_input(tool_call) }
872
- chat.input_recorder = proc { |tool_call, input| persist_tool_call_input(tool_call, input) }
1188
+ chat.run = ChatRun.new(self)
1189
+ chat.tool_call_state = ChatToolCallState.new(self)
1190
+ chat.message_queue = ChatQueue.new(self)
873
1191
  install_persistence_callbacks(chat)
874
1192
  end
875
1193
 
876
- def record_tool_call_decision(tool_call, decision)
877
- id = tool_call.respond_to?(:tool_call_id) ? tool_call.tool_call_id : tool_call
878
- id = id.id if id.respond_to?(:id) && !id.is_a?(String)
879
- record = find_tool_call(id)
880
- raise ArgumentError, "Unknown tool call: #{id.inspect}" unless record
881
-
882
- record.update!(approval: decision)
883
- self
884
- end
885
-
886
- def persisted_tool_call_input(tool_call)
887
- record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }
888
- record.mcp_state if record&.has_attribute?(:mcp_state)
889
- end
890
-
891
- def persist_tool_call_input(tool_call, input)
892
- record = find_tool_call(tool_call.id)
893
- record.update!(mcp_state: input) if record&.has_attribute?(:mcp_state)
894
- end
895
-
896
- def persisted_tool_call_approval(tool_call)
897
- record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }
898
- return unless record&.has_attribute?(:approval)
899
-
900
- case record.approval
901
- when 'approved' then true
902
- when 'denied' then false
903
- end
904
- end
905
-
906
1194
  def sync_messages(chat = @chat)
1195
+ snapshot_run_claim
907
1196
  message_records = eager_load_messages
1197
+ @last_message_id = message_records.last&.id
908
1198
  chat.messages = message_records
909
1199
  linked_entry_pairs = message_records.zip(chat.messages).flat_map do |record, message|
910
1200
  record.ruby_llm_usage_records.zip(message.ruby_llm_usage_entries)
911
1201
  end
912
1202
  linked_entries = linked_entry_pairs.to_h { |record, entry| [record.id, entry] }
1203
+ ::ActiveRecord::Associations::Preloader.new(records: ruby_llm_usages.to_a,
1204
+ associations: Usage.owner_preload).call
913
1205
  chat.usage_entries = ruby_llm_usages.map do |record|
914
1206
  linked_entries.fetch(record.id) { record.to_entry }
915
1207
  end
@@ -919,8 +1211,7 @@ module RubyLLM
919
1211
 
920
1212
  def install_persistence_callbacks(chat)
921
1213
  chat.before_message { persist_new_message }
922
- chat.usage_recorder = method(:persist_usage_entry)
923
- chat.after_message { |msg| persist_message_completion(msg) }
1214
+ chat.after_message { |msg| persist_message_completion(msg) unless msg&.role == :user }
924
1215
  chat
925
1216
  end
926
1217
 
@@ -944,9 +1235,7 @@ module RubyLLM
944
1235
  # Rewriting the same instructions every turn would move the system row
945
1236
  # behind the user messages and rebroadcast it each time.
946
1237
  def update_persisted_system_instruction(record, instructions, cache_until_here:)
947
- boundary = cache_boundary_attributes(cache_until_here)
948
- boundary.delete(:cache_until_here) unless record.has_attribute?(:cache_until_here)
949
- attributes = { content: instructions, **boundary }
1238
+ attributes = system_instruction_attributes(record, instructions, cache_until_here)
950
1239
  changed = attributes.any? { |column, value| record[column] != value }
951
1240
  record.update!(attributes) if changed
952
1241
  messages_association.reset
@@ -998,35 +1287,23 @@ module RubyLLM
998
1287
  end
999
1288
  end
1000
1289
 
1001
- def persist_new_message
1002
- @message.destroy if blank_placeholder?
1003
-
1004
- attrs = { role: :assistant, content: '' }
1005
- @message = messages_association.create!(attrs)
1006
- end
1007
-
1008
- def blank_placeholder?
1009
- return false unless @message&.persisted? && @message.content.blank?
1010
- return false if @message.respond_to?(:attachments) && @message.attachments.attached?
1011
-
1012
- !@message.ruby_llm_tool_calls.exists?
1013
- end
1014
-
1015
1290
  def persist_message_completion(message)
1016
1291
  return unless message
1017
1292
 
1018
1293
  tool_call = find_tool_call(message.tool_call_id) if message.tool_call_id
1019
1294
  attrs = message_attributes(message)
1020
1295
 
1021
- transaction do
1296
+ answered = fenced_by_run_claim do
1022
1297
  @message.assign_attributes(attrs)
1023
1298
  @message.save!
1024
- tool_call&.update!(tool_result_attributes(tool_call, message))
1299
+ raise ::ActiveRecord::Rollback if tool_call && !tool_result_recorded?(tool_call, message)
1025
1300
 
1026
1301
  persist_content(@message, message.attachments) if message.attachments.any?
1027
1302
  persist_tool_calls(message.tool_calls) if message.tool_calls.present?
1028
1303
  link_usage_entries(message)
1304
+ true
1029
1305
  end
1306
+ discard_superseded_message unless answered
1030
1307
  end
1031
1308
 
1032
1309
  def message_attributes(message)
@@ -1037,16 +1314,18 @@ module RubyLLM
1037
1314
  assign_supported_attribute(attrs, :server_tool_calls, message.server_tool_calls.map(&:to_h).presence)
1038
1315
  assign_supported_attribute(attrs, :raw_content, message.raw_content)
1039
1316
  assign_supported_attribute(attrs, :raw_reasoning, message.raw_reasoning)
1317
+ assign_supported_attribute(attrs, :phase, message.phase)
1318
+ assign_supported_attribute(attrs, :continuation, message.continuation)
1040
1319
  assign_supported_attribute(attrs, :finish_reason, message.finish_reason)
1041
1320
  assign_supported_attribute(attrs, :cache_until_here, message.cache_until_here?)
1042
1321
  assign_supported_attribute(attrs, :cache_ttl, message.cache_ttl)
1322
+ assign_fallback_attributes(attrs, message) if message.stood_in?
1043
1323
  attrs
1044
1324
  end
1045
1325
 
1046
- def tool_result_attributes(tool_call, message)
1047
- attributes = { result: @message }
1048
- attributes[:mcp_result] = message.mcp_result&.dump if tool_call.has_attribute?(:mcp_result)
1049
- attributes
1326
+ def assign_fallback_attributes(attributes, message)
1327
+ assign_supported_attribute(attributes, :model_id, message.model_id)
1328
+ assign_supported_attribute(attributes, :provider, message.provider)
1050
1329
  end
1051
1330
 
1052
1331
  def assign_supported_attribute(attributes, name, value)
@@ -1054,53 +1333,19 @@ module RubyLLM
1054
1333
  end
1055
1334
 
1056
1335
  def persist_tool_calls(tool_calls, message_record: @message)
1057
- tool_calls.each_value do |tool_call|
1336
+ tool_calls.each do |tool_call|
1058
1337
  attributes = tool_call.to_h
1059
1338
  attributes[:tool_call_id] = attributes.delete(:id)
1339
+ malformed = attributes.delete(:malformed_arguments)
1340
+ attributes[:arguments] = without_nul(malformed) if malformed
1341
+ attributes[:approval] = 'pending' if to_llm.approval_pending?(tool_call)
1060
1342
  message_record.ruby_llm_tool_calls.create!(**attributes)
1061
1343
  end
1062
1344
  end
1063
1345
 
1064
- def find_tool_call(tool_call_id)
1065
- RubyLLM::ActiveRecord::ToolCall.find_by(
1066
- tool_call_id: tool_call_id,
1067
- message_type: self.class.message_class.constantize.polymorphic_name,
1068
- message_id: messages_association.select(:id)
1069
- )
1070
- end
1071
-
1072
- def consume_persisted_cancellation_request
1073
- if self[:cancelled]
1074
- clear_cancellation_request
1075
- return :cancelled
1076
- end
1077
- return unless persisted?
1078
- return unless cancellation_poll_due?
1079
- return unless persisted_cancellation_request?
1080
-
1081
- clear_cancellation_request
1082
- :cancelled
1083
- end
1084
-
1085
- # Jobs and requests run with the query cache on, which would replay the
1086
- # first poll's answer for the rest of the run.
1087
- def persisted_cancellation_request?
1088
- self.class.uncached do
1089
- self.class.unscoped.where(self.class.primary_key => id).pick(:cancelled)
1090
- end
1091
- end
1092
-
1093
- def clear_cancellation_request
1094
- self[:cancelled] = false
1095
- self.class.unscoped.where(self.class.primary_key => id).update_all(cancelled: false) if persisted?
1096
- end
1097
-
1098
- def cancellation_poll_due?
1099
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1100
- return false if @last_cancellation_poll_at && now - @last_cancellation_poll_at < CANCELLATION_POLL_INTERVAL
1101
-
1102
- @last_cancellation_poll_at = now
1103
- true
1346
+ # PostgreSQL jsonb cannot store U+0000.
1347
+ def without_nul(text)
1348
+ text.gsub("\u0000", '\u0000')
1104
1349
  end
1105
1350
  end
1106
1351
  end