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
data/lib/ruby_llm/tool.rb CHANGED
@@ -38,8 +38,16 @@ module RubyLLM
38
38
  # or ::parameters when arguments need explicit types, descriptions, or
39
39
  # structure.
40
40
  class Tool
41
+ include Support::Rescuable
42
+
41
43
  KEYWORD_PARAMETER_KINDS = %i[keyreq key].freeze # :nodoc:
42
44
  TOOL_CALL_KEYWORD = :tool_call # :nodoc:
45
+ ASKING = :ruby_llm_tool_asking # :nodoc:
46
+ HALT = :ruby_llm_tool_halt # :nodoc:
47
+
48
+ # A result that ends the turn, from #halt.
49
+ Halt = Data.define(:content) # :nodoc:
50
+ RESUMING = :ruby_llm_tool_resuming # :nodoc:
43
51
 
44
52
  DUPED_INHERITED_CONFIG = {
45
53
  :@declared_parameters => {},
@@ -47,15 +55,73 @@ module RubyLLM
47
55
  }.freeze
48
56
  COPIED_INHERITED_CONFIG = %i[
49
57
  @description
58
+ @namespace
59
+ @namespace_description
50
60
  @parameters_schema_definition
51
61
  @requires_approval
52
62
  @approval_resolver
53
63
  @deferred
64
+ @exclusive
65
+ @max_run_time
66
+ @inline_limit
54
67
  ].freeze
55
68
  private_constant :DUPED_INHERITED_CONFIG, :COPIED_INHERITED_CONFIG
56
69
 
70
+ ##
71
+ # :singleton-method: rescue_from
72
+ # :call-seq: rescue_from(*exception_classes, with: nil, &block)
73
+ #
74
+ # Registers a handler for exceptions a call raises when a chat runs
75
+ # the tool, from #execute, #resume, or a #call the tool overrides. Name
76
+ # the handler with +with:+ or pass a block; either runs on the tool
77
+ # instance. The handler's return value becomes the call's result, as
78
+ # if #execute had returned it, so the model reads it and the chat
79
+ # goes on. Re-raise inside a handler to stop the chat with the
80
+ # exception, as an unhandled one does. Handlers only run for calls a
81
+ # chat makes. Calling #call or #execute yourself is supported and
82
+ # runs without handlers, so exceptions reach you.
83
+ #
84
+ # class ApplicationTool < RubyLLM::Tool
85
+ # rescue_from StandardError, with: :report_failure
86
+ #
87
+ # rescue_from Faraday::Error do |error|
88
+ # { error: "The service didn't answer (#{error.class.name.split("::").last}). Try again later." }
89
+ # end
90
+ #
91
+ # private
92
+ #
93
+ # def report_failure(error)
94
+ # Rails.error.report(error, context: { tool: name })
95
+ # { error: "This tool failed unexpectedly. Trying the same call again won't help." }
96
+ # end
97
+ # end
98
+ #
99
+ # Handlers are searched in reverse declaration order, a class's own
100
+ # before its parent's, so the last matching one wins. Subclasses use
101
+ # their parents' handlers, including ones declared after the subclass,
102
+ # so a handler on Tool itself also covers RubyLLM's own tools, such as
103
+ # CodeExecution. Declare handlers for a server's tools on its MCP
104
+ # class. A question from Tool#ask and a cancelled chat never reach a
105
+ # handler.
106
+ #
107
+ # Exception classes may be named as Strings, which defers constant
108
+ # lookup until an exception is raised. A class that can never match a
109
+ # StandardError, such as Interrupt, raises ArgumentError.
110
+
57
111
  class << self
58
- attr_reader :parameters_schema_definition, :approval_resolver # :nodoc:
112
+ attr_reader :parameters_schema_definition, :approval_resolver, :namespace_description # :nodoc:
113
+
114
+ # Opens the questions of the call a chat makes of +tool+, with
115
+ # +input+, the answers Chat saved for it, so the call and its
116
+ # ::rescue_from handler ask in turn and each reads its answers when
117
+ # the call runs again.
118
+ def asking(tool, input) # :nodoc:
119
+ previous = Fiber[ASKING]
120
+ Fiber[ASKING] = { 'input' => input, 'asked' => 0, 'tool' => tool }
121
+ yield
122
+ ensure
123
+ Fiber[ASKING] = previous
124
+ end
59
125
 
