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