ruby_llm 2.0.0 → 2.1.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 (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
data/lib/ruby_llm/chat.rb CHANGED
@@ -24,11 +24,14 @@ module RubyLLM
24
24
  class Chat # rubocop:disable Metrics/ClassLength
25
25
  include Enumerable
26
26
  include Support::Inspectable
27
+ include ToolSearch
27
28
 
28
29
  # The provider-neutral options #with_compaction accepts.
29
30
  COMPACTION_OPTIONS = %i[at instructions pause_after].freeze
30
31
  THINKING_OPTIONS = %i[effort budget display].freeze
31
- private_constant :THINKING_OPTIONS
32
+ PAUSED = Object.new.freeze
33
+ UNFINISHED_TOOL_RESULT = { error: 'The tool call did not finish.' }.to_json.freeze
34
+ private_constant :THINKING_OPTIONS, :PAUSED, :UNFINISHED_TOOL_RESULT
32
35
 
33
36
  # The Model the chat sends requests to.
34
37
  attr_reader :model
@@ -39,8 +42,9 @@ module RubyLLM
39
42
  # The Message objects exchanged so far, including system instructions.
40
43
  attr_reader :messages
41
44
 
42
- # The registered tools, as a Hash of tool name Symbols to Tool instances.
43
- attr_reader :tools
45
+ # The MCP servers connected with #with_mcp, readable by name:
46
+ # <tt>chat.mcp.linear</tt>.
47
+ attr_reader :mcp
44
48
 
45
49
  # The provider tools enabled with #with_provider_tools, as an array of
46
50
  # normalized entry Hashes.
@@ -119,6 +123,9 @@ module RubyLLM
119
123
  @messages = []
120
124
  @usage_entries = []
121
125
  @tools = {}
126
+ @mcp = MCP::Collection.new
127
+ @tool_deferrals = {}
128
+ @mcp_deferrals = {}
122
129
  @provider_tools = []
123
130
  @tool_prefs = { choice: nil, calls: nil }
124
131
  @concurrency = normalize_tool_concurrency(@config.tool_concurrency)
@@ -133,16 +140,21 @@ module RubyLLM
133
140
  @fallbacks = []
134
141
  @fallback_errors = Fallback::DEFAULT_ERRORS
135
142
  @callbacks = Hash.new { |callbacks, name| callbacks[name] = [] }
143
+ @attachment_replacements = {}.compare_by_identity
136
144
  @cancelled = false
137
145
  @cancellation_checker = nil
138
146
  @tool_call_decisions = {}
139
147
  @approval_checker = nil
148
+ @tool_call_inputs = {}
149
+ @input_checker = nil
150
+ @input_recorder = nil
140
151
  end
141
152
 
142
153
  # Adds +message+ as a user message and runs the conversation loop,
143
154
  # executing tools until the model answers or a call needs approval.
144
- # Returns the latest assistant Message; check #awaiting_approval? before
145
- # treating it as a final answer. Attach files with +with:+.
155
+ # Returns the latest assistant Message; check #waiting? before
156
+ # treating it as a final answer. Attach files with +with:+. An
157
+ # MCP::Prompt adds its messages instead.
146
158
  # A given block receives streamed Chunk objects as they arrive.
147
159
  #
148
160
  # String attachments read local paths or fetch URLs. Only pass trusted,
@@ -153,6 +165,7 @@ module RubyLLM
153
165
  # chat.ask "What's in this image?", with: "ruby_conf.jpg"
154
166
  # chat.ask "Analyze these files", with: ["diagram.png", "report.pdf"]
155
167
  # chat.ask("Tell me a story") { |chunk| print chunk.content }
168
+ # chat.ask github.prompt(:code_review, code: diff)
156
169
  #
157
170
  def ask(message = nil, with: nil, &)
158
171
  ask_later(message, with: with)
@@ -174,7 +187,11 @@ module RubyLLM
174
187
  # decisions for calls that require approval.
175
188
  def ask_later(message = nil, with: nil)
176
189
  raise_if_pending_tool_calls!
177
- add_message role: :user, content: message, attachments: with
190
+ if message.is_a?(MCP::Prompt)
191
+ message.messages.each { |prompt_message| add_message(prompt_message) }
192
+ else
193
+ add_message role: :user, content: message, attachments: with
194
+ end
178
195
  self
179
196
  end
180
197
 
@@ -198,20 +215,24 @@ module RubyLLM
198
215
  # was declared with Tool.requires_approval only execute once #approve
199
216
  # records a decision; denied calls receive a structured denial result,
200
217
  # and undecided calls stay pending. Does nothing when no tool calls
201
- # are pending. The chat is then ready for the next #generate, or the
202
- # next batch round. Returns +self+.
218
+ # are pending. Calls paused on an MCP::Task check on it once. The chat
219
+ # is then ready for the next #generate, or the next batch round.
220
+ # Returns +self+.
203
221
  def run_tools
204
222
  raise_if_cancelled!
205
223
 
206
224
  message = pending_tool_response
207
225
  execute_pending_tool_calls(message) if message
208
226
  self
227
+ rescue CancelledError
228
+ cancel_tasks
229
+ raise
209
230
  end
210
231
 
211
232
  # Advances the conversation by one move: runs the pending tool calls
212
233
  # if any are unanswered, otherwise generates the next response.
213
234
  # Returns the Message that move produced, and +nil+ once there is
214
- # nothing left to do or the loop is parked on an approval.
235
+ # nothing left to do or the chat is #waiting?.
215
236
  def step(&)
216
237
  return if complete?
217
238
 
@@ -221,23 +242,32 @@ module RubyLLM
221
242
  before = messages.length
222
243
  run_tools
223
244
  messages.last if messages.length > before
245
+ rescue CancelledError
246
+ cancel_tasks
247
+ raise
224
248
  end
225
249
 
226
- # Runs the conversation loop until #complete? or #awaiting_approval?
227
- # is +true+. Returns the last conversation Message, or +nil+ for an
228
- # empty chat. Used after #ask_later; #ask calls #complete for you.
250
+ # Runs the conversation loop until #complete? or #waiting? is +true+.
251
+ # Returns the last conversation Message, or +nil+ for an empty chat.
252
+ # Used after #ask_later; #ask calls #complete for you.
229
253
  #
230
254
  # When a pending tool call requires approval and no decision has been
231
- # recorded, the loop pauses. Record #approve or #deny decisions, then
232
- # call #complete again to continue.
255
+ # 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.
233
260
  def complete(&)
234
- step(&) until complete? || awaiting_approval?
261
+ run_tools if awaiting_tasks?
262
+ step(&) until complete? || waiting?
235
263
  last_non_system_message || messages.last
236
264
  end
237
265
 
238
266
  # Returns whether the chat has no pending response or tool execution:
239
267
  # nothing is staged, or the model answered without requesting tools.
240
268
  def complete?
269
+ return false if pending_tool_response
270
+
241
271
  last = last_non_system_message
242
272
  case last&.role
243
273
  when nil then true
@@ -266,9 +296,27 @@ module RubyLLM
266
296
  record_tool_call_decision(tool_call, false)
267
297
  end
268
298
 
299
+ # Returns whether the conversation can't continue until something
300
+ # 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:
305
+ #
306
+ # chat.step until chat.complete? || chat.waiting?
307
+ #
308
+ def waiting?
309
+ response = pending_tool_response
310
+ return false unless response
311
+
312
+ pending = pending_tool_calls(response).values
313
+ pending.any? && pending.all? { |tool_call| approval_pending?(tool_call) || paused?(tool_call) }
314
+ end
315
+
269
316
  # Returns whether the conversation can make no progress without an
270
317
  # approval decision: every remaining pending tool call requires
271
- # approval and has none recorded. While +true+, #complete returns
318
+ # approval and has none recorded, or waits on input. While +true+,
319
+ # #complete returns
272
320
  # without executing them; record decisions with #approve or #deny,
273
321
  # then call #complete again. Tool calls that need no approval still
274
322
  # execute before the loop pauses.
@@ -276,11 +324,7 @@ module RubyLLM
276
324
  # Consults each pending tool's approval resolver when one is declared,
277
325
  # so resolvers must be idempotent reads.
278
326
  def awaiting_approval?
279
- response = pending_tool_response
280
- return false unless response
281
-
282
- pending = pending_tool_calls(response)
283
- pending.any? && pending.all? { |_, tool_call| approval_pending?(tool_call) }
327
+ waiting? && pending_approvals.any?
284
328
  end
285
329
 
286
330
  # Returns the tool calls from the latest response that require approval
@@ -297,9 +341,80 @@ module RubyLLM
297
341
  pending_tool_calls(response).values.select { |tool_call| approval_pending?(tool_call) }
298
342
  end
299
343
 
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?
349
+ end
350
+
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.
354
+ #
355
+ # chat.pending_inputs.each { |request| puts request.message }
356
+ # chat.answer(chat.pending_inputs.first, environment: "staging")
357
+ #
358
+ def pending_inputs
359
+ response = pending_tool_response
360
+ return [] unless response
361
+
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
367
+ end
368
+
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
377
+ # chat.complete
378
+ #
379
+ def awaiting_tasks?
380
+ waiting? && pending_tasks.any?
381
+ end
382
+
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.
387
+ #
388
+ # chat.pending_tasks.each { |task| puts "#{task.tool_call.name}: #{task.status_message}" }
389
+ #
390
+ def pending_tasks
391
+ response = pending_tool_response
392
+ return [] unless response
393
+
394
+ pending_tool_calls(response).values.filter_map do |tool_call|
395
+ state = tool_call_input(tool_call)
396
+ task_for(tool_call, state) if state&.key?('task') && !unanswered?(state)
397
+ end
398
+ end
399
+
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
+
300
414
  # Cancels the current in-flight chat operation. The next cancellation
301
415
  # checkpoint raises CancelledError and clears the flag so the chat can be
302
- # reused.
416
+ # reused. The MCP tasks that pending tool calls wait on are cancelled
417
+ # then too; see #pending_tasks.
303
418
  def cancel
304
419
  @cancelled = true
305
420
  self
@@ -313,17 +428,21 @@ module RubyLLM
313
428
  # Sets the system instructions for the conversation, replacing any
314
429
  # existing system messages. With <tt>append: true</tt> the instructions
315
430
  # are added alongside the existing ones. With <tt>cache_until_here:
316
- # true</tt> the instruction becomes an explicit prompt cache boundary.
317
- # Pass +nil+ to remove all system instructions. Returns +self+.
431
+ # true</tt> the instruction becomes an explicit prompt cache boundary;
432
+ # pass <tt>cache_until_here: { ttl: "1h" }</tt> to give that boundary its
433
+ # own lifetime, as Message#cache_until_here describes. Pass +nil+ to
434
+ # remove all system instructions. Returns +self+.
318
435
  #
319
436
  # chat.with_instructions "You are a helpful Ruby tutor."
320
437
  # chat.with_instructions "Use exactly one short paragraph.", append: true
438
+ # chat.with_instructions policy, cache_until_here: { ttl: "1h" }
321
439
  # chat.with_instructions nil
322
440
  #
323
441
  def with_instructions(instructions, append: false, cache_until_here: false)
442
+ boundary = Message.cache_boundary_options(cache_until_here) if cache_until_here
324
443
  @messages.reject! { |message| message.role == :system } unless append
325
444
  @messages << Message.new(role: :system, content: instructions) unless instructions.nil?
326
- @messages.last.cache_until_here if instructions && cache_until_here
445
+ @messages.last.cache_until_here(**boundary) if instructions && boundary
327
446
  self
328
447
  end
329
448
 
@@ -334,15 +453,84 @@ module RubyLLM
334
453
  # chat.with_tools(Weather, Search)
335
454
  # chat.with_tools(Weather).with_tool_options(choice: :required)
336
455
  #
456
+ # Pass <tt>defer: true</tt> to keep the tools' definitions out of the
457
+ # model's context until the provider's tool search loads the ones it
458
+ # needs, or <tt>defer: false</tt> to offer a tool declared with
459
+ # Tool.defer up front. See #deferred_tools.
460
+ #
461
+ # chat.with_tools(*Catalog.tools, defer: true)
462
+ #
337
463
  # To replace the registered tools, clear them first:
338
464
  #
339
465
  # chat.with_tools(nil).with_tools(NewTool)
340
466
  #
341
- def with_tools(*tools)
342
- @tools.clear if tools == [nil]
467
+ def with_tools(*tools, defer: nil)
468
+ if tools == [nil]
469
+ @tools.clear
470
+ @tool_deferrals.clear
471
+ end
343
472
  tools.flatten.compact.each do |tool|
344
- tool_instance = tool.is_a?(Class) ? tool.new : tool
345
- @tools[tool_instance.name.to_sym] = tool_instance
473
+ tool = tool.new if tool.is_a?(Class)
474
+ name = tool.name.to_sym
475
+ @tools[name] = tool
476
+ @tool_deferrals[name] = defer.nil? ? tool.class.deferred? : defer
477
+ end
478
+ self
479
+ end
480
+
481
+ # Returns the tools the model can call, as a Hash of tool name Symbols
482
+ # to Tool instances: those registered with #with_tools and those of the
483
+ # MCP servers connected with #with_mcp. MCP tools that only the UI of
484
+ # an MCP App may call are left out; see MCP::Tool#visibility.
485
+ #
486
+ # Raises ArgumentError when two tools share a name.
487
+ def tools
488
+ offered = @tools.reject { |_, tool| hidden_from_model?(tool) }
489
+ mcp.flat_map(&:tools).each_with_object(offered) do |tool, tools|
490
+ next if hidden_from_model?(tool)
491
+
492
+ name = tool.name.to_sym
493
+ raise ArgumentError, "Two tools are named #{name}. Rename one with `tool :#{name}, as:`" if tools.key?(name)
494
+
495
+ tools[name] = tool
496
+ end
497
+ end
498
+
499
+ # Returns the tools whose definitions stay out of the model's context
500
+ # until the provider's tool search loads them, as a Hash like #tools.
501
+ # Providers without tool search receive them as ordinary tools.
502
+ #
503
+ # chat.with_tools(Weather).with_mcp(GitHub, defer: true)
504
+ # chat.deferred_tools.keys # => [:search_issues, :create_issue, ...]
505
+ #
506
+ def deferred_tools
507
+ tools.slice(*deferred_tool_names)
508
+ end
509
+
510
+ # Connects MCP servers, each an MCP instance or class, and gives the
511
+ # model their tools. The servers are contacted when the chat first
512
+ # needs their tools. Pass +nil+ to disconnect them all. Returns +self+.
513
+ #
514
+ # chat.with_mcp(Linear.new(user: current_user), Files)
515
+ # chat.mcp.linear
516
+ #
517
+ # Pass <tt>defer: true</tt> to keep every tool of these servers out of
518
+ # the model's context until the provider's tool search loads it, or
519
+ # <tt>defer: false</tt> to offer them up front despite MCP.defer. See
520
+ # #deferred_tools.
521
+ #
522
+ # chat.with_mcp(GitHub, defer: true)
523
+ #
524
+ def with_mcp(*servers, defer: nil)
525
+ if servers == [nil]
526
+ @mcp = MCP::Collection.new
527
+ @mcp_deferrals.clear
528
+ else
529
+ servers.flatten.compact.each do |server|
530
+ server = server.new if server.is_a?(Class)
531
+ @mcp << server
532
+ @mcp_deferrals[server.name.to_sym] = defer
533
+ end
346
534
  end
347
535
  self
348
536
  end
@@ -686,6 +874,23 @@ module RubyLLM
686
874
  add_callback(:after_tool_result, &)
687
875
  end
688
876
 
877
+ # Registers a callback that receives the ToolCall and a Progress each
878
+ # time a running local tool reports what it is doing, with
879
+ # Tool#progress or, for MCP tools, through the server's progress
880
+ # notifications. Returns +self+.
881
+ #
882
+ # chat.after_tool_progress do |tool_call, progress|
883
+ # puts "#{tool_call.name}: #{progress.message}"
884
+ # end
885
+ #
886
+ # The callback runs in the thread or fiber that reports, before the
887
+ # tool's result. On Ruby 3.2 and later, that includes threads and
888
+ # fibers the tool starts. With concurrent tool execution, callbacks for
889
+ # different tool calls can run at the same time.
890
+ def after_tool_progress(&)
891
+ add_callback(:after_tool_progress, &)
892
+ end
893
+
689
894
  # Registers a callback that receives the Fallback attempt after the
690
895
  # current model fails and before the fallback model is tried. Returns
691
896
  # +self+.
@@ -709,6 +914,22 @@ module RubyLLM
709
914
  add_callback(:before_request, &)
710
915
  end
711
916
 
917
+ # Converts attachments the current protocol cannot render with the
918
+ # given block. Returns +self+. The block receives an Attachment and
919
+ # returns a replacement Attachment, or +nil+ to keep the existing error.
920
+ # Blocks run in registration order until one returns a replacement.
921
+ # The transcript keeps the original file, even when the model changes.
922
+ # The chat converts each attachment once and reuses the replacement,
923
+ # and its upload, on later requests.
924
+ #
925
+ # chat.convert_unsupported_attachments do |attachment|
926
+ # text = TextExtraction.call(attachment)
927
+ # Attachment.new(StringIO.new(text), filename: 'extracted.txt') if text
928
+ # end
929
+ def convert_unsupported_attachments(&)
930
+ add_callback(:unsupported_attachment, &)
931
+ end
932
+
712
933
  # Yields each Message in the conversation. Returns an Enumerator when
713
934
  # no block is given. Chat includes Enumerable, so the usual collection
714
935
  # methods are available.
@@ -751,7 +972,7 @@ module RubyLLM
751
972
  @provider.count_tokens(
752
973
  preprocessed_messages(request_messages),
753
974
  model: @model,
754
- tools: @tools,
975
+ tools: request_tools,
755
976
  tool_prefs: @tool_prefs,
756
977
  thinking: resolved_thinking,
757
978
  schema: @schema,
@@ -802,7 +1023,7 @@ module RubyLLM
802
1023
  end
803
1024
 
804
1025
  # Hooks installed by the Rails integration.
805
- attr_writer :cancellation_checker, :usage_recorder, :approval_checker # :nodoc:
1026
+ attr_writer :cancellation_checker, :usage_recorder, :approval_checker, :input_checker, :input_recorder # :nodoc:
806
1027
 
807
1028
  # Appends a message to the conversation and returns it as a Message.
808
1029
  # Accepts a Message, an attribute Hash, or a record responding to
@@ -817,14 +1038,18 @@ module RubyLLM
817
1038
  end
818
1039
 
819
1040
  # Marks the latest message as an explicit prompt cache boundary, asking
820
- # the provider to cache everything up to this point. Returns +self+.
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+.
1044
+ #
1045
+ # chat.with_instructions(policy).cache_until_here(ttl: "1h")
821
1046
  #
822
1047
  # Raises ArgumentError if the chat has no messages.
823
- def cache_until_here
1048
+ def cache_until_here(ttl: nil)
824
1049
  message = messages.last
825
1050
  raise ArgumentError, 'No messages to cache' unless message
826
1051
 
827
- message.cache_until_here
1052
+ message.cache_until_here(ttl:)
828
1053
  self
829
1054
  end
830
1055
 
@@ -848,7 +1073,7 @@ module RubyLLM
848
1073
  def render
849
1074
  @provider.render(
850
1075
  preprocessed_messages,
851
- tools: @tools,
1076
+ tools: request_tools,
852
1077
  provider_tools: @provider_tools,
853
1078
  tool_prefs: @tool_prefs,
854
1079
  temperature: @temperature,
@@ -877,7 +1102,7 @@ module RubyLLM
877
1102
  raise PendingToolCallsError,
878
1103
  "The last response has unanswered tool calls (#{names.join(', ')}). " \
879
1104
  'Run complete, recording approve or deny decisions for calls that ' \
880
- 'require approval, before asking again.'
1105
+ 'require approval, answering pending inputs, and waiting for tasks, before asking again.'
881
1106
  end
882
1107
 
883
1108
  private
@@ -983,9 +1208,12 @@ module RubyLLM
983
1208
 
984
1209
  RubyLLM.instrument('chat.ruby_llm', payload, config: @config) do |event|
985
1210
  result = provider_completion(usage_recorder: method(:record_usage_entry), stream_tracker:, &block)
1211
+ event[:response_tokens] = result.tokens
986
1212
  record_out_of_band_usage(result) if usage_entries.length == entries_before
987
1213
  record_generated_message(result, usage_start, streaming: block_given?)
988
1214
  record_completion_event(event, result)
1215
+ ensure
1216
+ event[:response_tokens] ||= Tokens.aggregate(usage_entries[entries_before..].map(&:tokens))
989
1217
  end
990
1218
  result
991
1219
  end
@@ -1134,50 +1362,121 @@ module RubyLLM
1134
1362
  # Preprocessing builds a per-request view of the conversation: the
1135
1363
  # provider can change through fallbacks or with_model, so history keeps
1136
1364
  # the original attachments while each provider's upload is memoized on
1137
- # them. Reloaded Rails chats rebuild history from rows and upload again.
1365
+ # them. An attachment's store keeps its uploads for later processes.
1138
1366
  def preprocessed_messages(list = messages)
1367
+ list = request_history(list)
1139
1368
  return list unless @provider
1140
1369
 
1141
- list.map { |message| @provider.preprocess_message(message, model: @model, protocol: @protocol) }
1370
+ @provider.preprocess_messages(list, model: @model, protocol: @protocol,
1371
+ unsupported_attachment: unsupported_attachment_handler)
1372
+ end
1373
+
1374
+ def unsupported_attachment_handler
1375
+ callbacks = @callbacks[:unsupported_attachment]
1376
+ return if callbacks.empty?
1377
+
1378
+ lambda do |attachment|
1379
+ @attachment_replacements[attachment] ||= callbacks.lazy.filter_map { |handler| handler.call(attachment) }.first
1380
+ end
1381
+ end
1382
+
1383
+ # A process that dies mid-round leaves a blank assistant placeholder,
1384
+ # or tool calls without results, and providers refuse both. A request
1385
+ # leaves blank messages out and answers the calls of a round the
1386
+ # conversation moved past as unfinished. The latest round stays as it
1387
+ # is: the loop runs its calls or waits on them.
1388
+ def request_history(list)
1389
+ kept = list.reject { |message| blank_response?(message) }
1390
+ answered = kept.filter_map { |message| message.tool_call_id if message.tool_result? }
1391
+ current = latest_response(kept)
1392
+ kept.slice_before { |message| !message.tool_result? }.flat_map do |response, *results|
1393
+ abandoned = response.tool_call? && !response.equal?(current)
1394
+ [response, *results, *(abandoned ? unfinished_results(response, answered) : [])]
1395
+ end
1396
+ end
1397
+
1398
+ def unfinished_results(response, answered)
1399
+ response.tool_calls.values.reject { |tool_call| answered.include?(tool_call.id) }
1400
+ .map { |tool_call| unfinished_result(tool_call) }
1401
+ end
1402
+
1403
+ def unfinished_result(tool_call)
1404
+ (remote_refusal(tool_call) if tool_call.remote?) ||
1405
+ Message.new(role: :tool, content: UNFINISHED_TOOL_RESULT, tool_call_id: tool_call.id)
1406
+ end
1407
+
1408
+ # A provider-executed call is closed with the refusal its provider
1409
+ # expects. Another provider takes it as an ordinary tool call.
1410
+ def remote_refusal(tool_call)
1411
+ @provider.tool_approval_response(tool_call, approved: false, model: @model, protocol: @protocol)
1412
+ rescue Error
1413
+ nil
1142
1414
  end
1143
1415
 
1144
1416
  def provider_completion(usage_recorder:, stream_tracker: nil, &)
1145
1417
  raise_if_cancelled!
1146
1418
 
1147
- @provider.complete(
1148
- preprocessed_messages,
1149
- tools: @tools,
1150
- provider_tools: @provider_tools,
1151
- tool_prefs: @tool_prefs,
1152
- temperature: @temperature,
1153
- max_output_tokens: @max_output_tokens,
1154
- model: @model,
1155
- provider_options: Support::Utils.deep_dup(@provider_options),
1156
- headers: @headers,
1157
- schema: @schema,
1158
- thinking: resolved_thinking,
1159
- citations: @citations,
1160
- caching: @caching,
1161
- compaction: @compaction,
1162
- end_user: @end_user,
1163
- protocol: @protocol,
1164
- before_request: @callbacks[:before_request],
1165
- usage_recorder: usage_recorder,
1166
- &wrap_streaming_block(stream_tracker:, &)
1167
- )
1419
+ request_messages = preprocessed_messages
1420
+ streamed = false
1421
+ tracker = proc do |chunk|
1422
+ streamed = true
1423
+ stream_tracker&.call(chunk)
1424
+ 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
+ )
1449
+ end
1168
1450
  end
1169
1451
 
1170
1452
  def provider_compaction
1171
- @provider.compact(
1172
- preprocessed_messages, model: @model, protocol: @protocol,
1173
- headers: @headers, before_request: @callbacks[:before_request],
1174
- usage_recorder: method(:record_usage_entry)
1175
- )
1453
+ 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
+ )
1459
+ end
1460
+ end
1461
+
1462
+ # A provider can delete a file RubyLLM uploaded for an attachment. A
1463
+ # request that fails over such a file before streaming anything uploads
1464
+ # the file again and runs once more; one that streamed is not repeated.
1465
+ def replacing_missing_uploads(request_messages, streamed = -> { false })
1466
+ yield request_messages
1467
+ rescue Error => e
1468
+ raise if streamed.call || discard_missing_uploads(e).empty?
1469
+
1470
+ yield preprocessed_messages
1471
+ end
1472
+
1473
+ def discard_missing_uploads(error)
1474
+ @provider.discard_missing_uploads(messages, error, model: @model, protocol: @protocol)
1176
1475
  end
1177
1476
 
1178
1477
  def record_usage_entry(entry)
1179
1478
  usage_entries << entry
1180
- @usage_recorder&.call(entry)
1479
+ @usage_recorder ? @usage_recorder.call(entry) : Accounting::Usage.record(entry)
1181
1480
  entry
1182
1481
  end
1183
1482
 
@@ -1193,7 +1492,8 @@ module RubyLLM
1193
1492
  status: :succeeded,
1194
1493
  tokens: response.tokens,
1195
1494
  cost: response.cost,
1196
- message: response
1495
+ message: response,
1496
+ owner: Accounting::Usage.owner
1197
1497
  )