60
126
  def inherited(subclass) # :nodoc:
61
127
  super
@@ -102,6 +168,29 @@ module RubyLLM
102
168
  @description = text
103
169
  end
104
170
 
171
+ # Puts the tool in a namespace, or returns its namespace. Tools in
172
+ # different namespaces can share a name. +description:+ tells the
173
+ # model what the namespace's tools are for, on APIs that describe
174
+ # namespaces. Pass +nil+ to remove an inherited namespace.
175
+ #
176
+ # class CustomerLookup < RubyLLM::Tool
177
+ # namespace "crm", description: "Customer records and orders"
178
+ # end
179
+ #
180
+ # A native client toolset, such as Anthropic's computer toolset, calls
181
+ # the tools in its own namespace:
182
+ #
183
+ # class Screenshot < RubyLLM::Tool
184
+ # namespace "computer"
185
+ # end
186
+ def namespace(value = (get = true), description: nil)
187
+ return @namespace if get
188
+
189
+ @namespace = value&.to_s
190
+ @namespace_description = description
191
+ @namespace
192
+ end
193
+
105
194
  # Declares a parameter for the tool. +options+ accepts +type:+
106
195
  # (defaults to <tt>'string'</tt>), +description:+, and +required:+
107
196
  # (defaults to +true+).
@@ -149,7 +238,7 @@ module RubyLLM
149
238
 
150
239
  # Declares that this tool must be approved before it executes. The
151
240
  # conversation loop pauses the tool call until a decision is recorded with
152
- # Chat#approve or Chat#deny, so Chat#complete returns cleanly and
241
+ # Chat#approve or Chat#decline, so Chat#complete returns cleanly and
153
242
  # can be called again once the decision exists. In Rails the decision
154
243
  # persists on the tool call record and survives process restarts.
155
244
  #
@@ -182,6 +271,34 @@ module RubyLLM
182
271
  @requires_approval || false
183
272
  end
184
273
 
274
+ # Declares that this tool runs by itself. When a chat runs a round of
275
+ # tool calls concurrently (see Chat#with_tool_options), an exclusive
276
+ # call waits for the calls before it to finish, and the calls after
277
+ # it wait for it, while the other tools still run in parallel. Use it
278
+ # for tools that share state, such as a browser page. When an
279
+ # exclusive call waits for an approval or a Task, the calls after it
280
+ # stay pending until it finishes.
281
+ #
282
+ # class BrowserClick < RubyLLM::Tool
283
+ # exclusive
284
+ # end
285
+ #
286
+ def exclusive
287
+ @exclusive = true
288
+ end
289
+
290
+ # Returns whether this tool was declared with ::exclusive.
291
+ def exclusive?
292
+ @exclusive || false
293
+ end
294
+
295
+ # Seconds a run of this tool may take before a persisted chat treats
296
+ # it as crashed, overriding Configuration#max_run_time.
297
+ def max_run_time(seconds = nil) # :nodoc:
298
+ @max_run_time = seconds if seconds
299
+ @max_run_time
300
+ end
301
+
185
302
  # :call-seq:
186
303
  # provider_options(options) -> self
187
304
  # provider_options -> hash
@@ -219,6 +336,32 @@ module RubyLLM
219
336
  @deferred || false
220
337
  end
221
338
 
339
+ # :call-seq:
340
+ # inline_limit(characters) -> characters
341
+ # inline_limit(false) -> false
342
+ # inline_limit -> characters, false, or nil
343
+ #
344
+ # Sets how many characters this tool's results may have and still go
345
+ # to the model whole. In a chat with a file store, a longer result is
346
+ # saved as a file, and the model gets its start and the file's path.
347
+ # It overrides the chat's own limit (see Chat#with_file_store).
348
+ # +false+ always sends results whole, for tools that already return
349
+ # what they find in pages. Without an argument,
350
+ # returns the declared value, or +nil+.
351
+ #
352
+ # class ExportOrders < RubyLLM::Tool
353
+ # inline_limit 50_000
354
+ # end
355
+ #
356
+ def inline_limit(characters = (get = true))
357
+ return @inline_limit if get
358
+ unless characters == false || (characters.is_a?(Integer) && characters.positive?)
359
+ raise ArgumentError, 'inline_limit takes a positive number of characters or false'
360
+ end
361
+
362
+ @inline_limit = characters
363
+ end
364
+
222
365
  def split_result(result) # :nodoc:
