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
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'stringio'
5
+
6
+ module RubyLLM
7
+ class Chat
8
+ # What Chat#with_file_store changes in a conversation. Every attachment is
9
+ # kept in the chat's FileStore, and each request shows it in full only
10
+ # while it is new: on the latest question, and on read_file results
11
+ # since then. Before that, and on other tools' results, the model sees
12
+ # one line naming the file, and reads it again with the file tools. A
13
+ # tool result longer than the inline limit is saved as a file, and
14
+ # the model gets its start and the path. An attachment the provider
15
+ # cannot take, such as a Word file, goes as the text the store's
16
+ # converters read from it, or as that line when none can. An attachment
17
+ # a converter on this machine reads for free has its text extracted
18
+ # when it is kept, so search_files finds it at once.
19
+ class FileReferences # :nodoc:
20
+ INLINE_LIMIT = 10_000
21
+ PREVIEW = 2_000
22
+ SPILLED = Regexp.new('\n\n\[This result has \d+ characters, so only the start is shown\. All of it is in ' \
23
+ '(\S+): read more with read_file, or search it with search_files\.\]\z')
24
+ READ_TOOL = 'read_file'
25
+
26
+ # Stands for the store a chat picks when given none: a new
27
+ # FileStore::Memory, or a persisted chat's files in Active Storage.
28
+ DEFAULT_STORE = Object.new.tap { |default| def default.inspect = 'the default store' }.freeze
29
+
30
+ attr_reader :inline_limit, :tools, :store
31
+
32
+ class << self
33
+ def validate(store, inline_limit:, tools: FileStore::Tools::DEFAULT, store_lambda: false)
34
+ unless store?(store) || (store_lambda && store.is_a?(Proc))
35
+ raise ArgumentError, 'with_file_store takes a folder path, :memory, a RubyLLM::FileStore, or nil, ' \
36
+ "got #{store.inspect}"
37
+ end
38
+ FileStore::Tools.names(tools)
39
+ return if inline_limit == false || (inline_limit.is_a?(Integer) && inline_limit.positive?)
40
+
41
+ raise ArgumentError, 'inline_limit: takes a positive number of characters or false'
42
+ end
43
+
44
+ # Returns the path of the file holding the whole result whose start
45
+ # is +content+, or +nil+ when the result was sent whole.
46
+ def spilled_path(content)
47
+ content.to_s[SPILLED, 1]
48
+ end
49
+
50
+ def resolve(store)
51
+ return FileStore::Memory.new if store == :memory
52
+ return FileStore::Directory.new(store) if path?(store)
53
+
54
+ store
55
+ end
56
+
57
+ private
58
+
59
+ def store?(store)
60
+ store.is_a?(FileStore) || store.equal?(DEFAULT_STORE) || store == :memory || path?(store)
61
+ end
62
+
63
+ def path?(store)
64
+ store.is_a?(String) || store.respond_to?(:to_path)
65
+ end
66
+ end
67
+
68
+ def initialize(store:, inline_limit:, written:, tools: FileStore::Tools::DEFAULT, config: -> { RubyLLM.config })
69
+ @store = store
70
+ @config = config
71
+ @inline_limit = inline_limit
72
+ @tools = FileStore::Tools.names(tools)
73
+ @written = written
74
+ @stored = {}.compare_by_identity
75
+ @described = {}.compare_by_identity
76
+ end
77
+
78
+ def view(list)
79
+ current = list.rindex { |message| message.role == :user } || 0
80
+ reads = read_paths(list)
81
+ list.each_with_index.map do |message, index|
82
+ message.attachments.empty? ? message : view_message(message, current: index >= current, reads:)
83
+ end
84
+ end
85
+
86
+ def spill(tool, tool_call, content)
87
+ limit = tool.respond_to?(:inline_limit) ? tool.inline_limit : nil
88
+ limit = @inline_limit if limit.nil?
89
+ return content unless limit && content.is_a?(String) && content.length > limit
90
+
91
+ text, extension = readable(content)
92
+ file = write("results/#{safe_name(tool_call.name)}-#{safe_id(tool_call.id)}.#{extension}", text)
93
+ "#{text[0, PREVIEW]}\n\n[This result has #{text.length} characters, so only the start is shown. " \
94
+ "All of it is in #{file.path}: read more with read_file, or search it with search_files.]"
95
+ rescue StandardError => e
96
+ RubyLLM.logger.warn { "Could not save a long tool result in the chat's files: #{e.class}" }
97
+ content
98
+ end
99
+
100
+ def store_attachment(attachment)
101
+ @stored[attachment] ||= store.store_attachment(attachment) do |file|
102
+ @written.call(file)
103
+ extract(file)
104
+ end
105
+ end
106
+
107
+ def describe(attachment, config: RubyLLM.config)
108
+ file = store_attachment(attachment)
109
+ (as_text(file, config) || reference(file.path, file)).tap { |replacement| @described[replacement] = true }
110
+ end
111
+
112
+ # Records a read for each text attachment in +list+, the messages a
113
+ # request sent, that went as itself and matches the file in the store.
114
+ # Anything else the provider got for it, a reference, converted text,
115
+ # or an upload, is a new object that was never stored here.
116
+ def record_shown(list)
117
+ list.flat_map(&:attachments).each do |attachment|
118
+ file = @stored[attachment]
119
+ record_read(attachment, file) if file && attachment.text?
120
+ end
121
+ rescue StandardError => e
122
+ RubyLLM.logger.warn { "Could not record the files a request showed: #{e.class}" }
123
+ end
124
+
125
+ # Returns the StoredFiles of the attachments on +list+, the chat's
126
+ # messages, as they are now, keeping any not kept yet. Leaves out
127
+ # read_file results: they show a file to whoever read it, and a store
128
+ # with an owner may not show that file to this one.
129
+ def attached_files(list)
130
+ reads = read_paths(list)
131
+ list.reject { |message| message.role == :tool && reads.key?(message.tool_call_id) }.flat_map(&:attachments)
132
+ .filter_map { |attachment| attached_file(attachment) }.uniq(&:path)
133
+ end
134
+
135
+ # Removes the replacements #describe made from +replacements+, a
136
+ # chat's memo, so another store or limit describes them again.
137
+ def forget(replacements)
138
+ replacements.delete_if { |_attachment, replacement| @described.key?(replacement) }
139
+ end
140
+
141
+ private
142
+
143
+ def attached_file(attachment)
144
+ store[store_attachment(attachment).path] unless attachment.provider_file?
145
+ rescue StandardError => e
146
+ RubyLLM.logger.warn { "Could not keep an attachment in the chat's files: #{e.class}" }
147
+ nil
148
+ end
149
+
150
+ def record_read(attachment, file)
151
+ seen = FileStore.digest(attachment.content.to_s.b)
152
+ current = store[file.path]
153
+ return unless current && seen == (current.digest || FileStore.digest(store.read(current.path)))
154
+
155
+ store.record_read(current.path, seen) unless store.read_digest(current.path) == seen
156
+ end
157
+
158
+ def extract(file)
159
+ return if file.text? || store.cached_text(file)
160
+
161
+ text = FileStore::Converters.convert(store.attachment(file.path, file:), config: @config.call, local: true)
162
+ store.cache_text(file, text) if text
163
+ rescue StandardError => e
164
+ RubyLLM.logger.warn { "Could not extract the text of #{file.path}: #{e.class}" }
165
+ end
166
+
167
+ def view_message(message, current:, reads:)
168
+ read = (reads[message.tool_call_id] if message.tool_result?)
169
+ shown = current && (!message.tool_result? || read)
170
+ attachments = message.attachments.map { |attachment| view_of(attachment, shown:, read:) }
171
+ attachments == message.attachments ? message : message.with_attachments(attachments)
172
+ end
173
+
174
+ def view_of(attachment, shown:, read:)
175
+ return attachment if attachment.provider_file?
176
+ return (shown ? attachment : reference(read, nil)) if read
177
+
178
+ file = store_attachment(attachment)
179
+ return reference(file.path, file) unless shown
180
+
181
+ too_long?(attachment) ? start_of(attachment, file) : attachment
182
+ rescue StandardError => e
183
+ RubyLLM.logger.warn { "Could not keep an attachment in the chat's files: #{e.class}" }
184
+ attachment
185
+ end
186
+
187
+ def too_long?(attachment)
188
+ @inline_limit && attachment.text? && attachment.content.to_s.length > @inline_limit
189
+ end
190
+
191
+ def start_of(attachment, file)
192
+ text_attachment(file, attachment.content.to_s)
193
+ end
194
+
195
+ def as_text(file, config)
196
+ text = store.text(file.path, config:)
197
+ return if text.nil? || text.strip.empty?
198
+
199
+ text_attachment(file, text)
200
+ rescue StandardError => e
201
+ RubyLLM.logger.warn { "Could not read #{file.path} as text: #{e.class}" }
202
+ nil
203
+ end
204
+
205
+ def text_attachment(file, text)
206
+ partial = @inline_limit && text.length > @inline_limit
207
+ if partial
208
+ text = "#{text[0, @inline_limit]}\n\n[#{file.path} has #{text.length} characters, so only the start " \
209
+ 'is shown, and you have not seen the rest. Read more with read_file, or search it with search_files.]'
210
+ end
211
+ attachment = Attachment.new(StringIO.new("#{file.path}, read as text:\n\n#{text}"),
212
+ filename: "#{File.basename(file.path)}.txt")
213
+ partial ? attachment.mark_partial : attachment
214
+ end
215
+
216
+ def reference(path, file)
217
+ text = if file
218
+ "#{path} (#{file.content_type}, #{file.human_size}) is in this chat's files and not shown " \
219
+ 'here. Open it with read_file, or search it with search_files.'
220
+ else
221
+ "#{path} was shown here. Open it again with read_file to see it."
222
+ end
223
+ Attachment.new(StringIO.new(text), filename: "#{File.basename(path)}.txt")
224
+ end
225
+
226
+ # Returns the path each read_file call asked for, by call id.
227
+ def read_paths(list)
228
+ list.flat_map { |message| message.tool_calls.to_a }
229
+ .select { |call| call.name == READ_TOOL }
230
+ .to_h { |call| [call.id, read_path(call)] }
231
+ end
232
+
233
+ def read_path(call)
234
+ path = call.arguments.to_h.transform_keys(&:to_s)['path'].to_s
235
+ path.empty? ? 'the file' : path
236
+ end
237
+
238
+ def write(path, content)
239
+ store.write(path, content).tap { |file| @written.call(file) }
240
+ end
241
+
242
+ def readable(content)
243
+ return [content, 'txt'] unless content.lstrip.start_with?('{', '[')
244
+
245
+ [JSON.pretty_generate(JSON.parse(content)), 'json']
246
+ rescue JSON::ParserError
247
+ [content, 'txt']
248
+ end
249
+
250
+ def safe_name(name)
251
+ name.to_s.gsub(/[^A-Za-z0-9_-]/, '_')[0, 64]
252
+ end
253
+
254
+ def safe_id(id)
255
+ id.to_s.gsub(/[^A-Za-z0-9]/, '').chars.last(8).join
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Chat
5
+ # Holds a chat's queued messages in memory. Any thread may queue or
6
+ # withdraw while the conversation loop takes them, and each message is
7
+ # taken or withdrawn once. A message that fails to join stays queued,
8
+ # with the ones taken after it, and those that joined are already
9
+ # yielded.
10
+ class MessageQueue # :nodoc:
11
+ def initialize(messages = [], &add)
12
+ @add = add
13
+ @messages = messages.map { |message| message.requeue(self) }
14
+ @lock = Mutex.new
15
+ end
16
+
17
+ def push(content:, attachments:, steer:, sender:)
18
+ queued = QueuedMessage.new(content:, attachments:, steer:, sender:, queue: self)
19
+ @lock.synchronize { @messages << queued }
20
+ queued
21
+ end
22
+
23
+ def delete(queued)
24
+ id = queued.respond_to?(:id) ? queued.id : queued
25
+ @lock.synchronize do
26
+ index = @messages.index { |message| message.id == id }
27
+ @messages.delete_at(index) if index
28
+ end
29
+ end
30
+
31
+ def to_a
32
+ @lock.synchronize { @messages.dup }
33
+ end
34
+
35
+ def none?(steering_only: false)
36
+ @lock.synchronize { @messages.none? { |message| message.steer? || !steering_only } }
37
+ end
38
+
39
+ def take(steering_only: false)
40
+ taken = @lock.synchronize do
41
+ ready, @messages = @messages.partition { |message| message.steer? || !steering_only }
42
+ ready
43
+ end
44
+ taken.each_with_index do |queued, index|
45
+ joined = index
46
+ message = @add.call(queued)
47
+ joined = index + 1
48
+ yield message, queued
49
+ rescue StandardError
50
+ @lock.synchronize { @messages.unshift(*taken.drop(joined)) }
51
+ raise
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Chat
5
+ # Attributes every provider attempt a chat's operations make, including
6
+ # the tools they run, the converters a render runs, and the callbacks
7
+ # of a completion a batch delivers, to the owner set with
8
+ # Chat#with_owner, through the same fiber storage RubyLLM.with_owner
9
+ # uses. A chat without an owner leaves the surrounding one in place.
10
+ module Ownership # :nodoc:
11
+ [*Rescuable::OPERATIONS, :render, :add_completion].each do |operation|
12
+ define_method(operation) do |*args, **kwargs, &block|
13
+ Accounting::Usage.owned_by(owner) { super(*args, **kwargs, &block) }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Chat
5
+ # Runs a chat's operations through the Agent::rescue_from handlers of the
6
+ # agent that configured it, however the chat was reached. Only the
7
+ # outermost operation rescues: #ask runs #complete, and a handler's value
8
+ # must not stand in for a step of a loop that is still running.
9
+ module Rescuable # :nodoc:
10
+ OPERATIONS = %i[ask say ask_later complete generate run_tools step count_tokens compact].freeze
11
+
12
+ attr_writer :rescuer
13
+
14
+ # The agents whose callback macros run on this chat, by agent class.
15
+ def agent_callback_receivers
16
+ @agent_callback_receivers ||= {}
17
+ end
18
+
19
+ OPERATIONS.each do |operation|
20
+ define_method(operation) do |*args, **kwargs, &block|
21
+ return super(*args, **kwargs, &block) unless @rescuer
22
+
23
+ rescuing_with(@rescuer) { super(*args, **kwargs, &block) }
24
+ end
25
+ end
26
+
27
+ def rescuing_with(agent)
28
+ return yield if @rescuing
29
+
30
+ begin
31
+ @rescuing = true
32
+ yield
33
+ rescue StandardError => e
34
+ @rescuing = false
35
+ agent.rescue_with_handler(e)
36
+ ensure
37
+ @rescuing = false
38
+ end
39
+ end
40
+
41
+ def initialize_copy(source)
42
+ super
43
+ @rescuing = false
44
+ @agent_callback_receivers = @agent_callback_receivers&.dup
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Chat
5
+ # What the conversation loop tells the world around it, and asks of it.
6
+ # A plain chat reports usage through +usage.ruby_llm+ and has nothing
7
+ # else to consult. The Rails integration swaps in ActiveRecord::ChatRun,
8
+ # which reaches the record that owns the run. A copy of the chat shares
9
+ # it.
10
+ class Run # :nodoc:
11
+ def record_usage(entry)
12
+ Accounting::Usage.record(entry)
13
+ end
14
+
15
+ def record_progress(_partial); end
16
+
17
+ def chat_id; end
18
+
19
+ def cache_key; end
20
+
21
+ def owner; end
22
+
23
+ def cancellation_requested?
24
+ false
25
+ end
26
+
27
+ def interrupted_by?(_error)
28
+ false
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+ require 'json'
6
+
7
+ module RubyLLM
8
+ class Chat
9
+ class ToolCallIds # :nodoc:
10
+ def self.answer(result, messages)
11
+ calls = unanswered(messages)
12
+ call = calls.find { |candidate| candidate.id == result.tool_call_id } ||
13
+ calls.reverse.find { |candidate| candidate.provider_call_id == result.provider_call_id }
14
+ call ? result.with_tool_call_id(call.id) : result
15
+ end
16
+
17
+ def self.unanswered(messages)
18
+ answered = messages.select(&:tool_result?).to_set(&:tool_call_id)
19
+ messages.select(&:tool_call?).flat_map { |message| message.tool_calls.to_a }
20
+ .reject { |call| answered.include?(call.id) }
21
+ end
22
+ private_class_method :unanswered
23
+
24
+ def initialize(namespace: nil)
25
+ @ids = {}
26
+ @namespace = namespace
27
+ end
28
+
29
+ def assign(message)
30
+ return message unless message.tool_call?
31
+
32
+ calls = message.tool_calls.transform_values do |call|
33
+ call.provider_call_id ? call.with_id(id_for(call.provider_call_id)) : call
34
+ end
35
+ calls = ToolCall.index(calls.values) unless message.is_a?(Chunk)
36
+ message.with_tool_calls(calls)
37
+ end
38
+
39
+ private
40
+
41
+ def id_for(id)
42
+ @ids[id] ||= @namespace ? Digest::SHA256.hexdigest(JSON.generate([*@namespace, id])) : SecureRandom.uuid
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Chat
5
+ # Holds what a chat knows about its tool calls beyond the transcript, in
6
+ # memory. A copy of the chat gets a copy of the state. The Rails
7
+ # integration swaps in ActiveRecord::ChatToolCallState, which keeps it
8
+ # on the tool call records so any process can read it.
9
+ class ToolCallState # :nodoc:
10
+ def initialize
11
+ @inputs = {}
12
+ @decisions = {}
13
+ @notes = {}
14
+ @remembered = []
15
+ @spent_escalations = []
16
+ @lock = Mutex.new
17
+ end
18
+
19
+ def initialize_copy(source)
20
+ super
21
+ @inputs = @inputs.dup
22
+ @decisions = @decisions.dup
23
+ @notes = @notes.dup
24
+ @remembered = @remembered.dup
25
+ @spent_escalations = @spent_escalations.dup
26
+ end
27
+
28
+ def input(tool_call)
29
+ @inputs[tool_call.id]
30
+ end
31
+
32
+ def record_input(tool_call, input)
33
+ @inputs[tool_call.id] = input
34
+ end
35
+
36
+ # Replaces the input +tool_call+ is paused on with what the block
37
+ # returns for it, while the call is still paused. Answers recorded
38
+ # from several threads at once take turns, each on the input the one
39
+ # before left.
40
+ def settle(tool_call)
41
+ exclusively do
42
+ input = @inputs[tool_call.id]
43
+ @inputs[tool_call.id] = yield(input) if input
44
+ end
45
+ end
46
+
47
+ # Runs the block while no other thread settles or decides through
48
+ # this state, so a check and the write it leads to stay together.
49
+ def exclusively(&)
50
+ @lock.synchronize(&)
51
+ end
52
+
53
+ def record_decision(id, decision)
54
+ @decisions[id] = decision
55
+ end
56
+
57
+ def decision(tool_call)
58
+ @decisions[tool_call.id]
59
+ end
60
+
61
+ # The note a person gave when declining +tool_call+, kept apart from
62
+ # its input so a declined call never looks paused on a question.
63
+ def decline_note(tool_call)
64
+ @notes[tool_call.id]
65
+ end
66
+
67
+ def record_decline_note(tool_call, note)
68
+ @notes[tool_call.id] = note
69
+ end
70
+
71
+ def denied?(tool_call)
72
+ decision(tool_call) == false
73
+ end
74
+
75
+ def remember(identity)
76
+ @remembered |= [identity]
77
+ end
78
+
79
+ def remembered?(call)
80
+ @remembered.include?([call.namespace, call.name.to_s])
81
+ end
82
+
83
+ def escalation_spent?(tool_call)
84
+ @spent_escalations.include?(tool_call.id)
85
+ end
86
+
87
+ def spend_escalation(tool_call)
88
+ @spent_escalations << tool_call.id
89
+ end
90
+
91
+ def claim(requested, **)
92
+ requested
93
+ end
94
+
95
+ # Frees the claims on +ids+ once their round ends, whether it ended
96
+ # normally or by +stopped_by+, the signal or exit that stopped it.
97
+ # A round that +finished+ left only calls that paused unanswered.
98
+ def release(_ids, _stopped_by = nil, finished: false); end
99
+
100
+ # Notes that +tool_call+, claimed with its round, starts running now.
101
+ def started(_tool_call); end
102
+
103
+ # Whether +tool_call+, claimed with its round, had started in a
104
+ # process that stopped before saving its result, so it is answered
105
+ # as interrupted instead of running again.
106
+ def interrupted?(_tool_call)
107
+ false
108
+ end
109
+
110
+ def running_elsewhere?(_tool_call)
111
+ false
112
+ end
113
+ end
114
+ end
115
+ end