ruby_llm 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +522 -277
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1598 -332
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +67 -28
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +134 -109
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +23 -2
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +2 -9
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +8 -3
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +219 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
data/lib/ruby_llm/chat.rb CHANGED
@@ -16,26 +16,51 @@ module RubyLLM
16
16
  # chat.with_instructions("Be terse.").with_tools(Weather)
17
17
  #
18
18
  # #ask runs the conversation loop, executing tools until the model answers
19
- # or a call needs approval. #ask_later, #generate, #run_tools, and #step
20
- # expose individual operations. Resume an approval pause with #approve
21
- # or #deny followed by #complete.
19
+ # or a call waits on a person. #ask_later, #generate, #run_tools, and
20
+ # #step expose individual operations. Answer what a paused call asks,
21
+ # an approval included (see #pending_questions), then #complete carries
22
+ # it on. #queue adds a message from anywhere, even while the loop runs,
23
+ # and the loop takes it between two moves.
22
24
  #
23
- # A Chat is Enumerable over its messages.
25
+ # Read the conversation through #messages.
24
26
  class Chat # rubocop:disable Metrics/ClassLength
25
- include Enumerable
26
27
  include Support::Inspectable
28
+ include Support::DeprecatedEnumerable
27
29
  include ToolSearch
30
+ include ToolSequences
31
+ prepend AgentRun
32
+ prepend Rescuable
33
+ prepend Ownership
28
34
 
29
35
  # The provider-neutral options #with_compaction accepts.
30
36
  COMPACTION_OPTIONS = %i[at instructions pause_after].freeze
31
37
  THINKING_OPTIONS = %i[effort budget display].freeze
38
+ TOOL_OPTIONS = %i[choice calls concurrency rounds].freeze
32
39
  PAUSED = Object.new.freeze
40
+ TOOL_CHOICE_MODES = %i[auto none required].freeze
41
+ AGENT_RUN_KEY = :ruby_llm_agent_run
42
+ COPIED_STATE = %i[
43
+ @usage_entries @tools @tool_deferrals @mcp @mcp_deferrals @tool_prefs @headers @attachment_replacements
44
+ @tool_call_state
45
+ ].freeze
33
46
  UNFINISHED_TOOL_RESULT = { error: 'The tool call did not finish.' }.to_json.freeze
34
- private_constant :THINKING_OPTIONS, :PAUSED, :UNFINISHED_TOOL_RESULT
47
+ # Not a RubyLLM::Error, which a record takes for a failed request and
48
+ # answers by removing the round.
49
+ class StalledToolRound < StandardError; end # :nodoc:
50
+ private_constant :THINKING_OPTIONS, :TOOL_OPTIONS, :PAUSED, :TOOL_CHOICE_MODES, :AGENT_RUN_KEY, :COPIED_STATE,
51
+ :UNFINISHED_TOOL_RESULT, :StalledToolRound
35
52
 
36
53
  # The Model the chat sends requests to.
37
54
  attr_reader :model
38
55
 
56
+ # Returns the id of the Model the chat sends requests to.
57
+ #
58
+ # chat.model_id # => "gpt-6-luna"
59
+ #
60
+ def model_id
61
+ model.id
62
+ end
63
+
39
64
  # The Provider instance handling requests for the current model.
40
65
  attr_reader :provider
41
66
 
@@ -78,26 +103,45 @@ module RubyLLM
78
103
  # explicitly disabled, or +nil+ when not configured.
79
104
  attr_reader :compaction
80
105
 
106
+ # The shares of the context window set with #with_context_budget, or
107
+ # else Configuration#context_budget. Returns +nil+ when not configured.
108
+ attr_reader :context_budget
109
+
81
110
  # The opaque per-user identifier set with #with_end_user, or
82
111
  # +nil+.
83
112
  attr_reader :end_user
84
113
 
114
+ # Whoever the chat's usage is attributed to: the owner set with
115
+ # #with_owner, or else, for a persisted chat, the record its
116
+ # +acts_as_chat+ declaration names with +owner:+. Returns +nil+ when
117
+ # neither is set.
118
+ def owner
119
+ @owner || @run.owner
120
+ end
121
+
85
122
  # The Fallback models tried in order when generation fails.
86
123
  attr_reader :fallbacks
87
124
 
88
125
  # Whether #with_citations asked the provider for citations.
89
126
  attr_reader :citations
90
127
 
128
+ # The code execution options set with #with_code_execution, +false+
129
+ # when explicitly disabled, or +nil+ when not configured.
130
+ attr_reader :code_execution
131
+
132
+ # The Skill objects given with #with_skills, empty without skills.
133
+ attr_reader :skills
134
+
91
135
  # The Context this chat sends requests through, or +nil+ for the global
92
136
  # configuration.
93
137
  attr_reader :context
94
138
 
95
139
  attr_reader :tool_prefs, :fallback_errors, :usage_entries # :nodoc:
96
140
 
97
- # Returns the +choice+, +calls+, and +concurrency+ set with
141
+ # Returns the +choice+, +calls+, +concurrency+, and +rounds+ set with
98
142
  # #with_tool_options, with +nil+ for anything left at the default.
99
143
  def tool_options
100
- { choice: tool_prefs[:choice], calls: tool_prefs[:calls], concurrency: concurrency }
144
+ { choice: tool_prefs[:choice], calls: tool_prefs[:calls], concurrency: concurrency, rounds: @tool_rounds }
101
145
  end
102
146
 
103
147
  # Creates a chat with +model:+, or with the configured default model
@@ -128,7 +172,7 @@ module RubyLLM
128
172
  @mcp_deferrals = {}
129
173
  @provider_tools = []
130
174
  @tool_prefs = { choice: nil, calls: nil }
131
- @concurrency = normalize_tool_concurrency(@config.tool_concurrency)
175
+ apply_tool_concurrency(nil)
132
176
  @provider_options = {}
133
177
  @headers = {}
134
178
  @schema = nil
@@ -136,25 +180,68 @@ module RubyLLM
136
180
  @citations = false
137
181
  @caching = nil
138
182
  @compaction = nil
183
+ @context_budget = @config.context_budget
184
+ @code_execution = nil
185
+ @code_execution_tool = nil
186
+ @skills = []
187
+ @skill_loader = build_skill_loader
188
+ @file_references = nil
189
+ @file_tools = []
139
190
  @end_user = nil
140
191
  @fallbacks = []
141
192
  @fallback_errors = Fallback::DEFAULT_ERRORS
193
+ @drop_unsupported = false
194
+ @answering_fallback = false
142
195
  @callbacks = Hash.new { |callbacks, name| callbacks[name] = [] }
143
196
  @attachment_replacements = {}.compare_by_identity
144
197
  @cancelled = false
145
- @cancellation_checker = nil
146
- @tool_call_decisions = {}
147
- @approval_checker = nil
148
- @tool_call_inputs = {}
149
- @input_checker = nil
150
- @input_recorder = nil
198
+ @run = Run.new
199
+ @tool_call_state = ToolCallState.new
200
+ @message_queue = build_message_queue
201
+ end
202
+
203
+ ##
204
+ # :method: dup
205
+ # :call-seq: dup()
206
+ #
207
+ # Returns an independent copy of the chat, for branching a conversation.
208
+ # The copy starts with the same messages, tools, options, and callbacks,
209
+ # and changes to either chat leave the other alone. #clone does the
210
+ # same.
211
+ #
212
+ # branch = chat.dup
213
+ # branch.ask "What if we used Postgres instead?"
214
+ # chat.messages.length # unchanged
215
+ #
216
+ # Tool instances, connected MCP servers, and the provider connection
217
+ # are shared. A cancellation or a run in progress stays with the
218
+ # original. The copy starts with the same queued messages in a queue
219
+ # of its own.
220
+
221
+ def initialize_copy(source) # :nodoc:
222
+ super
223
+ @messages = @messages.map { |message| coerce_message(message.dup) }
224
+ COPIED_STATE.each { |name| instance_variable_set(name, instance_variable_get(name).dup) }
225
+ @provider_tools = Support::Utils.deep_dup(@provider_tools)
226
+ @provider_options = Support::Utils.deep_dup(@provider_options)
227
+ @callbacks = @callbacks.dup.transform_values!(&:dup)
228
+ @skill_loader = build_skill_loader
229
+ @tool_search_tool = nil
230
+ with_code_execution(**@code_execution) if @code_execution
231
+ if @file_references
232
+ with_file_store(file_store, inline_limit: @file_references.inline_limit, tools: @file_references.tools)
233
+ end
234
+ @message_queue = build_message_queue(@message_queue.to_a) if @message_queue.is_a?(MessageQueue)
235
+ @cancelled = false
151
236
  end
152
237
 
153
238
  # Adds +message+ as a user message and runs the conversation loop,
154
239
  # executing tools until the model answers or a call needs approval.
155
240
  # Returns the latest assistant Message; check #waiting? before
156
241
  # treating it as a final answer. Attach files with +with:+. An
157
- # MCP::Prompt adds its messages instead.
242
+ # MCP::Prompt adds its messages instead, and takes no attachments.
243
+ # Messages still in the queue
244
+ # (see #queue) join the conversation first, in order.
158
245
  # A given block receives streamed Chunk objects as they arrive.
159
246
  #
160
247
  # String attachments read local paths or fetch URLs. Only pass trusted,
@@ -182,11 +269,16 @@ module RubyLLM
182
269
  # chats = tickets.map { |t| RubyLLM.chat.ask_later(t.body) }
183
270
  # RubyLLM.batch(chats)
184
271
  #
272
+ # Every message still in the queue (see #queue) joins the conversation
273
+ # first, in order, whether it steers or not.
274
+ #
185
275
  # Raises PendingToolCallsError while the last response has unanswered
186
- # tool calls: finish the round first, recording #approve or #deny
276
+ # tool calls: finish the round first, recording #approve or #decline
187
277
  # decisions for calls that require approval.
188
278
  def ask_later(message = nil, with: nil)
189
279
  raise_if_pending_tool_calls!
280
+ raise_if_prompt_attachments!(message, with)
281
+ take_queued_messages
190
282
  if message.is_a?(MCP::Prompt)
191
283
  message.messages.each { |prompt_message| add_message(prompt_message) }
192
284
  else
@@ -203,9 +295,12 @@ module RubyLLM
203
295
  def generate(&)
204
296
  raise_if_cancelled!
205
297
 
206
- return generate_once(&) if fallbacks.empty?
298
+ return generate_once(&) if fallbacks.empty? && !@drop_unsupported
207
299
 
208
300
  with_model_restored { generate_with_fallbacks(&) }
301
+ rescue CancelledError
302
+ @cancelled = false
303
+ raise
209
304
  end
210
305
 
211
306
  # Executes the tool calls pending in the latest response and appends
@@ -215,7 +310,7 @@ module RubyLLM
215
310
  # was declared with Tool.requires_approval only execute once #approve
216
311
  # records a decision; denied calls receive a structured denial result,
217
312
  # and undecided calls stay pending. Does nothing when no tool calls
218
- # are pending. Calls paused on an MCP::Task check on it once. The chat
313
+ # are pending. Calls paused on a Task resume once. The chat
219
314
  # is then ready for the next #generate, or the next batch round.
220
315
  # Returns +self+.
221
316
  def run_tools
@@ -223,9 +318,10 @@ module RubyLLM
223
318
 
224
319
  message = pending_tool_response
225
320
  execute_pending_tool_calls(message) if message
321
+ raise_if_cancelled!
226
322
  self
227
323
  rescue CancelledError
228
- cancel_tasks
324
+ stop_tool_round
229
325
  raise
230
326
  end
231
327
 
@@ -233,75 +329,205 @@ module RubyLLM
233
329
  # if any are unanswered, otherwise generates the next response.
234
330
  # Returns the Message that move produced, and +nil+ once there is
235
331
  # nothing left to do or the chat is #waiting?.
332
+ #
333
+ # Unless tool calls are pending, every message in the queue (see
334
+ # #queue) joins the conversation before the model is asked, whether it
335
+ # steers or not.
236
336
  def step(&)
237
- return if complete?
238
-
239
- raise_if_cancelled!
240
- return generate(&) unless pending_tool_response
241
-
242
- before = messages.length
243
- run_tools
244
- messages.last if messages.length > before
245
- rescue CancelledError
246
- cancel_tasks
247
- raise
337
+ advance(steering_only: false, &)
248
338
  end
249
339
 
250
340
  # Runs the conversation loop until #complete? or #waiting? is +true+.
251
341
  # Returns the last conversation Message, or +nil+ for an empty chat.
342
+ # While the chat is #waiting?, returns the assistant Message that holds
343
+ # the pending tool calls, even after other tools in the round have run.
252
344
  # Used after #ask_later; #ask calls #complete for you.
253
345
  #
254
346
  # When a pending tool call requires approval and no decision has been
255
347
  # recorded, or waits on input for an MCP server, the loop pauses.
256
- # Record #approve, #deny, #answer, or #decline decisions, then call
257
- # #complete again to continue. A tool call that waits on an MCP::Task
258
- # pauses the loop too, and each #complete checks on the task once,
259
- # without waiting, and continues once it is done.
348
+ # Record #approve, #answer, or #decline decisions, then call
349
+ # #complete again to continue. A tool call that returns a Task pauses
350
+ # the loop too, and each #complete resumes it once with Tool#resume,
351
+ # without waiting, and continues once it has a result. For an MCP tool,
352
+ # that checks on its MCP::Task.
353
+ #
354
+ # A turn starts once a question is in place. When the model has
355
+ # answered the last one, every message in the queue joins first, in
356
+ # order, and starts the next turn. Within a turn, only messages that
357
+ # steer join, after each round of tool results and after the answer,
358
+ # so the turn goes on until the model has answered them. Messages
359
+ # queued with <tt>steer: false</tt> wait for the turn to end. A
360
+ # #complete on an unanswered chat, such as one after #ask_later, a
361
+ # retry, or a pause for an approval, continues that turn: only
362
+ # messages that steer join. See #queue.
260
363
  def complete(&)
261
- run_tools if awaiting_tasks?
262
- step(&) until complete? || waiting?
263
- last_non_system_message || messages.last
364
+ resuming = !answered?
365
+ run_tools if pending_tasks.any?
366
+ continue_turn(steering_only: resuming, &)
367
+ end
368
+
369
+ # Runs the loop like #complete. With <tt>steering_only: true</tt> the
370
+ # loop goes on with the turn that is running, so messages queued with
371
+ # <tt>steer: false</tt> stay queued.
372
+ def continue_turn(steering_only: true, &) # :nodoc:
373
+ rounds = 0
374
+ within_run do
375
+ until settled?(steering_only) || waiting?
376
+ boundary = !pending_tool_response
377
+ raise_if_tool_rounds_spent!(rounds) if boundary
378
+ move = advance(steering_only:, &)
379
+ break if placeholder?(move)
380
+
381
+ rounds += 1 if boundary && pending_tool_response
382
+ stalled = stalled_round?(stalled, generated: boundary, move:)
383
+ steering_only ||= boundary
384
+ end
385
+ end
386
+ @cancelled = false
387
+ turn_result
264
388
  end
265
389
 
266
390
  # Returns whether the chat has no pending response or tool execution:
267
- # nothing is staged, or the model answered without requesting tools.
391
+ # nothing is staged, the model answered without requesting tools, or a
392
+ # tool ended the turn with Tool#halt once its round finished,
393
+ # and no queued message steers the turn. Messages queued with
394
+ # <tt>steer: false</tt> don't count, so the chat is complete while
395
+ # they wait for the next #complete.
268
396
  def complete?
269
- return false if pending_tool_response
397
+ settled?(true)
398
+ end
270
399
 
