ruby_llm 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +522 -277
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1598 -332
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +67 -28
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +134 -109
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +23 -2
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +2 -9
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +8 -3
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +219 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -0,0 +1,781 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'stringio'
5
+
6
+ module RubyLLM
7
+ # CodeExecution is the +run_code+ tool Chat#with_code_execution gives the
8
+ # model. Each call runs Ruby, Python, JavaScript, or a shell script in a Sandbox, in a
9
+ # fresh run directory with read-only copies of the conversation's files,
10
+ # and returns the exit code, the output, and every file the code wrote
11
+ # there as an attachment on the tool result. In a chat with a file store
12
+ # (see Chat#with_file_store), runs work with the chat's files instead.
13
+ # With FileStore::Directory, code runs in the store's folder and changes
14
+ # the files in place. With any other store, each run has read-only
15
+ # copies of the conversation's files as usual, copies in the files the
16
+ # model names in +files+, and what the code writes is saved back to the
17
+ # store.
18
+ #
19
+ # Calls run in the chat's default sandbox. With +escalate_to:+, the model
20
+ # may name a wider sandbox for one call and say why; that call waits for
21
+ # Chat#approve, and the next call is back in the default.
22
+ #
23
+ # chat = RubyLLM.chat.with_code_execution(escalate_to: [WorkspaceSandbox])
24
+ # chat.tools[:run_code].escalations # => ["workspace"]
25
+ class CodeExecution < Tool
26
+ # The languages code execution can run.
27
+ LANGUAGES = %i[ruby python javascript shell].freeze
28
+
29
+ # The options Chat#with_code_execution accepts.
30
+ OPTIONS = %i[sandbox escalate_to languages gems python npm gem_home virtualenv node_modules mounts].freeze
31
+
32
+ # Characters of standard output and of standard error kept per run.
33
+ MAX_OUTPUT = 20_000
34
+
35
+ # Files a run returns at most.
36
+ MAX_FILES = 10
37
+
38
+ # Bytes of a text file the model reads back. Larger files, and files
39
+ # that are not images, PDFs, or text, reach the model as a description.
40
+ MAX_MODEL_TEXT = 64 * 1024
41
+
42
+ # The environment variable holding a run's directory, for code that
43
+ # starts in a sandbox's own directory.
44
+ RUN_DIR = 'RUN_DIR'
45
+
46
+ ESCALATION_NAME = /\A[a-z][a-z0-9_]*\z/
47
+ PROGRESS_LINE = 200
48
+ MAX_CHECK_ERROR = 2000
49
+ USAGE_PROVIDER = 'sandbox' # :nodoc:
50
+ USAGE_COUNTER = 'code_execution_seconds' # :nodoc:
51
+ LIMIT_ERRORS = {
52
+ cpu_time: 'The code used up its CPU time and was stopped',
53
+ memory: 'The code used more memory than it may and was stopped',
54
+ processes: 'The code started more processes than it may and was stopped'
55
+ }.freeze
56
+
57
+ # A WeakMap holds its values weakly too. A language that runs is kept
58
+ # as true, which is never collected; the reason one cannot may be, and
59
+ # the language is checked again.
60
+ CHECKS = Hash.new { |checks, language| checks[language] = ObjectSpace::WeakMap.new }
61
+ CHECKS_LOCK = Mutex.new
62
+ FILES_PROPERTY = {
63
+ 'type' => %w[array null], 'items' => { 'type' => 'string' },
64
+ 'description' => "Paths of this chat's files (see list_files) to change, or to read when they are not in " \
65
+ './attachments, copied into the run directory at the same paths; a folder copies everything ' \
66
+ 'in it. null copies nothing'
67
+ }.freeze
68
+ private_constant :CHECKS, :CHECKS_LOCK, :ESCALATION_NAME, :PROGRESS_LINE, :MAX_CHECK_ERROR, :LIMIT_ERRORS,
69
+ :FILES_PROPERTY
70
+
71
+ class << self
72
+ def tool_name # :nodoc:
73
+ 'run_code'
74
+ end
75
+
76
+ # Returns the options +enabled+ and +options+ describe, +false+ when
77
+ # disabled. Raises ArgumentError for anything else.
78
+ def options(enabled, options) # :nodoc:
79
+ return options(true, enabled.transform_keys(&:to_sym).merge(options)) if enabled.is_a?(Hash)
80
+ unless [true, false].include?(enabled)
81
+ raise ArgumentError, 'code execution accepts false or code execution options'
82
+ end
83
+ raise ArgumentError, 'disabled code execution does not accept options' if !enabled && options.any?
84
+ return false unless enabled
85
+
86
+ validate_options(options)
87
+ options.merge(languages: languages_for(options)).freeze
88
+ end
89
+
90
+ # Returns the sandboxes +escalate_to+ names, as a Hash of names to
91
+ # sandboxes. Raises ArgumentError for a name the model cannot use or
92
+ # two sandboxes with the same name.
93
+ def escalations(escalate_to) # :nodoc:
94
+ named_sandboxes(escalate_to).each_with_object({}) do |(name, sandbox), escalations|
95
+ raise ArgumentError, "#{name.inspect} is not a name like workspace" unless name.match?(ESCALATION_NAME)
96
+ raise ArgumentError, "Two sandboxes are named #{name}" if escalations.key?(name)
97
+ raise ArgumentError, "escalate_to: takes sandboxes, got nil for #{name}" if sandbox.nil?
98
+
99
+ escalations[name] = sandbox
100
+ end.freeze
101
+ end
102
+
103
+ # Returns +messages+ as the model reads them: files a run returned
104
+ # stay attached when they are images, PDFs, or short text, and
105
+ # become a description otherwise.
106
+ def for_model(messages) # :nodoc:
107
+ ids = messages.flat_map { |message| run_code_call_ids(message) }
108
+ return messages if ids.empty?
109
+
110
+ names = Workspace.input_names(messages.flat_map(&:attachments))
111
+ messages.map do |message|
112
+ returned_files?(message, ids) ? message.with_attachments(model_view(message.attachments, names)) : message
113
+ end
114
+ end
115
+
116
+ # Returns a callable that describes one of +attachments+, the
117
+ # conversation's files, under the name its run finds.
118
+ def describer(attachments) # :nodoc:
119
+ names = Workspace.input_names(attachments)
120
+ ->(attachment) { describe(attachment, name: names[attachment]) }
121
+ end
122
+
123
+ # Returns a text Attachment telling the model where its code finds
124
+ # +attachment+.
125
+ def describe(attachment, name: nil) # :nodoc:
126
+ name ||= Workspace.filename(attachment.filename)
127
+ text = "#{name} (#{attachment.mime_type}, #{attachment.byte_size} bytes) is not shown here. " \
128
+ "Code you run with #{tool_name} can read it at #{Workspace::ATTACHMENTS}/#{name}."
129
+ Attachment.new(StringIO.new(text), filename: "#{name}.txt")
130
+ end
131
+
132
+ # Returns +true+ when +sandbox+ runs +language+, or why it cannot.
133
+ def check(key, sandbox, language, **) # :nodoc:
134
+ CHECKS_LOCK.synchronize do
135
+ checks = CHECKS[[language.name, (sandbox.backend if sandbox.respond_to?(:backend))]]
136
+ return checks[key] if checks.key?(key)
137
+
138
+ checks[key] = failure(sandbox, language, **) || true
139
+ end
140
+ end
141
+
142
+ # Returns +name+ as a path inside the run directory. Raises
143
+ # ArgumentError for a path outside it, or inside a folder code
144
+ # execution fills itself.
145
+ def mount_name(name) # :nodoc:
146
+ path = Workspace.mount_path(name)
147
+ raise ArgumentError, "#{name.inspect} is reserved for skills" if path.split('/').first == Skill::Loader::MOUNT
148
+
149
+ path
150
+ end
151
+
152
+ private
153
+
154
+ def failure(sandbox, language, **)
155
+ reason = check_failure(sandbox.run(*language.check_command, timeout: 30, **))
156
+ rescue StandardError => e
157
+ reason = "#{e.class}: #{e.message}"
158
+ ensure
159
+ RubyLLM.logger.debug { "Code execution cannot run #{language.label}: #{reason}" } if reason
160
+ end
161
+
162
+ def check_failure(result)
163
+ return 'the check timed out' if result.timed_out?
164
+ return if result.exit_code&.zero?
165
+
166
+ stderr = result.stderr.to_s.strip
167
+ stderr.empty? ? "the check exited with #{result.exit_code.inspect}" : stderr[0, MAX_CHECK_ERROR]
168
+ end
169
+
170
+ def named_sandboxes(escalate_to)
171
+ return escalate_to.map { |name, sandbox| [name.to_s, sandbox] } if escalate_to.is_a?(Hash)
172
+
173
+ Array(escalate_to).map { |sandbox| [Sandbox.name_for(sandbox), sandbox] }
174
+ end
175
+
176
+ def validate_options(options)
177
+ unknown = options.keys - OPTIONS
178
+ raise ArgumentError, "code execution accepts #{OPTIONS.join(', ')}, got #{unknown.join(', ')}" if unknown.any?
179
+ raise ArgumentError, 'code execution options cannot be nil' if options.value?(nil)
180
+
181
+ validate_packages(options)
182
+ escalations(options[:escalate_to]) if options.key?(:escalate_to)
183
+ validate_mounts(options[:mounts]) if options.key?(:mounts)
184
+ end
185
+
186
+ def validate_packages(options)
187
+ { gems: :gem_home, python: :virtualenv, npm: :node_modules }.each do |declared, prebuilt|
188
+ next unless options.key?(declared) && options.key?(prebuilt)
189
+
190
+ raise ArgumentError, "pass #{declared}: or #{prebuilt}:, not both"
191
+ end
192
+ end
193
+
194
+ def validate_mounts(mounts)
195
+ return if mounts.is_a?(Proc)
196
+ raise ArgumentError, 'mounts: takes a Hash or a Proc that returns one' unless mounts.is_a?(Hash)
197
+
198
+ mounts.each_key { |name| mount_name(name) }
199
+ end
200
+
201
+ def languages_for(options)
202
+ default = [:ruby, (:python if options[:python] || options[:virtualenv]),
203
+ (:javascript if options[:npm] || options[:node_modules]), :shell].compact
204
+ languages = Array(options.fetch(:languages, default)).map { |language| language.to_s.to_sym }.uniq
205
+ unsupported = languages - LANGUAGES
206
+ if unsupported.any?
207
+ raise ArgumentError, "code execution runs #{LANGUAGES.join(', ')}, got #{unsupported.join(', ')}"
208
+ end
209
+ raise ArgumentError, 'code execution needs at least one language' if languages.empty?
210
+
211
+ languages.freeze
212
+ end
213
+
214
+ def returned_files?(message, ids)
215
+ message.tool_result? && ids.include?(message.tool_call_id) && message.attachments.any?
216
+ end
217
+
218
+ def model_view(files, names)
219
+ files.map { |file| readable_by_model?(file) ? file : describe(file, name: names[file]) }
220
+ end
221
+
222
+ def run_code_call_ids(message)
223
+ message.tool_calls.select { |call| call.name == tool_name }.map(&:id)
224
+ end
225
+
226
+ def readable_by_model?(attachment)
227
+ attachment.image? || attachment.pdf? || (attachment.text? && attachment.byte_size <= MAX_MODEL_TEXT)
228
+ end
229
+ end
230
+
231
+ # Creates the tool with the options Chat#with_code_execution takes.
232
+ # +sandbox+ is the default sandbox, or a Proc returning it, and
233
+ # +escalate_to+ the sandboxes the model may ask for, an Array or a Hash
234
+ # of names to sandboxes. +attachments+ returns the conversation's files
235
+ # when called, and +skills+ a Hash of paths under +skills/+ to the
236
+ # skill folders mounted there. +file_store+ returns the chat's
237
+ # FileStore, or +nil+: with one, runs work with its files instead of
238
+ # the attachments, in its folder when it has one, and each file a run
239
+ # writes or changes is passed to +written+. +attached_files+ returns
240
+ # the StoredFiles of the conversation's files, kept in that store, which
241
+ # a run without a folder has read-only. +mounts+ is a Hash of paths inside the
242
+ # run directory to directories mounted there read-only, or a Proc called
243
+ # before each run that returns one. +approval+ is called with the
244
+ # ToolCall of each escalated run right before it runs, and the run goes
245
+ # ahead only when it returns +true+. +chat+ and +config+ describe where
246
+ # the instrumentation events go. Without +sandbox+, runs use
247
+ # Configuration#sandbox from +config+. A Proc may return a new sandbox
248
+ # instance each call: it stands for one sandbox, checked once.
249
+ # Raises ConfigurationError without a sandbox.
250
+ def initialize(sandbox:, escalate_to: [], attachments: -> { [] }, file_store: -> {},
251
+ attached_files: -> { [] }, written: ->(_file) {}, skills: -> { {} }, mounts: {},
252
+ approval: ->(_tool_call) { false }, chat: nil, config: nil, package_cache_dir: nil,
253
+ languages: %i[ruby shell], **packages) # :nodoc:
254
+ super()
255
+ @sandbox = sandbox
256
+ @config = config
257
+ self.sandbox
258
+ @escalate_to = self.class.escalations(escalate_to)
259
+ @attachments = attachments
260
+ @file_store = file_store
261
+ @attached_files = attached_files
262
+ @written = written
263
+ @skills = skills
264
+ @mounts = mounts
265
+ @approval = approval
266
+ @chat = chat
267
+ @requested_languages = languages
268
+ node = NodeInstallation.current if languages.include?(:javascript)
269
+ @packages = Packages.new(sandbox: -> { isolated(self.sandbox) }, cache_dir: package_cache_dir, node:, **packages)
270
+ end
271
+
272
+ # Returns the default Sandbox calls run in. Raises ConfigurationError
273
+ # when there is none.
274
+ def sandbox
275
+ resolved(sandbox_value)
276
+ end
277
+
278
+ # Returns the names of the sandboxes the model may ask for, leaving out
279
+ # those that cannot run on this machine.
280
+ #
281
+ # chat.tools[:run_code].escalations # => ["workspace", "online"]
282
+ def escalations
283
+ escalation_sandboxes.keys
284
+ end
285
+
286
+ # Returns what the sandbox named +name+ allows, in one sentence, for the
287
+ # person approving a call that asks for it, or +nil+ for an unknown
288
+ # name.
289
+ #
290
+ # chat.tools[:run_code].summary("workspace")
291
+ # # => "Starts in /srv/app. Can change /srv/app. No network access. Stops after 300 seconds."
292
+ def summary(name)
293
+ sandbox = escalation_sandboxes[name.to_s]
294
+ describe_sandbox(sandbox) if sandbox
295
+ end
296
+
297
+ # Returns the directories mounted read-only in each run directory, keyed
298
+ # by their path there. Raises ArgumentError for a path outside the run
299
+ # directory or a directory that does not exist.
300
+ def mounts
301
+ mounts = @mounts.is_a?(Proc) ? @mounts.call : @mounts
302
+ raise ArgumentError, 'mounts: takes a Hash or a Proc that returns one' unless mounts.is_a?(Hash)
303
+
304
+ mounts.to_h do |name, directory|
305
+ directory = File.expand_path(directory)
306
+ raise ArgumentError, "#{directory} is not a directory" unless File.directory?(directory)
307
+
308
+ [self.class.mount_name(name), File.realpath(directory)]
309
+ end
310
+ end
311
+
312
+ # Returns whether +tool_call+ asks for a sandbox wider than the default:
313
+ # such a call waits for Chat#approve, every time.
314
+ def requires_approval?(tool_call = nil)
315
+ !escalation_for(tool_call).nil?
316
+ end
317
+
318
+ # Returns the languages the model may use: those requested that the
319
+ # sandbox can run. Raises ConfigurationError when it can run none.
320
+ def languages
321
+ value = sandbox_value
322
+ key = sandbox_key(value)
323
+ (@languages ||= {}.compare_by_identity)[key] ||= begin
324
+ options = { env: @packages.base_env(Dir.tmpdir), readable: @packages.runtimes,
325
+ denied: @packages.denied }
326
+ runner = @packages.with_libraries(resolved(value))
327
+ checks = @requested_languages.to_h do |name|
328
+ [name, self.class.check(key, runner, Language[name], **options)]
329
+ end
330
+ available = checks.select { |_, check| check == true }.keys
331
+ raise ConfigurationError, "The sandbox cannot run #{failed_checks(checks)}" if available.empty?
332
+
333
+ available
334
+ end
335
+ end
336
+
337
+ # Returns the description the model sees, naming only the languages
338
+ # the sandbox can run and the sandboxes it may ask for.
339
+ def description
340
+ [
341
+ "Runs #{language_list} code in a sandbox and returns its exit code, output, and the files it writes.",
342
+ *files_note,
343
+ mounts_note,
344
+ packages_note,
345
+ directory_note(sandbox),
346
+ describe_sandbox(sandbox, directory: false),
347
+ escalations_note
348
+ ].compact.join(' ')
349
+ end
350
+
351
+ # Returns the JSON Schema of the arguments: +language+ and +code+,
352
+ # +files+ when runs copy in the chat's files, and +sandbox+ and
353
+ # +reason+ when the model may ask for another sandbox.
354
+ def parameters_schema
355
+ properties = {
356
+ 'language' => { 'type' => 'string', 'enum' => languages.map(&:to_s),
357
+ 'description' => 'The language the code is written in' },
358
+ 'code' => { 'type' => 'string', 'description' => 'The complete program to run' }
359
+ }
360
+ properties['files'] = FILES_PROPERTY if copies_files?
361
+ properties.merge!(escalation_properties) if escalations.any?
362
+ { 'type' => 'object', 'properties' => properties, 'required' => properties.keys,
363
+ 'additionalProperties' => false, 'strict' => true }
364
+ end
365
+
366
+ # Runs +code+ written in +language+ and returns its report, followed by
367
+ # the files it wrote as Attachments. +files+ names the chat's files to
368
+ # copy into the run directory, for a store without a folder. With
369
+ # +sandbox+, runs in that sandbox only when the chat recorded an
370
+ # approval for +tool_call+.
371
+ def execute(language:, code:, sandbox: nil, reason: nil, files: nil, tool_call: nil) # rubocop:disable Lint/UnusedMethodArgument
372
+ language = language.to_s.downcase.to_sym
373
+ unless languages.include?(language)
374
+ return { error: "Unsupported language #{language}. Use one of: #{languages.join(', ')}" }
375
+ end
376
+
377
+ runner, error = runner_for(sandbox, code, tool_call)
378
+ return { error: } if error
379
+
380
+ store = @file_store.call
381
+ root = store&.local_root
382
+ Workspace.open(attachments: root.nil?) do |workspace|
383
+ options = { runner:, name: sandbox, tool_call:, store: }
384
+ next run_in_place(Language[language], code, workspace, root:, **options) if root
385
+
386
+ run(Language[language], code, workspace, names: files, **options)
387
+ end
388
+ rescue Workspace::Unavailable => e
389
+ { error: e.message }
390
+ end
391
+
392
+ private
393
+
394
+ def runner_for(name, code, tool_call)
395
+ return [sandbox, nil] if name.nil?
396
+
397
+ escalation = escalation_sandboxes[name.to_s]
398
+ return [nil, "Unknown sandbox #{name}. Use one of: #{escalations.join(', ')}"] unless escalation
399
+ return [nil, "Running in the #{name} sandbox was not approved."] unless approved?(tool_call, name, code)
400
+
401
+ [resolve(escalation), nil]
402
+ end
403
+
404
+ def approved?(tool_call, name, code)
405
+ return false unless tool_call && escalation_for(tool_call) == name.to_s
406
+
407
+ arguments = tool_call.arguments.to_h.transform_keys(&:to_s)
408
+ arguments['code'] == code && @approval.call(tool_call) == true
409
+ end
410
+
411
+ def escalation_for(tool_call)
412
+ return unless tool_call.respond_to?(:arguments) && tool_call.arguments.respond_to?(:to_h)
413
+
414
+ name = tool_call.arguments.to_h.transform_keys(&:to_s)['sandbox']
415
+ name.to_s if name && escalation_sandboxes.key?(name.to_s)
416
+ end
417
+
418
+ def escalation_sandboxes
419
+ @escalation_sandboxes ||= @escalate_to.select { |_, sandbox| resolve(sandbox).supported? }
420
+ end
421
+
422
+ def resolve(value)
423
+ Sandbox.resolve(value, backend: configuration.sandbox_backend)
424
+ end
425
+
426
+ def configuration
427
+ (@config.is_a?(Proc) ? @config.call : @config) || RubyLLM.config
428
+ end
429
+
430
+ def sandbox_value
431
+ @sandbox.is_a?(Proc) ? @sandbox.call : @sandbox || configuration.sandbox
432
+ end
433
+
434
+ def resolved(value)
435
+ resolve(value) ||
436
+ raise(ConfigurationError, 'Code execution needs a sandbox. Set config.sandbox to a sandbox class.')
437
+ end
438
+
439
+ def sandbox_key(value)
440
+ value = Sandbox.constant(value) if value.is_a?(String)
441
+ @sandbox.is_a?(Proc) && !value.is_a?(Module) ? @sandbox : value
442
+ end
443
+
444
+ def failed_checks(checks)
445
+ checks.map { |name, reason| "#{name}: #{reason}" }.join('; ')
446
+ end
447
+
448
+ def isolated(runner)
449
+ runner.respond_to?(:isolated) ? runner.isolated : runner
450
+ end
451
+
452
+ def escalation_properties
453
+ {
454
+ 'sandbox' => { 'type' => %w[string null], 'enum' => [*escalations, nil],
455
+ 'description' => 'null for the default sandbox, or a sandbox to ask the user for' },
456
+ 'reason' => { 'type' => %w[string null],
457
+ 'description' => 'Why the code needs that sandbox. The user reads it before deciding' }
458
+ }
459
+ end
460
+
461
+ def run(language, code, workspace, runner:, name:, tool_call:, store:, names:)
462
+ all_mounts = mounts.merge(@skills.call)
463
+ copied = store ? copy_from_store(workspace, store, names, all_mounts.keys) : workspace.copy_in(@attachments.call)
464
+ mounted = workspace.mount(all_mounts)
465
+ script = prepare(workspace, language, code)
466
+ instrument(language, runner, name, tool_call) do |event|
467
+ result = run_in_sandbox(runner, language.command(script), workspace, readable: mounted)
468
+ files, skipped = workspace.outputs(max_files: MAX_FILES, max_size: configuration.code_execution_max_file_size,
469
+ unchanged: copied)
470
+ record(event, result, files.map(&:filename))
471
+ next save(store, result, files, skipped) if store
472
+
473
+ files.empty? ? report(result, files, skipped) : [report(result, files, skipped), *workspace.keep(files)]
474
+ end
475
+ end
476
+
477
+ # Runs in the store's own folder: the code reads and writes the files in
478
+ # place, and each file it creates or changes is passed to +written+.
479
+ def run_in_place(language, code, workspace, root:, runner:, name:, tool_call:, store:)
480
+ @attached_files.call
481
+ mounted = workspace.mount(mounts.merge(@skills.call), into: root)
482
+ script = prepare(workspace, language, code)
483
+ before = snapshot(store)
484
+ instrument(language, runner, name, tool_call) do |event|
485
+ result = run_in_sandbox(runner, language.command(script), workspace, readable: mounted, directory: root)
486
+ changed = store.list.reject { |file| before[file.path] == [file.byte_size, file.updated_at] }
487
+ changed, unnamed = changed.partition { |file| file.path.valid_encoding? }
488
+ changed.each { |file| @written.call(file) }
489
+ record(event, result, changed.map(&:path))
490
+ unmounted = workspace.unmounted
491
+ skipped = unnamed.map { |file| Workspace.invalid_name(file.path) }
492
+ report(result, changed.map(&:path), skipped, unmounted: (unmounted_note(unmounted) if unmounted.any?),
493
+ read_again: read_again_note(store, changed))
494
+ end
495
+ end
496
+
497
+ def copy_from_store(workspace, store, names, read_only)
498
+ named = stored_files(store, names, [Workspace::ATTACHMENTS, *read_only])
499
+ workspace.copy_stored_inputs(store, @attached_files.call)
500
+ workspace.copy_files(store, named)
501
+ end
502
+
503
+ def prepare(workspace, language, code)
504
+ workspace.write_source(language, code).tap do
505
+ workspace.link_packages(@packages.node_modules) if language.name == :javascript && @packages.node_modules
506
+ end
507
+ end
508
+
509
+ def snapshot(store)
510
+ store.list.to_h { |file| [file.path, [file.byte_size, file.updated_at]] }
511
+ end
512
+
513
+ # Returns the StoredFiles +names+ asks for: each a file, or a folder
514
+ # standing for every file in it. Raises Workspace::Unavailable for a
515
+ # name with no file, or one under a read-only folder.
516
+ def stored_files(store, names, read_only)
517
+ Array(names).flat_map { |name| stored_files_at(store, name, read_only) }.uniq(&:path)
518
+ end
519
+
520
+ def stored_files_at(store, name, read_only)
521
+ path = FileStore.path(name)
522
+ mount = read_only.find { |folder| FileStore.inside?(path, folder) || FileStore.inside?(folder, path) }
523
+ raise Workspace::Unavailable, "#{name} is in the read-only folder #{mount}, which every run has" if mount
524
+
525
+ files = [store[path]].compact
526
+ files = store.list(path) if files.empty?
527
+ raise Workspace::Unavailable, "No file or folder #{name} in this chat's files. See list_files." if files.empty?
528
+
529
+ files
530
+ rescue ArgumentError => e
531
+ raise Workspace::Unavailable, e.message
532
+ end
533
+
534
+ def copies_files?
535
+ store = @file_store.call
536
+ !store.nil? && store.local_root.nil?
537
+ end
538
+
539
+ # Saves what the run wrote. A file the store has no room for, or whose
540
+ # name it refuses, is left out, and the report names it with the
541
+ # store's reason, so the model reads why instead of the reply failing.
542
+ def save(store, result, files, skipped)
543
+ saved = []
544
+ unsaved = []
545
+ reason = nil
546
+ files.each do |file|
547
+ stored = store.write(file.filename, file.source)
548
+ rescue FileStoreFullError, ArgumentError => e
549
+ unsaved << file.filename
550
+ reason ||= e.message
551
+ else
552
+ saved << stored
553
+ @written.call(stored)
554
+ end
555
+ report(result, saved.map(&:path), skipped, unsaved_files: (unsaved if unsaved.any?), files_error: reason,
556
+ read_again: read_again_note(store, saved))
557
+ end
558
+
559
+ # Names the files this run changed after the model read them, since
560
+ # edit_file refuses them until it reads them again.
561
+ def read_again_note(store, files)
562
+ paths = files.select { |file| changed_since_read?(store, file) }.map(&:path)
563
+ "This run changed files you had read: #{paths.join(', ')}. Read them before you edit them." if paths.any?
564
+ end
565
+
566
+ def changed_since_read?(store, file)
567
+ seen = store.read_digest(file.path)
568
+ seen && seen != (file.digest || FileStore.digest(store.read(file.path)))
569
+ rescue FileNotFoundError, SystemCallError
570
+ true
571
+ end
572
+
573
+ def run_in_sandbox(runner, command, workspace, readable:, directory: workspace.directory)
574
+ sandbox = @packages.with_libraries(runner)
575
+ sandbox.run(*command,
576
+ readable: [*@packages.readable, *@packages.resolver_config(setting(runner, :network)), *readable,
577
+ workspace.source, workspace.inputs],
578
+ denied: @packages.denied,
579
+ writable: [directory, workspace.home, workspace.tmp],
580
+ timeout: timeout_for(runner), directory: (directory unless setting(runner, :directory)),
581
+ env: @packages.env(workspace.home, workspace.tmp).merge(RUN_DIR => directory),
582
+ &progress_listener)
583
+ end
584
+
585
+ def progress_listener
586
+ method(:report_line) if Support::ProgressReporter.listener
587
+ end
588
+
589
+ def report_line(line)
590
+ text = line.strip
591
+ progress(text[0, PROGRESS_LINE]) unless text.empty?
592
+ end
593
+
594
+ def setting(runner, name)
595
+ runner.public_send(name) if runner.respond_to?(name)
596
+ end
597
+
598
+ def timeout_for(runner)
599
+ setting(runner, :timeout) || Sandbox::DEFAULT_TIMEOUT
600
+ end
601
+
602
+ def instrument(language, runner, name, tool_call)
603
+ payload = { chat: @chat, tool_call_id: tool_call&.id, tool_name: self.name, language: language.name,
604
+ sandbox: name&.to_s, timeout: timeout_for(runner), network: setting(runner, :network) }
605
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
606
+ config = @config.is_a?(Proc) ? @config.call : @config
607
+ status = :failed
608
+ RubyLLM.instrument('code_execution.ruby_llm', payload, config:) do |event|
609
+ yield(event).tap { status = :succeeded }
610
+ rescue CancelledError
611
+ status = :cancelled
612
+ raise
613
+ ensure
614
+ event[:duration] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
615
+ record_usage(runner, event, config, status)
616
+ end
617
+ end
618
+
619
+ # A run spends the application's machines, not a provider's, so its
620
+ # entry names the sandbox class that ran it and prices nothing. A run
621
+ # that failed or was cancelled still used its seconds. A class without
622
+ # a name has nothing to name, so its runs have no entry.
623
+ def record_usage(runner, event, config, status)
624
+ model = sandbox_class(runner).name
625
+ return unless model
626
+
627
+ entry = Accounting::Usage::Entry.new(
628
+ operation: :code_execution, provider: USAGE_PROVIDER, model:, status:,
629
+ tokens: Tokens.new(server_tool_use: { USAGE_COUNTER => (event[:cpu_time] || event[:duration]).to_f }),
630
+ cost: Cost.new(amounts: {}, reported: true, total: 0), owner: Accounting::Usage.owner, workflow: Accounting::Usage.workflow
631
+ )
632
+ @chat ? @chat.add_usage_entry(entry) : Accounting::Usage.record(entry)
633
+ Accounting::Usage.instrument(entry, config:)
634
+ rescue StandardError => e
635
+ RubyLLM.logger.warn("RubyLLM could not record code execution usage: #{e.class}: #{e.message}")
636
+ end
637
+
638
+ def sandbox_class(runner)
639
+ sandbox = runner.respond_to?(:policy) ? runner.policy : runner
640
+ sandbox.is_a?(Module) ? sandbox : sandbox.class
641
+ end
642
+
643
+ def record(event, result, paths)
644
+ event[:exit_code] = result.exit_code
645
+ event[:timed_out] = result.timed_out?
646
+ event[:blocked_hosts] = Array(result.blocked) if result.respond_to?(:blocked)
647
+ event[:cpu_time] = result.cpu_time if result.respond_to?(:cpu_time)
648
+ event[:limit_exceeded] = result.limit_exceeded if result.respond_to?(:limit_exceeded)
649
+ event[:files] = paths
650
+ end
651
+
652
+ def report(result, files, skipped, **notes)
653
+ blocked = Array(result.blocked) if result.respond_to?(:blocked)
654
+ {
655
+ exit_code: result.exit_code,
656
+ stdout: truncate(result.stdout),
657
+ stderr: truncate(result.stderr),
658
+ error: run_error(result),
659
+ blocked_hosts: (blocked if blocked&.any?),
660
+ files: (files.map { |file| file.is_a?(String) ? file : file.filename } if files.any?),
661
+ skipped_files: (skipped if skipped.any?),
662
+ **notes
663
+ }.compact.to_json
664
+ end
665
+
666
+ def unmounted_note(paths)
667
+ "The read-only folders #{paths.join(', ')} were not mounted, because this chat's files have something " \
668
+ 'of their own at those paths: the code saw that instead.'
669
+ end
670
+
671
+ def run_error(result)
672
+ return 'The code ran past its time limit and was stopped' if result.timed_out?
673
+
674
+ limit = result.limit_exceeded if result.respond_to?(:limit_exceeded)
675
+ LIMIT_ERRORS[limit] if limit
676
+ end
677
+
678
+ def truncate(text)
679
+ text = text.to_s.dup.force_encoding(Encoding::UTF_8).scrub
680
+ return text if text.length <= MAX_OUTPUT
681
+
682
+ half = MAX_OUTPUT / 2
683
+ "#{text[0, half]}\n[#{text.length - MAX_OUTPUT} characters omitted]\n#{text[-half..]}"
684
+ end
685
+
686
+ def language_list
687
+ labels = languages.map { |name| Language[name].label }
688
+ labels.size > 1 ? "#{labels[0..-2].join(', ')} or #{labels.last}" : labels.first
689
+ end
690
+
691
+ def files_note
692
+ store = @file_store.call
693
+ if store&.local_root
694
+ ["Code runs in the folder that holds this chat's files, the ones list_files shows, and reads them " \
695
+ 'there by their paths, files attached to the conversation included.',
696
+ 'What it writes, changes, or deletes there changes the chat\'s files at once, for read_file and later runs.']
697
+ elsif store
698
+ ['Each call gets a new run directory. Files attached to the conversation are in ./attachments (read-only), ' \
699
+ 'at the paths list_files shows.',
700
+ 'To change one of this chat\'s files, or use one that is not in ./attachments, name it in files and it is ' \
701
+ 'copied in at the same path; a folder copies everything in it.',
702
+ 'Files the code writes or changes in the run directory are saved to the chat\'s files when it ends, where ' \
703
+ 'read_file and later runs find them. Deleting a file there does not delete it from the chat\'s files.']
704
+ else
705
+ ['Each call gets a new empty run directory, and nothing carries over between calls.',
706
+ "Files attached to the conversation, and files earlier calls returned, are in ./#{Workspace::ATTACHMENTS} " \
707
+ '(read-only). Write files to the run directory to return them to the user.']
708
+ end
709
+ end
710
+
711
+ def mounts_note
712
+ names = mounts.keys
713
+ "These folders are available read-only: #{names.map { |name| "./#{name}" }.join(', ')}." if names.any?
714
+ end
715
+
716
+ def packages_note
717
+ notes = []
718
+ gems = @packages.gem_names
719
+ python = @packages.python_names
720
+ npm = @packages.npm_names
721
+ notes << "Ruby #{RUBY_VERSION} has these gems: #{gems.join(', ')}." if gems.any?
722
+ notes << "Python has these packages: #{python.join(', ')}." if python.any?
723
+ notes << "JavaScript has these npm packages: #{npm.join(', ')}." if npm.any?
724
+ notes.join(' ') unless notes.empty?
725
+ end
726
+
727
+ def directory_note(runner)
728
+ directory = setting(runner, :directory)
729
+ return unless directory
730
+
731
+ "Code starts in #{File.expand_path(directory)}, not in the run directory: read the conversation's files " \
732
+ "and write files to return under $#{RUN_DIR}."
733
+ end
734
+
735
+ def escalations_note
736
+ return if escalations.empty?
737
+
738
+ sandboxes = escalation_sandboxes.map do |name, sandbox|
739
+ runner = resolve(sandbox)
740
+ "#{name}: #{[directory_note(runner), describe_sandbox(runner, directory: false)].compact.join(' ')}"
741
+ end
742
+ 'When the code needs more than this sandbox allows, set sandbox to one of these and explain why in reason. ' \
743
+ 'The user approves each such call, and the next call runs in the default sandbox again. ' \
744
+ "#{sandboxes.join(' ')}"
745
+ end
746
+
747
+ def describe_sandbox(sandbox, directory: true)
748
+ runner = sandbox.respond_to?(:run) ? sandbox : resolve(sandbox)
749
+ [(directory_sentence(runner) if directory), paths_sentence('Can read', setting(runner, :readable)),
750
+ paths_sentence('Can change', setting(runner, :writable)), network_sentence(setting(runner, :network)),
751
+ limits_sentence(setting(runner, :limits)), "Stops after #{timeout_for(runner)} seconds."].compact.join(' ')
752
+ end
753
+
754
+ def directory_sentence(runner)
755
+ directory = setting(runner, :directory)
756
+ "Starts in #{File.expand_path(directory)}." if directory
757
+ end
758
+
759
+ def paths_sentence(label, paths)
760
+ paths = Array(paths).map { |path| File.expand_path(path) }
761
+ "#{label} #{paths.join(', ')}." if paths.any?
762
+ end
763
+
764
+ def limits_sentence(limits)
765
+ limits = limits.to_h
766
+ seconds = limits[:cpu_time].to_f.ceil if limits[:cpu_time]
767
+ parts = [("#{seconds} #{seconds == 1 ? 'second' : 'seconds'} of CPU time" if seconds),
768
+ ("#{(limits[:memory].to_i / (1024.0**2)).ceil} MB of memory" if limits[:memory]),
769
+ ("#{limits[:processes]} processes at once" if limits[:processes])].compact
770
+ "Each process may use #{parts.join(' and ')}." if parts.any?
771
+ end
772
+
773
+ def network_sentence(network)
774
+ case network
775
+ when true then 'Can reach the network.'
776
+ when Hash then "Can reach only #{Array(network[:allow]).join(', ')}."
777
+ else 'No network access.'
778
+ end
779
+ end
780
+ end
781
+ end