ruby_llm 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +515 -274
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1593 -331
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +50 -24
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +127 -106
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +15 -15
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +1 -7
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +5 -14
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +213 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Tools
6
+ # The +edit_file+ tool: replaces exact spans of text files, one or
7
+ # several in a call, all or none.
8
+ class Edit < Base # :nodoc:
9
+ Change = Struct.new(:label, :path, :old_text, :new_text, :replace_all) # :nodoc:
10
+
11
+ def self.tool_name
12
+ 'edit_file'
13
+ end
14
+
15
+ def description
16
+ 'Changes text files you have read by replacing old_text, which must appear exactly once, with ' \
17
+ 'new_text. A text file attached to the question counts as read, at its file name. Include ' \
18
+ 'surrounding text to make old_text unique, or set replace_all. Pass edits for several changes in ' \
19
+ 'one call, to one file or several: they apply in order, all or none.'
20
+ end
21
+
22
+ def parameters_schema
23
+ {
24
+ 'type' => 'object',
25
+ 'properties' => {
26
+ 'path' => { 'type' => %w[string null], 'description' => 'The file to change' },
27
+ 'old_text' => { 'type' => %w[string null], 'description' => 'The exact text to replace' },
28
+ 'new_text' => { 'type' => %w[string null], 'description' => 'The text to put in its place' },
29
+ 'replace_all' => { 'type' => %w[boolean null], 'description' => 'Replace every occurrence' },
30
+ 'edits' => { 'type' => %w[array null], 'description' => 'Several changes; each may name a path',
31
+ 'items' => edit_schema }
32
+ },
33
+ 'additionalProperties' => false
34
+ }
35
+ end
36
+
37
+ def execute(path: nil, old_text: nil, new_text: nil, replace_all: false, edits: nil)
38
+ guarded_write do |write|
39
+ raise ToolError, 'Pass old_text and new_text, or edits, not both.' if edits && (old_text || new_text)
40
+
41
+ changes = changes_for(path, old_text, new_text, replace_all, edits)
42
+ store.with_lock do
43
+ files = apply(changes)
44
+ write_all(files, write) || report(files)
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def edit_schema
52
+ {
53
+ 'type' => 'object',
54
+ 'properties' => {
55
+ 'path' => { 'type' => %w[string null] },
56
+ 'old_text' => { 'type' => 'string' },
57
+ 'new_text' => { 'type' => 'string' },
58
+ 'replace_all' => { 'type' => %w[boolean null] }
59
+ },
60
+ 'required' => %w[old_text new_text],
61
+ 'additionalProperties' => false
62
+ }
63
+ end
64
+
65
+ def changes_for(path, old_text, new_text, replace_all, edits)
66
+ return batch(path, edits) unless edits.nil?
67
+ raise ToolError, 'Pass path.' if path.to_s.strip.empty?
68
+
69
+ [Change.new('', path, old_text, new_text, replace_all)]
70
+ end
71
+
72
+ def batch(path, edits)
73
+ raise ToolError, 'edits is empty.' if Array(edits).empty?
74
+
75
+ Array(edits).each_with_index.map do |edit, index|
76
+ edit = edit.to_h.transform_keys(&:to_s)
77
+ target = edit['path'] || path
78
+ if target.to_s.strip.empty?
79
+ raise ToolError,
80
+ "Edit #{index + 1} has no path. Pass path, or a path in each edit."
81
+ end
82
+
83
+ Change.new("Edit #{index + 1}: ", target, edit['old_text'], edit['new_text'], edit['replace_all'])
84
+ end
85
+ end
86
+
87
+ def apply(changes)
88
+ changes.each_with_object({}) do |change, files|
89
+ path = labelled(change) { path_for(change.path) }
90
+ files[path] ||= labelled(change) { [contents(path), 0] }
91
+ content, count = labelled(change) { replace(files[path].first, path, change) }
92
+ files[path] = [content, files[path].last + count]
93
+ end
94
+ end
95
+
96
+ # Returns the error once a write fails after others landed, instead
97
+ # of raising, so a store's transaction keeps what the answer reports.
98
+ def write_all(files, write)
99
+ changed = []
100
+ files.each do |path, (content, _count)|
101
+ write.call(path, content)
102
+ changed << path
103
+ end
104
+ nil
105
+ rescue FileStoreFullError, SystemCallError => e
106
+ raise if changed.empty?
107
+
108
+ reason = e.is_a?(SystemCallError) ? SystemCallError.new(e.errno).message : e.message
109
+ { error: "#{reason} These files already changed: #{changed.join(', ')}." }
110
+ end
111
+
112
+ def labelled(change)
113
+ yield
114
+ rescue ToolError => e
115
+ raise ToolError, "#{change.label}#{e.message}"
116
+ end
117
+
118
+ def contents(path)
119
+ file = store[path] || raise(ToolError, "There is no file at #{path}. #{missing_hint(path)}")
120
+ fresh!(file)
121
+ text_of(file)
122
+ end
123
+
124
+ def replace(content, path, change)
125
+ raise ToolError, 'Pass new_text, or an empty string to delete old_text.' if change.new_text.nil?
126
+
127
+ old_text = change.old_text.to_s
128
+ count = old_text.empty? ? 0 : content.scan(old_text).size
129
+ raise ToolError, "old_text does not appear in #{path}." if count.zero?
130
+ if count > 1 && !change.replace_all
131
+ raise ToolError, "old_text appears #{count} times in #{path}. Add the text around it to make " \
132
+ 'it unique, or set replace_all.'
133
+ end
134
+
135
+ [content.public_send(change.replace_all ? :gsub : :sub, old_text) { change.new_text.to_s }, count]
136
+ end
137
+
138
+ def report(files)
139
+ count = files.values.sum(&:last)
140
+ replaced = "Replaced #{count} #{count == 1 ? 'occurrence' : 'occurrences'}"
141
+ return "#{replaced} in #{files.keys.first}." if files.one?
142
+
143
+ "#{replaced} in #{files.size} files: #{files.keys.join(', ')}."
144
+ end
145
+
146
+ def text_of(file)
147
+ content = store.read(file.path).dup.force_encoding(Encoding::UTF_8)
148
+ return content if content.valid_encoding? && !content.include?("\0")
149
+
150
+ raise ToolError, "#{file.path} is not a text file, so edit_file cannot change it."
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Tools
6
+ # The +list_files+ tool: the chat's files, those in one folder, or
7
+ # those matching a glob, with their sizes and types.
8
+ class List < Base # :nodoc:
9
+ MAX_FILES = 200
10
+
11
+ def self.tool_name
12
+ 'list_files'
13
+ end
14
+
15
+ def description
16
+ 'Lists the files, with sizes and types. Pass path for one folder, and pattern for a glob: ' \
17
+ '*.csv matches names in any folder, reports/**/*.csv matches paths.'
18
+ end
19
+
20
+ def parameters_schema
21
+ {
22
+ 'type' => 'object',
23
+ 'properties' => {
24
+ 'path' => { 'type' => %w[string null], 'description' => 'A folder to list' },
25
+ 'pattern' => { 'type' => %w[string null], 'description' => 'A glob, like *.md or reports/**/*.csv' }
26
+ },
27
+ 'additionalProperties' => false
28
+ }
29
+ end
30
+
31
+ def execute(path: nil, pattern: nil)
32
+ guarded do
33
+ folder = folder_for(path)
34
+ files = store.list(folder)
35
+ pattern = pattern.to_s.strip.then { |glob| glob unless glob.empty? }
36
+ files = files.select { |file| FileStore.match?(file.path, pattern, inside: folder) } if pattern
37
+ next empty(folder, pattern) if files.empty?
38
+
39
+ lines = files.first(MAX_FILES).map { |file| "#{file.path}\t#{file.human_size}\t#{file.content_type}" }
40
+ if files.size > MAX_FILES
41
+ lines << "[#{files.size - MAX_FILES} more files. Narrow the list with path or pattern.]"
42
+ end
43
+ lines.join("\n")
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def empty(folder, pattern)
50
+ return "No files#{" in #{folder}" if folder} match #{pattern}." if pattern
51
+
52
+ folder ? "There are no files in #{folder}." : 'There are no files yet.'
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Tools
6
+ # The +read_file+ tool: a range of a file's text, by lines or by
7
+ # characters, with the text a converter makes of other files. Images,
8
+ # and PDFs no converter reads, come back as an attachment the model
9
+ # sees.
10
+ class Read < Base # :nodoc:
11
+ MAX_LINES = 1_000
12
+ MAX_CHARACTERS = 20_000
13
+ MAX_LINE = 2_000
14
+ UNITS = %w[lines characters].freeze
15
+
16
+ def self.tool_name
17
+ 'read_file'
18
+ end
19
+
20
+ def description
21
+ "Reads a file. Text comes back with line numbers, #{MAX_LINES} lines or #{MAX_CHARACTERS} characters " \
22
+ 'at a time: pass offset and limit to read further, and unit "characters" for very long lines. ' \
23
+ 'After search_files, read just the lines around a match with offset and limit. Documents a ' \
24
+ 'converter reads come back as text, and other images and PDFs as the file itself.'
25
+ end
26
+
27
+ def parameters_schema
28
+ {
29
+ 'type' => 'object',
30
+ 'properties' => {
31
+ 'path' => { 'type' => 'string', 'description' => 'The file to read' },
32
+ 'offset' => { 'type' => %w[integer null],
33
+ 'description' => 'The line to start at, from 1, or the character, from 0' },
34
+ 'limit' => { 'type' => %w[integer null], 'description' => 'How many lines or characters to read' },
35
+ 'unit' => { 'type' => %w[string null], 'enum' => [*UNITS, nil],
36
+ 'description' => 'lines (the default) or characters' }
37
+ },
38
+ 'required' => %w[path],
39
+ 'additionalProperties' => false
40
+ }
41
+ end
42
+
43
+ def execute(path:, offset: nil, limit: nil, unit: nil)
44
+ seen = nil
45
+ result = guarded do
46
+ file = file_at(path)
47
+ seen = [file.path, digest_of(file)]
48
+ unit = (unit || 'lines').to_s
49
+ raise ToolError, "unit is lines or characters, not #{unit}." unless UNITS.include?(unit)
50
+ next "#{file.path} is empty." if file.byte_size.zero?
51
+
52
+ text = store.text(file.path, config:)
53
+ next unreadable(file) if text.nil?
54
+
55
+ unit == 'characters' ? characters(file, text, offset, limit) : lines(file, text, offset, limit)
56
+ end
57
+ store.record_read(*seen) if seen && !result.is_a?(Hash)
58
+ result
59
+ end
60
+
61
+ private
62
+
63
+ def lines(file, text, offset, limit)
64
+ all = text.lines
65
+ start = [offset.to_i, 1].max
66
+ raise ToolError, "#{file.path} has #{all.size} lines; offset #{start} is past the end." if
67
+ start > all.size && !all.empty?
68
+
69
+ count = limit.to_i.positive? ? [limit.to_i, MAX_LINES].min : MAX_LINES
70
+ shown = []
71
+ size = 0
72
+ all.drop(start - 1).first(count).each_with_index do |line, index|
73
+ line = shorten(line.chomp)
74
+ break if size + line.size > MAX_CHARACTERS && shown.any?
75
+
76
+ shown << format('%<number>6d %<line>s', number: start + index, line:)
77
+ size += line.size
78
+ end
79
+ last = start + shown.size - 1
80
+ [header(file, "lines #{start}-#{last} of #{all.size}"), *shown, more_lines(all.size, last)].compact.join("\n")
81
+ end
82
+
83
+ def characters(file, text, offset, limit)
84
+ start = [offset.to_i, 0].max
85
+ count = limit.to_i.positive? ? [limit.to_i, MAX_CHARACTERS].min : MAX_CHARACTERS
86
+ part = text[start, count].to_s
87
+ finish = start + part.size
88
+ more = "[#{text.size - finish} more characters. Continue with offset #{finish}.]" if finish < text.size
89
+ [header(file, "characters #{start}-#{finish} of #{text.size}"), part, more].compact.join("\n")
90
+ end
91
+
92
+ def header(file, range)
93
+ source = " (text read from #{file.content_type})" unless file.text?
94
+ "#{file.path}#{source}, #{range}:"
95
+ end
96
+
97
+ def more_lines(total, last)
98
+ "[#{total - last} more lines. Continue with offset #{last + 1}.]" if last < total
99
+ end
100
+
101
+ def shorten(line)
102
+ return line if line.size <= MAX_LINE
103
+
104
+ "#{line[0, MAX_LINE]} [line cut: #{line.size - MAX_LINE} more characters, read them with unit characters]"
105
+ end
106
+
107
+ def unreadable(file)
108
+ type = Files::MimeType
109
+ if type.image?(file.content_type) || type.pdf?(file.content_type)
110
+ return ["#{file.path} (#{file.content_type}, #{file.human_size}) is attached to this result.",
111
+ store.attachment(file.path, file:)]
112
+ end
113
+
114
+ { error: "#{file.path} is #{file.content_type} and has no text to read. Work with it in code if you " \
115
+ 'can run code, or describe it from its name.' }
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Tools
6
+ # The +search_files+ tool: lines matching a regular expression across
7
+ # the text of the chat's files, or the files that have one, like grep.
8
+ class Search < Base # :nodoc:
9
+ MAX_MATCHES = 100
10
+ MAX_CHARACTERS = 20_000
11
+ MAX_CONTEXT = 10
12
+ MAX_LINE = 300
13
+ MAX_FILE_SIZE = 50 * 1024 * 1024
14
+ MAX_SECONDS = 10
15
+
16
+ def self.tool_name
17
+ 'search_files'
18
+ end
19
+
20
+ def description
21
+ 'Searches the text of the files for lines matching a regular expression, like grep, and returns ' \
22
+ 'path:line: text. Start with files_only to see which files match, then read them with read_file. ' \
23
+ 'Files whose text read_file has not extracted yet are skipped and named.'
24
+ end
25
+
26
+ def parameters_schema
27
+ {
28
+ 'type' => 'object',
29
+ 'properties' => {
30
+ 'query' => { 'type' => 'string', 'description' => 'A regular expression, or plain words' },
31
+ 'path' => { 'type' => %w[string null], 'description' => 'A file or folder to search' },
32
+ 'glob' => { 'type' => %w[string null], 'description' => 'Only files matching this, like *.md' },
33
+ 'context' => { 'type' => %w[integer null], 'description' => 'Lines to show around each match' },
34
+ 'case_sensitive' => { 'type' => %w[boolean null], 'description' => 'Match case. Default: false' },
35
+ 'files_only' => { 'type' => %w[boolean null], 'description' => 'Return only the matching paths' }
36
+ },
37
+ 'required' => %w[query],
38
+ 'additionalProperties' => false
39
+ }
40
+ end
41
+
42
+ def execute(query:, path: nil, glob: nil, context: nil, case_sensitive: false, files_only: false)
43
+ guarded do
44
+ pattern = pattern_for(query.to_s, case_sensitive)
45
+ glob = nil if glob.to_s.strip.empty?
46
+ files = scope(path, glob)
47
+ next "No files match #{glob}." if files.empty? && glob
48
+
49
+ Run.new(pattern, context: context.to_i.clamp(0, MAX_CONTEXT), files_only:).call(files) do |file|
50
+ searchable_text(file)
51
+ end.report(query)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def scope(path, glob)
58
+ folder = folder_for(path)
59
+ files = folder ? files_at(folder) : store.list
60
+ return files unless glob
61
+
62
+ files.select { |file| FileStore.match?(file.path, glob, inside: folder) }
63
+ end
64
+
65
+ def files_at(folder)
66
+ file = store[folder]
67
+ files = file ? [file] : store.list(folder)
68
+ raise ToolError, "There is no file or folder at #{folder}." if files.empty?
69
+
70
+ files
71
+ end
72
+
73
+ def pattern_for(query, case_sensitive)
74
+ raise ToolError, 'query is empty.' if query.strip.empty?
75
+
76
+ options = case_sensitive ? 0 : Regexp::IGNORECASE
77
+ begin
78
+ Regexp.new(query, options, timeout: 1.0)
79
+ rescue RegexpError
80
+ Regexp.new(Regexp.escape(query), options, timeout: 1.0)
81
+ end
82
+ end
83
+
84
+ # Converters can cost money, like OCR, so search reads only text
85
+ # files and conversions already made.
86
+ def searchable_text(file)
87
+ return '' if file.byte_size.zero?
88
+ return if file.byte_size > MAX_FILE_SIZE
89
+
90
+ cached = store.cached_text(file)
91
+ return cached if cached
92
+ return Converters::Text.call(store.attachment(file.path, file:)) if file.text?
93
+
94
+ :unextracted if Converters.for_type(file.content_type, config:).any?
95
+ end
96
+
97
+ # One search: walks the files, collects what it shows, and stops at
98
+ # its limits.
99
+ class Run # :nodoc:
100
+ def initialize(pattern, context:, files_only:)
101
+ @pattern = pattern
102
+ @context = context
103
+ @files_only = files_only
104
+ @lines = []
105
+ @size = 0
106
+ @matches = 0
107
+ @skipped = { none: [], unextracted: [] }
108
+ @stopped = nil
109
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MAX_SECONDS
110
+ end
111
+
112
+ def call(files)
113
+ files.each do |file|
114
+ break if @stopped
115
+
116
+ text = yield(file)
117
+ next @skipped[text.is_a?(Symbol) ? text : :none] << file.path unless text.is_a?(String)
118
+
119
+ search(file.path, text.lines)
120
+ end
121
+ self
122
+ rescue Regexp::TimeoutError
123
+ raise Base::ToolError, 'The regular expression took too long. Use a simpler one.'
124
+ end
125
+
126
+ def report(query)
127
+ lines = @lines.empty? ? ["No lines match #{query}."] : @lines.dup
128
+ lines << notice if @stopped
129
+ if @skipped[:unextracted].any?
130
+ unextracted = @skipped[:unextracted]
131
+ lines << "[Not searched, text not extracted yet: #{unextracted.join(', ')}. " \
132
+ "read_file extracts #{unextracted.one? ? 'it' : 'them'}.]"
133
+ end
134
+ lines << "[Not searched, no text: #{@skipped[:none].join(', ')}]" if @skipped[:none].any?
135
+ lines.join("\n")
136
+ end
137
+
138
+ private
139
+
140
+ def search(path, lines)
141
+ matched = []
142
+ lines.each_with_index do |line, index|
143
+ return stop(:time) if late?
144
+ next unless @pattern.match?(line)
145
+ return show([path]) if @files_only
146
+
147
+ matched << index
148
+ end
149
+ show_matches(path, lines, matched)
150
+ end
151
+
152
+ def show_matches(path, lines, matched)
153
+ hits = matched.to_set
154
+ windows(matched, lines.size).each_with_index.all? do |(first, last), index|
155
+ (index.zero? || @context.zero? || show(['--'], match: false)) &&
156
+ (first..last).all? do |number|
157
+ mark = hits.include?(number) ? ':' : '-'
158
+ show(["#{path}#{mark}#{number + 1}#{mark} #{clean(lines[number])}"], match: mark == ':')
159
+ end
160
+ end
161
+ end
162
+
163
+ def windows(matched, size)
164
+ matched.each_with_object([]) do |index, windows|
165
+ first = [index - @context, 0].max
166
+ last = [index + @context, size - 1].min
167
+ next windows.last[1] = last if windows.any? && first <= windows.last[1] + 1
168
+
169
+ windows << [first, last]
170
+ end
171
+ end
172
+
173
+ def show(lines, match: true)
174
+ text = lines.join("\n")
175
+ return stop(:size) if @size + text.size > MAX_CHARACTERS && @lines.any?
176
+ return stop(:size) if match && @matches >= MAX_MATCHES
177
+
178
+ @lines.concat(lines)
179
+ @size += text.size + 1
180
+ @matches += 1 if match
181
+ true
182
+ end
183
+
184
+ def clean(line)
185
+ line.chomp.strip[0, MAX_LINE]
186
+ end
187
+
188
+ def late?
189
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) > @deadline
190
+ end
191
+
192
+ def stop(reason)
193
+ @stopped ||= reason
194
+ nil
195
+ end
196
+
197
+ def notice
198
+ if @stopped == :time
199
+ '[The search took too long and stopped. Narrow it with path or glob.]'
200
+ else
201
+ '[More matches. Narrow the query, path, or glob.]'
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Tools
6
+ # The +write_file+ tool: creates a text file in the chat's files, or
7
+ # replaces one.
8
+ class Write < Base # :nodoc:
9
+ def self.tool_name
10
+ 'write_file'
11
+ end
12
+
13
+ def description
14
+ 'Writes a text file, replacing any file at path. Read an existing file before you replace it. ' \
15
+ 'To change part of a file, use edit_file.'
16
+ end
17
+
18
+ def parameters_schema
19
+ {
20
+ 'type' => 'object',
21
+ 'properties' => {
22
+ 'path' => { 'type' => 'string', 'description' => 'Where to write, such as notes/summary.md' },
23
+ 'content' => { 'type' => 'string', 'description' => 'The complete content of the file' }
24
+ },
25
+ 'required' => %w[path content],
26
+ 'additionalProperties' => false
27
+ }
28
+ end
29
+
30
+ def execute(path:, content:)
31
+ guarded_write do |write|
32
+ path = path_for(path)
33
+ file = changing(path) { write.call(path, content.to_s) }
34
+ "Wrote #{file.path} (#{file.human_size})."
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ # The tools Chat#with_file_store gives the model, by the names its
6
+ # +tools:+ option takes: +:read+ for +list_files+, +read_file+, and
7
+ # +search_files+, +:write+ for +write_file+, +:edit+ for +edit_file+,
8
+ # and +:delete+ for +delete_file+.
9
+ module Tools # :nodoc:
10
+ GROUPS = { read: %i[List Read Search], write: %i[Write], edit: %i[Edit], delete: %i[Delete] }.freeze
11
+ DEFAULT = %i[read write edit].freeze
12
+
13
+ class << self
14
+ # Returns +tools+, a name or an Array of them, as Symbols in the
15
+ # order of GROUPS. Raises ArgumentError for a name it does not know,
16
+ # and without +:read+.
17
+ def names(tools)
18
+ names = Array(tools).map { |name| name.to_s.to_sym }
19
+ unknown = names - GROUPS.keys
20
+ if unknown.any?
21
+ raise ArgumentError,
22
+ "tools: takes :read, :write, :edit, and :delete, got #{unknown.map(&:inspect).join(', ')}"
23
+ end
24
+ unless names.include?(:read)
25
+ raise ArgumentError,
26
+ 'tools: needs :read, since the model reads its files with it'
27
+ end
28
+
29
+ GROUPS.keys & names
30
+ end
31
+
32
+ # Returns the tool classes for +names+.
33
+ def classes(names)
34
+ names.flat_map { |name| GROUPS.fetch(name) }.map { |name| const_get(name) }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end