271
- last = last_non_system_message
272
- case last&.role
273
- when nil then true
274
- when :user, :tool then false
275
- else !last.tool_call?
400
+ # Adds +message+ to the queue: a user message that waits outside the
401
+ # conversation until the loop takes it. Call it at any time, from any
402
+ # thread, even while #complete runs in another one. Attach files with
403
+ # +with:+, as with #ask. Returns the QueuedMessage.
404
+ #
405
+ # chat.queue "Use the EU file instead"
406
+ # chat.queue "Then email it to me", steer: false
407
+ # chat.queue "Check this one too", with: "q3.pdf", sender: current_user
408
+ #
409
+ # A message that steers, the default, joins the turn that is running:
410
+ # after the next round of tool results, or once the model finishes its
411
+ # answer, which then goes on to answer it too. Pass <tt>steer: false</tt>
412
+ # to wait for the turn to end instead: #complete returns, the chat is
413
+ # #complete?, and the next #ask, #ask_later, or #complete on the
414
+ # answered chat takes the message, as does a #step with no tool calls
415
+ # pending. Steering messages can join before waiting ones queued
416
+ # earlier. Messages that join together become separate user messages,
417
+ # oldest first.
418
+ #
419
+ # Nothing is taken while the chat is #waiting?, and #cancel leaves the
420
+ # queue as it is. Queueing never runs the loop: when nothing is
421
+ # running, call #complete. +sender+, whoever queued the message, is
422
+ # stored with it and handed back without RubyLLM interpreting it.
423
+ def queue(message = nil, with: nil, steer: true, sender: nil)
424
+ @message_queue.push(content: message, attachments: with, steer:, sender:)
425
+ end
426
+
427
+ # Returns the messages waiting in the queue, as QueuedMessage objects,
428
+ # oldest first.
429
+ def queued_messages
430
+ @message_queue.to_a
431
+ end
432
+
433
+ # Takes +queued+, a QueuedMessage or its id, out of the queue before the
434
+ # loop takes it. Returns +true+, or +false+ when it is no longer queued:
435
+ # the loop took it into the conversation, or it was withdrawn already.
436
+ # QueuedMessage#withdraw does the same from the message.
437
+ #
438
+ # chat.withdraw(queued) # => true
439
+ #
440
+ def withdraw(queued)
441
+ !@message_queue.delete(queued).nil?
442
+ end
443
+
444
+ # Adds every queued message to the conversation, without asking the
445
+ # model, and returns the user Message objects they became, oldest
446
+ # first. Each message joins through #around_join and runs
447
+ # #after_message, whether it steers or not.
448
+ #
449
+ # chat.queue "Use the EU file"
450
+ # chat.queue "Then email it to me", steer: false
451
+ # chat.join_queued_messages.map(&:content) # => ["Use the EU file", "Then email it to me"]
452
+ #
453
+ # Raises PendingToolCallsError while the last response has unanswered
454
+ # tool calls, as #ask_later does.
455
+ def join_queued_messages
456
+ raise_if_pending_tool_calls!
457
+ take_queued_messages
458
+ end
459
+
460
+ # Adds every queued message to the conversation, or with
461
+ # <tt>steering_only: true</tt> only those that steer, and runs
462
+ # #after_message for each one that joined. Returns the joined messages.
463
+ def take_queued_messages(steering_only: false) # :nodoc:
464
+ joined = []
465
+ @message_queue.take(steering_only:) do |message|
466
+ next unless message
467
+
468
+ joined << message
469
+ run_callbacks(:after_message, messages.last)
470
+ end
471
+ joined
472
+ end
473
+
474
+ # Runs the #around_join callbacks for +queued+, outermost first, around
475
+ # the given block, which adds the message with the attributes the
476
+ # callbacks pass to +join+. Returns the message, or +nil+ when a
477
+ # callback withdrew it. Takes the message back out of the conversation
478
+ # when a callback raises.
479
+ def run_around_join(queued, &add) # :nodoc:
480
+ joined = nil
481
+ size = messages.length
482
+ innermost = ->(**attributes) { joined ||= add.call(**attributes) }
483
+ chain = @callbacks[:around_join].reverse.reduce(innermost) do |inner, callback|
484
+ lambda do |**outer|
485
+ callback.call(queued, lambda { |**attributes|
486
+ inner.call(**outer, **attributes)
487
+ joined
488
+ })
489
+ end
276
490
  end
491
+ chain.call
492
+ joined
493
+ rescue StandardError
494
+ messages.slice!(size..)
495
+ raise
277
496
  end
278
497
 
279
- # Records approval for +tool_call+, a ToolCall or its id, so the next
280
- # #complete or #run_tools executes a local tool or records permission for
281
- # the provider to execute a remote tool on the next request. Returns +self+.
498
+ # Records approval for +tool_call+, the approval Question from
499
+ # #pending_questions, its ToolCall, or its id, so the next #complete or
500
+ # #run_tools executes a local tool or records permission for the
501
+ # provider to execute a remote tool on the next request. Returns
502
+ # +self+. Question#approve does the same.
282
503
  #
283
504
  # chat.approve(tool_call)
284
505
  # chat.complete
285
506
  #
286
- def approve(tool_call)
507
+ # With <tt>remember: true</tt>, later calls to the same tool run without
508
+ # asking for the rest of the chat.
509
+ #
510
+ # chat.approve(tool_call, remember: true)
511
+ #
512
+ # Approving a call that already has a result remembers nothing.
513
+ #
514
+ # A +run_code+ call that asks for another sandbox is approved one call
515
+ # at a time: passing <tt>remember: true</tt> for it raises
516
+ # ArgumentError, and the approval never applies to another call.
517
+ # Raises ArgumentError for a Question that is not an approval.
518
+ def approve(tool_call, remember: false)
519
+ tool_call = Question.decided_call(tool_call, 'answer')
520
+ name = remembered_tool_identity(tool_call) if remember
287
521
  record_tool_call_decision(tool_call, true)
288
- end
289
-
290
- # Records denial for +tool_call+, a ToolCall or its id. The next
291
- # #complete or #run_tools appends a structured denial result instead
292
- # of executing a local tool, or sends a refusal for a remote tool on the
293
- # next request. The model continues from there. Returns
294
- # +self+.
295
- def deny(tool_call)
296
- record_tool_call_decision(tool_call, false)
522
+ @tool_call_state.remember(name) if name && !tool_call_answered?(tool_call)
523
+ self
297
524
  end
298
525
 
299
526
  # Returns whether the conversation can't continue until something
300
527
  # outside it happens: every remaining pending tool call waits on an
301
- # approval decision, an answer to an MCP input request, or an MCP task.
302
- # #complete stops here, and #awaiting_approval?, #awaiting_input?, and
303
- # #awaiting_tasks? tell what it waits on. Stop a loop you drive with
304
- # #step here too:
528
+ # answer to a question, a Task, or a run in another process.
529
+ # #complete stops here, and #pending_questions and #pending_tasks tell
530
+ # what it waits on. Stop a loop you drive with #step here too:
305
531
  #
306
532
  # chat.step until chat.complete? || chat.waiting?
307
533
  #
@@ -309,112 +535,142 @@ module RubyLLM
309
535
  response = pending_tool_response
310
536
  return false unless response
311
537
 
312
- pending = pending_tool_calls(response).values
313
- pending.any? && pending.all? { |tool_call| approval_pending?(tool_call) || paused?(tool_call) }
538
+ pending = pending_tool_calls(response)
539
+ pending.any? && pending.all? do |tool_call|
540
+ approval_pending?(tool_call) || paused?(tool_call) ||
541
+ running_elsewhere?(tool_call) || sequence_blocked?(tool_call) || exclusive_blocked?(tool_call)
542
+ end
314
543
  end
315
544
 
316
- # Returns whether the conversation can make no progress without an
317
- # approval decision: every remaining pending tool call requires
318
- # approval and has none recorded, or waits on input. While +true+,
319
- # #complete returns
320
- # without executing them; record decisions with #approve or #deny,
321
- # then call #complete again. Tool calls that need no approval still
322
- # execute before the loop pauses.
545
+ # Returns the questions the conversation waits on a person to answer,
546
+ # as Question objects, in the order the model made their calls: the
547
+ # approvals of tools declared with Tool.requires_approval, which ask
548
+ # before the tool runs, and the questions tools ask with Tool#ask and
549
+ # MCP servers ask while they work. Empty when nothing waits on a person.
323
550
  #
324
- # Consults each pending tool's approval resolver when one is declared,
325
- # so resolvers must be idempotent reads.
326
- def awaiting_approval?
327
- waiting? && pending_approvals.any?
328
- end
329
-
330
- # Returns the tool calls from the latest response that require approval
331
- # and have no recorded decision, as an array of ToolCall objects. Pairs
332
- # with #approve and #deny. ToolCall#remote? identifies provider-executed calls.
551
+ # chat.ask "Book me a flight to Lisbon"
552
+ # question = chat.pending_questions.first
553
+ # question.message # => "Window or aisle?", or nil for an approval
554
+ # question.answer "window" # as the person chose; approve or decline an approval
555
+ # chat.complete
333
556
  #
334
- # chat.pending_approvals.each { |tool_call| puts tool_call.name }
335
- # chat.approve(chat.pending_approvals.first)
557
+ # chat.pending_questions.select(&:approval?).each { |question| puts question.tool_call.name }
336
558
  #
337
- def pending_approvals
559
+ # #complete carries each call on once nothing it asked waits for an
560
+ # answer. Calls that need no answer still run before the loop pauses.
561
+ # Consults each pending tool's approval resolver when one is declared,
562
+ # so resolvers must be idempotent reads.
563
+ def pending_questions
338
564
  response = pending_tool_response
339
565
  return [] unless response
340
566
 
341
- pending_tool_calls(response).values.select { |tool_call| approval_pending?(tool_call) }
342
- end
567
+ pending_tool_calls(response).flat_map do |tool_call|
568
+ next [Question.approval(tool_call, chat: self)] if approval_pending?(tool_call)
343
569
 
344
- # Returns whether the conversation can make no progress until the user
345
- # answers an MCP server: every remaining pending tool call waits on
346
- # input or an approval decision, and at least one waits on input.
347
- def awaiting_input?
348
- waiting? && pending_inputs.any?
570
+ unanswered_questions(tool_call)
571
+ end
349
572
  end
350
573
 
351
- # Returns the unanswered requests for input that paused MCP tool calls,
352
- # as MCP::InputRequest objects. Settle each with #answer or #decline,
353
- # then call #complete to resume the calls.
574
+ # Answers +question+, a Question from #pending_questions or its id, as
575
+ # Question#answer describes, and returns +self+. Answering an approval
576
+ # approves its call. Takes the ActionController::Parameters a form
577
+ # sends for several fields, and keeps only the fields the question asks
578
+ # for. A question already answered, or one whose call has finished,
579
+ # stays as it is, so a form sent twice is safe, and of two answers to
580
+ # one approval at once, the first stands. #approve instead records a
581
+ # decision whatever came before.
354
582
  #
355
- # chat.pending_inputs.each { |request| puts request.message }
356
- # chat.answer(chat.pending_inputs.first, environment: "staging")
583
+ # chat.answer(question, "window")
584
+ # chat.answer(params[:question_id], params[:answer])
585
+ # chat.complete
357
586
  #
358
- def pending_inputs
359
- response = pending_tool_response
360
- return [] unless response
587
+ # Raises ArgumentError for a question the chat never asked, and for an
588
+ # answer the question can't take.
589
+ def answer(question, value = nil, **values)
590
+ pending = pending_question(question)
591
+ response = pending&.response_to(value, values)
592
+ return self unless pending && settle_question(pending, response)
361
593
 
362
- pending_tool_calls(response).values.flat_map do |tool_call|
363
- Array(tool_call_input(tool_call)&.[]('requests'))
364
- .map { |data| MCP::InputRequest.from_h(data, tool_call:) }
365
- .reject(&:answered?)
366
- end
594
+ question.respond(response) if question.is_a?(Question)
595
+ self
367
596
  end
368
597
 
369
- # Returns whether the conversation can make no progress until MCP
370
- # servers finish tasks: every remaining pending tool call waits on a
371
- # task, input, or an approval decision, and at least one waits on a
372
- # task. #complete checks on the tasks again.
373
- #
374
- # chat.ask "Render the quarterly report"
375
- # chat.awaiting_tasks? # => true
376
- # sleep chat.pending_tasks.first.poll_interval
598
+ # Declines +question+, a Question from #pending_questions, its id, or
599
+ # the ToolCall of an approval, and returns +self+. A declined
600
+ # approval's call doesn't run: the next #complete or #run_tools gives
601
+ # the model a refusal instead, or sends one for a remote tool on the
602
+ # next request, unless another decision came first, also once the chat
603
+ # no longer offers the call's tool. A tool that asked
604
+ # gets +nil+ from Tool#ask. A +note+ tells the model why: it is the
605
+ # declined approval's result in place of the plain refusal, and it
606
+ # follows the result of a tool whose question was declined. A Hash
607
+ # +note+ is the declined approval's whole result, as given, and
608
+ # follows a tool's result as JSON. A blank note counts as none. A question already answered, or one whose call
609
+ # has finished, stays as it is.
610
+ #
611
+ # chat.decline(question)
612
+ # chat.decline(params[:question_id], "The order shipped already")
377
613
  # chat.complete
378
614
  #
379
- def awaiting_tasks?
380
- waiting? && pending_tasks.any?
615
+ # Raises ArgumentError for a question the chat never asked.
616
+ def decline(question, note = nil)
617
+ pending = pending_question(question)
618
+ response = Question.declined(note)
619
+ return self unless pending && settle_question(pending, response)
620
+
621
+ question.respond(response) if question.is_a?(Question)
622
+ self
381
623
  end
382
624
 
383
- # Returns the MCP::Task objects that MCP tool calls wait on, as they
384
- # were when the chat last checked. MCP::Task#refresh checks on one
385
- # without resuming the chat; #complete checks on all of them and
386
- # resumes their calls once they are done.
625
+ # Returns whether +tool_call+ requires approval and has no recorded
626
+ # decision.
627
+ def approval_pending?(tool_call) # :nodoc:
628
+ return false if tool_call.malformed_arguments? || skipped_result(tool_call)
629
+ return approval_decision(nil, tool_call).nil? if tool_call.remote?
630
+
631
+ tool = tool_for(tool_call)
632
+ return false unless tool&.requires_approval?(tool_call)
633
+
634
+ approval_decision(tool, tool_call).nil?
635
+ end
636
+
637
+ # Returns the Task objects that tool calls wait on, as they were when
638
+ # the chat last checked, with an MCP::Task for each MCP tool call.
639
+ # Task#refresh checks on one without resuming the chat; #complete
640
+ # resumes every call with Tool#resume.
387
641
  #
388
642
  # chat.pending_tasks.each { |task| puts "#{task.tool_call.name}: #{task.status_message}" }
389
643
  #
644
+ # A task that is Task#input_required? waits on a person, as the
645
+ # questions in #pending_questions do, so there is no point in checking
646
+ # on it before they act:
647
+ #
648
+ # notify(chat.owner) if chat.pending_questions.any? || chat.pending_tasks.any?(&:input_required?)
649
+ #
650
+ # Check on the others after their Task#poll_interval:
651
+ #
652
+ # sleep chat.pending_tasks.first.poll_interval
653
+ # chat.complete
654
+ #
390
655
  def pending_tasks
391
656
  response = pending_tool_response
392
657
  return [] unless response
393
658
 
394
- pending_tool_calls(response).values.filter_map do |tool_call|
659
+ pending_tool_calls(response).filter_map do |tool_call|
395
660
  state = tool_call_input(tool_call)
396
661
  task_for(tool_call, state) if state&.key?('task') && !unanswered?(state)
397
662
  end
398
663
  end
399
664
 
400
- # Answers +request+, an MCP::InputRequest from #pending_inputs, with
401
- # +values+ for a form, or none to accept a URL request. The next
402
- # #complete resumes the call once its requests are settled. Returns
403
- # +self+.
404
- def answer(request, **values)
405
- settle_input(request) { |pending| pending.answer(**values) }
406
- end
407
-
408
- # Declines +request+, an MCP::InputRequest from #pending_inputs.
409
- # Returns +self+.
410
- def decline(request)
411
- settle_input(request, &:decline)
412
- end
413
-
414
- # Cancels the current in-flight chat operation. The next cancellation
415
- # checkpoint raises CancelledError and clears the flag so the chat can be
416
- # reused. The MCP tasks that pending tool calls wait on are cancelled
417
- # then too; see #pending_tasks.
665
+ # Cancels the current in-flight chat operation. Every cancellation
666
+ # checkpoint raises CancelledError until the operation ends, so tool
667
+ # calls running concurrently all stop, and the flag is cleared then so
668
+ # the chat can be reused. The tasks that pending tool calls wait on are
669
+ # cancelled then too; see #pending_tasks. Tool calls left without a
670
+ # result are closed as unfinished, so the next #ask can go on and no
671
+ # tool runs twice. A tool that already returned keeps its result. The
672
+ # text a cancelled stream showed stays as a reply that is
673
+ # Message#cancelled?.
418
674
  def cancel
419
675
  @cancelled = true
420
676
  self