223
366
  case result
224
367
  when Attachment then ['', [result]]
@@ -260,13 +403,29 @@ module RubyLLM
260
403
  self.class.tool_name
261
404
  end
262
405
 
406
+ # Returns the tool's namespace, or +nil+ when it has none. See
407
+ # ::namespace.
408
+ def namespace
409
+ self.class.namespace
410
+ end
411
+
412
+ def namespace_description # :nodoc:
413
+ self.class.namespace_description
414
+ end
415
+
416
+ def qualified_name # :nodoc:
417
+ namespace ? "#{namespace}_#{name}" : name
418
+ end
419
+
263
420
  # Returns the tool description declared on the class with ::description.
264
421
  def description
265
422
  self.class.description
266
423
  end
267
424
 
268
- # Returns whether this tool was declared with ::requires_approval.
269
- def requires_approval?
425
+ # Returns whether +tool_call+ must be approved before it runs: whether
426
+ # this tool was declared with ::requires_approval. A subclass may
427
+ # override it to decide from the call's arguments.
428
+ def requires_approval?(tool_call = nil) # rubocop:disable Lint/UnusedMethodArgument
270
429
  self.class.requires_approval?
271
430
  end
272
431
 
@@ -274,6 +433,22 @@ module RubyLLM
274
433
  self.class.approval_resolver
275
434
  end
276
435
 
436
+ # Returns whether this tool runs by itself in a concurrent round of
437
+ # tool calls: whether its class was declared with ::exclusive.
438
+ def exclusive?
439
+ self.class.exclusive?
440
+ end
441
+
442
+ # Returns the limit declared on the class with ::inline_limit: a number
443
+ # of characters, +false+, or +nil+ to use the chat's.
444
+ def inline_limit
445
+ self.class.inline_limit
446
+ end
447
+
448
+ def max_run_time # :nodoc:
449
+ self.class.max_run_time
450
+ end
451
+
277
452
  def declared_parameters # :nodoc:
278
453
  self.class.declared_parameters
279
454
  end
@@ -283,9 +458,39 @@ module RubyLLM
283
458
  self.class.provider_options
284
459
  end
285
460
 
461
+ # Keeps this tool, and no other of its class, out of the model's
462
+ # context until tool search loads it, as ::defer does for the class.
463
+ # Returns +self+. Chat#with_tools and Chat#with_mcp take +defer:+ to
464
+ # decide for every tool they register instead.
465
+ #
466
+ # chat.with_tools(CurrentTime.new, *connector_tools.map(&:defer))
467
+ #
468
+ def defer
469
+ @deferred = true
470
+ self
471
+ end
472
+
473
+ # Returns whether this tool waits to be found by tool search: whether
474
+ # it was deferred with #defer, or its class with ::defer.
475
+ def deferred?
476
+ @deferred || self.class.deferred?
477
+ end
478
+
286
479
  class Deferred < SimpleDelegator # :nodoc:
287
480
  end
288
481
 
482
+ class Flattened < SimpleDelegator # :nodoc:
483
+ def self.wrap(tool)
484
+ return Deferred.new(new(tool.__getobj__)) if tool.is_a?(Deferred)
485
+
486
+ new(tool)
487
+ end
488
+
489
+ def name = __getobj__.qualified_name
490
+ def namespace = nil
491
+ def qualified_name = name
492
+ end
493
+
289
494
  # Returns the JSON Schema for the tool's arguments, whether declared
290
495
  # explicitly or inferred from the +execute+ signature.
291
496
  def parameters_schema
@@ -317,11 +522,61 @@ module RubyLLM
317
522
 
318
523
  RubyLLM.logger.debug { "Tool #{name} called with: #{normalized_args.inspect}" }
319
524
  normalized_args[TOOL_CALL_KEYWORD] = tool_call if execute_accepts_tool_call?