1198
1498
  response.ruby_llm_usage_entries = [entry]
1199
1499
  Accounting::Usage.instrument(entry, config: @config)
@@ -1249,22 +1549,20 @@ module RubyLLM
1249
1549
  def partition_pending_tool_calls(pending)
1250
1550
  executable = {}
1251
1551
  denied = {}
1252
- pending.each do |id, tool_call|
1253
- tool = tools[tool_call.name.to_sym]
1254
- if tool&.requires_approval?
1255
- decision = tool_call_approval(tool, tool_call)
1256
- next if decision.nil?
1257
-
1258
- (decision ? executable : denied)[id] = tool_call
1259
- elsif @tool_call_decisions[tool_call.id] == false
1260
- denied[id] = tool_call
1261
- else
1262
- executable[id] = tool_call
1263
- end
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?
1264
1555
  end
1265
1556
  [executable, denied]
1266
1557
  end
1267
1558
 
1559
+ 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?
1562
+
1563
+ @tool_call_decisions[tool_call.id] != false
1564
+ end
1565
+
1268
1566
  def deny_tool_calls(tool_calls)
1269
1567
  tool_calls.each_value do |tool_call|
1270
1568
  raise_if_cancelled!
@@ -1298,14 +1596,18 @@ module RubyLLM
1298
1596
  def handle_sequential_tool_calls(tool_calls)
