ruby_llm 2.1.1 → 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 +160 -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 +515 -274
- 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 +1593 -331
- 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 +50 -24
- 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 +127 -106
- 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 +15 -15
- 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 +1 -7
- 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 +5 -14
- 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 +213 -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
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Claims a persisted chat for the process that runs its conversation
|
|
6
|
+
# loop, and each tool call for the process that answers it. Claims are
|
|
7
|
+
# conditional updates, so no transaction stays open while a model or a
|
|
8
|
+
# tool runs.
|
|
9
|
+
#
|
|
10
|
+
# A claim is a lease of CLAIM_LEASE seconds that a heartbeat thread
|
|
11
|
+
# renews every CLAIM_RENEWAL seconds, or every quarter of a shorter
|
|
12
|
+
# Configuration#max_run_time, so the work of a process that dies
|
|
13
|
+
# is free again within a lease. Renewal stops at the claim's deadline:
|
|
14
|
+
# Configuration#max_run_time after the run last made progress, or for
|
|
15
|
+
# a tool call, its run time after it starts, so a run that hangs past
|
|
16
|
+
# it counts as crashed. A call waiting for the calls before it in its
|
|
17
|
+
# round keeps its claim as long as the chat's claim lasts. A run inside
|
|
18
|
+
# a transaction can't renew from another connection, so its claims
|
|
19
|
+
# last until their deadline instead.
|
|
20
|
+
module RunClaims # :nodoc:
|
|
21
|
+
class ClaimLost < StandardError; end # :nodoc:
|
|
22
|
+
private_constant :ClaimLost
|
|
23
|
+
|
|
24
|
+
# A tool call this process claimed: its row, when its claim ends, the
|
|
25
|
+
# expiries written to the row, the seconds it may run, whether it
|
|
26
|
+
# started, and whether it was interrupted: claimed from a process that
|
|
27
|
+
# had started it and stopped before it saved the result.
|
|
28
|
+
ToolCallClaim = Struct.new(:row, :deadline, :written, :seconds, :started, :interrupted)
|
|
29
|
+
private_constant :ToolCallClaim
|
|
30
|
+
|
|
31
|
+
CLAIM_LEASE = 60
|
|
32
|
+
CLAIM_RENEWAL = 15
|
|
33
|
+
|
|
34
|
+
RUN_STATE = %i[
|
|
35
|
+
@chat @message @last_message_id @last_cancellation_poll_at @unpersisted_instructions
|
|
36
|
+
@usage_records_by_entry @run_claim_token @run_claim_deadline @run_claim_renewed_at
|
|
37
|
+
@run_claim_lost_token @synced_run_claim_token @synced_usage_count @tool_call_claims
|
|
38
|
+
@claim_heartbeat @claim_lock @tool_call_renewal_lock @lost_tool_calls
|
|
39
|
+
].freeze
|
|
40
|
+
private_constant :RUN_STATE
|
|
41
|
+
|
|
42
|
+
EXIT_LOCK = Mutex.new
|
|
43
|
+
private_constant :EXIT_LOCK
|
|
44
|
+
|
|
45
|
+
# Tells the runs of this process that it will exit by +time+ without
|
|
46
|
+
# unwinding, as a Solid Queue worker does once its shutdown timeout
|
|
47
|
+
# passes, so no run can expire its claims as it stops. Claims end by
|
|
48
|
+
# then from now on, and live heartbeats cut theirs back to it at once,
|
|
49
|
+
# so a chat still running at the exit counts as interrupted as the
|
|
50
|
+
# process ends rather than a lease later. A run that finishes first
|
|
51
|
+
# releases its claims as usual. The earliest time wins, to the whole
|
|
52
|
+
# second, which a column that rounds fractions up still holds.
|
|
53
|
+
def self.process_exits_by(time)
|
|
54
|
+
time = time.change(usec: 0)
|
|
55
|
+
EXIT_LOCK.synchronize { @exit_deadline = [@exit_deadline, time].compact.min }
|
|
56
|
+
ClaimHeartbeat.hurry_all
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.exit_deadline # :nodoc:
|
|
60
|
+
@exit_deadline
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The chats whose run died holding its claim. Every run that ends
|
|
64
|
+
# releases its claim, so a token whose lease expired is left only by
|
|
65
|
+
# a process that died or shut down mid-run. A chat someone stopped
|
|
66
|
+
# stays stopped. It skips the query cache, since a job polls it for
|
|
67
|
+
# what other processes wrote.
|
|
68
|
+
def self.interrupted(relation)
|
|
69
|
+
chat = relation.klass
|
|
70
|
+
return relation.none unless (%w[claim_token claimed_until] - chat.column_names).empty?
|
|
71
|
+
|
|
72
|
+
died = relation.where.not(claim_token: nil).where(claimed_until: ..Time.current)
|
|
73
|
+
died = died.where(cancelled: false) if chat.column_names.include?('cancelled')
|
|
74
|
+
died.skip_query_cache!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_claim_lost?(error) # :nodoc:
|
|
78
|
+
error.is_a?(ClaimLost)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def claim_persisted_tool_calls(leases, executing:) # :nodoc:
|
|
82
|
+
return leases.keys.map(&:id) unless tool_call_claims?
|
|
83
|
+
|
|
84
|
+
start_claim_heartbeat
|
|
85
|
+
claimed = claim_tool_call_rows(leases, executing)
|
|
86
|
+
resync_messages if claimed.size < leases.size
|
|
87
|
+
claimed
|
|
88
|
+
rescue StandardError
|
|
89
|
+
claim_lock.synchronize { leases.each_key { |tool_call| tool_call_claims.delete(tool_call.id) } }
|
|
90
|
+
stop_idle_claim_heartbeat
|
|
91
|
+
raise
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A call that was running when its process shut down may have done
|
|
95
|
+
# its work, such as a message it already sent, so it keeps its claim,
|
|
96
|
+
# expired at once like the chat's: the expired claim is the only
|
|
97
|
+
# record that it started. So does an interrupted call this run didn't
|
|
98
|
+
# get to answer, which stays interrupted for the next one. Every other
|
|
99
|
+
# call is released. A round that
|
|
100
|
+
# stopped before it +finished+ writes its claims without yielding to a
|
|
101
|
+
# fiber scheduler, as #unyielding explains, while one that finished
|
|
102
|
+
# releases the calls that paused as usual.
|
|
103
|
+
def release_persisted_tool_calls(tool_call_ids, stopped_by = nil, finished: false) # :nodoc:
|
|
104
|
+
expired = Time.current.change(usec: 0)
|
|
105
|
+
shutting_down = shutting_down?(stopped_by)
|
|
106
|
+
tool_call_ids.each do |tool_call_id|
|
|
107
|
+
claim = claim_lock.synchronize { tool_call_claims.delete(tool_call_id) }
|
|
108
|
+
next unless claim
|
|
109
|
+
|
|
110
|
+
kept = claim.interrupted || (claim.started && shutting_down)
|
|
111
|
+
unyielding(!finished) do
|
|
112
|
+
RubyLLM::ActiveRecord::ToolCall.where(id: claim.row, result_id: nil, claimed_until: claim.written)
|
|
113
|
+
.update_all(claimed_until: (expired if kept))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
ensure
|
|
117
|
+
stop_idle_claim_heartbeat
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Counts a claimed tool call's run time from now, when it starts, not
|
|
121
|
+
# from when its round was claimed, so waiting for the calls before it
|
|
122
|
+
# costs it nothing, and stretches the chat's claim to cover it. Until
|
|
123
|
+
# then the call keeps its claim as long as the chat does. Both claims
|
|
124
|
+
# are written before the call runs, so neither can lapse before the
|
|
125
|
+
# heartbeat's next renewal, and the call doesn't run once another
|
|
126
|
+
# process took either over.
|
|
127
|
+
def persisted_tool_call_started(tool_call_id) # :nodoc:
|
|
128
|
+
now = Time.current.change(usec: 0)
|
|
129
|
+
claim = claim_lock.synchronize do
|
|
130
|
+
started = tool_call_claims[tool_call_id]
|
|
131
|
+
raise ClaimLost if started.nil? && lost_tool_calls.include?(tool_call_id)
|
|
132
|
+
next unless started
|
|
133
|
+
|
|
134
|
+
started.started = true
|
|
135
|
+
started.deadline = now + started.seconds
|
|
136
|
+
@run_claim_deadline = [@run_claim_deadline, started.deadline].max if @run_claim_token
|
|
137
|
+
started
|
|
138
|
+
end
|
|
139
|
+
return unless claim
|
|
140
|
+
|
|
141
|
+
write_run_claim(now) if @run_claim_token
|
|
142
|
+
raise ClaimLost unless started_tool_call_claimed?(tool_call_id, claim, now)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Whether this run claimed +tool_call_id+ from a process that had
|
|
146
|
+
# started it and stopped, by a shutdown or a crash, before it saved
|
|
147
|
+
# the result. Its claim is left expired, or lapses, while a call that
|
|
148
|
+
# never started is released.
|
|
149
|
+
def persisted_tool_call_interrupted?(tool_call_id) # :nodoc:
|
|
150
|
+
claim_lock.synchronize { tool_call_claims[tool_call_id]&.interrupted }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def tool_call_running_elsewhere?(tool_call) # :nodoc:
|
|
154
|
+
RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }&.running? || false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def claim_tool_call_rows(leases, executing)
|
|
160
|
+
rows = RubyLLM::ActiveRecord::ToolCall.uncached { tool_call_rows(leases.keys.map(&:id)) }
|
|
161
|
+
RubyLLM::ActiveRecord::ToolCall.transaction do
|
|
162
|
+
ids = leases.filter_map do |tool_call, seconds|
|
|
163
|
+
row = rows[tool_call.id]
|
|
164
|
+
tool_call.id if row.nil? || tool_call_claimed?(tool_call.id, row, seconds, executing.include?(tool_call.id))
|
|
165
|
+
end
|
|
166
|
+
extend_run_claim_for_tool_calls
|
|
167
|
+
ids
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def initialize_dup(*)
|
|
172
|
+
super
|
|
173
|
+
RUN_STATE.each { |name| instance_variable_set(name, nil) }
|
|
174
|
+
%i[claim_token claimed_until].each { |name| self[name] = nil if has_attribute?(name) }
|
|
175
|
+
self[:cancelled] = false if has_attribute?(:cancelled)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def exclusively(busy, &)
|
|
179
|
+
return yield if @run_claim_token
|
|
180
|
+
|
|
181
|
+
holding_run_claim(busy, &)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A run stopped because its process is shutting down didn't end, so
|
|
185
|
+
# it keeps its claim but expires it, and the chat counts as
|
|
186
|
+
# interrupted at once.
|
|
187
|
+
def holding_run_claim(busy)
|
|
188
|
+
if run_claims?
|
|
189
|
+
start_claim_heartbeat
|
|
190
|
+
return busy.call unless run_claim_acquired?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
settle_cancellation_request
|
|
194
|
+
result = yield
|
|
195
|
+
completed = true
|
|
196
|
+
result
|
|
197
|
+
rescue ClaimLost
|
|
198
|
+
@message.destroy if blank_placeholder?
|
|
199
|
+
resync_messages if @chat
|
|
200
|
+
busy.call
|
|
201
|
+
rescue SignalException, SystemExit => e
|
|
202
|
+
stopped_by = e
|
|
203
|
+
raise
|
|
204
|
+
ensure
|
|
205
|
+
unyielding(!completed) do
|
|
206
|
+
stop_claim_heartbeat
|
|
207
|
+
ensure
|
|
208
|
+
shutting_down?(stopped_by) ? expire_run_claim : release_run_claim(completed)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Under a fiber scheduler, as in Async or Solid Queue's fiber workers,
|
|
213
|
+
# a fiber whose thread is exiting or whose reactor is stopping is
|
|
214
|
+
# cancelled, or never resumed, once it waits on a socket, a file or a
|
|
215
|
+
# thread. So a run that +stopped+ before it finished ends its claims
|
|
216
|
+
# without yielding to the scheduler, and one that finished yields as
|
|
217
|
+
# usual.
|
|
218
|
+
def unyielding(stopped, &)
|
|
219
|
+
stopped ? Fiber.blocking(&) : yield
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Whether the process is shutting down: a thread killed at exit, or
|
|
223
|
+
# +stopped_by+ a signal such as TERM or an exit. Interrupt doesn't
|
|
224
|
+
# count: Ctrl-C in a console stops the run, and a job backend that
|
|
225
|
+
# interrupts a job on shutdown, as Sidekiq does, runs it again.
|
|
226
|
+
def shutting_down?(stopped_by)
|
|
227
|
+
return true if Thread.current.status == 'aborting'
|
|
228
|
+
|
|
229
|
+
(stopped_by.is_a?(SignalException) || stopped_by.is_a?(SystemExit)) && !stopped_by.is_a?(Interrupt)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def run_claim_acquired?
|
|
233
|
+
now = Time.current
|
|
234
|
+
deadline = now + run_claim_duration
|
|
235
|
+
claim = { claim_token: SecureRandom.uuid, claimed_until: leased_until(deadline, now) }
|
|
236
|
+
available = unclaimed(chat_row, now)
|
|
237
|
+
unless available.where(claim_token: @synced_run_claim_token).update_all(claim) == 1
|
|
238
|
+
return false unless available.update_all(claim) == 1
|
|
239
|
+
|
|
240
|
+
stale = true
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
@run_claim_token = claim[:claim_token]
|
|
244
|
+
@run_claim_deadline = deadline
|
|
245
|
+
@run_claim_renewed_at = now
|
|
246
|
+
resync_messages if @chat && (stale || run_finished_elsewhere?)
|
|
247
|
+
true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# A released claim leaves no token behind, so what another process's
|
|
251
|
+
# run wrote is the only sign of it. A failed attempt adds no message,
|
|
252
|
+
# but each model call adds a usage row. Continuing an unfinished reply
|
|
253
|
+
# keeps its id and may lose its usage row, so such a reply is always
|
|
254
|
+
# read again: repeating it would bill the model twice.
|
|
255
|
+
def run_finished_elsewhere?
|
|
256
|
+
@chat.messages.last&.continuation || latest_message_id != @last_message_id ||
|
|
257
|
+
usage_row_count != @synced_usage_count
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def usage_row_count
|
|
261
|
+
ruby_llm_usages.klass.uncached { ruby_llm_usages.count }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Whole seconds, so a column that rounds fractions up still holds a
|
|
265
|
+
# time that has passed.
|
|
266
|
+
def expire_run_claim
|
|
267
|
+
token = @run_claim_token
|
|
268
|
+
@synced_usage_count = @synced_run_claim_token = @run_claim_token = nil
|
|
269
|
+
chat_row.where(claim_token: token).update_all(claimed_until: Time.current.change(usec: 0)) if token
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def release_run_claim(completed)
|
|
273
|
+
token = @run_claim_token
|
|
274
|
+
return unless token
|
|
275
|
+
|
|
276
|
+
@synced_usage_count = @synced_run_claim_token = @run_claim_token = nil
|
|
277
|
+
@synced_usage_count = usage_row_count if completed
|
|
278
|
+
ensure
|
|
279
|
+
free_chat_row(token) if token
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def free_chat_row(token)
|
|
283
|
+
cancellable = has_attribute?(:cancelled)
|
|
284
|
+
released = { claim_token: nil, claimed_until: nil }
|
|
285
|
+
released[:cancelled] = false if cancellable
|
|
286
|
+
self[:cancelled] = false if chat_row.where(claim_token: token).update_all(released) == 1 && cancellable
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def renew_run_claim(force: false)
|
|
290
|
+
return unless @run_claim_token
|
|
291
|
+
raise ClaimLost if @run_claim_lost_token == @run_claim_token
|
|
292
|
+
|
|
293
|
+
now = Time.current
|
|
294
|
+
claim_lock.synchronize { @run_claim_deadline = [@run_claim_deadline, now + run_claim_duration].max }
|
|
295
|
+
write_run_claim(now) if force || now >= @run_claim_renewed_at + claim_renewal_interval
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def write_run_claim(now = Time.current)
|
|
299
|
+
claimed_until = leased_until(@run_claim_deadline, now)
|
|
300
|
+
raise ClaimLost unless chat_claim_extended?(@run_claim_token, claimed_until)
|
|
301
|
+
|
|
302
|
+
@run_claim_renewed_at = now
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Writes the chat's claim, moving its stored expiry forward and never
|
|
306
|
+
# back: the heartbeat and a starting tool call write it from separate
|
|
307
|
+
# threads, and the older of two writes can land last. The one way back
|
|
308
|
+
# is to the process's exit, past which no claim lasts. Returns whether
|
|
309
|
+
# the claim is still this run's.
|
|
310
|
+
def chat_claim_extended?(token, claimed_until)
|
|
311
|
+
exit_deadline = RunClaims.exit_deadline
|
|
312
|
+
cut = ' WHEN claimed_until > ? THEN ?' if exit_deadline
|
|
313
|
+
chat_row.where(claim_token: token).update_all(
|
|
314
|
+
["claimed_until = CASE WHEN claimed_until IS NULL OR claimed_until < ? THEN ?#{cut} ELSE claimed_until END",
|
|
315
|
+
claimed_until, claimed_until, *([exit_deadline] * 2 if exit_deadline)]
|
|
316
|
+
) == 1
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Runs on the heartbeat's thread. A chat claim it finds taken is lost,
|
|
320
|
+
# but only the run's own thread raises, at its next renewal.
|
|
321
|
+
def renew_claim_leases
|
|
322
|
+
now = Time.current
|
|
323
|
+
renew_run_claim_lease(now)
|
|
324
|
+
renew_tool_call_leases(now)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def renew_run_claim_lease(now)
|
|
328
|
+
token = @run_claim_token
|
|
329
|
+
deadline = @run_claim_deadline
|
|
330
|
+
return unless token && deadline > now
|
|
331
|
+
|
|
332
|
+
claimed_until = by_exit([now + CLAIM_LEASE, deadline].min)
|
|
333
|
+
if chat_claim_extended?(token, claimed_until)
|
|
334
|
+
@run_claim_renewed_at = now
|
|
335
|
+
else
|
|
336
|
+
@run_claim_lost_token = token
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def renew_tool_call_leases(now)
|
|
341
|
+
tool_call_renewal_lock.synchronize do
|
|
342
|
+
renewals = claim_lock.synchronize do
|
|
343
|
+
tool_call_claims.filter_map do |tool_call_id, claim|
|
|
344
|
+
deadline = tool_call_deadline(claim)
|
|
345
|
+
next unless deadline
|
|
346
|
+
|
|
347
|
+
claimed_until = by_exit([now.change(usec: 0) + CLAIM_LEASE, deadline].min)
|
|
348
|
+
next if deadline <= now || claim.written.include?(claimed_until)
|
|
349
|
+
|
|
350
|
+
[tool_call_id, claim.row, claim.written.dup, claim.written.push(claimed_until).last]
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
renewals.each { |renewal| renew_tool_call_lease(*renewal) }
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Writes a starting call's claim to its row, unless another process
|
|
358
|
+
# took the row over, and returns whether this process still holds it.
|
|
359
|
+
def started_tool_call_claimed?(tool_call_id, claim, now)
|
|
360
|
+
tool_call_renewal_lock.synchronize do
|
|
361
|
+
claimed_until = leased_until(claim.deadline, now)
|
|
362
|
+
written = claim_lock.synchronize { claim.written.dup.tap { claim.written.push(claimed_until) } }
|
|
363
|
+
renew_tool_call_lease(tool_call_id, claim.row, written, claimed_until)
|
|
364
|
+
claim_lock.synchronize { tool_call_claims[tool_call_id].equal?(claim) }
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# A call that hasn't started yet, waiting for the calls before it in
|
|
369
|
+
# its round, keeps its claim as long as the chat's claim lasts. Once
|
|
370
|
+
# another process takes the chat over, this run won't start it, so
|
|
371
|
+
# there is no deadline to renew to.
|
|
372
|
+
def tool_call_deadline(claim)
|
|
373
|
+
return claim.deadline if claim.started || !@run_claim_token
|
|
374
|
+
return if @run_claim_lost_token == @run_claim_token
|
|
375
|
+
|
|
376
|
+
[claim.deadline, @run_claim_deadline].max
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def renew_tool_call_lease(tool_call_id, row, written, claimed_until)
|
|
380
|
+
renewed = RubyLLM::ActiveRecord::ToolCall.where(id: row, result_id: nil, claimed_until: written)
|
|
381
|
+
.update_all(claimed_until:) == 1
|
|
382
|
+
claim_lock.synchronize do
|
|
383
|
+
claim = tool_call_claims[tool_call_id]
|
|
384
|
+
next unless claim&.row == row
|
|
385
|
+
next claim.written = [claimed_until] if renewed
|
|
386
|
+
|
|
387
|
+
tool_call_claims.delete(tool_call_id)
|
|
388
|
+
lost_tool_calls << tool_call_id
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def start_claim_heartbeat
|
|
393
|
+
return if @claim_heartbeat || claims_in_transaction?
|
|
394
|
+
|
|
395
|
+
claim_lock
|
|
396
|
+
@claim_heartbeat = ClaimHeartbeat.new([self.class, RubyLLM::ActiveRecord::ToolCall], claim_renewal_interval) do
|
|
397
|
+
renew_claim_leases
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def stop_idle_claim_heartbeat
|
|
402
|
+
stop_claim_heartbeat unless @run_claim_token || claim_lock.synchronize { tool_call_claims.any? }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def stop_claim_heartbeat
|
|
406
|
+
heartbeat = @claim_heartbeat
|
|
407
|
+
@claim_heartbeat = nil
|
|
408
|
+
heartbeat&.stop
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def claims_in_transaction?
|
|
412
|
+
self.class.connection_pool.with_connection(&:transaction_open?)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def leased_until(deadline, now)
|
|
416
|
+
by_exit(@claim_heartbeat ? [now + CLAIM_LEASE, deadline].min : deadline)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def by_exit(claimed_until)
|
|
420
|
+
exit_deadline = RunClaims.exit_deadline
|
|
421
|
+
exit_deadline && exit_deadline < claimed_until ? exit_deadline : claimed_until
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# A max_run_time shorter than the lease is the claim's whole life, so
|
|
425
|
+
# a renewal has to land within it.
|
|
426
|
+
def claim_renewal_interval
|
|
427
|
+
[CLAIM_RENEWAL, [CLAIM_LEASE, run_claim_duration].min / 4.0].min
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def running_elsewhere?
|
|
431
|
+
return false unless run_claims?
|
|
432
|
+
|
|
433
|
+
token, claimed_until = self.class.uncached { chat_row.pick(:claim_token, :claimed_until) }
|
|
434
|
+
(claimed_until&.future? && token != @run_claim_token) || false
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def work_arrived_during_run?
|
|
438
|
+
return false if @run_claim_token || !run_claims?
|
|
439
|
+
|
|
440
|
+
resync_messages if latest_message_id != @last_message_id
|
|
441
|
+
!to_llm.complete? && !to_llm.waiting?
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def latest_message_id
|
|
445
|
+
messages_association.klass.uncached do
|
|
446
|
+
messages_association.reorder(created_at: :desc, id: :desc).pick(:id)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def resync_messages
|
|
451
|
+
messages_association.reset
|
|
452
|
+
reload_usages
|
|
453
|
+
messages_association.klass.uncached { sync_messages }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def reload_usages
|
|
457
|
+
ruby_llm_usages.reset
|
|
458
|
+
ruby_llm_usages.klass.uncached { ruby_llm_usages.load }
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def snapshot_run_claim
|
|
462
|
+
return unless run_claims?
|
|
463
|
+
|
|
464
|
+
# Counting first means a run that starts after the token is read
|
|
465
|
+
# still changes the count this process compares against.
|
|
466
|
+
@synced_usage_count = usage_row_count
|
|
467
|
+
@synced_run_claim_token = self.class.uncached { chat_row.pick(:claim_token) }
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Tool call rows carry no token, so this process recognizes its claim
|
|
471
|
+
# on a row by the whole-second expiries it wrote there.
|
|
472
|
+
def tool_call_claimed?(tool_call_id, row, seconds, executing)
|
|
473
|
+
now = Time.current.change(usec: 0)
|
|
474
|
+
seconds = seconds.to_f.ceil
|
|
475
|
+
deadline = now + seconds
|
|
476
|
+
claimed_until = leased_until(deadline, now)
|
|
477
|
+
scope = unclaimed(RubyLLM::ActiveRecord::ToolCall.where(id: row, result_id: nil), now)
|
|
478
|
+
if executing && row_supports_approval?
|
|
479
|
+
approval = RubyLLM::ActiveRecord::ToolCall.arel_table[:approval]
|
|
480
|
+
scope = scope.where(approval.eq(nil).or(approval.not_eq('denied')))
|
|
481
|
+
end
|
|
482
|
+
claimed, expired = claim_tool_call_row(scope, { claimed_until:, updated_at: now })
|
|
483
|
+
if claimed
|
|
484
|
+
claim = ToolCallClaim.new(row, deadline, [claimed_until], seconds, false, expired)
|
|
485
|
+
claim_lock.synchronize do
|
|
486
|
+
lost_tool_calls.delete(tool_call_id)
|
|
487
|
+
tool_call_claims[tool_call_id] = claim
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
claimed
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# A released call has no expiry, while a call that started keeps its
|
|
494
|
+
# expired claim when its process shuts down, or lets it lapse when the
|
|
495
|
+
# process dies. Returns whether the claim was written, and whether it
|
|
496
|
+
# took over an expired one.
|
|
497
|
+
def claim_tool_call_row(scope, written)
|
|
498
|
+
return [true, false] if scope.where(claimed_until: nil).update_all(written) == 1
|
|
499
|
+
|
|
500
|
+
[scope.where.not(claimed_until: nil).update_all(written) == 1, true]
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def extend_run_claim_for_tool_calls
|
|
504
|
+
extended = claim_lock.synchronize do
|
|
505
|
+
latest = tool_call_claims.values.map(&:deadline).max
|
|
506
|
+
@run_claim_deadline = [@run_claim_deadline, latest].max if @run_claim_token && latest
|
|
507
|
+
end
|
|
508
|
+
write_run_claim if extended
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def tool_result_recorded?(tool_call, message)
|
|
512
|
+
attributes = { result_type: @message.class.polymorphic_name, result_id: @message.id, updated_at: Time.current }
|
|
513
|
+
attributes[:mcp_result] = message.mcp_result&.dump if tool_call.has_attribute?(:mcp_result)
|
|
514
|
+
attributes[:claimed_until] = nil if tool_call.has_attribute?(:claimed_until)
|
|
515
|
+
claim_lock.synchronize { tool_call_claims.delete(tool_call.tool_call_id) }
|
|
516
|
+
RubyLLM::ActiveRecord::ToolCall.where(id: tool_call.id, result_id: nil).update_all(attributes) == 1
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def decide_persisted_tool_call(record, decision)
|
|
520
|
+
return if record.approval == decision || decision_recorded?(record, decision)
|
|
521
|
+
return unless decision == 'denied' && RubyLLM::ActiveRecord::ToolCall.uncached { record.reload }.running?
|
|
522
|
+
|
|
523
|
+
raise ToolCallRunningError, "The #{record.name} tool call is already running"
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def decision_recorded?(record, decision)
|
|
527
|
+
now = Time.current
|
|
528
|
+
scope = RubyLLM::ActiveRecord::ToolCall.where(id: record.id, result_id: nil)
|
|
529
|
+
scope = unclaimed(scope, now) if record.has_attribute?(:claimed_until)
|
|
530
|
+
transaction do
|
|
531
|
+
forget_stale_cancellation
|
|
532
|
+
scope.update_all(approval: decision, updated_at: now) == 1 || raise(::ActiveRecord::Rollback)
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def discard_superseded_message
|
|
537
|
+
@message.destroy
|
|
538
|
+
raise ClaimLost
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Renewing inside the transaction holds the chat row until the result
|
|
542
|
+
# commits, so no other process can take the chat over in between.
|
|
543
|
+
def fenced_by_run_claim(&)
|
|
544
|
+
transaction do
|
|
545
|
+
renew_run_claim(force: true)
|
|
546
|
+
yield
|
|
547
|
+
end
|
|
548
|
+
rescue ClaimLost
|
|
549
|
+
@message.destroy
|
|
550
|
+
raise
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def tool_call_rows(tool_call_ids)
|
|
554
|
+
RubyLLM::ActiveRecord::ToolCall.where(
|
|
555
|
+
tool_call_id: tool_call_ids,
|
|
556
|
+
message_type: messages_association.klass.polymorphic_name,
|
|
557
|
+
message_id: messages_association.select(:id)
|
|
558
|
+
).pluck(:tool_call_id, :id).to_h
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def unclaimed(scope, now)
|
|
562
|
+
claimed_until = scope.arel_table[:claimed_until]
|
|
563
|
+
scope.where(claimed_until.eq(nil).or(claimed_until.lteq(now)))
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def chat_row
|
|
567
|
+
self.class.unscoped.where(self.class.primary_key => id)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def tool_call_claims
|
|
571
|
+
@tool_call_claims ||= {}
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Calls whose claim the heartbeat found taken, so their start fails
|
|
575
|
+
# rather than taking them for calls that were never claimed.
|
|
576
|
+
def lost_tool_calls
|
|
577
|
+
@lost_tool_calls ||= Set.new
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def claim_lock
|
|
581
|
+
@claim_lock ||= Mutex.new
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Keeps the heartbeat and a starting call from writing a tool call's
|
|
585
|
+
# row at once, when one write would find the other's expiry there and
|
|
586
|
+
# take the claim for lost.
|
|
587
|
+
def tool_call_renewal_lock
|
|
588
|
+
claim_lock.synchronize { @tool_call_renewal_lock ||= Mutex.new }
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def run_claims?
|
|
592
|
+
persisted? && has_attribute?(:claim_token) && has_attribute?(:claimed_until)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def tool_call_claims?
|
|
596
|
+
RubyLLM::ActiveRecord::ToolCall.column_names.include?('claimed_until')
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def row_supports_approval?
|
|
600
|
+
RubyLLM::ActiveRecord::ToolCall.column_names.include?('approval')
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def run_claim_duration
|
|
604
|
+
(context&.config || RubyLLM.config).max_run_time.to_f.ceil
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Solid Queue stops a worker on TERM and waits its shutdown timeout for
|
|
6
|
+
# the jobs to finish. Then it ends the process with exit!, which runs no
|
|
7
|
+
# ensure block, so a run still going can't expire its claims as it stops.
|
|
8
|
+
# Once a process that ends that way starts stopping, its claims end by
|
|
9
|
+
# the time it exits. A forked worker's process ends with the worker. In
|
|
10
|
+
# async mode the workers are threads of the supervisor's process, which
|
|
11
|
+
# ends when the supervisor stops, while a worker the supervisor replaces
|
|
12
|
+
# leaves the process running.
|
|
13
|
+
module SolidQueueShutdown # :nodoc:
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def install(solid_queue)
|
|
17
|
+
solid_queue.on_worker_stop { |worker| exiting(solid_queue) if forked?(worker) }
|
|
18
|
+
solid_queue.on_stop { |supervisor| exiting(solid_queue) if supervisor.try(:mode).to_s == 'async' }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def forked?(worker)
|
|
22
|
+
supervisor = worker.supervisor
|
|
23
|
+
!supervisor.nil? && supervisor.pid != ::Process.pid
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exiting(solid_queue)
|
|
27
|
+
require 'ruby_llm/active_record/claim_heartbeat'
|
|
28
|
+
require 'ruby_llm/active_record/run_claims'
|
|
29
|
+
RunClaims.process_exits_by(Time.current + solid_queue.shutdown_timeout)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Compares the system messages a chat record persisted with the
|
|
6
|
+
# instructions about to be applied to it.
|
|
7
|
+
module SystemInstructions # :nodoc:
|
|
8
|
+
# Returns whether the persisted system messages are exactly
|
|
9
|
+
# +instructions+, pairs of content and +cache_until_here+ in order.
|
|
10
|
+
# An agent applied to a record on every request asks before it
|
|
11
|
+
# rewrites them.
|
|
12
|
+
def system_instructions_persisted?(instructions)
|
|
13
|
+
records = messages_association.where(role: :system).order(:id).to_a
|
|
14
|
+
records.size == instructions.size && records.zip(instructions).all? do |record, (content, cache_until_here)|
|
|
15
|
+
system_instruction_attributes(record, content, cache_until_here).all? do |column, value|
|
|
16
|
+
record[column] == value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def system_instruction_attributes(record, instructions, cache_until_here)
|
|
24
|
+
boundary = cache_boundary_attributes(cache_until_here)
|
|
25
|
+
boundary.delete(:cache_until_here) unless record.has_attribute?(:cache_until_here)
|
|
26
|
+
{ content: instructions, **boundary }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|