@@ -460,6 +716,10 @@ module RubyLLM
460
716
  #
461
717
  # chat.with_tools(*Catalog.tools, defer: true)
462
718
  #
719
+ # A namespaced tool is registered under its namespace and name joined,
720
+ # as in <tt>:crm_customer_lookup</tt>. Raises ArgumentError when that
721
+ # key belongs to another tool.
722
+ #
463
723
  # To replace the registered tools, clear them first:
464
724
  #
465
725
  # chat.with_tools(nil).with_tools(NewTool)
@@ -470,10 +730,16 @@ module RubyLLM
470
730
  @tool_deferrals.clear
471
731
  end
472
732
  tools.flatten.compact.each do |tool|
733
+ tool = tool.to_tool if tool.respond_to?(:to_tool)
473
734
  tool = tool.new if tool.is_a?(Class)
474
- name = tool.name.to_sym
735
+ name = tool.qualified_name.to_sym
736
+ existing = @tools[name]
737
+ if existing && [existing.namespace, existing.name.to_s] != [tool.namespace, tool.name.to_s]
738
+ raise ArgumentError, "Two tools are named #{name}. Rename one with `tool :#{name}, as:`"
739
+ end
740
+
475
741
  @tools[name] = tool
476
- @tool_deferrals[name] = defer.nil? ? tool.class.deferred? : defer
742
+ @tool_deferrals[name] = defer.nil? ? tool.deferred? : defer
477
743
  end
478
744
  self
479
745
  end
@@ -483,13 +749,16 @@ module RubyLLM
483
749
  # MCP servers connected with #with_mcp. MCP tools that only the UI of
484
750
  # an MCP App may call are left out; see MCP::Tool#visibility.
485
751
  #
486
- # Raises ArgumentError when two tools share a name.
752
+ # A namespaced tool's key joins its namespace and name, as in
753
+ # <tt>:crm_customer_lookup</tt>. Raises ArgumentError when two tools
754
+ # share a key.
487
755
  def tools
488
756
  offered = @tools.reject { |_, tool| hidden_from_model?(tool) }
489
- mcp.flat_map(&:tools).each_with_object(offered) do |tool, tools|
757
+ built_in = [@code_execution_tool, (@skill_loader if skills.any?), *@file_tools, tool_search_tool]
758
+ [*built_in, *mcp.reachable_tools].compact.each_with_object(offered) do |tool, tools|
490
759
  next if hidden_from_model?(tool)
491
760
 
492
- name = tool.name.to_sym
761
+ name = tool.qualified_name.to_sym
493
762
  raise ArgumentError, "Two tools are named #{name}. Rename one with `tool :#{name}, as:`" if tools.key?(name)
494
763
 
495
764
  tools[name] = tool
@@ -511,6 +780,10 @@ module RubyLLM
511
780
  # model their tools. The servers are contacted when the chat first
512
781
  # needs their tools. Pass +nil+ to disconnect them all. Returns +self+.
513
782
  #
783
+ # The chat closes the servers it built from a class when it disconnects
784
+ # them, or when another server takes their name. Instances you pass in
785
+ # stay open: close them with MCP#close.
786
+ #
514
787
  # chat.with_mcp(Linear.new(user: current_user), Files)
515
788
  # chat.mcp.linear
516
789
  #
@@ -523,12 +796,12 @@ module RubyLLM
523
796
  #
524
797
  def with_mcp(*servers, defer: nil)
525
798
  if servers == [nil]
799
+ @mcp.disconnect(self)
526
800
  @mcp = MCP::Collection.new
527
801
  @mcp_deferrals.clear
528
802
  else
529
803
  servers.flatten.compact.each do |server|
530
- server = server.new if server.is_a?(Class)
531
- @mcp << server
804
+ server = @mcp.connect(server, self)
532
805
  @mcp_deferrals[server.name.to_sym] = defer
533
806
  end
534
807
  end
@@ -540,13 +813,19 @@ module RubyLLM
540
813
  # keywords whose options use the provider's own vocabulary, and raw
541
814
  # Hashes passed to the provider verbatim, so provider tools RubyLLM has
542
815
  # no alias for yet work without a gem update. Entries add to any tools
543
- # enabled earlier; pass +nil+ to clear them all. Returns +self+.
816
+ # enabled earlier. Enabling an alias again replaces its options, and an
817
+ # identical raw Hash or MCP server is not repeated, so a chat sends
818
+ # each tool once. Pass +nil+ to clear them all. Returns +self+.
544
819
  #
545
820
  # chat.with_provider_tools(:web_search)
546
821
  # chat.with_provider_tools(:web_search, :code_execution)
547
822
  # chat.with_provider_tools(web_search: { allowed_domains: ["ruby-lang.org"] })
548
823
  # chat.with_provider_tools({ type: "web_search_20260318", name: "web_search" })
549
824
  #
825
+ # Raw client-toolset declarations also use this method. Their member
826
+ # calls run through application tools registered with #with_tools and
827
+ # Tool.namespace, and appear in Message#tool_calls.
828
+ #
550
829
  # The tool steps the model ran come back on
551
830
  # Message#server_tool_calls, citations from search tools on
552
831
  # Message#citations, and per-use billing counters on
@@ -560,7 +839,8 @@ module RubyLLM
560
839
  return self
561
840
  end
562
841
 
563
- @provider_tools += RubyLLM::Tools::ProviderTools.normalize(tools, tools_with_options)
842
+ additions = RubyLLM::Tools::ProviderTools.normalize(tools, tools_with_options)
843
+ @provider_tools = RubyLLM::Tools::ProviderTools.merge(@provider_tools, additions)
564
844
  self
565
845
  end
566
846
 
@@ -569,12 +849,18 @@ module RubyLLM
569
849
  # a Tool class. +calls:+ limits how many tool calls one response may
570
850
  # contain (+:many+ or +:one+). +concurrency:+ runs tool calls
571
851
  # concurrently: +true+ or +:threads+ for threads, +:fibers+ for fibers.
572
- # An omitted option is left unchanged; passing +nil+ explicitly resets
573
- # that option (+concurrency: nil+ returns to the configured default).
574
- # Returns +self+.
852
+ # +rounds:+ limits how many model responses that ask for tools one
853
+ # #complete may run, however many calls each holds. It is off by
854
+ # default. Once the last round's results are saved, #complete raises
855
+ # ToolRoundLimitError instead of asking the model again, and another
856
+ # #complete continues with a fresh count, as does a pause for a person.
857
+ # #step is not limited. An omitted option is left unchanged; passing
858
+ # +nil+ explicitly resets that option (+concurrency: nil+ returns to the
859
+ # configured default, +rounds: nil+ removes the limit). Returns +self+.
575
860
  #
576
861
  # chat.with_tools(Weather, Search).with_tool_options(choice: :required)
577
862
  # chat.with_tool_options(calls: :one, concurrency: :threads)
863
+ # chat.with_tool_options(rounds: 25)
578
864
  # chat.with_tool_options(choice: nil)
579
865
  #
580
866
  def with_tool_options(**options)
@@ -582,8 +868,9 @@ module RubyLLM
582
868
  case option
583
869
  when :choice then apply_tool_choice(value)
584
870
  when :calls then @tool_prefs[:calls] = value.nil? ? nil : normalize_calls(value)
585
- when :concurrency then @concurrency = normalize_tool_concurrency(value.nil? ? @config.tool_concurrency : value)
586
- else raise ArgumentError, "Unknown tool option: #{option}. Valid options are: choice, calls, concurrency"
871
+ when :concurrency then apply_tool_concurrency(value)
872
+ when :rounds then @tool_rounds = normalize_rounds(value)
873
+ else raise ArgumentError, "Unknown tool option: #{option}. Valid options are: #{TOOL_OPTIONS.join(', ')}"
587
874
  end
588
875
  end
589
876
  self
@@ -604,7 +891,7 @@ module RubyLLM
604
891
  # +assume_model_exists:+ is false.
605
892
  #
606
893
  # chat.with_model('claude-sonnet-5')
607
- # chat.with_model('gpt-5.6', protocol: :chat_completions)
894
+ # chat.with_model('gpt-6.1-sol', protocol: :chat_completions)
608
895
  #
609
896
  def with_model(model_id, provider: nil, protocol: nil, assume_model_exists: false)
610
897
  model_id ||= @config.default_model
@@ -615,17 +902,43 @@ module RubyLLM
615
902
  end
616
903
 
617
904
  # Sets fallback models to try, in order, when generation fails. +on:+
618
- # selects the error classes that trigger a fallback; the default covers
619
- # transient provider and network errors. Pass +nil+ to remove all
620
- # fallbacks and restore the default error classes. Returns +self+.
905
+ # selects the error classes that trigger a fallback; the default,
906
+ # Fallback::DEFAULT_ERRORS, covers transient provider and network errors.
907
+ # Keep it unless you have a reason: providers signal overload as HTTP 529
908
+ # (OverloadedError) or 503 (ServiceUnavailableError), so a list narrowed
909
+ # by name misses some, and an +on:+ that adds an error should splat the
910
+ # default list into it. Cancelling the chat never triggers a fallback,
911
+ # and neither does another process taking over a persisted chat or an
912
+ # error your streaming block raises. Pass +nil+ to remove all fallbacks
913
+ # and restore the default error classes. Returns +self+.
914
+ #
915
+ # While a fallback is left, a timeout, a failed connection, or a retry
916
+ # wait longer than two seconds moves on to it at once instead of
917
+ # retrying the same model. A fallback that took over answers the rest
918
+ # of that #complete, including its tool rounds; the next one starts on
919
+ # the chat's own model.
621
920
  #
622
921
  # chat.with_fallbacks("gpt-4.1-mini", "claude-haiku-4-5")
623
922
  # chat.with_fallbacks(nil)
624
923
  #
625
- def with_fallbacks(*models, on: Fallback::DEFAULT_ERRORS)
924
+ # With <tt>drop_unsupported: true</tt>, when a model rejects a parameter
925
+ # with UnsupportedParameterError, the chat retries that model without
926
+ # it: the temperature, thinking, the output cap, citations, or a key of
927
+ # #with_provider_options. A model that rejects another parameter is
928
+ # retried without that one too, and each is dropped at most once. The
929
+ # next model starts again from the request as written. The chat keeps
930
+ # its settings for later requests. #before_fallback and
931
+ # #after_fallback receive each retry as a Fallback from the model to
932
+ # itself, whose Fallback#dropped_parameter names the parameter. Without
933
+ # the option, nothing is ever dropped.
934
+ #
935
+ # chat.with_temperature(0.2).with_fallbacks(drop_unsupported: true)
936
+ #
937
+ def with_fallbacks(*models, on: Fallback::DEFAULT_ERRORS, drop_unsupported: false)
626
938
  fallback_models = models.flatten.compact
627
939
  @fallbacks = fallback_models.map { |model| Fallback.build(model) }
628
940
  @fallback_errors = fallback_models.empty? ? Fallback::DEFAULT_ERRORS : Array(on).flatten.compact
941
+ @drop_unsupported = drop_unsupported
629
942
  self
630
943
  end
631
944
 
@@ -687,9 +1000,12 @@ module RubyLLM
687
1000
  end
688
1001
 
689
1002
  # Returns the thinking options resolved for the current model, or +nil+
690
- # when thinking was not configured or needs no provider control.
1003
+ # when thinking was not configured or needs no provider control. When
1004
+ # the model registry has no thinking controls for the model, returns
1005
+ # the request itself, <tt>{ enabled: true }</tt> or
1006
+ # <tt>{ enabled: false }</tt>; the request then raises ArgumentError.
691
1007
  def thinking
692
- config = resolved_thinking
1008
+ config = @thinking&.resolve(@model, strict: false)
693
1009
  return unless config
694
1010
 