320
- result = execute(**normalized_args)
525
+ result = asking { halting { execute(**normalized_args) } }
321
526
  RubyLLM.logger.debug { "Tool #{name} returned: #{result.inspect}" }
322
527
  result
323
528
  end
324
529
 
530
+ # Asks the person who runs the chat +message+ while #execute runs, and
531
+ # returns their answer. The call pauses until they answer (see
532
+ # Chat#pending_questions), then #execute runs again from the top, and
533
+ # this time +ask+ returns the answer:
534
+ #
535
+ # def execute(destination:)
536
+ # seat = ask("Window or aisle?", choices: %w[window aisle]) or return "No seat chosen."
537
+ # Flights.book!(destination, seat:)
538
+ # end
539
+ #
540
+ # Code before +ask+ runs again too, so make it safe to repeat: ask
541
+ # first, then act. A call may ask several questions, one after
542
+ # another, and each run asks them in the same order.
543
+ #
544
+ # Without options, +ask+ takes free text and returns the String. With
545
+ # +choices:+ it returns the one the person picked. With a block, it
546
+ # asks for the fields the block declares, in the DSL of
547
+ # Tool.parameters, and returns their values by name. With +url:+, it
548
+ # asks the person to visit a page, such as to connect an account, and
549
+ # returns +true+ once they say they did.
550
+ #
551
+ # passenger = ask("Who is flying?") do
552
+ # string :name, description: "As on the passport"
553
+ # integer :age, required: false
554
+ # end
555
+ # passenger # => {name: "Ada Lovelace", age: 36}
556
+ #
557
+ # ask("Connect your calendar", url: authorization_url)
558
+ #
559
+ # Returns +nil+ when the person declines. Outside a chat, nothing can
560
+ # pause the call, so +ask+ raises InputRequiredError. Raises
561
+ # ArgumentError when given more than one of +choices:+, +url:+, and a
562
+ # block.
563
+ def ask(message, choices: nil, url: nil, &fields)
564
+ if [choices, url, fields].compact.size > 1
565
+ raise ArgumentError, 'ask takes choices:, url:, or a block of fields: one kind of answer at a time'
566
+ end
567
+
568
+ state = (Fiber[ASKING] ||= { 'input' => nil, 'asked' => 0 })
569
+ index = state['asked']
570
+ state['asked'] += 1
571
+ requests = Array(state['input']&.[]('requests'))
572
+ return Question.from_h(requests[index]).asked_answer(fields) if requests[index]&.[]('response')
573
+
574
+ schema = SchemaDefinition.new(block: fields).json_schema if fields
575
+ asked = requests.first(index).map { |data| Question.from_h(data) }
576
+ question = Question.asked(index, message, choices:, url:, schema:)
577
+ raise InputRequiredError.new(name, { 'requests' => [*asked, question] })
578
+ end
579
+
325
580
  # Runs the tool with the arguments chosen by the model. Subclasses must
326
581
  # implement this method; the base implementation raises
327
582
  # NotImplementedError. The return value is sent back to the model.
@@ -341,6 +596,55 @@ module RubyLLM
341
596
  raise NotImplementedError, 'Subclasses must implement #execute'
342
597
  end
343
598
 