1299
1597
  tool_calls.each_value do |tool_call|
1300
1598
  raise_if_cancelled!
1301
- run_callbacks(:before_message)
1302
1599
  result = execute_tool_with_callbacks(tool_call)
1600
+ next if result.equal?(PAUSED)
1601
+
1602
+ run_callbacks(:before_message)
1303
1603
  add_tool_result_message(tool_call, result)
1304
1604
  end
1305
1605
  end
1306
1606
 
1307
1607
  def handle_concurrent_tool_calls(tool_calls)
1308
1608
  execute_tools_concurrently(tool_calls) do |tool_call, result|
1609
+ next if result.equal?(PAUSED)
1610
+
1309
1611
  raise_if_cancelled!
1310
1612
  run_callbacks(:before_message)
1311
1613
  add_tool_result_message(tool_call, result)
@@ -1322,6 +1624,8 @@ module RubyLLM
1322
1624
  raise_if_cancelled!
1323
1625
  run_callbacks(:before_tool_call, tool_call)
1324
1626
  result = execute_tool tool_call
1627
+ return result if result.equal?(PAUSED)
1628
+
1325
1629
  raise_if_cancelled!
1326
1630
  run_callbacks(:after_tool_result, result)
1327
1631
  result
@@ -1329,7 +1633,8 @@ module RubyLLM
1329
1633
 