695
1011
  {
@@ -716,23 +1032,25 @@ module RubyLLM
716
1032
  end
717
1033
 
718
1034
  # Enables provider prompt caching. With no arguments the provider's
719
- # default behavior applies; options such as +ttl:+ apply where
720
- # supported. Pass +id:+ with a CachedContent (or its name) from
721
- # RubyLLM.cache to attach an explicit
722
- # content cache. Pass +false+ to stop RubyLLM from sending cache
1035
+ # default behavior applies; options apply where supported. +ttl:+ is
1036
+ # a lifetime as a provider String, seconds, or a Duration. Pass a
1037
+ # CachedContent from RubyLLM.cache to attach an explicit content cache,
1038
+ # or +id:+ with its name. Pass +false+ to stop RubyLLM from sending cache
723
1039
  # controls or rendering explicit cache boundaries. A provider may still
724
1040
  # cache prompts implicitly. Passing +nil+ raises ArgumentError.
725
1041
  # Returns +self+.
726
1042
  #
727
1043
  # chat.with_caching
728
1044
  # chat.with_caching(ttl: "1h")
729
- # chat.with_caching(id: cache)
1045
+ # chat.with_caching(ttl: 1.hour)
1046
+ # chat.with_caching(cache)
730
1047
  # chat.with_caching(false)
731
1048
  #
732
1049
  def with_caching(options = {})
733
1050
  options = {} if options == true
1051
+ options = { id: options } if options.is_a?(CachedContent)
734
1052
  unless options == false || options.is_a?(Hash)
735
- raise ArgumentError, 'with_caching accepts true, false, or caching options'
1053
+ raise ArgumentError, 'with_caching accepts true, false, a CachedContent, or caching options'
736
1054
  end
737
1055
 
738
1056
  @caching = options == false ? false : options.transform_keys(&:to_sym).freeze
@@ -773,6 +1091,203 @@ module RubyLLM
773
1091
  self
774
1092
  end
775
1093
 
1094
+ # Keeps tool results within shares of the model's context window, from
1095
+ # the model registry, in place of Configuration#context_budget. Pass
1096
+ # +nil+ to remove the budget. Returns +self+.
1097
+ #
1098
+ # +per_result+:: a tool result longer than this share is cut when it
1099
+ # is added, ending with a note that tells the model to
1100
+ # ask for less.
1101
+ # +per_request+:: when a request would take more than this share, the
1102
+ # oldest tool results are left out of what is sent,
1103
+ # each replaced by a one-line note. The transcript
1104
+ # keeps them. The results of the latest round go out
1105
+ # whenever they fit. The cut point moves in steps, so
1106
+ # the request prefix, and the provider's prompt cache,
1107
+ # hold for many rounds.
1108
+ #
1109
+ # +window+:: the context window in tokens, for a model the registry
1110
+ # has no window for. It wins over the registry's.
1111
+ #
1112
+ # chat.with_context_budget(per_result: 0.25, per_request: 0.7)
1113
+ # chat.with_context_budget(window: 128_000, per_request: 0.7)
1114
+ #
1115
+ # Shares are above 0 and at most 1. Sizes are estimated from
1116
+ # characters, at two a token. A result a file store already saved as
1117
+ # a file (see #with_file_store) is short enough to keep. Without a
1118
+ # known window the budget does nothing, and the chat logs a warning
1119
+ # once.
1120
+ def with_context_budget(budget = {}, **shares)
1121
+ @context_budget = ContextBudget.normalize(budget, **shares)
1122
+ @context_budget_chosen = true
1123
+ @context_budget_warned = false
1124
+ self
1125
+ end
1126
+
1127
+ # Lets the model run code in a Sandbox. The model gets one tool,
1128
+ # +run_code+, that runs Ruby, Python, JavaScript, or a shell script in a fresh run
1129
+ # directory, with read-only copies of the conversation's files, and
1130
+ # returns the output and every file the code writes there as
1131
+ # attachments on the tool result. With #with_file_store, runs work with
1132
+ # the chat's files instead: with FileStore::Directory, in the store's
1133
+ # folder, where attachments are at their own paths. With other stores,
1134
+ # runs still have read-only copies of the conversation's files
1135
+ # in +attachments/+, copy in the other files the model names, and what
1136
+ # the code writes is saved back. The options are:
1137
+ #
1138
+ # +sandbox+:: the sandbox every call runs in. Default:
1139
+ # Configuration#sandbox.
1140
+ # +escalate_to+:: sandboxes the model may ask for, one call at a time,
1141
+ # as an Array named after their classes
1142
+ # (WorkspaceSandbox is +workspace+) or a Hash of names
1143
+ # to sandboxes. Each call that asks waits for #approve.
1144
+ # +languages+:: the languages the model may use, from +:ruby+,
1145
+ # +:python+, +:javascript+, and +:shell+. Default: Ruby
1146
+ # and shell, plus Python and JavaScript when their
1147
+ # packages are declared.
1148
+ # +gems+:: gems installed once and available to every Ruby run.
1149
+ # +python+:: Python packages installed once and available to every
1150
+ # Python run.
1151
+ # +npm+:: npm packages installed once, without their install scripts,
1152
+ # and available to every JavaScript run.
1153
+ # +gem_home+:: a GEM_HOME with gems already installed, used instead
1154
+ # of +gems+.
1155
+ # +virtualenv+:: a Python virtualenv with packages already installed,
1156
+ # used instead of +python+.
1157
+ # +node_modules+:: a +node_modules+ directory with packages already
1158
+ # installed, used instead of +npm+.
1159
+ # +mounts+:: directories mounted read-only in each run directory, keyed
1160
+ # by their path there, or a Proc called before each run
1161
+ # that returns them.
1162
+ #
1163
+ # Accepts keywords or an options Hash. Pass +false+ to disable it.
1164
+ # Passing +nil+ raises ArgumentError. Returns +self+.
1165
+ #
1166
+ # chat.with_code_execution
1167
+ # chat.with_code_execution(gems: %w[prawn], python: %w[pandas matplotlib])
1168
+ # chat.with_code_execution(sandbox: ReadOnlySandbox, escalate_to: [WorkspaceSandbox, OnlineSandbox])
1169
+ # chat.with_code_execution(mounts: { "templates" => "app/templates/reports" })
1170
+ # chat.with_code_execution(false)
1171
+ #
1172
+ # Raises ConfigurationError when no sandbox is configured. Code never
1173
+ # runs outside a sandbox. To run code on the provider's servers
1174
+ # instead, use <tt>with_provider_tools(:code_execution)</tt>.
1175
+ def with_code_execution(enabled = true, **options)
1176
+ options = CodeExecution.options(enabled, options)
1177
+ @code_execution_tool = if options
1178
+ CodeExecution.new(
1179
+ sandbox: options[:sandbox],
1180
+ attachments: -> { messages.flat_map(&:attachments) },
1181
+ file_store: -> { file_store },
1182
+ attached_files: -> { @file_references&.attached_files(messages).to_a },
1183
+ written: ->(file) { run_callbacks(:after_file_write, file) },
1184
+ skills: -> { skills.to_h { |skill| [Skill::Loader.mount_path(skill), skill.path] } },
1185
+ chat: self,
1186
+ config: -> { @config },
1187
+ package_cache_dir: @config.package_cache_dir,
1188
+ approval: ->(tool_call) { escalation_approved?(tool_call) },
1189
+ **options.except(:sandbox)
1190
+ )
1191
+ end
1192
+ @code_execution = options
1193
+ self
1194
+ end
1195
+
1196
+ # Gives the model skills: folders of instructions, scripts, and files
1197
+ # in the open Agent Skills format. Called bare or with +true+, adds
1198
+ # every skill directly under Skill.roots, +app/skills+ by default.
1199
+ # Symbols name folders under the roots, and Strings and Pathnames are
1200
+ # paths to skill folders. Pass +false+ to remove every skill. Passing
1201
+ # +nil+ raises ArgumentError. Returns +self+.
1202
+ #
1203
+ # chat.with_skills
1204
+ # chat.with_skills(:invoices, :reports)
1205
+ # chat.with_skills(:invoices, "vendor/skills/pdf")
1206
+ # chat.with_skills(false)
1207
+ #
1208
+ # Every request lists each skill's name and description, and the model
1209
+ # loads a skill's instructions and files with its +load_skill+ tool.
1210
+ # With #with_code_execution, each skill folder is mounted read-only in
1211
+ # every run at <tt>skills/<name>/</tt>, so the model can run the
1212
+ # skill's scripts.
1213
+ #
1214
+ # Raises SkillNotFoundError for a missing skill, InvalidSkillError for
1215
+ # a +SKILL.md+ that breaks the specification, and ArgumentError when
1216
+ # two skills share a name.
1217
+ def with_skills(*skills)
1218
+ selection = Skill.selection(skills)
1219
+ @skills = selection == false ? [] : Skill.merge(@skills, Skill.resolve(selection)).freeze
1220
+ self
1221
+ end
1222
+
1223
+ # Attaches +store+ to the chat and gives the model tools to work
1224
+ # with it: +list_files+, +read_file+, and +search_files+ to find and
1225
+ # read files, and +write_file+ and +edit_file+ to change them. Files
1226
+ # stop filling the conversation:
1227
+ #
1228
+ # * Every attachment is kept in the store. The model sees the files
1229
+ # attached to the latest question, and those it opens with
1230
+ # +read_file+, in full until the next question; after that, and for
1231
+ # files other tools return, it sees one line naming the file.
1232
+ # * A tool result longer than +inline_limit+ characters is saved under
1233
+ # +results/+, and the model gets its start and the file's path.
1234
+ # * +write_file+ and +edit_file+ change only a file the model read, as
1235
+ # it is now, so the model never writes over a change it hasn't seen.
1236
+ # * With #with_code_execution, code runs in the store's folder when it
1237
+ # has one, such as FileStore::Directory, changing the files in place.
1238
+ # With other stores, each run has read-only copies of the attachments
1239
+ # in +attachments/+, copies in the files the model names to change or
1240
+ # read, and what the code writes is saved back to the store.
1241
+ #
1242
+ # +store+ is a folder path, for a FileStore::Directory, +:memory+, for a
1243
+ # new FileStore::Memory, or a FileStore. Without it, the chat gets a new
1244
+ # FileStore::Memory, or for a persisted chat, its files in Active Storage.
1245
+ #
1246
+ # +inline_limit+ is the characters a tool result may have and still go
1247
+ # to the model whole, or +false+ to send every result whole; tools can
1248
+ # set their own with Tool.inline_limit. Default: 10,000.
1249
+ #
1250
+ # +tools+ names the tools the model gets: +:read+ for +list_files+,
1251
+ # +read_file+, and +search_files+, and +:write+, +:edit+, and
1252
+ # +:delete+ for +write_file+, +edit_file+, and +delete_file+. Default:
1253
+ # <tt>%i[read write edit]</tt>. Raises ArgumentError without +:read+,
1254
+ # since older attachments and long results reach the model through it.
1255
+ #
1256
+ # Pass +nil+ to remove the store and its tools. Returns +self+.
1257
+ #
1258
+ # chat.with_file_store
1259
+ # chat.with_file_store("tmp/chats/42")
1260
+ # chat.with_file_store(:memory)
1261
+ # chat.with_file_store(RubyLLM::FileStore::Directory.new("tmp/chats/42"))
1262
+ # chat.with_file_store(inline_limit: 20_000)
1263
+ # chat.with_file_store(tools: :read)
1264
+ # chat.with_file_store(tools: %i[read write edit delete])
1265
+ # chat.with_file_store(nil)
1266
+ #
1267
+ # Text files are read as text; register converters, such as the OCR
1268
+ # converter for PDFs and images, in Configuration#file_converters to
1269
+ # read more.
1270
+ def with_file_store(store = :memory, inline_limit: FileReferences::INLINE_LIMIT, tools: FileStore::Tools::DEFAULT)
1271
+ FileReferences.validate(store, inline_limit:, tools:) unless store.nil?
1272
+ store = FileReferences.resolve(store)
1273
+ @file_references&.forget(@attachment_replacements)
1274
+ @file_references = if store
1275
+ FileReferences.new(store:, inline_limit:, tools:, config: -> { @config },
1276
+ written: ->(file) { run_callbacks(:after_file_write, file) })
1277
+ end
1278
+ @file_tools = store ? build_file_tools : []
1279
+ self
1280
+ end
1281
+
1282
+ # Returns the FileStore attached with #with_file_store, or +nil+
1283
+ # without one.
1284
+ #
1285
+ # chat.file_store.list
1286
+ # chat.file_store.read("notes/plan.md")
1287
+ def file_store
1288
+ @file_references&.store
1289
+ end
1290
+
776
1291
  # Identifies the end user behind the conversation for the provider's
777
1292
  # abuse monitoring. Providers without an equivalent field omit it.
778
1293
  # Pass +nil+ to remove it.
@@ -787,13 +1302,33 @@ module RubyLLM
787
1302
  self
788
1303
  end
789
1304
 
1305
+ # Attributes the usage of every attempt the chat makes to +owner+, such
1306
+ # as a user or an account record: each request, the tools it runs, the
1307
+ # file converters it triggers, and compaction. In Rails, the usage
1308
+ # ledger stores the owner with each row; everywhere, the
1309
+ # +usage.ruby_llm+ event carries it. It wins over RubyLLM.with_owner,
1310
+ # and over the owner a persisted chat's record names. Pass +nil+ to
1311
+ # remove it. Returns +self+.
1312
+ #
1313
+ # chat.with_owner(current_user).ask "Summarize this thread"
1314
+ #
1315
+ # RubyLLM never sends the owner to the provider; see #with_end_user
1316
+ # for the identifier providers receive.
1317
+ def with_owner(owner)
1318
+ @owner = owner
1319
+ self
1320
+ end
1321
+
790
1322
  # Rebinds the chat to +context+, a Context built with RubyLLM.context,
791
1323
  # so subsequent requests use its configuration. Pass +nil+ to return to
792
1324
  # the global RubyLLM.config. Returns +self+.
793
1325
  def with_context(context)
794
1326
  @context = context
795
1327
  @config = context&.config || RubyLLM.config
1328
+ @file_references&.forget(@attachment_replacements)
796
1329
  with_model(@model.id, provider: @provider.slug, protocol: @protocol, assume_model_exists: true)
1330
+ apply_tool_concurrency(nil) unless @concurrency_chosen
1331
+ @context_budget = @config.context_budget unless @context_budget_chosen
797
1332
  self
798
1333
  end
799
1334
 
@@ -809,7 +1344,9 @@ module RubyLLM
809
1344
  end
810
1345
 
811
1346
  # Sets extra HTTP headers sent with completion requests, replacing any
812
- # previously set headers; +nil+ clears them. Returns +self+.
1347
+ # previously set headers; +nil+ clears them. Returns +self+. The
1348
+ # provider's own headers, such as authentication and API version
1349
+ # headers, win over a header of the same name set here.
813
1350
  #
814
1351
  # chat.with_headers('anthropic-beta' => 'fine-grained-tool-streaming-2025-05-14')
815
1352
  #
@@ -850,8 +1387,9 @@ module RubyLLM
850
1387
  add_callback(:before_message, &)
851
1388
  end
852
1389
 
853
- # Registers a callback that receives each assistant response and each
854
- # tool result message once it has been appended. Returns +self+.
1390
+ # Registers a callback that receives each assistant response, each
1391
+ # tool result message, and each user message a queued message becomes,
1392
+ # once it has been appended. Returns +self+.
855
1393
  #
856
1394
  # chat.after_message { |message| puts message.content }
857
1395
  #
@@ -859,6 +1397,36 @@ module RubyLLM
859
1397
  add_callback(:after_message, &)
860
1398
  end
861
1399
 
1400
+ # Registers a callback that wraps the moment each queued message joins
1401
+ # the conversation. It receives the QueuedMessage and +join+, and runs
1402
+ # in the loop's thread. <tt>join.call</tt> adds the user Message and
1403
+ # returns it. Returns +self+.
1404
+ #
1405
+ # chat.around_join do |queued, join|
1406
+ # message = join.call
1407
+ # audit_log << [queued.sender, message.content]
1408
+ # end
1409
+ #
1410
+ # A block that never calls +join+ withdraws the message: it leaves the
1411
+ # queue without joining, and the turn goes on. A block that raises
1412
+ # leaves the message queued and out of the conversation. Several
1413
+ # blocks nest in the order they were registered, the first outermost,
1414
+ # and #after_message runs once they all return.
1415
+ # When an inner block withdraws the message, <tt>join.call</tt> in an
1416
+ # outer block returns +nil+.
1417
+ #
1418
+ # On a persisted chat, the block runs inside the transaction that
1419
+ # creates the message record and deletes the queued one, and
1420
+ # <tt>join.call</tt> takes attributes for the new record, such as
1421
+ # columns of your own. See RubyLLM::ActiveRecord::ChatMethods. A plain
1422
+ # chat has no such columns, so its +join+ raises ArgumentError when
1423
+ # given attributes. Registering the same block again does nothing.
1424
+ def around_join(&block)
1425
+ return self if @callbacks[:around_join].include?(block)
1426
+
1427
+ add_callback(:around_join, &block)
1428
+ end
1429
+
862
1430
  # Registers a callback that receives each local ToolCall before the tool
863
1431
  # executes. Returns +self+.
864
1432
  #
@@ -868,6 +1436,23 @@ module RubyLLM
868
1436
  add_callback(:before_tool_call, &)
869
1437
  end
870
1438
 
1439
+ # Registers a callback that wraps each local tool's execution. It
1440
+ # receives the ToolCall and a block, +run+, that runs the tool and
1441
+ # returns its result. Returns +self+.
1442
+ #
1443
+ # chat.around_tool_call { |tool_call, &run| ActsAsTenant.with_tenant(account, &run) }
1444
+ #
1445
+ # The block runs in the thread or fiber that runs the tool, so
1446
+ # fiber-local state, +Current+ attributes, and tenants it sets reach the
1447
+ # tool, also with concurrent tool execution. It runs after
1448
+ # #before_tool_call and before #after_tool_result. Several blocks nest
1449
+ # in the order they were registered, the first outermost. The result is
1450
+ # the tool's, whatever the block returns. A block that never calls
1451
+ # +run+ skips the tool, and the model is told the call did not run.
1452
+ def around_tool_call(&)
1453
+ add_callback(:around_tool_call, &)
1454
+ end
1455
+
871
1456
  # Registers a callback that receives each local tool's result after
872
1457
  # execution. Returns +self+.
873
1458
  def after_tool_result(&)
@@ -891,9 +1476,16 @@ module RubyLLM
891
1476
  add_callback(:after_tool_progress, &)
892
1477
  end
893
1478
 
1479
+ # Registers a callback that receives each StoredFile the conversation
1480
+ # writes to the chat's FileStore (see #with_file_store): attachments as they
1481
+ # are kept, long tool results, and the files tools and code write.
1482
+ def after_file_write(&)
1483
+ add_callback(:after_file_write, &)
1484
+ end
1485
+
894
1486
  # Registers a callback that receives the Fallback attempt after the
895
- # current model fails and before the fallback model is tried. Returns
896
- # +self+.
1487
+ # current model fails and before the next model is tried, or the same
1488
+ # model again without a parameter it rejected. Returns +self+.
897
1489
  def before_fallback(&)
898
1490
  add_callback(:before_fallback, &)
899
1491
  end
@@ -930,10 +1522,17 @@ module RubyLLM
930
1522
  add_callback(:unsupported_attachment, &)
931
1523
  end
932
1524
 
933
- # Yields each Message in the conversation. Returns an Enumerator when
934
- # no block is given. Chat includes Enumerable, so the usual collection
935
- # methods are available.
1525
+ # Deprecated: iterate #messages instead. Yields each Message in the
1526
+ # conversation, or returns an Enumerator without a block. The
1527
+ # Enumerable methods built on it, such as +map+ and +count+, are
1528
+ # deprecated with it. Warns through RubyLLM.deprecator. RubyLLM 3.0
1529
+ # removes them.
1530
+ #
1531
+ # chat.messages.each { |message| puts message.content }
1532
+ #
936
1533
  def each(&)
1534
+ RubyLLM.deprecator.deprecate("Enumerable on #{self.class}", replacement: "#{self.class}#messages",
1535
+ removal: '3.0')
937
1536
  messages.each(&)
938
1537
  end
939
1538
 
@@ -977,7 +1576,7 @@ module RubyLLM
977
1576
  thinking: resolved_thinking,
978
1577
  schema: @schema,
979
1578
  citations: @citations,
980
- caching: @caching,
1579
+ caching: request_caching,
981
1580
  protocol: @protocol
982
1581
  )
983
1582
  end
@@ -1003,10 +1602,13 @@ module RubyLLM
1003
1602
  RubyLLM.instrument('compaction.ruby_llm', payload, config: @config) do |event|
1004
1603
  result = provider_compaction
1005
1604
  record_out_of_band_usage(result) if usage_entries.length == usage_start
1006
- record_generated_message(result, usage_start)
1605
+ record_generated_message(result, usage_start, keep_unfinished: true)
1007
1606
  record_completion_event(event, result)
1008
1607
  result
1009
1608
  end