599
+ # Carries on a call that paused on +task+, the Task #execute returned,
600
+ # with the call's +arguments+. Each Chat#complete calls it once for
601
+ # every paused call of the round. Return the call's result to finish
602
+ # it, or a Task, +task+ itself or a new one, to keep it paused. Tools
603
+ # that return a Task must implement this method; the base
604
+ # implementation raises NotImplementedError.
605
+ #
606
+ # def resume(task, **)
607
+ # envelope = Signatures.find(task.data["envelope_id"])
608
+ # envelope.signed? ? "Signed on #{envelope.signed_on}" : task
609
+ # end
610
+ #
611
+ def resume(task, **arguments)
612
+ raise NotImplementedError, "#{self.class} returned a Task, so it must implement #resume"
613
+ end
614
+
615
+ # Returns the Task that +tool_call+ paused on, from +state+, the Hash
616
+ # Task#to_h returned. Override it to return a Task subclass.
617
+ #
618
+ # def task(state, tool_call: nil)
619
+ # SignatureTask.load(state, tool_call:)
620
+ # end
621
+ #
622
+ def task(state, tool_call: nil)
623
+ Task.load(state, tool_call:)
624
+ end
625
+
626
+ def resume_call(state, arguments, tool_call:) # :nodoc:
627
+ return answered(state, arguments, tool_call:) unless state.key?('task')
628
+
629
+ halting { resume(task(state, tool_call:), **arguments.transform_keys(&:to_sym)) }
630
+ end
631
+
632
+ # Ends the turn with +result+ as this call's result: the model is not
633
+ # asked to respond once the round's other calls finish. It stops
634
+ # #execute where it is called, so call it, or return it, when the tool
635
+ # has said the last word, such as filing a ticket a triage turn exists
636
+ # for. The result reaches the conversation as any other, and the next
637
+ # Chat#ask starts a new turn. See Chat#complete?.
638
+ #
639
+ # def execute(category:)
640
+ # ticket.update!(category:)
641
+ # halt "Filed as #{category}"
642
+ # end
643
+ #
644
+ def halt(result = nil)
645
+ throw HALT, Halt.new(result)
646
+ end
647
+
344
648
  # Reports what the tool is doing while #execute runs, so an application
345
649
  # can show it before the result arrives. Chat#after_tool_progress
346
650
  # callbacks receive it as a Progress. Pass +value:+ and +total:+ when
@@ -376,6 +680,36 @@ module RubyLLM
376
680
  nil
377
681
  end
378
682
 
683
+ # A call's questions are counted from its start; one resumed keeps the
684
+ # answers it was given (#answered), and a call it makes of another tool
685
+ # neither sees them nor moves its count.
686
+ # A fresh Hash each time: fibers inherit their parent's storage, and
687
+ # parallel calls mustn't share a count.
688
+ def halting(&) # :nodoc:
689
+ catch(HALT, &)
690
+ end
691
+
692
+ # The call a chat makes of a tool takes the questions Tool.asking
693
+ # opened for it, which its handler then shares.
694
+ def asking # :nodoc:
695
+ previous = Fiber[ASKING]
696
+ input = Fiber[RESUMING]
697
+ Fiber[RESUMING] = nil
698
+ Fiber[ASKING] = previous&.delete('tool') == self ? previous : { 'input' => input, 'asked' => 0 }
699
+ yield
700
+ ensure
701
+ Fiber[ASKING] = previous
702
+ end
703
+
704
+ # Runs a call again that paused on its questions (#ask), now answered or
705
+ # declined, with +input+, the state Chat saved for it.
706
+ def answered(input, arguments, tool_call:) # :nodoc:
707
+ Fiber[RESUMING] = input
708
+ call(**arguments, tool_call:)
709
+ ensure
710
+ Fiber[RESUMING] = nil
711
+ end
712
+
379
713
  def execute_accepts_tool_call? # :nodoc:
380
714
  method(:execute).parameters.any? do |kind, name|