1330
1634
  def add_tool_result_message(tool_call, result)
1331
1635
  content, attachments = Tool.split_result(result)
1332
- message = add_message role: :tool, content:, attachments:, tool_call_id: tool_call.id
1636
+ 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:)
1333
1638
  run_callbacks(:after_message, message)
1334
1639
  message
1335
1640
  end
@@ -1358,7 +1663,9 @@ module RubyLLM
1358
1663
  }
1359
1664
 
1360
1665
  RubyLLM.instrument('tool_call.ruby_llm', payload, config: @config) do |event|
1361
- result = tool.call(**args, tool_call: tool_call)
1666
+ result = Support::Cancellation.watch(-> { raise_if_cancelled! }) do
1667
+ Support::ProgressReporter.listen(progress_listener(tool_call)) { invoke_tool(tool, tool_call, args) }
1668
+ end
1362
1669
  event[:result] = result
1363
1670
  event[:result_content] = result
1364
1671
  event[:result_class] = result.class.name
@@ -1366,6 +1673,87 @@ module RubyLLM
1366
1673
  end
1367
1674
  end
1368
1675
 
1676
+ def progress_listener(tool_call)
1677
+ return if @callbacks.fetch(:after_tool_progress, []).empty?
1678
+
1679
+ ->(progress) { run_callbacks(:after_tool_progress, tool_call, progress) }
1680
+ end
1681
+
1682
+ def invoke_tool(tool, tool_call, arguments)
1683
+ 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)
1686
+
1687
+ record_tool_call_input(tool_call, nil) if input
1688
+ result
1689
+ rescue MCP::InputRequiredError => e
1690
+ pause_tool_call(tool_call, e.to_h)
1691
+ end
1692
+
1693
+ def pause_tool_call(tool_call, state)
1694
+ record_tool_call_input(tool_call, state)
1695
+ PAUSED
1696
+ end
1697
+
1698
+ def paused?(tool_call)
1699
+ state = tool_call_input(tool_call)
1700
+ !state.nil? && (unanswered?(state) || state.key?('task'))
1701
+ end
1702
+
1703
+ def input_pending?(tool_call)
1704
+ unanswered?(tool_call_input(tool_call))
1705
+ end
1706
+
1707
+ def unanswered?(state)
1708
+ Array(state&.[]('requests')).any? { |request| request['response'].nil? }
1709
+ end
1710
+
1711
+ 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:)
1714
+ end
1715
+
1716
+ def cancel_tasks
1717
+ response = pending_tool_response
1718
+ return unless response
1719
+
1720
+ pending_tool_calls(response).each_value do |tool_call|
1721
+ state = tool_call_input(tool_call)
1722
+ next unless state&.key?('task')
1723
+
1724
+ cancel_task(task_for(tool_call, state))
1725
+ record_tool_call_input(tool_call, nil)
1726
+ end
1727
+ end
1728
+
1729
+ def cancel_task(task)
1730
+ task.cancel
1731
+ rescue Error, ConfigurationError, Faraday::Error => e
1732
+ RubyLLM.logger.debug { "RubyLLM could not cancel task #{task.id}: #{e.message}" }
1733
+ end
1734
+
1735
+ def tool_call_input(tool_call)
1736
+ @input_checker ? @input_checker.call(tool_call) : @tool_call_inputs[tool_call.id]
1737
+ end
1738
+
1739
+ def record_tool_call_input(tool_call, input)
1740
+ @tool_call_inputs[tool_call.id] = input
1741
+ @input_recorder&.call(tool_call, input)
1742
+ end
1743
+
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
1748
+
1749
+ pending = MCP::InputRequest.from_h(data)
1750
+ yield pending
1751
+ pending.to_h
1752
+ end
1753
+ record_tool_call_input(request.tool_call, input.merge('requests' => requests))
1754
+ self
1755
+ end
1756
+
1369
1757
  def apply_tool_choice(choice)