1609
+ rescue CancelledError
1610
+ @cancelled = false
1611
+ raise
1010
1612
  end
1011
1613
 
1012
1614
  # Replaces the conversation with +new_messages+, coercing each element
@@ -1023,7 +1625,31 @@ module RubyLLM
1023
1625
  end
1024
1626
 
1025
1627
  # Hooks installed by the Rails integration.
1026
- attr_writer :cancellation_checker, :usage_recorder, :approval_checker, :input_checker, :input_recorder # :nodoc:
1628
+ attr_writer :run, :message_queue, :tool_call_state # :nodoc:
1629
+
1630
+ # The Agent class that configured the chat, which its instrumentation
1631
+ # events name.
1632
+ attr_accessor :agent_class # :nodoc:
1633
+
1634
+ # Runs the block inside one +agent.ruby_llm+ event when an Agent
1635
+ # configured the chat, unless a run of this chat is already open. Fibers
1636
+ # and threads started during a run inherit its marker, which is emptied
1637
+ # when the run ends, so their later turns open events of their own.
1638
+ def running_agent(&) # :nodoc:
1639
+ return yield if @agent_class.nil? || Fiber[AGENT_RUN_KEY]&.first.equal?(self)
1640
+
1641
+ previous = Fiber[AGENT_RUN_KEY]
1642
+ Fiber[AGENT_RUN_KEY] = run = [self]
1643
+ begin
1644
+ payload = { agent_name: @agent_class.name, chat: self, agent_description: @agent_class.description,
1645
+ chat_id: @run.chat_id }
1646
+ payload.delete_if { |key, value| value.nil? && key != :agent_name }
1647
+ RubyLLM.instrument('agent.ruby_llm', payload, config: @config, &)
1648
+ ensure
1649
+ run.clear
1650
+ Fiber[AGENT_RUN_KEY] = previous
1651
+ end
1652
+ end
1027
1653
 
1028
1654
  # Appends a message to the conversation and returns it as a Message.
1029
1655
  # Accepts a Message, an attribute Hash, or a record responding to
@@ -1032,20 +1658,33 @@ module RubyLLM
1032
1658
  # chat.add_message(role: :user, content: "What's the capital of France?")
1033
1659
  #
1034
1660
  def add_message(message_or_attributes)
1035
- message = coerce_message(message_or_attributes)
1661
+ message = prepare_message(message_or_attributes)
1036
1662
  messages << message
1037
1663
  message
1038
1664
  end
1039
1665
 
1040
- # Marks the latest message as an explicit prompt cache boundary, asking
1041
- # the provider to cache everything up to this point. Pass +ttl:+ to give
1042
- # this boundary its own cache lifetime, as Message#cache_until_here
1043
- # describes. Returns +self+.
1666
+ def prepare_message(message_or_attributes, &taken) # :nodoc:
1667
+ message = coerce_message(message_or_attributes)
1668
+ return ToolCallIds.answer(message, messages) if message.tool_result?
1669
+ return message unless message.tool_call?
1670
+ return message if message.tool_calls.none? { |call| reused_tool_call_id?(call.id, &taken) }
1671
+
1672
+ ToolCallIds.new.assign(message)
1673
+ end
1674
+
1675
+ # Deprecated: mark the message itself with Message#cache_until_here, or
1676
+ # pass +cache_until_here:+ to #with_instructions. Marks the latest
1677
+ # message as an explicit prompt cache boundary. Warns through
1678
+ # RubyLLM.deprecator. RubyLLM 3.0 removes it. Returns +self+.
1044
1679
  #
1045
- # chat.with_instructions(policy).cache_until_here(ttl: "1h")
1680
+ # chat.with_instructions(policy, cache_until_here: { ttl: "1h" })
1681
+ # chat.add_message(role: :user, content: handbook).cache_until_here
1046
1682
  #
1047
1683
  # Raises ArgumentError if the chat has no messages.
1048
1684
  def cache_until_here(ttl: nil)
1685
+ RubyLLM.deprecator.deprecate("#{self.class}#cache_until_here",
1686
+ replacement: 'Message#cache_until_here or with_instructions(cache_until_here:)',
1687
+ removal: '3.0')
1049
1688
  message = messages.last
1050
1689
  raise ArgumentError, 'No messages to cache' unless message
1051
1690
 
@@ -1062,17 +1701,22 @@ module RubyLLM
1062
1701
  response.ruby_llm_usage_entries.each { |entry| record_usage_entry(entry) }
1063
1702
  end
1064
1703
  run_callbacks(:before_message)
1065
- add_message response
1704
+ response = add_message(response)
1066
1705
  run_callbacks(:after_message, response)
1067
1706
  response
1068
1707
  end
1069
1708
 
1709
+ def add_usage_entry(entry) # :nodoc:
1710
+ record_usage_entry(entry)
1711
+ end
1712
+
1070
1713
  # Returns the request payload this chat would send to the provider for
1071
1714
  # its next completion, with #before_request hooks applied. Useful for
1072
1715
  # inspecting and testing request output.
1073
1716
  def render
1717
+ check_requested_tool_choice
1074
1718
  @provider.render(
1075
- preprocessed_messages,
1719
+ with_mcp_outages(preprocessed_messages),
1076
1720
  tools: request_tools,
1077
1721
  provider_tools: @provider_tools,
1078
1722
  tool_prefs: @tool_prefs,
@@ -1083,7 +1727,7 @@ module RubyLLM
1083
1727
  schema: @schema,
1084
1728
  thinking: resolved_thinking,
1085
1729
  citations: @citations,
1086
- caching: @caching,
1730
+ caching: request_caching,
1087
1731
  compaction: @compaction,
1088
1732
  end_user: @end_user,
1089
1733
  protocol: @protocol,
@@ -1091,6 +1735,14 @@ module RubyLLM
1091
1735
  )
1092
1736
  end
1093
1737
 
1738
+ # Refuses attachments an MCP::Prompt has no place for. Called by
1739
+ # #ask_later here and in the Rails integration.
1740
+ def raise_if_prompt_attachments!(message, with) # :nodoc:
1741
+ return unless message.is_a?(MCP::Prompt) && with && with != []
1742
+
1743
+ raise ArgumentError, 'An MCP::Prompt carries its own messages, so it takes no attachments'
1744
+ end
1745
+
1094
1746
  # Refuses to stage a user message onto an unfinished tool round, which
1095
1747
  # providers reject. Called by #ask_later here and in the Rails
1096
1748
  # integration before it persists anything.
@@ -1098,19 +1750,47 @@ module RubyLLM
1098
1750
  response = pending_tool_response
1099
1751
  return unless response
1100
1752
 
1101
- names = pending_tool_calls(response).values.map(&:name).uniq
1753
+ names = pending_tool_calls(response).map(&:name).uniq
1102
1754
  raise PendingToolCallsError,
1103
1755
  "The last response has unanswered tool calls (#{names.join(', ')}). " \
1104
- 'Run complete, recording approve or deny decisions for calls that ' \
1105
- 'require approval, answering pending inputs, and waiting for tasks, before asking again.'
1756
+ 'Run complete, answering their questions (see pending_questions) and waiting for ' \
1757
+ 'their tasks, before asking again.'
1106
1758
  end
1107
1759
 
1108
1760
  private
1109
1761
 
1762
+ def agent_run_chat = self
1763
+
1764
+ def agent_fields
1765
+ name = @agent_class&.name
1766
+ name ? { agent_name: name } : {}
1767
+ end
1768
+
1769
+ def build_skill_loader
1770
+ Skill::Loader.new(skills: -> { @skills }, code_execution: -> { !@code_execution_tool.nil? })
1771
+ end
1772
+
1773
+ def build_file_tools
1774
+ written = ->(file) { run_callbacks(:after_file_write, file) }
1775
+ FileStore::Tools.classes(@file_references.tools)
1776
+ .map { |tool| tool.new(store: -> { file_store }, config: -> { @config }, written:) }
1777
+ end
1778
+
1110
1779
  def resolved_thinking
1111
1780
  @thinking&.resolve(@model)
1112
1781
  end
1113
1782
 
1783
+ def request_caching
1784
+ return @caching unless @caching.is_a?(Hash)
1785
+
1786
+ caching = @caching
1787
+ caching = @provider.fallback_caching(caching, model: @model, protocol: @protocol) if @answering_fallback
1788
+ key = @run.cache_key
1789
+ return caching if key.nil? || @caching.key?(:key) || !@provider.cache_key?(model: @model, protocol: @protocol)
1790
+
1791
+ caching.merge(key:)
1792
+ end
1793
+
1114
1794
  def normalize_compaction(options)
1115
1795
  compaction = options.to_h.transform_keys(&:to_sym)
1116
1796
  unsupported = compaction.keys - COMPACTION_OPTIONS
@@ -1191,26 +1871,135 @@ module RubyLLM
1191
1871
  end
1192
1872
 
1193
1873
  def raise_if_cancelled!
1194
- external_cancelled = @cancellation_checker&.call
1195
- return unless @cancelled || external_cancelled
1874
+ @cancelled = true if @run.cancellation_requested?
1875
+ raise CancelledError if @cancelled
1876
+ end
1877
+
1878
+ # The transport retries through the checkpoint, so a cancelled request
1879
+ # stops at its failure. A chat cancelled while a request failed was going
1880
+ # to stop anyway, so the cancellation is what the caller hears about.
1881
+ def cancellable_request(&)
1882
+ Support::Cancellation.watch(-> { raise_if_cancelled! }, &)
1883
+ rescue Error
1884
+ raise_if_cancelled!
1885
+ raise
1886
+ end
1887
+
1888
+ def build_message_queue(queued = [])
1889
+ MessageQueue.new(queued) do |message|
1890
+ run_around_join(message) do |**attributes|
1891
+ raise ArgumentError, 'join takes attributes only on a persisted chat' if attributes.any?
1892
+
1893
+ add_message(role: :user, content: message.content, attachments: message.attachments)
1894
+ end
1895
+ end
1896
+ end
1196
1897
 
1898
+ def advance(steering_only:, &)
1899
+ return if settled?(steering_only)
1900
+
1901
+ raise_if_cancelled!
1902
+ take_queued_messages(steering_only:) unless pending_tool_response
1903
+ return if answered?
1904
+ return generate(&) unless pending_tool_response
1905
+
1906
+ before = messages.length
1907
+ run_tools
1908
+ messages.last if messages.length > before
1909
+ rescue CancelledError
1910
+ stop_tool_round
1911
+ raise
1912
+ end
1913
+
1914
+ # A round can stall once while another process lets its claim go, and
1915
+ # goes on the next time, perhaps to pause. A second stall in a row that
1916
+ # leaves nothing to wait on would repeat forever.
1917
+ def stalled_round?(stalled, generated:, move:)
1918
+ return false if generated || move
1919
+ return true unless stalled && !waiting?
1920
+
1921
+ ids = pending_tool_response&.then { |response| pending_tool_calls(response).keys }
1922
+ raise StalledToolRound,
1923
+ "RubyLLM made no progress on tool calls #{Array(ids).join(', ')}: they neither ran nor wait"
1924
+ end
1925
+
1926
+ def turn_result
1927
+ pending_tool_response || halted_result || last_non_system_message || messages.last
1928
+ end
1929
+
1930
+ def raise_if_tool_rounds_spent!(rounds)
1931
+ return unless @tool_rounds && rounds >= @tool_rounds
1932
+
1933
+ raise ToolRoundLimitError,
1934
+ "Reached the tool round limit (#{@tool_rounds}). Every tool call has its result, so call complete " \
1935
+ 'to continue, or raise the limit with with_tool_options(rounds:).'
1936
+ end
1937
+
1938
+ def stop_tool_round
1197
1939
  @cancelled = false
1198
- raise CancelledError
1940
+ cancel_tasks
1941
+ close_unstarted_tool_calls
1942
+ end
1943
+
1944
+ def settled?(steering_only)
1945
+ answered? && @message_queue.none?(steering_only:)
1946
+ end
1947
+
1948
+ def last_conversation_message
1949
+ messages.reverse.find { |message| message.role != :system && !placeholder?(message) }
1950
+ end
1951
+
1952
+ def answered?
1953
+ return false if pending_tool_response
1954
+
1955
+ last = last_conversation_message
1956
+ case last&.role
1957
+ when nil then true
1958
+ when :user then false
1959
+ when :tool then !halted_result.nil? || last.cancelled?
1960
+ else !last.tool_call? && !unfinished?(last)
1961
+ end
1962
+ end
1963
+
1964
+ # A tool that halted ends the turn once its round has every result.
1965
+ def halted_result
1966
+ messages.reverse_each.take_while(&:tool_result?).find(&:halted?)
1967
+ end
1968
+
1969
+ def generate_once(stream_tracker: nil, usage_start: nil, &)
1970
+ usage_start ||= usage_entries.length
1971
+ resumed = unfinished?(messages.last)
1972
+ streamed = +''
1973
+ tracker = proc do |chunk|
1974
+ streamed << chunk.content if chunk.content.is_a?(String)
1975
+ stream_tracker&.call(chunk)
1976
+ end
1977
+ generate_attempt(stream_tracker: tracker, usage_start:, &)
1978
+ rescue CancelledError
1979
+ keep_cancelled_reply(streamed, usage_start) unless resumed
1980
+ raise
1199
1981
  end
1200
1982
 
1201
- def generate_once(stream_tracker: nil, usage_start: nil, &block)
1983
+ def generate_attempt(stream_tracker:, usage_start:, &block)
1202
1984
  raise_if_cancelled!
1203
1985
 
1204
1986
  result = nil
1205
1987
  entries_before = usage_entries.length
1206
- usage_start ||= entries_before
1207
1988
  payload = instrumentation_payload(streaming: block_given?)
1989
+ announced = block_given?
1990
+ resumed = messages.last if unfinished?(messages.last)
1991
+ progress = proc do |partial|
1992
+ run_callbacks(:before_message) unless announced
1993
+ announced = true
1994
+ record_progress(partial)
1995
+ end
1208
1996
 
1209
1997
  RubyLLM.instrument('chat.ruby_llm', payload, config: @config) do |event|
1210
- result = provider_completion(usage_recorder: method(:record_usage_entry), stream_tracker:, &block)
1998
+ result = provider_completion(usage_recorder: method(:record_usage_entry), progress_recorder: progress,
1999
+ stream_tracker:, &block)
1211
2000
  event[:response_tokens] = result.tokens
1212
2001
  record_out_of_band_usage(result) if usage_entries.length == entries_before
1213
- record_generated_message(result, usage_start, streaming: block_given?)
2002
+ record_generated_message(result, usage_start, announce: !announced, resumed:)
1214
2003
  record_completion_event(event, result)
1215
2004
  ensure
1216
2005
  event[:response_tokens] ||= Tokens.aggregate(usage_entries[entries_before..].map(&:tokens))
@@ -1218,14 +2007,49 @@ module RubyLLM
1218
2007
  result
1219
2008
  end
1220
2009
 
1221
- def record_generated_message(result, usage_start, streaming: false)
2010
+ # Keeps the text a cancelled stream showed, as a reply marked
2011
+ # cancelled. Tool calls it had not finished and its thinking, whose
2012
+ # signature would not verify, are left out.
2013
+ def keep_cancelled_reply(text, usage_start)
2014
+ return if text.nil? || text.strip.empty?
2015
+
2016
+ reply = Message.new(role: :assistant, content: text, model: @model.id, finish_reason: :cancelled)
2017
+ link_completion_usage(reply, usage_start)
2018
+ note_fallback(reply)
2019
+ add_message(reply)
2020
+ run_callbacks(:after_message, reply)
2021
+ end
2022
+
2023
+ def record_generated_message(result, usage_start, announce: true, resumed: nil, keep_unfinished: false)
1222
2024
  raise_if_cancelled!
1223
- link_completion_usage(result, usage_start)
1224
- run_callbacks(:before_message) unless streaming
1225
- add_message result
2025
+ link_completion_usage(result, usage_start, resumed)
2026
+ note_fallback(result)
2027
+ run_callbacks(:before_message) if announce
2028
+ keep_unfinished ? add_message(result) : replace_unfinished(result)
1226
2029
  run_callbacks(:after_message, result)
1227
2030
  end
1228
2031
 