381
715
  name == TOOL_CALL_KEYWORD && KEYWORD_PARAMETER_KINDS.include?(kind)
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class ToolCall
5
+ # The tool calls of a Message: an Enumerable of ToolCall objects in the
6
+ # order the provider returned them, also readable by call ID. Empty on
7
+ # a message without calls.
8
+ #
9
+ # response.tool_calls.first
10
+ # response.tool_calls.map(&:name) # => ["search_issues"]
11
+ # response.tool_calls["call_1"]
12
+ #
13
+ class Collection
14
+ include Enumerable
15
+ include Support::Inspectable
16
+
17
+ def initialize(calls = {}) # :nodoc:
18
+ @calls = calls.to_h
19
+ end
20
+
21
+ # Yields each ToolCall, in provider order.
22
+ #
23
+ # response.tool_calls.each { |call| puts call.name }
24
+ #
25
+ def each(&)
26
+ return enum_for(__method__) { size } unless block_given?
27
+
28
+ @calls.each_value(&)
29
+ self
30
+ end
31
+ alias each_value each
32
+
33
+ # Yields each key, in provider order: the call ID on a complete message,
34
+ # and on a streamed Chunk the stream index when the provider sends one.
35
+ def each_key(&)
36
+ return enum_for(__method__) { size } unless block_given?
37
+
38
+ @calls.each_key(&)
39
+ self
40
+ end
41
+
42
+ # Returns the ToolCall with the ID +id+, or +nil+.
43
+ def [](id)
44
+ @calls[key_for(id)]
45
+ end
46
+
47
+ def []=(id, call) # :nodoc:
48
+ @calls[id] = call
49
+ end
50
+
51
+ # Returns the ToolCall with the ID +id+. Without a default or a block,
52
+ # raises KeyError when there is none.
53
+ def fetch(id, ...)
54
+ @calls.fetch(key_for(id), ...)
55
+ end
56
+
57
+ # Returns the number of tool calls.
58
+ def size
59
+ @calls.size
60
+ end
61
+ alias length size
62
+
63
+ # Returns +true+ when there are no tool calls.
64
+ def empty?
65
+ @calls.empty?
66
+ end
67
+
68
+ # Returns the tool calls as an Array.
69
+ def values
70
+ @calls.values
71
+ end
72
+
73
+ # Returns the keys as an Array, as #each_key yields them.
74
+ def keys
75
+ @calls.keys
76
+ end
77
+
78
+ # Returns +true+ when there is a tool call with the ID +id+.
79
+ def key?(id)
80
+ @calls.key?(key_for(id))
81
+ end
82
+
83
+ # Returns a Hash of what the block returns for each ToolCall, keyed by
84
+ # call ID.
85
+ def transform_values(&)
86
+ @calls.transform_values(&)
87
+ end
88
+
89
+ # Returns a Collection without the calls with the given IDs.
90
+ def except(*ids)
91
+ self.class.new(@calls.except(*ids.map { |id| key_for(id) }))
92
+ end
93
+
94
+ # Returns the tool calls as a new Hash of ToolCall objects keyed by call
95
+ # ID. With a block, returns the Hash of the pairs it makes from each ID
96
+ # and ToolCall.
97
+ def to_h(&block)
98
+ block ? @calls.to_h(&block) : @calls.dup
99
+ end
100
+ alias to_hash to_h
101
+
102
+ # Returns +true+ when +other+ is a Collection or Hash of the same calls
103
+ # under the same IDs.
104
+ def ==(other)
105
+ other.respond_to?(:to_hash) && @calls == other.to_hash
106
+ end
107
+
108
+ # Returns the JSON of #to_h.
109
+ def to_json(*)
110
+ @calls.to_json(*)
111
+ end
112
+
113
+ # Returns the JSON-ready form of #to_h, where Active Support provides
114
+ # one, or #to_h.
115
+ def as_json(*)
116
+ @calls.respond_to?(:as_json) ? @calls.as_json(*) : to_h
117
+ end
118
+
119
+ private
120
+
121
+ def key_for(id)
122
+ return id if id.nil? || @calls.key?(id)
123
+
124
+ @calls.find(-> { [id] }) { |_, call| call.id == id }.first
125
+ end
126
+
127
+ def inspect_attributes
128
+ { calls: map(&:name) }
129
+ end
130
+ end
131
+ end
132
+ end
@@ -4,7 +4,8 @@ module RubyLLM
4
4
  # A ToolCall is a request to run a tool with specific arguments. Instances
5
5
  # appear in Message#tool_calls. Local calls are yielded to tool callbacks
6
6
  # such as Chat#before_tool_call. Remote calls awaiting approval return
7
- # +true+ from #remote? and appear in Chat#pending_approvals.
7
+ # +true+ from #remote?, and their approvals appear among
8
+ # Chat#pending_questions.
8
9
  #
9
10
  # chat.before_tool_call do |tool_call|
10
11
  # puts "Calling tool: #{tool_call.name}"
@@ -14,13 +15,31 @@ module RubyLLM
14
15
  class ToolCall
15
16
  include Support::Inspectable
16
17
 
17
- # The unique identifier for this call. The tool result message
18
- # answering this call carries the same id.
18
+ def self.index(calls, by: :id) # :nodoc:
19
+ calls.each_with_object({}) do |call, indexed|
20
+ id = call.public_send(by) || call.provider_call_id
21
+ raise Error, "Duplicate tool call ID in one response: #{id.inspect}" if indexed.key?(id)
22
+
23
+ indexed[id] = call
24
+ end
25
+ end
26
+
27
+ # The RubyLLM identifier for this invocation. The tool result message
28
+ # answering this call carries the same id, including after a reload.
29
+ # This is independent of the ID in the provider's response.
19
30
  attr_reader :id
