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.
- checksums.yaml +4 -4
- data/README.md +65 -8
- data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
- data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
- data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
- data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
- data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
- data/lib/ruby_llm/accounting/usage.rb +86 -49
- data/lib/ruby_llm/active_record/acts_as.rb +3 -0
- data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
- data/lib/ruby_llm/active_record/batch.rb +1 -1
- data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
- data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
- data/lib/ruby_llm/active_record/message_methods.rb +32 -7
- data/lib/ruby_llm/active_record/model.rb +1 -1
- data/lib/ruby_llm/active_record/provider_file.rb +64 -0
- data/lib/ruby_llm/active_record/record.rb +19 -0
- data/lib/ruby_llm/active_record/tool_call.rb +1 -1
- data/lib/ruby_llm/active_record/usage.rb +53 -3
- data/lib/ruby_llm/agent.rb +198 -33
- data/lib/ruby_llm/aliases.json +66 -8
- data/lib/ruby_llm/attachment.rb +54 -10
- data/lib/ruby_llm/batch.rb +26 -3
- data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
- data/lib/ruby_llm/chat/tool_search.rb +26 -0
- data/lib/ruby_llm/chat.rb +488 -82
- data/lib/ruby_llm/choice.rb +38 -0
- data/lib/ruby_llm/configuration.rb +36 -11
- data/lib/ruby_llm/context.rb +45 -32
- data/lib/ruby_llm/cost.rb +42 -1
- data/lib/ruby_llm/embedding.rb +9 -2
- data/lib/ruby_llm/error.rb +34 -2
- data/lib/ruby_llm/evaluation/assertions.rb +40 -0
- data/lib/ruby_llm/evaluation/case.rb +52 -0
- data/lib/ruby_llm/evaluation/dataset.rb +65 -0
- data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
- data/lib/ruby_llm/evaluation/evidence.rb +125 -0
- data/lib/ruby_llm/evaluation/minitest.rb +26 -0
- data/lib/ruby_llm/evaluation/report.rb +103 -0
- data/lib/ruby_llm/evaluation/result.rb +75 -0
- data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
- data/lib/ruby_llm/evaluation/rspec.rb +23 -0
- data/lib/ruby_llm/evaluation/runner.rb +93 -0
- data/lib/ruby_llm/evaluation/trial.rb +101 -0
- data/lib/ruby_llm/evaluation.rb +289 -0
- data/lib/ruby_llm/image.rb +12 -4
- data/lib/ruby_llm/judge/builder.rb +51 -0
- data/lib/ruby_llm/judge/data.rb +43 -0
- data/lib/ruby_llm/judge/question.rb +101 -0
- data/lib/ruby_llm/judge.rb +179 -0
- data/lib/ruby_llm/judgment.rb +125 -0
- data/lib/ruby_llm/mcp/apps.rb +25 -0
- data/lib/ruby_llm/mcp/client.rb +213 -0
- data/lib/ruby_llm/mcp/collection.rb +56 -0
- data/lib/ruby_llm/mcp/content.rb +44 -0
- data/lib/ruby_llm/mcp/error.rb +27 -0
- data/lib/ruby_llm/mcp/http.rb +379 -0
- data/lib/ruby_llm/mcp/input_request.rb +118 -0
- data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
- data/lib/ruby_llm/mcp/listener.rb +168 -0
- data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
- data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
- data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
- data/lib/ruby_llm/mcp/oauth.rb +653 -0
- data/lib/ruby_llm/mcp/param_headers.rb +83 -0
- data/lib/ruby_llm/mcp/prompt.rb +71 -0
- data/lib/ruby_llm/mcp/resource.rb +84 -0
- data/lib/ruby_llm/mcp/resource_template.rb +99 -0
- data/lib/ruby_llm/mcp/result.rb +76 -0
- data/lib/ruby_llm/mcp/session_expired.rb +10 -0
- data/lib/ruby_llm/mcp/stdio.rb +250 -0
- data/lib/ruby_llm/mcp/task.rb +151 -0
- data/lib/ruby_llm/mcp/tool.rb +130 -0
- data/lib/ruby_llm/mcp.rb +1101 -0
- data/lib/ruby_llm/message.rb +41 -6
- data/lib/ruby_llm/model.rb +2 -1
- data/lib/ruby_llm/models/aliases.rb +5 -2
- data/lib/ruby_llm/models/lookup.rb +80 -0
- data/lib/ruby_llm/models/registry.rb +2 -2
- data/lib/ruby_llm/models/schema.rb +1 -1
- data/lib/ruby_llm/models.json +23082 -10158
- data/lib/ruby_llm/models.rb +20 -69
- data/lib/ruby_llm/moderation.rb +7 -2
- data/lib/ruby_llm/ocr.rb +7 -2
- data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
- data/lib/ruby_llm/open_telemetry.rb +82 -0
- data/lib/ruby_llm/probability.rb +30 -0
- data/lib/ruby_llm/progress.rb +44 -0
- data/lib/ruby_llm/prompt/context.rb +55 -0
- data/lib/ruby_llm/prompt.rb +1 -1
- data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
- data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
- data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
- data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
- data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
- data/lib/ruby_llm/protocol/streaming.rb +38 -31
- data/lib/ruby_llm/protocol.rb +192 -40
- data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
- data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
- data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
- data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
- data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
- data/lib/ruby_llm/protocols/anthropic.rb +20 -1
- data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
- data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
- data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
- data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
- data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
- data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
- data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
- data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
- data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
- data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
- data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
- data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
- data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
- data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
- data/lib/ruby_llm/protocols/cohere.rb +1 -0
- data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
- data/lib/ruby_llm/protocols/converse/media.rb +16 -2
- data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
- data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
- data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
- data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
- data/lib/ruby_llm/protocols/converse.rb +3 -5
- data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
- data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
- data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
- data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
- data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
- data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
- data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
- data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
- data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
- data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
- data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
- data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
- data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
- data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
- data/lib/ruby_llm/protocols/gemini.rb +1 -0
- data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
- data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
- data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
- data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
- data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
- data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
- data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
- data/lib/ruby_llm/protocols/interactions.rb +1 -0
- data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
- data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
- data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
- data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
- data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
- data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
- data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
- data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
- data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
- data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
- data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
- data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
- data/lib/ruby_llm/protocols/openai/files.rb +0 -7
- data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
- data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
- data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
- data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
- data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
- data/lib/ruby_llm/protocols/responses/media.rb +11 -7
- data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
- data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
- data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
- data/lib/ruby_llm/protocols/responses.rb +1 -0
- data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
- data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
- data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
- data/lib/ruby_llm/protocols/system_one.rb +27 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
- data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
- data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
- data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
- data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
- data/lib/ruby_llm/provider.rb +87 -15
- data/lib/ruby_llm/providers/anthropic.rb +23 -0
- data/lib/ruby_llm/providers/azure/chat.rb +4 -0
- data/lib/ruby_llm/providers/azure/images.rb +2 -2
- data/lib/ruby_llm/providers/azure/media.rb +8 -3
- data/lib/ruby_llm/providers/azure/videos.rb +7 -2
- data/lib/ruby_llm/providers/azure.rb +24 -4
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
- data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
- data/lib/ruby_llm/providers/bedrock.rb +38 -6
- data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
- data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
- data/lib/ruby_llm/providers/deepseek.rb +4 -0
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
- data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
- data/lib/ruby_llm/providers/hetzner.rb +41 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
- data/lib/ruby_llm/providers/mistral/media.rb +8 -4
- data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
- data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
- data/lib/ruby_llm/providers/ollama/media.rb +6 -2
- data/lib/ruby_llm/providers/ollama/models.rb +2 -0
- data/lib/ruby_llm/providers/ollama.rb +11 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
- data/lib/ruby_llm/providers/openai.rb +14 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
- data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
- data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
- data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
- data/lib/ruby_llm/providers/openrouter.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
- data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
- data/lib/ruby_llm/providers/perplexity.rb +11 -0
- data/lib/ruby_llm/providers/typesafe.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
- data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
- data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/vertexai.rb +27 -28
- data/lib/ruby_llm/providers/xai/chat.rb +6 -0
- data/lib/ruby_llm/providers/xai/images.rb +9 -10
- data/lib/ruby_llm/providers/xai/responses.rb +10 -8
- data/lib/ruby_llm/providers/xai/videos.rb +11 -9
- data/lib/ruby_llm/providers/xai.rb +4 -0
- data/lib/ruby_llm/railtie.rb +11 -0
- data/lib/ruby_llm/request_shape/part.rb +117 -0
- data/lib/ruby_llm/request_shape/problem.rb +63 -0
- data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
- data/lib/ruby_llm/request_shape/turn.rb +54 -0
- data/lib/ruby_llm/request_shape.rb +160 -0
- data/lib/ruby_llm/rerank.rb +9 -2
- data/lib/ruby_llm/research_job.rb +21 -5
- data/lib/ruby_llm/score.rb +42 -0
- data/lib/ruby_llm/search_results.rb +2 -2
- data/lib/ruby_llm/server_tool_call.rb +10 -2
- data/lib/ruby_llm/speech.rb +8 -1
- data/lib/ruby_llm/support/cancellation.rb +39 -0
- data/lib/ruby_llm/support/inspectable.rb +3 -2
- data/lib/ruby_llm/support/instrumentation.rb +38 -1
- data/lib/ruby_llm/support/process_cache.rb +60 -0
- data/lib/ruby_llm/support/progress_reporter.rb +33 -0
- data/lib/ruby_llm/thinking.rb +2 -1
- data/lib/ruby_llm/tokens.rb +57 -14
- data/lib/ruby_llm/tool.rb +50 -3
- data/lib/ruby_llm/tool_call.rb +17 -2
- data/lib/ruby_llm/transcription.rb +10 -3
- data/lib/ruby_llm/transport/connection.rb +127 -44
- data/lib/ruby_llm/transport/error_middleware.rb +90 -24
- data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
- data/lib/ruby_llm/transport/json_response.rb +33 -0
- data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm/video.rb +5 -3
- data/lib/ruby_llm/video_job.rb +48 -5
- data/lib/ruby_llm.rb +106 -5
- data/lib/tasks/ruby_llm/evaluations.rb +43 -0
- data/lib/tasks/ruby_llm.rake +6 -9
- data/skills/rubyllm/SKILL.md +33 -12
- metadata +95 -37
- data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
- data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
- data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
- data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
- data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
- data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
- data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
- 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
|
-
|
|
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
|
|
43
|
-
|
|
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 #
|
|
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
|
-
|
|
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.
|
|
202
|
-
# next batch round.
|
|
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
|
|
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 #
|
|
227
|
-
#
|
|
228
|
-
#
|
|
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,
|
|
232
|
-
#
|
|
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
|
-
|
|
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+,
|
|
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
|
-
|
|
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
|
-
#
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
|
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
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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)
|