1370
1758
  if choice.nil?
1371
1759
  @tool_prefs[:choice] = nil
@@ -1427,11 +1815,29 @@ module RubyLLM
1427
1815
  messages.reverse.find { |message| message.role != :system }
1428
1816
  end
1429
1817
 
1818
+ def hidden_from_model?(tool)
1819
+ tool.is_a?(MCP::Tool) && !tool.visibility.include?(:model)
1820
+ end
1821
+
1430
1822
  def pending_tool_response
1431
- response = messages.reverse.find { |message| message.role != :system && !message.tool_result? }
1823
+ response = latest_response(messages)
1432
1824
  response if response&.tool_call? && pending_tool_calls(response).any?
1433
1825
  end
1434
1826
 
1827
+ # A blank response answers nothing, so it never hides the round before
1828
+ # it, as the placeholder of a process that died after a tool finished.
1829
+ def latest_response(list)
1830
+ list.reverse.find do |message|
1831
+ message.role != :system && !message.tool_result? && !blank_response?(message)
1832
+ end
1833
+ end
1834
+
1835
+ def blank_response?(message)
1836
+ message.role == :assistant && !message.tool_call? && message.content.to_s.strip.empty? &&
1837
+ message.attachments.empty? && message.thinking.nil? && message.server_tool_calls.empty? &&
1838
+ message.raw_content.nil? && message.raw_reasoning.nil?
1839
+ end
1840
+
1435
1841
  def pending_tool_calls(response)
1436
1842
  answered = messages.filter_map { |message| message.tool_call_id if message.tool_result? }
1437
1843
  response.tool_calls.except(*answered)