20
31
 
21
32
  # The name of the tool the model wants to invoke.
22
33
  attr_reader :name
23
34
 
35
+ # The namespace of the tool the model wants to invoke, or +nil+ for a
36
+ # tool without one. See Tool.namespace.
37
+ attr_reader :namespace
38
+
39
+ def qualified_name # :nodoc:
40
+ namespace ? "#{namespace}_#{name}" : name
41
+ end
42
+
24
43
  # The arguments the model supplied for the invocation, as a Hash. Keys
25
44
  # are ordered the way PostgreSQL +jsonb+ and MySQL +json+ store them,
26
45
  # shorter keys first, so a call reloaded from the database renders the
@@ -35,24 +54,67 @@ module RubyLLM
35
54
  # RubyLLM replays it to the provider on later requests.
36
55
  attr_accessor :thought_signature
37
56
 
38
- def initialize(id:, name:, arguments: {}, thought_signature: nil, remote: false) # :nodoc:
57
+ def self.parse_arguments(text, response: nil, finish_reason: nil) # :nodoc:
58
+ return { arguments: text } if text.is_a?(Hash)
59
+
60
+ text = JSON.generate(text) unless text.nil? || text.is_a?(String)
61
+ return { arguments: {} } if text.nil? || text.empty?
62
+
63
+ { arguments: Hash(JSON.parse(text)) }
64
+ rescue JSON::ParserError, TypeError => e
65
+ raise ToolCallTruncatedError.new(response:), cause: e if finish_reason == :max_tokens
66
+
67
+ { malformed_arguments: text }
68
+ end
69
+
70
+ def initialize(name:, id: nil, provider_call_id: nil, arguments: {}, thought_signature: nil,
71
+ remote: false, namespace: nil, malformed_arguments: nil) # :nodoc:
39
72
  @id = id
73
+ @provider_call_id = provider_call_id
40
74
  @name = name
41
- @arguments = canonical_arguments(arguments)
75
+ @namespace = namespace&.to_s
76
+ @malformed_arguments = malformed_arguments
77
+ @arguments = malformed_arguments ? {} : canonical_arguments(arguments)
42
78
  @thought_signature = thought_signature
43
79
  @remote = remote
44
80
  end
45
81
 
82
+ # The provider's argument text when it was not a JSON object, or +nil+.
83
+ # A call with such text has empty #arguments and never runs.
84
+ attr_reader :malformed_arguments # :nodoc:
85
+
86
+ def malformed_arguments? = !@malformed_arguments.nil? # :nodoc:
87
+
88
+ def malformed_arguments_problem # :nodoc:
89
+ JSON.parse(@malformed_arguments)
90
+ 'not a JSON object'
91
+ rescue JSON::ParserError => e
92
+ "not valid JSON (#{e.message})"
93
+ end
94
+
95
+ def provider_call_id = @provider_call_id || id # :nodoc:
96
+
97
+ def with_id(id) # :nodoc:
98
+ dup.tap do |call|
99
+ call.instance_variable_set(:@provider_call_id, provider_call_id)
100
+ call.instance_variable_set(:@id, id)
101
+ end
102
+ end
103
+
46
104
  def inspect_attributes # :nodoc:
47
- { id: id, name: name, arguments: arguments, remote: remote? || nil }.compact
105
+ { id: id, name: name, namespace: namespace, arguments: arguments, remote: remote? || nil,
106
+ malformed_arguments: malformed_arguments }.compact
48
107
  end
49
108
 
50
109
  # Returns the call as a Hash, omitting +nil+ values.
51
110
  def to_h
52
111
  {
53
112
  id: @id,
113
+ provider_call_id: @provider_call_id,
54
114
  name: @name,
115
+ namespace: @namespace,
55
116
  arguments: @arguments,
117
+ malformed_arguments: @malformed_arguments,
56
118
  remote: remote? || nil,
57
119
  thought_signature: @thought_signature
58
120
  }.compact