ruby_llm 2.1.1 → 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 +160 -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 +515 -274
  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 +1593 -331
  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 +50 -24
  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 +127 -106
  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 +15 -15
  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 +1 -7
  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 +5 -14
  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 +213 -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
723
+
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
591
730
 
592
- message_record = messages_association.create!(message_attributes(llm_message))
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+.
829
+ #
830
+ # chat.ask_later params[:content]
831
+ # ChatResponseJob.perform_later(chat.id)
662
832
  #
663
- # chat.ask_later "Summarize this document."
664
- # chat.complete
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.
665
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,24 +897,148 @@ 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)
@@ -758,7 +1059,7 @@ module RubyLLM
758
1059
  def resolve_model
759
1060
  config = context&.config || RubyLLM.config
760
1061
  @pending_model_id ||= config.default_model unless model
761
- return unless @pending_model_id
1062
+ return unless @pending_model_id || (@pending_provider && model)
762
1063
 
763
1064
  model_info = resolve_model_info
764
1065
 
@@ -775,25 +1076,34 @@ module RubyLLM
775
1076
  begin
776
1077
  find_registered_model
777
1078
  rescue RubyLLM::ModelNotFoundError
778
- RubyLLM::Model.default(@pending_model_id, @pending_provider)
1079
+ RubyLLM::Model.default(model_id, @pending_provider)
779
1080
  end
780
1081
  end
781
1082
 
782
1083
  def find_registered_model
783
- 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)
784
1085
  end
785
1086
 
786
1087
  # An empty store would otherwise hold only this chat's model on the next
787
1088
  # boot, since the registry reads whatever the store returns.
788
1089
  def load_model_registry_into_store
789
1090
  RubyLLM::ActiveRecord::Model.save_to_database(RubyLLM.models)
1091
+ @model_registry_loaded_into = RubyLLM::ActiveRecord::Model.connection_db_config
790
1092
  rescue ::ActiveRecord::RecordInvalid, ::ActiveRecord::RecordNotUnique
791
1093
  nil
792
1094
  end
793
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
+
794
1104
  def find_or_create_model(model_info)
795
1105
  attributes = { model_id: model_info.id, provider: model_info.provider }
796
- load_model_registry_into_store if RubyLLM::ActiveRecord::Model.none?
1106
+ load_model_registry_into_store if model_store_empty?
797
1107
 
798
1108
  RubyLLM::ActiveRecord::Model.find_or_create_by!(attributes) do |record|
799
1109
  record.name = model_info.name || model_info.id
@@ -816,6 +1126,11 @@ module RubyLLM
816
1126
  reason = error.is_a?(RubyLLM::CancelledError) ? 'chat cancelled' : 'API call failed'
817
1127
  cleanup_failed_messages(reason:) if blank_placeholder?
818
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?
819
1134
  end
820
1135
 
821
1136
  def cleanup_failed_messages(reason:)
@@ -830,13 +1145,13 @@ module RubyLLM
830
1145
  return unless last&.tool_call? || last&.tool_result?
831
1146
 
832
1147
  if last.tool_call?
833
- last.destroy
834
- elsif last.tool_result?
1148
+ last.destroy unless last.ruby_llm_tool_calls.any?(&:paused?)
1149
+ else
835
1150
  parent = last.ruby_llm_parent_tool_call
836
1151
  tool_call_message = parent.message
837
1152
  calls = tool_call_message.ruby_llm_tool_calls
838
1153
 
839
- if calls.any? { |call| call.result.nil? }
1154
+ if calls.any? { |call| call.result.nil? } && calls.none?(&:paused?)
840
1155
  calls.filter_map(&:result).each(&:destroy)
841
1156
  tool_call_message.destroy
842
1157
  end
@@ -848,7 +1163,7 @@ module RubyLLM
848
1163
  messages = assoc.to_a
849
1164
  return messages unless assoc.respond_to?(:klass)
850
1165
 
851
- 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 }]
852
1167
  associations << { attachments_attachments: :blob } if attachment_association?(assoc.klass)
853
1168
  rich_text_reflection = assoc.klass.reflect_on_association(:rich_text_content)
854
1169
  associations << :rich_text_content if rich_text_reflection