2032
+ # An unfinished response stands in the transcript while it continues,
2033
+ # so a later #complete resumes it when the run stops early.
2034
+ def record_progress(partial)
2035
+ replace_unfinished(partial)
2036
+ raise_if_cancelled!
2037
+ @run.record_progress(partial)
2038
+ end
2039
+
2040
+ def note_fallback(message)
2041
+ message.stood_in(model: @model.id, provider: @provider.slug) if @answering_fallback
2042
+ end
2043
+
2044
+ def replace_unfinished(message)
2045
+ messages.pop if unfinished?(messages.last)
2046
+ add_message(message)
2047
+ end
2048
+
2049
+ def unfinished?(message)
2050
+ message&.role == :assistant && !message.continuation.nil?
2051
+ end
2052
+
1229
2053
  def instrumentation_payload(streaming:)
1230
2054
  empty_tokens = Tokens.new
1231
2055
  {
@@ -1249,7 +2073,8 @@ module RubyLLM
1249
2073
  caching: @caching,
1250
2074
  streaming: streaming,
1251
2075
  tokens: empty_tokens,
1252
- cost: Cost.new(tokens: empty_tokens, model: @model)
2076
+ cost: Cost.new(tokens: empty_tokens, model: @model),
2077
+ **agent_fields
1253
2078
  }
1254
2079
  end
1255
2080
 
@@ -1268,31 +2093,129 @@ module RubyLLM
1268
2093
 
1269
2094
  def generate_with_fallbacks(&block)
1270
2095
  fallback_queue = fallbacks.dup
2096
+ resume_stand_in(fallback_queue)
1271
2097
  attempt = 0
1272
2098
  active_fallback = nil
1273
2099
  streaming = block_given?
1274
2100
  usage_start = usage_entries.length
2101
+ block_failed = false
2102
+ on_chunk = block && proc do |chunk|
2103
+ block.call(chunk)
2104
+ rescue StandardError
2105
+ block_failed = true
2106
+ raise
2107
+ end
1275
2108
 
2109
+ drops = []
1276
2110
  loop do
1277
2111
  chunks_yielded = false
1278
2112
 
1279
2113
  begin
1280
- result = generate_once(stream_tracker: proc { chunks_yielded = true }, usage_start:, &block)
2114
+ result = with_fallback_ready(fallback_queue) do
2115
+ generate_once(stream_tracker: proc { chunks_yielded = true }, usage_start:, &on_chunk)
2116
+ end
1281
2117
  finish_fallback(active_fallback, response: result)
1282
2118
  return result
1283
2119
  rescue StandardError => e
1284
2120
  finish_fallback(active_fallback, fallback_error: e)
1285
- raise e unless fallback_error?(e)
1286
-
1287
- active_fallback, attempt = fallback_to_next_model!(
1288
- fallback_queue,
1289
- error: e,
1290
- attempt: attempt,
1291
- streaming: streaming,
1292
- chunks_yielded: chunks_yielded
1293
- )
2121
+ raise e if block_failed || unfinished?(messages.last)
2122
+
2123
+ active_fallback, attempt = next_attempt!(fallback_queue, drops, error: e, attempt:, streaming:,
2124
+ chunks_yielded:)
1294
2125
  end
1295
2126
  end
2127
+ ensure
2128
+ @answering_fallback = false
2129
+ restore_parameters(drops)
2130
+ end
2131
+
2132
+ # One #complete is a run. While it lasts, @stand_ins holds the
2133
+ # fallback that took over once a model failed with an error in +on:+.
2134
+ # Each later request in the run starts on that fallback, and its
2135
+ # replies are marked as stood in, rather than waiting on the model that
2136
+ # just failed again; the next run starts on the chat's own model.
2137
+ def within_run
2138
+ outer = @stand_ins
2139
+ @stand_ins = []
2140
+ yield
2141
+ ensure
2142
+ @stand_ins = outer
2143
+ end
2144
+
2145
+ def resume_stand_in(fallback_queue)
2146
+ stand_in = @stand_ins&.last
2147
+ index = fallback_queue.index { |fallback| fallback.equal?(stand_in) }
2148
+ return unless index
2149
+
2150
+ switch_to_fallback_model(fallback_queue[index])
2151
+ fallback_queue.shift(index + 1)
2152
+ @answering_fallback = true
2153
+ end
2154
+
2155
+ # While a fallback is left, the transport hands a timeout, a failed
2156
+ # connection, or a long retry wait to it instead of retrying the same
2157
+ # model. Without one, the model is retried as configured.
2158
+ def with_fallback_ready(fallback_queue, &)
2159
+ return yield if fallback_queue.empty?
2160
+
2161
+ Transport::Connection.with_fallback_ready(method(:fallback_error?), &)
2162
+ end
2163
+
2164
+ # Retries the same model without each parameter it rejects, when the
2165
+ # chat opted in, then moves on to the next model with every parameter
2166
+ # back. Each parameter is dropped at most once per model.
2167
+ def next_attempt!(fallback_queue, drops, error:, **details)
2168
+ if (restore = drop_unsupported_parameter(error))
2169
+ drops << restore
2170
+ return retry_without_parameter!(error:, **details)
2171
+ end
2172
+ restore_parameters(drops)
2173
+ raise error unless fallback_error?(error)
2174
+
2175
+ fallback_to_next_model!(fallback_queue, error:, **details)
2176
+ end
2177
+
2178
+ DROPPED_VALUES = { temperature: nil, max_output_tokens: nil, thinking: nil, citations: false }.freeze
2179
+ private_constant :DROPPED_VALUES
2180
+
2181
+ # Clears the setting +error+ names for one retry, and returns how to put
2182
+ # it back, or +nil+ when the chat did not opt in or never set it.
2183
+ def drop_unsupported_parameter(error)
2184
+ return unless @drop_unsupported && error.is_a?(UnsupportedParameterError)
2185
+
2186
+ parameter = error.parameter
2187
+ return drop_provider_option(parameter) unless parameter.is_a?(Symbol)
2188
+ return unless DROPPED_VALUES.key?(parameter)
2189
+
2190
+ name = :"@#{parameter}"
2191
+ original = instance_variable_get(name)
2192
+ return if original.nil? || original == false
2193
+
2194
+ instance_variable_set(name, DROPPED_VALUES[parameter])
2195
+ -> { instance_variable_set(name, original) }
2196
+ end
2197
+
2198
+ def drop_provider_option(parameter)
2199
+ key = @provider_options.keys.find { |candidate| candidate.to_s == parameter.to_s }
2200
+ return unless key
2201
+
2202
+ original = @provider_options
2203
+ @provider_options = original.except(key)
2204
+ -> { @provider_options = original }
2205
+ end
2206
+
2207
+ def restore_parameters(drops)
2208
+ drops.pop.call until drops.nil? || drops.empty?
2209
+ end
2210
+
2211
+ def retry_without_parameter!(error:, attempt:, streaming:, chunks_yielded:)
2212
+ attempt += 1
2213
+ fallback = Fallback.new(model: @model).with_attempt(
2214
+ chat: self, error:, from: @model, to: @model, attempt:, streaming:, chunks_yielded:,
2215
+ dropped_parameter: error.parameter
2216
+ )
2217
+ run_callbacks(:before_fallback, fallback)
2218
+ [fallback, attempt]
1296
2219
  end
1297
2220
 
1298
2221
  def with_model_restored
@@ -1321,20 +2244,20 @@ module RubyLLM
1321
2244
  end
1322
2245
 
1323
2246
  def with_resolved_model(model)
1324
- provider_class = Provider.resolve!(model.provider)
2247
+ @provider = Provider.resolve!(model.provider).new(@config)
1325
2248
  @model = model
1326
- @provider = provider_class.new(@config)
1327
2249
  @connection = @provider.connection
1328
2250
  self
1329
2251
  end
1330
2252
 
1331
2253
  def fallback_to_next_model!(fallback_queue, error:, attempt:, streaming:, chunks_yielded:)
1332
- fallback = fallback_queue.shift
2254
+ from_model = @model
2255
+ fallback = switch_to_usable_fallback(fallback_queue)
1333
2256
  raise error unless fallback
1334
2257
 
2258
+ @stand_ins&.replace([fallback])
2259
+ @answering_fallback = true
1335
2260
  attempt += 1
1336
- from_model = @model
1337
- switch_to_fallback_model(fallback)
1338
2261
  fallback = fallback.with_attempt(
1339
2262
  chat: self,
1340
2263
  error: error,
@@ -1348,6 +2271,10 @@ module RubyLLM
1348
2271
  [fallback, attempt]
1349
2272
  end
1350
2273
 
2274
+ def switch_to_usable_fallback(fallback_queue)
2275
+ Fallback.next_usable(fallback_queue) { |fallback| switch_to_fallback_model(fallback) }&.first
2276
+ end
2277
+
1351
2278
  def finish_fallback(fallback, response: nil, fallback_error: nil)
1352
2279
  return unless fallback
1353
2280
 
@@ -1356,7 +2283,7 @@ module RubyLLM
1356
2283
  end
1357
2284
 
1358
2285
  def fallback_error?(error)
1359
- fallback_errors.any? { |error_class| error.is_a?(error_class) }
2286
+ !@run.interrupted_by?(error) && Fallback.error?(error, fallback_errors)
1360
2287
  end
1361
2288
 
1362
2289
  # Preprocessing builds a per-request view of the conversation: the
@@ -1365,21 +2292,63 @@ module RubyLLM
1365
2292
  # them. An attachment's store keeps its uploads for later processes.
1366
2293
  def preprocessed_messages(list = messages)
1367
2294
  list = request_history(list)
2295
+ list = @file_references.view(list) if @file_references
2296
+ list = context_budget_for_model.fit(list) if @context_budget
2297
+ list = CodeExecution.for_model(with_skill_listing(list))
1368
2298
  return list unless @provider
1369
2299
 
1370
2300
  @provider.preprocess_messages(list, model: @model, protocol: @protocol,
1371
2301
  unsupported_attachment: unsupported_attachment_handler)
1372
2302
  end
1373
2303
 
2304
+ def context_budget_for_model
2305
+ ContextBudget.new(@context_budget, @model&.context_window).tap do |budget|
2306
+ warn_about_unknown_window unless budget.window? || @context_budget_warned
2307
+ end
2308
+ end
2309
+
2310
+ def warn_about_unknown_window
2311
+ @context_budget_warned = true
2312
+ RubyLLM.logger.warn("RubyLLM has no context window for #{@model&.id}, so this chat's context budget does " \
2313
+ 'nothing. Pass window: to with_context_budget.')
2314
+ end
2315
+
2316
+ def with_skill_listing(list)
2317
+ return list if skills.empty?
2318
+
2319
+ index = list.index { |message| message.role != :system } || list.size
2320
+ list.dup.insert(index, Message.new(role: :system, content: @skill_loader.listing))
2321
+ end
2322
+
2323
+ def with_mcp_outages(list)
2324
+ unreachable = mcp.unreachable
2325
+ return list if unreachable.empty?
2326
+
2327
+ note = "These MCP servers can't be reached right now, so their tools are unavailable: " \
2328
+ "#{unreachable.join(', ')}. If a request needs them, say so."
2329
+ index = list.index { |message| message.role != :system } || list.size
2330
+ list.dup.insert(index, Message.new(role: :system, content: note))
2331
+ end
2332
+
1374
2333
  def unsupported_attachment_handler
1375
2334
  callbacks = @callbacks[:unsupported_attachment]
1376
- return if callbacks.empty?
2335
+ callbacks += [->(attachment) { @file_references.describe(attachment, config: @config) }] if @file_references
2336
+ return if callbacks.empty? && !@code_execution
1377
2337
 
2338
+ describe = attachment_describer
1378
2339
  lambda do |attachment|
1379
2340
  @attachment_replacements[attachment] ||= callbacks.lazy.filter_map { |handler| handler.call(attachment) }.first
2341
+ @attachment_replacements[attachment] || describe.call(attachment)
1380
2342
  end
1381
2343
  end
1382
2344
 
2345
+ # A run's names shift as files arrive, so descriptions are not memoized.
2346
+ def attachment_describer
2347
+ return ->(_attachment) {} unless @code_execution
2348
+
2349
+ CodeExecution.describer(messages.flat_map(&:attachments))
2350
+ end
2351
+
1383
2352
  # A process that dies mid-round leaves a blank assistant placeholder,
1384
2353
  # or tool calls without results, and providers refuse both. A request
1385
2354
  # leaves blank messages out and answers the calls of a round the
@@ -1396,13 +2365,17 @@ module RubyLLM
1396
2365
  end
1397
2366
 
1398
2367
  def unfinished_results(response, answered)
1399
- response.tool_calls.values.reject { |tool_call| answered.include?(tool_call.id) }
2368
+ response.tool_calls.reject { |tool_call| answered.include?(tool_call.id) }
1400
2369
  .map { |tool_call| unfinished_result(tool_call) }
1401
2370
  end
1402
2371
 
1403
- def unfinished_result(tool_call)
2372
+ # A call +interrupted+ after it started may have done its work, so the
2373
+ # model hears it may have partly run, as for an MCP call that broke off.
2374
+ def unfinished_result(tool_call, interrupted: false)
2375
+ content = UNFINISHED_TOOL_RESULT
2376
+ content = { error: "The tool call did not finish. #{MCP::Outages::PARTLY_RUN}" }.to_json if interrupted
1404
2377
  (remote_refusal(tool_call) if tool_call.remote?) ||
1405
- Message.new(role: :tool, content: UNFINISHED_TOOL_RESULT, tool_call_id: tool_call.id)
2378
+ Message.new(role: :tool, content:, tool_call_id: tool_call.id, provider_call_id: tool_call.provider_call_id)
1406
2379
  end
1407
2380
 
1408
2381
  # A provider-executed call is closed with the refusal its provider
@@ -1413,49 +2386,60 @@ module RubyLLM
1413
2386
  nil
1414
2387
  end
1415
2388
 
1416
- def provider_completion(usage_recorder:, stream_tracker: nil, &)
2389
+ def provider_completion(usage_recorder:, progress_recorder: nil, stream_tracker: nil, &)
1417
2390
  raise_if_cancelled!
2391
+ check_requested_tool_choice
1418
2392
 
1419
- request_messages = preprocessed_messages
2393
+ request_messages = with_mcp_outages(preprocessed_messages)
2394
+ tool_call_ids = ToolCallIds.new
1420
2395
  streamed = false
1421
2396
  tracker = proc do |chunk|
1422
2397
  streamed = true
1423
2398
  stream_tracker&.call(chunk)
1424
2399
  end
1425
- on_chunk = wrap_streaming_block(stream_tracker: tracker, &)
1426
-
1427
- replacing_missing_uploads(request_messages, -> { streamed }) do |sent_messages|
1428
- @provider.complete(
1429
- sent_messages,
1430
- tools: request_tools,
1431
- provider_tools: @provider_tools,
1432
- tool_prefs: @tool_prefs,
1433
- temperature: @temperature,
1434
- max_output_tokens: @max_output_tokens,
1435
- model: @model,
1436
- provider_options: Support::Utils.deep_dup(@provider_options),
1437
- headers: @headers,
1438
- schema: @schema,
1439
- thinking: resolved_thinking,
1440
- citations: @citations,
1441
- caching: @caching,
1442
- compaction: @compaction,
1443
- end_user: @end_user,
1444
- protocol: @protocol,
1445
- before_request: @callbacks[:before_request],
1446
- usage_recorder: usage_recorder,
1447
- &on_chunk
1448
- )
2400
+ on_chunk = wrap_streaming_block(tool_call_ids:, stream_tracker: tracker, &)
2401
+
2402
+ sent = request_messages
2403
+ response = replacing_missing_uploads(request_messages, -> { streamed }) do |sent_messages|
2404
+ sent = sent_messages
2405
+ cancellable_request do
2406
+ @provider.complete(
2407
+ sent_messages,
2408
+ tools: request_tools,
2409
+ provider_tools: @provider_tools,
2410
+ tool_prefs: @tool_prefs,
2411
+ temperature: @temperature,
2412
+ max_output_tokens: @max_output_tokens,
2413
+ model: @model,
2414
+ provider_options: Support::Utils.deep_dup(@provider_options),
2415
+ headers: @headers,
2416
+ schema: @schema,
2417
+ thinking: resolved_thinking,
2418
+ citations: @citations,
2419
+ caching: request_caching,
2420
+ compaction: @compaction,
2421
+ end_user: @end_user,
2422
+ protocol: @protocol,
2423
+ before_request: @callbacks[:before_request],
2424
+ usage_recorder: usage_recorder,
2425
+ progress_recorder: progress_recorder,
2426
+ &on_chunk
2427
+ )
2428
+ end
1449
2429
  end
2430
+ @file_references&.record_shown(sent)
2431
+ tool_call_ids.assign(response)
1450
2432
  end
1451
2433
 
1452
2434
  def provider_compaction
1453
2435
  replacing_missing_uploads(preprocessed_messages) do |sent_messages|
1454
- @provider.compact(
1455
- sent_messages, model: @model, protocol: @protocol,
1456
- headers: @headers, before_request: @callbacks[:before_request],
1457
- usage_recorder: method(:record_usage_entry)
1458
- )
2436
+ cancellable_request do
2437
+ @provider.compact(
2438
+ sent_messages, model: @model, protocol: @protocol,
2439
+ headers: @headers, before_request: @callbacks[:before_request],
2440
+ usage_recorder: method(:record_usage_entry)
2441
+ )
2442
+ end
1459
2443
  end
1460
2444
  end
1461
2445
 
@@ -1471,17 +2455,22 @@ module RubyLLM
1471
2455
  end
1472
2456
 
1473
2457
  def discard_missing_uploads(error)
1474
- @provider.discard_missing_uploads(messages, error, model: @model, protocol: @protocol)
2458
+ upload_messages = messages.map do |message|
2459
+ replacements = message.attachments.filter_map { |attachment| @attachment_replacements[attachment] }
2460
+ message.with_attachments(message.attachments + replacements)
2461
+ end
2462
+ @provider.discard_missing_uploads(upload_messages, error, model: @model, protocol: @protocol)
1475
2463
  end
1476
2464
 
1477
2465
  def record_usage_entry(entry)
1478
2466
  usage_entries << entry
1479
- @usage_recorder ? @usage_recorder.call(entry) : Accounting::Usage.record(entry)
2467
+ @run.record_usage(entry)
1480
2468
  entry
1481
2469
  end
1482
2470
 
1483
- def link_completion_usage(response, usage_start)
1484
- response.ruby_llm_usage_entries = usage_entries.drop(usage_start)
2471
+ def link_completion_usage(response, usage_start, resumed = nil)
2472
+ earlier = resumed ? resumed.ruby_llm_usage_entries : []
2473
+ response.ruby_llm_usage_entries = earlier + usage_entries.drop(usage_start)
1485
2474
  end
1486
2475
 
1487
2476
  def record_out_of_band_usage(response)
@@ -1493,7 +2482,8 @@ module RubyLLM
1493
2482
  tokens: response.tokens,
1494
2483
  cost: response.cost,
1495
2484
  message: response,
1496
- owner: Accounting::Usage.owner
2485
+ owner: Accounting::Usage.owner,
2486
+ workflow: Accounting::Usage.workflow
1497
2487
  )