@@ -870,50 +1185,23 @@ module RubyLLM
870
1185
  assume_model_exists: assume_model_exists || false
871
1186
  )
872
1187
  sync_messages(chat)
873
- chat.cancellation_checker = proc { consume_persisted_cancellation_request }
874
- chat.approval_checker = proc { |tool_call| persisted_tool_call_approval(tool_call) }
875
- chat.input_checker = proc { |tool_call| persisted_tool_call_input(tool_call) }
876
- 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)
877
1191
  install_persistence_callbacks(chat)
878
1192
  end
879
1193
 
880
- def record_tool_call_decision(tool_call, decision)
881
- id = tool_call.respond_to?(:tool_call_id) ? tool_call.tool_call_id : tool_call
882
- id = id.id if id.respond_to?(:id) && !id.is_a?(String)
883
- record = find_tool_call(id)
884
- raise ArgumentError, "Unknown tool call: #{id.inspect}" unless record
885
-
886
- record.update!(approval: decision)
887
- self
888
- end
889
-
890
- def persisted_tool_call_input(tool_call)
891
- record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }
892
- record.mcp_state if record&.has_attribute?(:mcp_state)
893
- end
894
-
895
- def persist_tool_call_input(tool_call, input)
896
- record = find_tool_call(tool_call.id)
897
- record.update!(mcp_state: input) if record&.has_attribute?(:mcp_state)
898
- end
899
-
900
- def persisted_tool_call_approval(tool_call)
901
- record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }
902
- return unless record&.has_attribute?(:approval)
903
-
904
- case record.approval
905
- when 'approved' then true
906
- when 'denied' then false
907
- end
908
- end
909
-
910
1194
  def sync_messages(chat = @chat)
1195
+ snapshot_run_claim
911
1196
  message_records = eager_load_messages
1197
+ @last_message_id = message_records.last&.id
912
1198
  chat.messages = message_records
913
1199
  linked_entry_pairs = message_records.zip(chat.messages).flat_map do |record, message|
914
1200
  record.ruby_llm_usage_records.zip(message.ruby_llm_usage_entries)
915
1201
  end
916
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
917
1205
  chat.usage_entries = ruby_llm_usages.map do |record|
918
1206
  linked_entries.fetch(record.id) { record.to_entry }
919
1207
  end
@@ -923,8 +1211,7 @@ module RubyLLM
923
1211
 
924
1212
  def install_persistence_callbacks(chat)
925
1213
  chat.before_message { persist_new_message }
926
- chat.usage_recorder = method(:persist_usage_entry)
927
- chat.after_message { |msg| persist_message_completion(msg) }
1214
+ chat.after_message { |msg| persist_message_completion(msg) unless msg&.role == :user }
928
1215
  chat
929
1216
  end
930
1217
 
@@ -948,9 +1235,7 @@ module RubyLLM
948
1235
  # Rewriting the same instructions every turn would move the system row
949
1236
  # behind the user messages and rebroadcast it each time.
950
1237
  def update_persisted_system_instruction(record, instructions, cache_until_here:)
951
- boundary = cache_boundary_attributes(cache_until_here)
952
- boundary.delete(:cache_until_here) unless record.has_attribute?(:cache_until_here)
953
- attributes = { content: instructions, **boundary }
1238
+ attributes = system_instruction_attributes(record, instructions, cache_until_here)
954
1239
  changed = attributes.any? { |column, value| record[column] != value }
955
1240
  record.update!(attributes) if changed
956
1241
  messages_association.reset
@@ -1002,35 +1287,23 @@ module RubyLLM
1002
1287
  end
1003
1288
  end
1004
1289
 
1005
- def persist_new_message
1006
- @message.destroy if blank_placeholder?
1007
-
1008
- attrs = { role: :assistant, content: '' }
1009
- @message = messages_association.create!(attrs)
1010
- end
1011
-
1012
- def blank_placeholder?
1013
- return false unless @message&.persisted? && @message.content.blank?
1014
- return false if @message.respond_to?(:attachments) && @message.attachments.attached?
1015
-
1016
- !@message.ruby_llm_tool_calls.exists?
1017
- end
1018
-
1019
1290
  def persist_message_completion(message)
1020
1291
  return unless message
1021
1292
 
1022
1293
  tool_call = find_tool_call(message.tool_call_id) if message.tool_call_id
1023
1294
  attrs = message_attributes(message)
1024
1295
 
1025
- transaction do
1296
+ answered = fenced_by_run_claim do
1026
1297
  @message.assign_attributes(attrs)
1027
1298
  @message.save!
1028
- tool_call&.update!(tool_result_attributes(tool_call, message))
1299
+ raise ::ActiveRecord::Rollback if tool_call && !tool_result_recorded?(tool_call, message)
1029
1300
 
1030
1301
  persist_content(@message, message.attachments) if message.attachments.any?
1031
1302
  persist_tool_calls(message.tool_calls) if message.tool_calls.present?
1032
1303
  link_usage_entries(message)
1304
+ true
1033
1305
  end
1306
+ discard_superseded_message unless answered
1034
1307
  end
1035
1308
 
1036
1309
  def message_attributes(message)
@@ -1041,16 +1314,18 @@ module RubyLLM
1041
1314
  assign_supported_attribute(attrs, :server_tool_calls, message.server_tool_calls.map(&:to_h).presence)
1042
1315
  assign_supported_attribute(attrs, :raw_content, message.raw_content)
1043
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)
1044
1319
  assign_supported_attribute(attrs, :finish_reason, message.finish_reason)
1045
1320
  assign_supported_attribute(attrs, :cache_until_here, message.cache_until_here?)
1046
1321
  assign_supported_attribute(attrs, :cache_ttl, message.cache_ttl)
1322
+ assign_fallback_attributes(attrs, message) if message.stood_in?
1047
1323
  attrs
1048
1324
  end
1049
1325
 
1050
- def tool_result_attributes(tool_call, message)
1051
- attributes = { result: @message }
1052
- attributes[:mcp_result] = message.mcp_result&.dump if tool_call.has_attribute?(:mcp_result)
1053
- 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)
1054
1329
  end
1055
1330
 
1056
1331
  def assign_supported_attribute(attributes, name, value)
@@ -1058,53 +1333,19 @@ module RubyLLM
1058
1333
  end
1059
1334
 
1060
1335
  def persist_tool_calls(tool_calls, message_record: @message)
1061
- tool_calls.each_value do |tool_call|
1336
+ tool_calls.each do |tool_call|
1062
1337
  attributes = tool_call.to_h
1063
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)
1064
1342
  message_record.ruby_llm_tool_calls.create!(**attributes)
1065
1343
  end
1066
1344
  end
1067
1345
 
1068
- def find_tool_call(tool_call_id)
1069
- RubyLLM::ActiveRecord::ToolCall.find_by(
1070
- tool_call_id: tool_call_id,
1071
- message_type: self.class.message_class.constantize.polymorphic_name,
1072
- message_id: messages_association.select(:id)
1073
- )
1074
- end
1075
-
1076
- def consume_persisted_cancellation_request
1077
- if self[:cancelled]
1078
- clear_cancellation_request
1079
- return :cancelled
1080
- end
1081
- return unless persisted?
1082
- return unless cancellation_poll_due?
1083
- return unless persisted_cancellation_request?
1084
-
1085
- clear_cancellation_request
1086
- :cancelled
1087
- end
1088
-
1089
- # Jobs and requests run with the query cache on, which would replay the
1090
- # first poll's answer for the rest of the run.
1091
- def persisted_cancellation_request?
1092
- self.class.uncached do
1093
- self.class.unscoped.where(self.class.primary_key => id).pick(:cancelled)
1094
- end
1095
- end
1096
-
1097
- def clear_cancellation_request
1098
- self[:cancelled] = false
1099
- self.class.unscoped.where(self.class.primary_key => id).update_all(cancelled: false) if persisted?
1100
- end
1101
-
1102
- def cancellation_poll_due?
1103
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1104
- return false if @last_cancellation_poll_at && now - @last_cancellation_poll_at < CANCELLATION_POLL_INTERVAL
1105
-
1106
- @last_cancellation_poll_at = now
1107
- true
1346
+ # PostgreSQL jsonb cannot store U+0000.
1347
+ def without_nul(text)
1348
+ text.gsub("\u0000", '\u0000')
1108
1349
  end
1109
1350
  end
1110
1351
  end