1498
2488
  response.ruby_llm_usage_entries = [entry]
1499
2489
  Accounting::Usage.instrument(entry, config: @config)
@@ -1504,13 +2494,16 @@ module RubyLLM
1504
2494
  @callbacks[name].each { |callback| callback.call(*args) }
1505
2495
  end
1506
2496
 
1507
- def wrap_streaming_block(stream_tracker: nil, &block)
2497
+ def wrap_streaming_block(tool_call_ids:, stream_tracker: nil, &block)
1508
2498
  return nil unless block
1509
2499
 
1510
2500
  run_callbacks(:before_message)
2501
+ index = -1
1511
2502
 
1512
2503
  proc do |chunk|
1513
2504
  raise_if_cancelled!
2505
+ chunk = tool_call_ids.assign(chunk)
2506
+ chunk.index = index += 1
1514
2507
  stream_tracker&.call(chunk)
1515
2508
  block.call(chunk)
1516
2509
  raise_if_cancelled!
@@ -1520,83 +2513,183 @@ module RubyLLM
1520
2513
  def execute_pending_tool_calls(response)
1521
2514
  raise_if_cancelled!
1522
2515
 
1523
- server_calls, local_calls = pending_tool_calls(response).partition { |_, call| call.remote? }.map(&:to_h)
1524
- respond_to_tool_approvals(server_calls)
2516
+ server_calls, local_calls = pending_tool_calls(response).to_h.partition { |_, call| call.remote? }.map(&:to_h)
2517
+ decisions = server_calls.transform_values { |tool_call| approval_decision(nil, tool_call) }.compact
1525
2518
  executable, denied = partition_pending_tool_calls(local_calls)
1526
- deny_tool_calls(denied)
1527
- if concurrency
1528
- handle_concurrent_tool_calls(executable)
2519
+ executable = executable.reject { |_id, tool_call| exclusive_blocked?(tool_call, executable) }
2520
+ claimed = claim_tool_calls(executable, server_calls.slice(*decisions.keys).merge(denied))
2521
+ answer_claimed_tool_calls(claimed, server_calls, decisions, executable, denied)
2522
+ @tool_prefs[:choice] = nil if forced_tool_choice?
2523
+ end
2524
+
2525
+ def answer_claimed_tool_calls(claimed, server_calls, decisions, executable, denied)
2526
+ respond_to_tool_approvals(server_calls.slice(*claimed), decisions)
2527
+ deny_tool_calls(denied.slice(*claimed))
2528
+ runnable = answer_interrupted_tool_calls(executable.slice(*claimed))
2529
+ if concurrency && executable.values.none? { |tool_call| ordered?(tool_call) }
2530
+ handle_concurrent_tool_calls(runnable)
1529
2531
  else
1530
- handle_sequential_tool_calls(executable)
2532
+ handle_sequential_tool_calls(runnable)
1531
2533
  end
2534
+ finished = true
2535
+ rescue SignalException, SystemExit => e
2536
+ stopped_by = e
2537
+ raise
2538
+ ensure
2539
+ release_tool_calls(claimed, stopped_by, finished:)
2540
+ end
1532
2541
 
1533
- @tool_prefs[:choice] = nil if forced_tool_choice?
2542
+ # A call that had started in a process that stopped before saving its
2543
+ # result may have done its work, so it doesn't run again: the model
2544
+ # reads that it was interrupted and decides what to do. A call that
2545
+ # paused for an answer or a task carries on as usual. Returns the calls
2546
+ # left to run.
2547
+ def answer_interrupted_tool_calls(tool_calls)
2548
+ interrupted, runnable = tool_calls.partition do |_, tool_call|
2549
+ @tool_call_state.interrupted?(tool_call) && !tool_call_input(tool_call)
2550
+ end
2551
+ interrupted.to_h.each_value do |tool_call|
2552
+ run_callbacks(:before_message)
2553
+ run_callbacks(:after_message, add_message(unfinished_result(tool_call, interrupted: true)))
2554
+ end
2555
+ runnable.to_h
1534
2556
  end
1535
2557
 
1536
- def respond_to_tool_approvals(tool_calls)
1537
- tool_calls.each_value do |tool_call|
1538
- decision = tool_call_approval(nil, tool_call)
1539
- next if decision.nil?
2558
+ def claim_tool_calls(executable, answered)
2559
+ requested = executable.keys + answered.keys
2560
+ @tool_call_state.claim(requested, executing: executable.keys) do
2561
+ leases = executable.values.to_h { |tool_call| [tool_call, max_run_time_for(tool_call)] }
2562
+ answered.each_value { |tool_call| leases[tool_call] = @config.max_run_time }
2563
+ leases
2564
+ end
2565
+ end
2566
+
2567
+ def release_tool_calls(ids, stopped_by, finished:)
2568
+ answered = messages.filter_map { |message| message.tool_call_id if message.tool_result? }
2569
+ @tool_call_state.release(ids - answered, stopped_by, finished:)
2570
+ end
2571
+
2572
+ def max_run_time_for(tool_call)
2573
+ tool_for(tool_call)&.max_run_time || @config.max_run_time
2574
+ end
1540
2575
 
2576
+ def running_elsewhere?(tool_call)
2577
+ @tool_call_state.running_elsewhere?(tool_call)
2578
+ end
2579
+
2580
+ def respond_to_tool_approvals(tool_calls, decisions)
2581
+ tool_calls.each do |id, tool_call|
2582
+ decision = decisions.fetch(id)
1541
2583
  raise_if_cancelled!
1542
2584
  run_callbacks(:before_message)
1543
- response = @provider.tool_approval_response(tool_call, approved: decision, model: @model, protocol: @protocol)
2585
+ note = note_text(decline_note(tool_call)) unless decision
2586
+ response = @provider.tool_approval_response(tool_call, approved: decision, model: @model, protocol: @protocol,
2587
+ note:)
1544
2588
  message = add_message(response)
1545
2589
  run_callbacks(:after_message, message)
1546
2590
  end
1547
2591
  end
1548
2592
 
1549
2593
  def partition_pending_tool_calls(pending)
1550
- executable = {}
1551
- denied = {}
1552
- pending.reject { |_, tool_call| input_pending?(tool_call) }.each do |id, tool_call|
1553
- decision = execution_decision(tool_call)
1554
- (decision ? executable : denied)[id] = tool_call unless decision.nil?
1555
- end
1556
- [executable, denied]
2594
+ partition_tool_sequences(pending)
1557
2595
  end
1558
2596
 
1559
2597
  def execution_decision(tool_call)
1560
- tool = tools[tool_call.name.to_sym]
1561
- return tool_call_approval(tool, tool_call) if tool&.requires_approval?
2598
+ return false if tool_call.malformed_arguments?
2599
+
2600
+ tool = tool_for(tool_call)
2601
+ return approval_decision(tool, tool_call) if tool&.requires_approval?(tool_call)
1562
2602
 
1563
- @tool_call_decisions[tool_call.id] != false
2603
+ !@tool_call_state.denied?(tool_call)
1564
2604
  end
1565
2605
 
1566
2606
  def deny_tool_calls(tool_calls)
1567
2607
  tool_calls.each_value do |tool_call|
1568
2608
  raise_if_cancelled!
1569
2609
  run_callbacks(:before_message)
1570
- add_tool_result_message(tool_call, { error: "The user denied the #{tool_call.name} tool call." })
2610
+ add_tool_result_message(tool_call, refusal(tool_call))
1571
2611
  end
1572
2612
  end
1573
2613
 
2614
+ def refusal(tool_call)
2615
+ note = decline_note(tool_call) unless tool_call.malformed_arguments?
2616
+ note.is_a?(Hash) ? note : { error: refusal_error(tool_call, note) }
2617
+ end
2618
+
2619
+ def refusal_error(tool_call, note)
2620
+ name = tool_call.name
2621
+ return note || "The user denied the #{name} tool call." unless tool_call.malformed_arguments?
2622
+
2623
+ "The arguments for #{name} were #{tool_call.malformed_arguments_problem}. Call #{name} again with a JSON object."
2624
+ end
2625
+
1574
2626
  def record_tool_call_decision(tool_call, decision)
1575
2627
  id = tool_call.respond_to?(:id) ? tool_call.id : tool_call
1576
- @tool_call_decisions[id] = decision
2628
+ @tool_call_state.record_decision(id, decision)
1577
2629
  self
1578
2630
  end
1579
2631
 
1580
- def approval_pending?(tool_call)
1581
- return tool_call_approval(nil, tool_call).nil? if tool_call.remote?
2632
+ def approval_decision(tool, tool_call)
2633
+ return false if tool_call.malformed_arguments?
2634
+
2635
+ decision = tool_call_approval(tool, tool_call)
2636
+ return decision unless decision.nil?
2637
+ return if tool.is_a?(CodeExecution)
2638
+
2639
+ true if remembered_tool?(tool_call)
2640
+ end
2641
+
2642
+ def remembered_tool?(call)
2643
+ @tool_call_state.remembered?(call)
2644
+ end
2645
+
2646
+ def remembered_tool_identity(tool_call)
2647
+ id = tool_call.respond_to?(:id) ? tool_call.id : tool_call
2648
+ call = tool_call.respond_to?(:name) ? tool_call : find_tool_call(id)
2649
+ raise ArgumentError, "Unknown tool call: #{id.inspect}" unless call
2650
+ if call.name == CodeExecution.tool_name && call.namespace.nil?
2651
+ raise ArgumentError, "#{call.name} calls are approved one at a time and cannot be remembered"
2652
+ end
2653
+
2654
+ [call.namespace, call.name.to_s]
2655
+ end
2656
+
2657
+ def tool_call_answered?(tool_call)
2658
+ id = tool_call.respond_to?(:id) ? tool_call.id : tool_call
2659
+ messages.any? { |message| message.tool_result? && message.tool_call_id == id }
2660
+ end
2661
+
2662
+ def find_tool_call(id)
2663
+ messages.reverse_each do |message|
2664
+ call = message.tool_calls[id]
2665
+ return call if call
2666
+ end
2667
+ nil
2668
+ end
2669
+
2670
+ def reused_tool_call_id?(id)
2671
+ id.nil? || find_tool_call(id) || (block_given? && yield(id))
2672
+ end
1582
2673
 
1583
- tool = tools[tool_call.name.to_sym]
1584
- return false unless tool&.requires_approval?
2674
+ def escalation_approved?(tool_call)
2675
+ return false if tool_call.nil? || @tool_call_state.escalation_spent?(tool_call)
1585
2676
 
1586
- tool_call_approval(tool, tool_call).nil?
2677
+ @tool_call_state.spend_escalation(tool_call)
2678
+ tool_call_approval(nil, tool_call) == true
1587
2679
  end
1588
2680
 
1589
2681
  def tool_call_approval(tool, tool_call)
1590
2682
  return tool.approval_resolver.call(tool_call) if tool&.approval_resolver
1591
- return @tool_call_decisions[tool_call.id] if @tool_call_decisions.key?(tool_call.id)
1592
2683
 
1593
- @approval_checker&.call(tool_call)
2684
+ @tool_call_state.decision(tool_call)
1594
2685
  end
1595
2686
 
1596
2687
  def handle_sequential_tool_calls(tool_calls)
1597
2688
  tool_calls.each_value do |tool_call|
2689
+ next if sequence_blocked?(tool_call) || exclusive_blocked?(tool_call)
2690
+
1598
2691
  raise_if_cancelled!
1599
- result = execute_tool_with_callbacks(tool_call)
2692
+ result = skipped_result(tool_call) || execute_tool_with_callbacks(tool_call)
1600
2693
  next if result.equal?(PAUSED)
1601
2694
 
1602
2695
  run_callbacks(:before_message)
@@ -1608,45 +2701,74 @@ module RubyLLM
1608
2701
  execute_tools_concurrently(tool_calls) do |tool_call, result|
1609
2702
  next if result.equal?(PAUSED)
1610
2703
 
1611
- raise_if_cancelled!
1612
2704
  run_callbacks(:before_message)
1613
2705
  add_tool_result_message(tool_call, result)
1614
2706
  end
1615
2707
  end
1616
2708
 
1617
2709
  def execute_tools_concurrently(tool_calls, &on_result)
1618
- ToolConcurrency.run(concurrency, tool_calls, on_result:) do |tool_call|
2710
+ exclusive = ->(tool_call) { exclusive_call?(tool_call) }
2711
+ paused = ->(result) { result.equal?(PAUSED) }
2712
+ ToolConcurrency.run(concurrency, tool_calls, exclusive:, paused:, on_result:, config: @config) do |tool_call|
1619
2713
  execute_tool_with_callbacks(tool_call)
1620
2714
  end
1621
2715
  end
1622
2716
 
1623
2717
  def execute_tool_with_callbacks(tool_call)
1624
2718
  raise_if_cancelled!
2719
+ @tool_call_state.started(tool_call)
1625
2720
  run_callbacks(:before_tool_call, tool_call)
1626
- result = execute_tool tool_call
2721
+ result = run_around_tool_call(tool_call) { execute_tool tool_call }
1627
2722
  return result if result.equal?(PAUSED)
1628
2723
 
1629
- raise_if_cancelled!
1630
- run_callbacks(:after_tool_result, result)
2724
+ run_callbacks(:after_tool_result, result.is_a?(Tool::Halt) ? result.content : result)
1631
2725
  result
1632
2726
  end
1633
2727
 
2728
+ def run_around_tool_call(tool_call, &execute)
2729
+ ran = false
2730
+ result = nil
2731
+ innermost = lambda do
2732
+ result = execute.call unless ran
2733
+ ran = true
2734
+ result
2735
+ end
2736
+ outcome = -> { ran ? result : { error: "The #{tool_call.name} call did not run." } }
2737
+ chain = @callbacks[:around_tool_call].reverse.reduce(innermost) do |inner, callback|
2738
+ lambda do
2739
+ callback.call(tool_call) { inner.call }
2740
+ outcome.call
2741
+ end
2742
+ end
2743
+ chain.call
2744
+ outcome.call
2745
+ end
2746
+
1634
2747
  def add_tool_result_message(tool_call, result)
1635
- content, attachments = Tool.split_result(result)
2748
+ halted = result.is_a?(Tool::Halt)
2749
+ result = result.content if halted
2750
+ content, attachments = split_tool_result(result)
2751
+ content = @file_references.spill(tool_for(tool_call), tool_call, content) if @file_references
2752
+ content = context_budget_for_model.cap(content) if @context_budget
1636
2753
  mcp_result = result if result.is_a?(MCP::Result) && result.ui_uri
1637
- message = add_message(role: :tool, content:, attachments:, tool_call_id: tool_call.id, mcp_result:)
2754
+ message = add_message(role: :tool, content:, attachments:, tool_call_id: tool_call.id,
2755
+ provider_call_id: tool_call.provider_call_id, mcp_result:,
2756
+ finish_reason: (:halted if halted))
1638
2757
  run_callbacks(:after_message, message)
1639
2758
  message
1640
2759
  end
1641
2760
 
2761
+ # The tool has run, so a result that cannot be sent goes back as an
2762
+ # error: raising would leave the call pending and run it again.
2763
+ def split_tool_result(result)
2764
+ Tool.split_result(result)
2765
+ rescue StandardError => e
2766
+ Tool.split_result({ error: e.message })
2767
+ end
2768
+
1642
2769
  def execute_tool(tool_call)
1643
- tool = tools[tool_call.name.to_sym]
1644
- if tool.nil?
1645
- return {
1646
- error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
1647
- "Available tools: #{tools.keys.to_json}."
1648
- }
1649
- end
2770
+ tool = tool_for(tool_call)
2771
+ return { error: unavailable_tool_error(tool_call.qualified_name) } if tool.nil?
1650
2772
 
1651
2773
  args = tool_call.arguments
1652
2774
  payload = {
@@ -1659,7 +2781,8 @@ module RubyLLM
1659
2781
  tool_call: tool_call,
1660
2782
  tool_name: tool.name,
1661
2783
  tool_arguments: args,
1662
- tool_call_id: tool_call.id
2784
+ tool_call_id: tool_call.id,
2785
+ **agent_fields
1663
2786
  }
1664
2787
 
1665
2788
  RubyLLM.instrument('tool_call.ruby_llm', payload, config: @config) do |event|
@@ -1673,6 +2796,16 @@ module RubyLLM
1673
2796
  end
1674
2797
  end
1675
2798
 
2799
+ # Names a few close tools rather than every tool, which can run to
2800
+ # thousands of characters in a chat with many.
2801
+ def unavailable_tool_error(name)
2802
+ close = DidYouMean::SpellChecker.new(dictionary: tools.keys.map(&:to_s)).correct(name).first(3)
2803
+ message = "There is no tool named `#{name}`."
2804
+ message += " Did you mean #{close.map { |tool| "`#{tool}`" }.join(', ')}?" if close.any?
2805
+ message += " Find tools with `#{Tools::Search.tool_name}`." if tool_search_tool
2806
+ message
2807
+ end
2808
+
1676
2809
  def progress_listener(tool_call)
1677
2810
  return if @callbacks.fetch(:after_tool_progress, []).empty?
1678
2811
 
@@ -1681,15 +2814,55 @@ module RubyLLM
1681
2814
 
1682
2815
  def invoke_tool(tool, tool_call, arguments)
1683
2816
  input = tool_call_input(tool_call)
1684
- result = input ? tool.resume(input, arguments) : tool.call(**arguments, tool_call:)
1685
- return pause_tool_call(tool_call, result.to_h) if result.is_a?(MCP::Task)
2817
+ result = Tool.asking(tool, input) do
2818
+ rescuing_tool(tool) do
2819
+ input ? tool.resume_call(input, arguments, tool_call:) : tool.call(**arguments, tool_call:)
2820
+ end
2821
+ end
2822
+ return pause_tool_call(tool_call, result.to_h) if result.is_a?(Task)
1686
2823
 
1687
2824
  record_tool_call_input(tool_call, nil) if input
1688
- result
1689
- rescue MCP::InputRequiredError => e
2825
+ with_decline_notes(result, input)
2826
+ rescue InputRequiredError => e
1690
2827
  pause_tool_call(tool_call, e.to_h)
1691
2828
  end
1692
2829
 
2830
+ # The one place a tool's Tool.rescue_from handlers run, so they cover
2831
+ # every call a chat makes, whether the tool overrides Tool#call or
2832
+ # Tool#execute. A question or a cancellation is how a call pauses or
2833
+ # stops, not a failure, so it never reaches a handler; a handler may
2834
+ # end the turn with Tool#halt.
2835
+ def rescuing_tool(tool)
2836
+ catch(Tool::HALT) do
2837
+ yield
2838
+ rescue InputRequiredError, CancelledError
2839
+ raise
2840
+ rescue StandardError => e
2841
+ tool.rescue_with_handler(e)
2842
+ end
2843
+ end
2844
+
2845
+ def with_decline_notes(result, input)
2846
+ notes = Array(input&.[]('requests')).filter_map do |request|
2847
+ note = request.dig('response', 'note')
2848
+ "The user declined: #{note_text(note)}" if note
2849
+ end
2850
+ notes.empty? ? result : noted_result(result, notes)
2851
+ end
2852
+
2853
+ def noted_result(result, notes)
2854
+ case result
2855
+ when Tool::Halt then Tool::Halt.new(noted_result(result.content, notes))
2856
+ when MCP::Result
2857
+ blocks = notes.map { |note| { 'type' => 'text', 'text' => note } }
2858
+ MCP::Result.new(result.to_h.merge('content' => [*result.to_h['content'], *blocks]), result.ui_uri)
2859
+ else
2860
+ content, attachments = split_tool_result(result)
2861
+ text = [content, *notes].reject(&:empty?).join("\n\n")
2862
+ attachments.empty? ? text : [text, *attachments]
2863
+ end
2864
+ end
2865
+
1693
2866
  def pause_tool_call(tool_call, state)
1694
2867
  record_tool_call_input(tool_call, state)
1695
2868
  PAUSED
@@ -1709,15 +2882,18 @@ module RubyLLM
1709
2882
  end
1710
2883
 
1711
2884
  def task_for(tool_call, state)
1712
- tool = tools[tool_call.name.to_sym]
1713
- tool.respond_to?(:task) ? tool.task(state, tool_call:) : MCP::Task.load(nil, state, tool_call:)
2885
+ tool = tool_for(tool_call)
2886
+ return tool.task(state, tool_call:) if tool
2887
+ return MCP::Task.load(nil, state, tool_call:) if state['task'].key?('taskId')
2888
+
2889
+ Task.load(state, tool_call:)
1714
2890
  end
1715
2891
 
1716
2892
  def cancel_tasks
1717
2893
  response = pending_tool_response
1718
2894
  return unless response
1719
2895
 
1720
- pending_tool_calls(response).each_value do |tool_call|
2896
+ pending_tool_calls(response).each do |tool_call|
1721
2897
  state = tool_call_input(tool_call)
1722
2898
  next unless state&.key?('task')
1723
2899
 
@@ -1726,32 +2902,81 @@ module RubyLLM
1726
2902
  end
1727
2903
  end
1728
2904
 
2905
+ def close_unstarted_tool_calls
2906
+ response = pending_tool_response
2907
+ return unless response
2908
+
2909
+ pending_tool_calls(response).each do |tool_call|
2910
+ next if running_elsewhere?(tool_call)
2911
+
2912
+ record_tool_call_input(tool_call, nil) if tool_call_input(tool_call)
2913
+ run_callbacks(:before_message)
2914
+ run_callbacks(:after_message, add_message(unfinished_result(tool_call).mark_cancelled))
2915
+ end
2916
+ end
2917
+
1729
2918
  def cancel_task(task)
1730
2919
  task.cancel
1731
2920
  rescue Error, ConfigurationError, Faraday::Error => e
1732
- RubyLLM.logger.debug { "RubyLLM could not cancel task #{task.id}: #{e.message}" }
2921
+ RubyLLM.logger.debug { "RubyLLM could not cancel the task of tool call #{task.tool_call&.id}: #{e.message}" }
1733
2922
  end
1734
2923
 
1735
2924
  def tool_call_input(tool_call)
1736
- @input_checker ? @input_checker.call(tool_call) : @tool_call_inputs[tool_call.id]
2925
+ @tool_call_state.input(tool_call)
1737
2926
  end
1738
2927
 
1739
2928
  def record_tool_call_input(tool_call, input)
1740
- @tool_call_inputs[tool_call.id] = input
1741
- @input_recorder&.call(tool_call, input)
2929
+ @tool_call_state.record_input(tool_call, input)
2930
+ end
2931
+
2932
+ def unanswered_questions(tool_call)
2933
+ Array(tool_call_input(tool_call)&.[]('requests')).filter_map do |data|
2934
+ Question.from_h(data, tool_call:, chat: self) unless data['response']
2935
+ end
1742
2936
  end
1743
2937
 
1744
- def settle_input(request)
1745
- input = tool_call_input(request.tool_call) or raise ArgumentError, 'Unknown input request'
1746
- requests = input['requests'].map do |data|
1747
- next data unless data['key'] == request.key
2938
+ def pending_question(question)
2939
+ id = question.respond_to?(:id) ? question.id : question.to_s
2940
+ pending_questions.find { |pending| pending.id == id } ||
2941
+ (asked_question?(id) ? nil : raise(ArgumentError, "Unknown question: #{id.inspect}"))
2942
+ end
2943
+
2944
+ def asked_question?(id)
2945
+ [id, id.rpartition('/').first].any? { |tool_call_id| find_tool_call(tool_call_id) }
2946
+ end
1748
2947
 
1749
- pending = MCP::InputRequest.from_h(data)
1750
- yield pending
1751
- pending.to_h
2948
+ def record_answer(question, response)
2949
+ @tool_call_state.settle(question.tool_call) { |input| question.answered_input(input, response) }
2950
+ end
2951
+
2952
+ # Records +response+ to +question+ and returns whether it did. An
2953
+ # approval is approved or denied only while no decision came first, so
2954
+ # of two people deciding at once, the first one's decision stands.
2955
+ def settle_question(question, response)
2956
+ unless question.approval?
2957
+ record_answer(question, response)
2958
+ return true
2959
+ end
2960
+
2961
+ @tool_call_state.exclusively do
2962
+ next false unless approval_pending?(question.tool_call)
2963
+
2964
+ response == Question::ACCEPTED ? approve(question) : refuse(question.tool_call, response['note'])
2965
+ true
1752
2966
  end
1753
- record_tool_call_input(request.tool_call, input.merge('requests' => requests))
1754
- self
2967
+ end
2968
+
2969
+ def refuse(tool_call, note)
2970
+ record_tool_call_decision(tool_call, false)
2971
+ @tool_call_state.record_decline_note(tool_call, note) if note
2972
+ end
2973
+
2974
+ def decline_note(tool_call)
2975
+ @tool_call_state.decline_note(tool_call)
2976
+ end
2977
+
2978
+ def note_text(note)
2979
+ note.is_a?(Hash) ? JSON.generate(note) : note
1755
2980
  end
1756
2981
 
1757
2982
  def apply_tool_choice(choice)
@@ -1759,16 +2984,41 @@ module RubyLLM
1759
2984
  @tool_prefs[:choice] = nil
1760
2985
  else
1761
2986
  normalized_choice = normalize_tool_choice(choice)
1762
- valid_tool_choices = %i[auto none required] + tools.keys
1763
- unless valid_tool_choices.include?(normalized_choice)
1764
- raise InvalidToolChoiceError,
1765
- "Invalid tool choice: #{choice}. Valid choices are: #{valid_tool_choices.join(', ')}"
1766
- end
1767
-
2987
+ check_tool_choice(normalized_choice, choice) if mcp.empty?
1768
2988
  @tool_prefs[:choice] = normalized_choice
1769
2989
  end
1770
2990
  end
1771
2991
 
2992
+ # A chat with MCP servers checks its choice once it lists their tools,
2993
+ # so building it never contacts them.
2994
+ def check_tool_choice(choice, given = choice)
2995
+ return if choice.nil? || TOOL_CHOICE_MODES.include?(choice) || tools.key?(choice)
2996
+
2997
+ invalid_tool_choice("Invalid tool choice: #{given}. Valid choices are: " \
2998
+ "#{(TOOL_CHOICE_MODES + tools.keys).join(', ')}")
2999
+ end
3000
+
3001
+ # A forced call with no tools to call would reach the provider without
3002
+ # the constraint, since protocols drop a choice when there are no tools.
3003
+ def check_requested_tool_choice
3004
+ return if mcp.empty?
3005
+
3006
+ check_tool_choice(@tool_prefs[:choice])
3007
+ invalid_tool_choice('Tool choice required needs a tool, and the chat has none') if
3008
+ @tool_prefs[:choice] == :required && tools.empty?
3009
+ end
3010
+
3011
+ def invalid_tool_choice(message)
3012
+ unreachable = mcp.unreachable
3013
+ message += ". These MCP servers can't be reached right now: #{unreachable.join(', ')}" if unreachable.any?
3014
+ raise InvalidToolChoiceError, message
3015
+ end
3016
+
3017
+ def apply_tool_concurrency(value)
3018
+ @concurrency_chosen = !value.nil?
3019
+ @concurrency = normalize_tool_concurrency(value.nil? ? @config.tool_concurrency : value)
3020
+ end
3021
+
1772
3022
  def normalize_tool_concurrency(concurrency)
1773
3023
  return nil if concurrency.nil? || concurrency == false
1774
3024
  return :threads if concurrency == true
@@ -1792,19 +3042,27 @@ module RubyLLM
1792
3042
  end
1793
3043
  end
1794
3044
 
3045
+ def normalize_rounds(rounds)
3046
+ return rounds if rounds.nil? || (rounds.is_a?(Integer) && rounds.positive?)
3047
+
3048
+ raise ArgumentError, "Invalid rounds value: #{rounds.inspect}. Valid values are positive integers"
3049
+ end
3050
+
1795
3051
  def normalize_tool_choice(choice)
1796
3052
  return choice.to_sym if choice.is_a?(String) || choice.is_a?(Symbol)
1797
3053
  return tool_name_for_choice_class(choice) if choice.is_a?(Class)
3054
+ return choice.qualified_name.to_sym if choice.is_a?(Tool)
1798
3055
 
1799
3056
  choice.respond_to?(:name) ? choice.name.to_sym : choice.to_sym
1800
3057
  end
1801
3058
 
1802
3059
  def tool_name_for_choice_class(tool_class)
1803
- matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
3060
+ matched_tool_name = @tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
1804
3061
  return matched_tool_name if matched_tool_name
1805
- return tool_class.tool_name.to_sym if tool_class.respond_to?(:tool_name)
3062
+ return tool_class.name.to_s.to_sym unless tool_class.respond_to?(:tool_name)
1806
3063
 
1807
- tool_class.name.to_s.to_sym
3064
+ namespace = tool_class.namespace if tool_class.respond_to?(:namespace)
3065
+ [namespace, tool_class.tool_name].compact.join('_').to_sym
1808
3066
  end
1809
3067
 
1810
3068
  def forced_tool_choice?
@@ -1832,6 +3090,14 @@ module RubyLLM
1832
3090
  end
1833
3091
  end
1834
3092
 
3093
+ # What a process leaves behind before the provider answers, unlike a
3094
+ # blank answer, which carries its finish reason or usage.
3095
+ def placeholder?(message)
3096
+ return false unless message && blank_response?(message) && !unfinished?(message)
3097
+
3098
+ message.finish_reason.nil? && message.tokens.input.nil?
3099
+ end
3100
+
1835
3101
  def blank_response?(message)
1836
3102
  message.role == :assistant && !message.tool_call? && message.content.to_s.strip.empty? &&
1837
3103
  message.attachments.empty? && message.thinking.nil? && message.server_tool_calls.empty? &&
@@ -1854,7 +3120,7 @@ module RubyLLM
1854
3120
  end
1855
3121
 
1856
3122
  def awaiting_approval_names
1857
- pending_approvals.map(&:name).uniq
3123
+ pending_questions.select(&:approval?).map { |question| question.tool_call.name }.uniq
1858
3124
  end
1859
3125
  end
1860
3126
  end