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,781 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
# CodeExecution is the +run_code+ tool Chat#with_code_execution gives the
|
|
8
|
+
# model. Each call runs Ruby, Python, JavaScript, or a shell script in a Sandbox, in a
|
|
9
|
+
# fresh run directory with read-only copies of the conversation's files,
|
|
10
|
+
# and returns the exit code, the output, and every file the code wrote
|
|
11
|
+
# there as an attachment on the tool result. In a chat with a file store
|
|
12
|
+
# (see Chat#with_file_store), runs work with the chat's files instead.
|
|
13
|
+
# With FileStore::Directory, code runs in the store's folder and changes
|
|
14
|
+
# the files in place. With any other store, each run has read-only
|
|
15
|
+
# copies of the conversation's files as usual, copies in the files the
|
|
16
|
+
# model names in +files+, and what the code writes is saved back to the
|
|
17
|
+
# store.
|
|
18
|
+
#
|
|
19
|
+
# Calls run in the chat's default sandbox. With +escalate_to:+, the model
|
|
20
|
+
# may name a wider sandbox for one call and say why; that call waits for
|
|
21
|
+
# Chat#approve, and the next call is back in the default.
|
|
22
|
+
#
|
|
23
|
+
# chat = RubyLLM.chat.with_code_execution(escalate_to: [WorkspaceSandbox])
|
|
24
|
+
# chat.tools[:run_code].escalations # => ["workspace"]
|
|
25
|
+
class CodeExecution < Tool
|
|
26
|
+
# The languages code execution can run.
|
|
27
|
+
LANGUAGES = %i[ruby python javascript shell].freeze
|
|
28
|
+
|
|
29
|
+
# The options Chat#with_code_execution accepts.
|
|
30
|
+
OPTIONS = %i[sandbox escalate_to languages gems python npm gem_home virtualenv node_modules mounts].freeze
|
|
31
|
+
|
|
32
|
+
# Characters of standard output and of standard error kept per run.
|
|
33
|
+
MAX_OUTPUT = 20_000
|
|
34
|
+
|
|
35
|
+
# Files a run returns at most.
|
|
36
|
+
MAX_FILES = 10
|
|
37
|
+
|
|
38
|
+
# Bytes of a text file the model reads back. Larger files, and files
|
|
39
|
+
# that are not images, PDFs, or text, reach the model as a description.
|
|
40
|
+
MAX_MODEL_TEXT = 64 * 1024
|
|
41
|
+
|
|
42
|
+
# The environment variable holding a run's directory, for code that
|
|
43
|
+
# starts in a sandbox's own directory.
|
|
44
|
+
RUN_DIR = 'RUN_DIR'
|
|
45
|
+
|
|
46
|
+
ESCALATION_NAME = /\A[a-z][a-z0-9_]*\z/
|
|
47
|
+
PROGRESS_LINE = 200
|
|
48
|
+
MAX_CHECK_ERROR = 2000
|
|
49
|
+
USAGE_PROVIDER = 'sandbox' # :nodoc:
|
|
50
|
+
USAGE_COUNTER = 'code_execution_seconds' # :nodoc:
|
|
51
|
+
LIMIT_ERRORS = {
|
|
52
|
+
cpu_time: 'The code used up its CPU time and was stopped',
|
|
53
|
+
memory: 'The code used more memory than it may and was stopped',
|
|
54
|
+
processes: 'The code started more processes than it may and was stopped'
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
# A WeakMap holds its values weakly too. A language that runs is kept
|
|
58
|
+
# as true, which is never collected; the reason one cannot may be, and
|
|
59
|
+
# the language is checked again.
|
|
60
|
+
CHECKS = Hash.new { |checks, language| checks[language] = ObjectSpace::WeakMap.new }
|
|
61
|
+
CHECKS_LOCK = Mutex.new
|
|
62
|
+
FILES_PROPERTY = {
|
|
63
|
+
'type' => %w[array null], 'items' => { 'type' => 'string' },
|
|
64
|
+
'description' => "Paths of this chat's files (see list_files) to change, or to read when they are not in " \
|
|
65
|
+
'./attachments, copied into the run directory at the same paths; a folder copies everything ' \
|
|
66
|
+
'in it. null copies nothing'
|
|
67
|
+
}.freeze
|
|
68
|
+
private_constant :CHECKS, :CHECKS_LOCK, :ESCALATION_NAME, :PROGRESS_LINE, :MAX_CHECK_ERROR, :LIMIT_ERRORS,
|
|
69
|
+
:FILES_PROPERTY
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
def tool_name # :nodoc:
|
|
73
|
+
'run_code'
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the options +enabled+ and +options+ describe, +false+ when
|
|
77
|
+
# disabled. Raises ArgumentError for anything else.
|
|
78
|
+
def options(enabled, options) # :nodoc:
|
|
79
|
+
return options(true, enabled.transform_keys(&:to_sym).merge(options)) if enabled.is_a?(Hash)
|
|
80
|
+
unless [true, false].include?(enabled)
|
|
81
|
+
raise ArgumentError, 'code execution accepts false or code execution options'
|
|
82
|
+
end
|
|
83
|
+
raise ArgumentError, 'disabled code execution does not accept options' if !enabled && options.any?
|
|
84
|
+
return false unless enabled
|
|
85
|
+
|
|
86
|
+
validate_options(options)
|
|
87
|
+
options.merge(languages: languages_for(options)).freeze
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the sandboxes +escalate_to+ names, as a Hash of names to
|
|
91
|
+
# sandboxes. Raises ArgumentError for a name the model cannot use or
|
|
92
|
+
# two sandboxes with the same name.
|
|
93
|
+
def escalations(escalate_to) # :nodoc:
|
|
94
|
+
named_sandboxes(escalate_to).each_with_object({}) do |(name, sandbox), escalations|
|
|
95
|
+
raise ArgumentError, "#{name.inspect} is not a name like workspace" unless name.match?(ESCALATION_NAME)
|
|
96
|
+
raise ArgumentError, "Two sandboxes are named #{name}" if escalations.key?(name)
|
|
97
|
+
raise ArgumentError, "escalate_to: takes sandboxes, got nil for #{name}" if sandbox.nil?
|
|
98
|
+
|
|
99
|
+
escalations[name] = sandbox
|
|
100
|
+
end.freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Returns +messages+ as the model reads them: files a run returned
|
|
104
|
+
# stay attached when they are images, PDFs, or short text, and
|
|
105
|
+
# become a description otherwise.
|
|
106
|
+
def for_model(messages) # :nodoc:
|
|
107
|
+
ids = messages.flat_map { |message| run_code_call_ids(message) }
|
|
108
|
+
return messages if ids.empty?
|
|
109
|
+
|
|
110
|
+
names = Workspace.input_names(messages.flat_map(&:attachments))
|
|
111
|
+
messages.map do |message|
|
|
112
|
+
returned_files?(message, ids) ? message.with_attachments(model_view(message.attachments, names)) : message
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns a callable that describes one of +attachments+, the
|
|
117
|
+
# conversation's files, under the name its run finds.
|
|
118
|
+
def describer(attachments) # :nodoc:
|
|
119
|
+
names = Workspace.input_names(attachments)
|
|
120
|
+
->(attachment) { describe(attachment, name: names[attachment]) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns a text Attachment telling the model where its code finds
|
|
124
|
+
# +attachment+.
|
|
125
|
+
def describe(attachment, name: nil) # :nodoc:
|
|
126
|
+
name ||= Workspace.filename(attachment.filename)
|
|
127
|
+
text = "#{name} (#{attachment.mime_type}, #{attachment.byte_size} bytes) is not shown here. " \
|
|
128
|
+
"Code you run with #{tool_name} can read it at #{Workspace::ATTACHMENTS}/#{name}."
|
|
129
|
+
Attachment.new(StringIO.new(text), filename: "#{name}.txt")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Returns +true+ when +sandbox+ runs +language+, or why it cannot.
|
|
133
|
+
def check(key, sandbox, language, **) # :nodoc:
|
|
134
|
+
CHECKS_LOCK.synchronize do
|
|
135
|
+
checks = CHECKS[[language.name, (sandbox.backend if sandbox.respond_to?(:backend))]]
|
|
136
|
+
return checks[key] if checks.key?(key)
|
|
137
|
+
|
|
138
|
+
checks[key] = failure(sandbox, language, **) || true
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Returns +name+ as a path inside the run directory. Raises
|
|
143
|
+
# ArgumentError for a path outside it, or inside a folder code
|
|
144
|
+
# execution fills itself.
|
|
145
|
+
def mount_name(name) # :nodoc:
|
|
146
|
+
path = Workspace.mount_path(name)
|
|
147
|
+
raise ArgumentError, "#{name.inspect} is reserved for skills" if path.split('/').first == Skill::Loader::MOUNT
|
|
148
|
+
|
|
149
|
+
path
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def failure(sandbox, language, **)
|
|
155
|
+
reason = check_failure(sandbox.run(*language.check_command, timeout: 30, **))
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
reason = "#{e.class}: #{e.message}"
|
|
158
|
+
ensure
|
|
159
|
+
RubyLLM.logger.debug { "Code execution cannot run #{language.label}: #{reason}" } if reason
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def check_failure(result)
|
|
163
|
+
return 'the check timed out' if result.timed_out?
|
|
164
|
+
return if result.exit_code&.zero?
|
|
165
|
+
|
|
166
|
+
stderr = result.stderr.to_s.strip
|
|
167
|
+
stderr.empty? ? "the check exited with #{result.exit_code.inspect}" : stderr[0, MAX_CHECK_ERROR]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def named_sandboxes(escalate_to)
|
|
171
|
+
return escalate_to.map { |name, sandbox| [name.to_s, sandbox] } if escalate_to.is_a?(Hash)
|
|
172
|
+
|
|
173
|
+
Array(escalate_to).map { |sandbox| [Sandbox.name_for(sandbox), sandbox] }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_options(options)
|
|
177
|
+
unknown = options.keys - OPTIONS
|
|
178
|
+
raise ArgumentError, "code execution accepts #{OPTIONS.join(', ')}, got #{unknown.join(', ')}" if unknown.any?
|
|
179
|
+
raise ArgumentError, 'code execution options cannot be nil' if options.value?(nil)
|
|
180
|
+
|
|
181
|
+
validate_packages(options)
|
|
182
|
+
escalations(options[:escalate_to]) if options.key?(:escalate_to)
|
|
183
|
+
validate_mounts(options[:mounts]) if options.key?(:mounts)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_packages(options)
|
|
187
|
+
{ gems: :gem_home, python: :virtualenv, npm: :node_modules }.each do |declared, prebuilt|
|
|
188
|
+
next unless options.key?(declared) && options.key?(prebuilt)
|
|
189
|
+
|
|
190
|
+
raise ArgumentError, "pass #{declared}: or #{prebuilt}:, not both"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate_mounts(mounts)
|
|
195
|
+
return if mounts.is_a?(Proc)
|
|
196
|
+
raise ArgumentError, 'mounts: takes a Hash or a Proc that returns one' unless mounts.is_a?(Hash)
|
|
197
|
+
|
|
198
|
+
mounts.each_key { |name| mount_name(name) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def languages_for(options)
|
|
202
|
+
default = [:ruby, (:python if options[:python] || options[:virtualenv]),
|
|
203
|
+
(:javascript if options[:npm] || options[:node_modules]), :shell].compact
|
|
204
|
+
languages = Array(options.fetch(:languages, default)).map { |language| language.to_s.to_sym }.uniq
|
|
205
|
+
unsupported = languages - LANGUAGES
|
|
206
|
+
if unsupported.any?
|
|
207
|
+
raise ArgumentError, "code execution runs #{LANGUAGES.join(', ')}, got #{unsupported.join(', ')}"
|
|
208
|
+
end
|
|
209
|
+
raise ArgumentError, 'code execution needs at least one language' if languages.empty?
|
|
210
|
+
|
|
211
|
+
languages.freeze
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def returned_files?(message, ids)
|
|
215
|
+
message.tool_result? && ids.include?(message.tool_call_id) && message.attachments.any?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def model_view(files, names)
|
|
219
|
+
files.map { |file| readable_by_model?(file) ? file : describe(file, name: names[file]) }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def run_code_call_ids(message)
|
|
223
|
+
message.tool_calls.select { |call| call.name == tool_name }.map(&:id)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def readable_by_model?(attachment)
|
|
227
|
+
attachment.image? || attachment.pdf? || (attachment.text? && attachment.byte_size <= MAX_MODEL_TEXT)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Creates the tool with the options Chat#with_code_execution takes.
|
|
232
|
+
# +sandbox+ is the default sandbox, or a Proc returning it, and
|
|
233
|
+
# +escalate_to+ the sandboxes the model may ask for, an Array or a Hash
|
|
234
|
+
# of names to sandboxes. +attachments+ returns the conversation's files
|
|
235
|
+
# when called, and +skills+ a Hash of paths under +skills/+ to the
|
|
236
|
+
# skill folders mounted there. +file_store+ returns the chat's
|
|
237
|
+
# FileStore, or +nil+: with one, runs work with its files instead of
|
|
238
|
+
# the attachments, in its folder when it has one, and each file a run
|
|
239
|
+
# writes or changes is passed to +written+. +attached_files+ returns
|
|
240
|
+
# the StoredFiles of the conversation's files, kept in that store, which
|
|
241
|
+
# a run without a folder has read-only. +mounts+ is a Hash of paths inside the
|
|
242
|
+
# run directory to directories mounted there read-only, or a Proc called
|
|
243
|
+
# before each run that returns one. +approval+ is called with the
|
|
244
|
+
# ToolCall of each escalated run right before it runs, and the run goes
|
|
245
|
+
# ahead only when it returns +true+. +chat+ and +config+ describe where
|
|
246
|
+
# the instrumentation events go. Without +sandbox+, runs use
|
|
247
|
+
# Configuration#sandbox from +config+. A Proc may return a new sandbox
|
|
248
|
+
# instance each call: it stands for one sandbox, checked once.
|
|
249
|
+
# Raises ConfigurationError without a sandbox.
|
|
250
|
+
def initialize(sandbox:, escalate_to: [], attachments: -> { [] }, file_store: -> {},
|
|
251
|
+
attached_files: -> { [] }, written: ->(_file) {}, skills: -> { {} }, mounts: {},
|
|
252
|
+
approval: ->(_tool_call) { false }, chat: nil, config: nil, package_cache_dir: nil,
|
|
253
|
+
languages: %i[ruby shell], **packages) # :nodoc:
|
|
254
|
+
super()
|
|
255
|
+
@sandbox = sandbox
|
|
256
|
+
@config = config
|
|
257
|
+
self.sandbox
|
|
258
|
+
@escalate_to = self.class.escalations(escalate_to)
|
|
259
|
+
@attachments = attachments
|
|
260
|
+
@file_store = file_store
|
|
261
|
+
@attached_files = attached_files
|
|
262
|
+
@written = written
|
|
263
|
+
@skills = skills
|
|
264
|
+
@mounts = mounts
|
|
265
|
+
@approval = approval
|
|
266
|
+
@chat = chat
|
|
267
|
+
@requested_languages = languages
|
|
268
|
+
node = NodeInstallation.current if languages.include?(:javascript)
|
|
269
|
+
@packages = Packages.new(sandbox: -> { isolated(self.sandbox) }, cache_dir: package_cache_dir, node:, **packages)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Returns the default Sandbox calls run in. Raises ConfigurationError
|
|
273
|
+
# when there is none.
|
|
274
|
+
def sandbox
|
|
275
|
+
resolved(sandbox_value)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Returns the names of the sandboxes the model may ask for, leaving out
|
|
279
|
+
# those that cannot run on this machine.
|
|
280
|
+
#
|
|
281
|
+
# chat.tools[:run_code].escalations # => ["workspace", "online"]
|
|
282
|
+
def escalations
|
|
283
|
+
escalation_sandboxes.keys
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Returns what the sandbox named +name+ allows, in one sentence, for the
|
|
287
|
+
# person approving a call that asks for it, or +nil+ for an unknown
|
|
288
|
+
# name.
|
|
289
|
+
#
|
|
290
|
+
# chat.tools[:run_code].summary("workspace")
|
|
291
|
+
# # => "Starts in /srv/app. Can change /srv/app. No network access. Stops after 300 seconds."
|
|
292
|
+
def summary(name)
|
|
293
|
+
sandbox = escalation_sandboxes[name.to_s]
|
|
294
|
+
describe_sandbox(sandbox) if sandbox
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Returns the directories mounted read-only in each run directory, keyed
|
|
298
|
+
# by their path there. Raises ArgumentError for a path outside the run
|
|
299
|
+
# directory or a directory that does not exist.
|
|
300
|
+
def mounts
|
|
301
|
+
mounts = @mounts.is_a?(Proc) ? @mounts.call : @mounts
|
|
302
|
+
raise ArgumentError, 'mounts: takes a Hash or a Proc that returns one' unless mounts.is_a?(Hash)
|
|
303
|
+
|
|
304
|
+
mounts.to_h do |name, directory|
|
|
305
|
+
directory = File.expand_path(directory)
|
|
306
|
+
raise ArgumentError, "#{directory} is not a directory" unless File.directory?(directory)
|
|
307
|
+
|
|
308
|
+
[self.class.mount_name(name), File.realpath(directory)]
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Returns whether +tool_call+ asks for a sandbox wider than the default:
|
|
313
|
+
# such a call waits for Chat#approve, every time.
|
|
314
|
+
def requires_approval?(tool_call = nil)
|
|
315
|
+
!escalation_for(tool_call).nil?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Returns the languages the model may use: those requested that the
|
|
319
|
+
# sandbox can run. Raises ConfigurationError when it can run none.
|
|
320
|
+
def languages
|
|
321
|
+
value = sandbox_value
|
|
322
|
+
key = sandbox_key(value)
|
|
323
|
+
(@languages ||= {}.compare_by_identity)[key] ||= begin
|
|
324
|
+
options = { env: @packages.base_env(Dir.tmpdir), readable: @packages.runtimes,
|
|
325
|
+
denied: @packages.denied }
|
|
326
|
+
runner = @packages.with_libraries(resolved(value))
|
|
327
|
+
checks = @requested_languages.to_h do |name|
|
|
328
|
+
[name, self.class.check(key, runner, Language[name], **options)]
|
|
329
|
+
end
|
|
330
|
+
available = checks.select { |_, check| check == true }.keys
|
|
331
|
+
raise ConfigurationError, "The sandbox cannot run #{failed_checks(checks)}" if available.empty?
|
|
332
|
+
|
|
333
|
+
available
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Returns the description the model sees, naming only the languages
|
|
338
|
+
# the sandbox can run and the sandboxes it may ask for.
|
|
339
|
+
def description
|
|
340
|
+
[
|
|
341
|
+
"Runs #{language_list} code in a sandbox and returns its exit code, output, and the files it writes.",
|
|
342
|
+
*files_note,
|
|
343
|
+
mounts_note,
|
|
344
|
+
packages_note,
|
|
345
|
+
directory_note(sandbox),
|
|
346
|
+
describe_sandbox(sandbox, directory: false),
|
|
347
|
+
escalations_note
|
|
348
|
+
].compact.join(' ')
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Returns the JSON Schema of the arguments: +language+ and +code+,
|
|
352
|
+
# +files+ when runs copy in the chat's files, and +sandbox+ and
|
|
353
|
+
# +reason+ when the model may ask for another sandbox.
|
|
354
|
+
def parameters_schema
|
|
355
|
+
properties = {
|
|
356
|
+
'language' => { 'type' => 'string', 'enum' => languages.map(&:to_s),
|
|
357
|
+
'description' => 'The language the code is written in' },
|
|
358
|
+
'code' => { 'type' => 'string', 'description' => 'The complete program to run' }
|
|
359
|
+
}
|
|
360
|
+
properties['files'] = FILES_PROPERTY if copies_files?
|
|
361
|
+
properties.merge!(escalation_properties) if escalations.any?
|
|
362
|
+
{ 'type' => 'object', 'properties' => properties, 'required' => properties.keys,
|
|
363
|
+
'additionalProperties' => false, 'strict' => true }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Runs +code+ written in +language+ and returns its report, followed by
|
|
367
|
+
# the files it wrote as Attachments. +files+ names the chat's files to
|
|
368
|
+
# copy into the run directory, for a store without a folder. With
|
|
369
|
+
# +sandbox+, runs in that sandbox only when the chat recorded an
|
|
370
|
+
# approval for +tool_call+.
|
|
371
|
+
def execute(language:, code:, sandbox: nil, reason: nil, files: nil, tool_call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
372
|
+
language = language.to_s.downcase.to_sym
|
|
373
|
+
unless languages.include?(language)
|
|
374
|
+
return { error: "Unsupported language #{language}. Use one of: #{languages.join(', ')}" }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
runner, error = runner_for(sandbox, code, tool_call)
|
|
378
|
+
return { error: } if error
|
|
379
|
+
|
|
380
|
+
store = @file_store.call
|
|
381
|
+
root = store&.local_root
|
|
382
|
+
Workspace.open(attachments: root.nil?) do |workspace|
|
|
383
|
+
options = { runner:, name: sandbox, tool_call:, store: }
|
|
384
|
+
next run_in_place(Language[language], code, workspace, root:, **options) if root
|
|
385
|
+
|
|
386
|
+
run(Language[language], code, workspace, names: files, **options)
|
|
387
|
+
end
|
|
388
|
+
rescue Workspace::Unavailable => e
|
|
389
|
+
{ error: e.message }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
def runner_for(name, code, tool_call)
|
|
395
|
+
return [sandbox, nil] if name.nil?
|
|
396
|
+
|
|
397
|
+
escalation = escalation_sandboxes[name.to_s]
|
|
398
|
+
return [nil, "Unknown sandbox #{name}. Use one of: #{escalations.join(', ')}"] unless escalation
|
|
399
|
+
return [nil, "Running in the #{name} sandbox was not approved."] unless approved?(tool_call, name, code)
|
|
400
|
+
|
|
401
|
+
[resolve(escalation), nil]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def approved?(tool_call, name, code)
|
|
405
|
+
return false unless tool_call && escalation_for(tool_call) == name.to_s
|
|
406
|
+
|
|
407
|
+
arguments = tool_call.arguments.to_h.transform_keys(&:to_s)
|
|
408
|
+
arguments['code'] == code && @approval.call(tool_call) == true
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def escalation_for(tool_call)
|
|
412
|
+
return unless tool_call.respond_to?(:arguments) && tool_call.arguments.respond_to?(:to_h)
|
|
413
|
+
|
|
414
|
+
name = tool_call.arguments.to_h.transform_keys(&:to_s)['sandbox']
|
|
415
|
+
name.to_s if name && escalation_sandboxes.key?(name.to_s)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def escalation_sandboxes
|
|
419
|
+
@escalation_sandboxes ||= @escalate_to.select { |_, sandbox| resolve(sandbox).supported? }
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def resolve(value)
|
|
423
|
+
Sandbox.resolve(value, backend: configuration.sandbox_backend)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def configuration
|
|
427
|
+
(@config.is_a?(Proc) ? @config.call : @config) || RubyLLM.config
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def sandbox_value
|
|
431
|
+
@sandbox.is_a?(Proc) ? @sandbox.call : @sandbox || configuration.sandbox
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def resolved(value)
|
|
435
|
+
resolve(value) ||
|
|
436
|
+
raise(ConfigurationError, 'Code execution needs a sandbox. Set config.sandbox to a sandbox class.')
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def sandbox_key(value)
|
|
440
|
+
value = Sandbox.constant(value) if value.is_a?(String)
|
|
441
|
+
@sandbox.is_a?(Proc) && !value.is_a?(Module) ? @sandbox : value
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def failed_checks(checks)
|
|
445
|
+
checks.map { |name, reason| "#{name}: #{reason}" }.join('; ')
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def isolated(runner)
|
|
449
|
+
runner.respond_to?(:isolated) ? runner.isolated : runner
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def escalation_properties
|
|
453
|
+
{
|
|
454
|
+
'sandbox' => { 'type' => %w[string null], 'enum' => [*escalations, nil],
|
|
455
|
+
'description' => 'null for the default sandbox, or a sandbox to ask the user for' },
|
|
456
|
+
'reason' => { 'type' => %w[string null],
|
|
457
|
+
'description' => 'Why the code needs that sandbox. The user reads it before deciding' }
|
|
458
|
+
}
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def run(language, code, workspace, runner:, name:, tool_call:, store:, names:)
|
|
462
|
+
all_mounts = mounts.merge(@skills.call)
|
|
463
|
+
copied = store ? copy_from_store(workspace, store, names, all_mounts.keys) : workspace.copy_in(@attachments.call)
|
|
464
|
+
mounted = workspace.mount(all_mounts)
|
|
465
|
+
script = prepare(workspace, language, code)
|
|
466
|
+
instrument(language, runner, name, tool_call) do |event|
|
|
467
|
+
result = run_in_sandbox(runner, language.command(script), workspace, readable: mounted)
|
|
468
|
+
files, skipped = workspace.outputs(max_files: MAX_FILES, max_size: configuration.code_execution_max_file_size,
|
|
469
|
+
unchanged: copied)
|
|
470
|
+
record(event, result, files.map(&:filename))
|
|
471
|
+
next save(store, result, files, skipped) if store
|
|
472
|
+
|
|
473
|
+
files.empty? ? report(result, files, skipped) : [report(result, files, skipped), *workspace.keep(files)]
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Runs in the store's own folder: the code reads and writes the files in
|
|
478
|
+
# place, and each file it creates or changes is passed to +written+.
|
|
479
|
+
def run_in_place(language, code, workspace, root:, runner:, name:, tool_call:, store:)
|
|
480
|
+
@attached_files.call
|
|
481
|
+
mounted = workspace.mount(mounts.merge(@skills.call), into: root)
|
|
482
|
+
script = prepare(workspace, language, code)
|
|
483
|
+
before = snapshot(store)
|
|
484
|
+
instrument(language, runner, name, tool_call) do |event|
|
|
485
|
+
result = run_in_sandbox(runner, language.command(script), workspace, readable: mounted, directory: root)
|
|
486
|
+
changed = store.list.reject { |file| before[file.path] == [file.byte_size, file.updated_at] }
|
|
487
|
+
changed, unnamed = changed.partition { |file| file.path.valid_encoding? }
|
|
488
|
+
changed.each { |file| @written.call(file) }
|
|
489
|
+
record(event, result, changed.map(&:path))
|
|
490
|
+
unmounted = workspace.unmounted
|
|
491
|
+
skipped = unnamed.map { |file| Workspace.invalid_name(file.path) }
|
|
492
|
+
report(result, changed.map(&:path), skipped, unmounted: (unmounted_note(unmounted) if unmounted.any?),
|
|
493
|
+
read_again: read_again_note(store, changed))
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def copy_from_store(workspace, store, names, read_only)
|
|
498
|
+
named = stored_files(store, names, [Workspace::ATTACHMENTS, *read_only])
|
|
499
|
+
workspace.copy_stored_inputs(store, @attached_files.call)
|
|
500
|
+
workspace.copy_files(store, named)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def prepare(workspace, language, code)
|
|
504
|
+
workspace.write_source(language, code).tap do
|
|
505
|
+
workspace.link_packages(@packages.node_modules) if language.name == :javascript && @packages.node_modules
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def snapshot(store)
|
|
510
|
+
store.list.to_h { |file| [file.path, [file.byte_size, file.updated_at]] }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Returns the StoredFiles +names+ asks for: each a file, or a folder
|
|
514
|
+
# standing for every file in it. Raises Workspace::Unavailable for a
|
|
515
|
+
# name with no file, or one under a read-only folder.
|
|
516
|
+
def stored_files(store, names, read_only)
|
|
517
|
+
Array(names).flat_map { |name| stored_files_at(store, name, read_only) }.uniq(&:path)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def stored_files_at(store, name, read_only)
|
|
521
|
+
path = FileStore.path(name)
|
|
522
|
+
mount = read_only.find { |folder| FileStore.inside?(path, folder) || FileStore.inside?(folder, path) }
|
|
523
|
+
raise Workspace::Unavailable, "#{name} is in the read-only folder #{mount}, which every run has" if mount
|
|
524
|
+
|
|
525
|
+
files = [store[path]].compact
|
|
526
|
+
files = store.list(path) if files.empty?
|
|
527
|
+
raise Workspace::Unavailable, "No file or folder #{name} in this chat's files. See list_files." if files.empty?
|
|
528
|
+
|
|
529
|
+
files
|
|
530
|
+
rescue ArgumentError => e
|
|
531
|
+
raise Workspace::Unavailable, e.message
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def copies_files?
|
|
535
|
+
store = @file_store.call
|
|
536
|
+
!store.nil? && store.local_root.nil?
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Saves what the run wrote. A file the store has no room for, or whose
|
|
540
|
+
# name it refuses, is left out, and the report names it with the
|
|
541
|
+
# store's reason, so the model reads why instead of the reply failing.
|
|
542
|
+
def save(store, result, files, skipped)
|
|
543
|
+
saved = []
|
|
544
|
+
unsaved = []
|
|
545
|
+
reason = nil
|
|
546
|
+
files.each do |file|
|
|
547
|
+
stored = store.write(file.filename, file.source)
|
|
548
|
+
rescue FileStoreFullError, ArgumentError => e
|
|
549
|
+
unsaved << file.filename
|
|
550
|
+
reason ||= e.message
|
|
551
|
+
else
|
|
552
|
+
saved << stored
|
|
553
|
+
@written.call(stored)
|
|
554
|
+
end
|
|
555
|
+
report(result, saved.map(&:path), skipped, unsaved_files: (unsaved if unsaved.any?), files_error: reason,
|
|
556
|
+
read_again: read_again_note(store, saved))
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Names the files this run changed after the model read them, since
|
|
560
|
+
# edit_file refuses them until it reads them again.
|
|
561
|
+
def read_again_note(store, files)
|
|
562
|
+
paths = files.select { |file| changed_since_read?(store, file) }.map(&:path)
|
|
563
|
+
"This run changed files you had read: #{paths.join(', ')}. Read them before you edit them." if paths.any?
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def changed_since_read?(store, file)
|
|
567
|
+
seen = store.read_digest(file.path)
|
|
568
|
+
seen && seen != (file.digest || FileStore.digest(store.read(file.path)))
|
|
569
|
+
rescue FileNotFoundError, SystemCallError
|
|
570
|
+
true
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def run_in_sandbox(runner, command, workspace, readable:, directory: workspace.directory)
|
|
574
|
+
sandbox = @packages.with_libraries(runner)
|
|
575
|
+
sandbox.run(*command,
|
|
576
|
+
readable: [*@packages.readable, *@packages.resolver_config(setting(runner, :network)), *readable,
|
|
577
|
+
workspace.source, workspace.inputs],
|
|
578
|
+
denied: @packages.denied,
|
|
579
|
+
writable: [directory, workspace.home, workspace.tmp],
|
|
580
|
+
timeout: timeout_for(runner), directory: (directory unless setting(runner, :directory)),
|
|
581
|
+
env: @packages.env(workspace.home, workspace.tmp).merge(RUN_DIR => directory),
|
|
582
|
+
&progress_listener)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def progress_listener
|
|
586
|
+
method(:report_line) if Support::ProgressReporter.listener
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def report_line(line)
|
|
590
|
+
text = line.strip
|
|
591
|
+
progress(text[0, PROGRESS_LINE]) unless text.empty?
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def setting(runner, name)
|
|
595
|
+
runner.public_send(name) if runner.respond_to?(name)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def timeout_for(runner)
|
|
599
|
+
setting(runner, :timeout) || Sandbox::DEFAULT_TIMEOUT
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def instrument(language, runner, name, tool_call)
|
|
603
|
+
payload = { chat: @chat, tool_call_id: tool_call&.id, tool_name: self.name, language: language.name,
|
|
604
|
+
sandbox: name&.to_s, timeout: timeout_for(runner), network: setting(runner, :network) }
|
|
605
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
606
|
+
config = @config.is_a?(Proc) ? @config.call : @config
|
|
607
|
+
status = :failed
|
|
608
|
+
RubyLLM.instrument('code_execution.ruby_llm', payload, config:) do |event|
|
|
609
|
+
yield(event).tap { status = :succeeded }
|
|
610
|
+
rescue CancelledError
|
|
611
|
+
status = :cancelled
|
|
612
|
+
raise
|
|
613
|
+
ensure
|
|
614
|
+
event[:duration] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
615
|
+
record_usage(runner, event, config, status)
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# A run spends the application's machines, not a provider's, so its
|
|
620
|
+
# entry names the sandbox class that ran it and prices nothing. A run
|
|
621
|
+
# that failed or was cancelled still used its seconds. A class without
|
|
622
|
+
# a name has nothing to name, so its runs have no entry.
|
|
623
|
+
def record_usage(runner, event, config, status)
|
|
624
|
+
model = sandbox_class(runner).name
|
|
625
|
+
return unless model
|
|
626
|
+
|
|
627
|
+
entry = Accounting::Usage::Entry.new(
|
|
628
|
+
operation: :code_execution, provider: USAGE_PROVIDER, model:, status:,
|
|
629
|
+
tokens: Tokens.new(server_tool_use: { USAGE_COUNTER => (event[:cpu_time] || event[:duration]).to_f }),
|
|
630
|
+
cost: Cost.new(amounts: {}, reported: true, total: 0), owner: Accounting::Usage.owner, workflow: Accounting::Usage.workflow
|
|
631
|
+
)
|
|
632
|
+
@chat ? @chat.add_usage_entry(entry) : Accounting::Usage.record(entry)
|
|
633
|
+
Accounting::Usage.instrument(entry, config:)
|
|
634
|
+
rescue StandardError => e
|
|
635
|
+
RubyLLM.logger.warn("RubyLLM could not record code execution usage: #{e.class}: #{e.message}")
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def sandbox_class(runner)
|
|
639
|
+
sandbox = runner.respond_to?(:policy) ? runner.policy : runner
|
|
640
|
+
sandbox.is_a?(Module) ? sandbox : sandbox.class
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def record(event, result, paths)
|
|
644
|
+
event[:exit_code] = result.exit_code
|
|
645
|
+
event[:timed_out] = result.timed_out?
|
|
646
|
+
event[:blocked_hosts] = Array(result.blocked) if result.respond_to?(:blocked)
|
|
647
|
+
event[:cpu_time] = result.cpu_time if result.respond_to?(:cpu_time)
|
|
648
|
+
event[:limit_exceeded] = result.limit_exceeded if result.respond_to?(:limit_exceeded)
|
|
649
|
+
event[:files] = paths
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def report(result, files, skipped, **notes)
|
|
653
|
+
blocked = Array(result.blocked) if result.respond_to?(:blocked)
|
|
654
|
+
{
|
|
655
|
+
exit_code: result.exit_code,
|
|
656
|
+
stdout: truncate(result.stdout),
|
|
657
|
+
stderr: truncate(result.stderr),
|
|
658
|
+
error: run_error(result),
|
|
659
|
+
blocked_hosts: (blocked if blocked&.any?),
|
|
660
|
+
files: (files.map { |file| file.is_a?(String) ? file : file.filename } if files.any?),
|
|
661
|
+
skipped_files: (skipped if skipped.any?),
|
|
662
|
+
**notes
|
|
663
|
+
}.compact.to_json
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def unmounted_note(paths)
|
|
667
|
+
"The read-only folders #{paths.join(', ')} were not mounted, because this chat's files have something " \
|
|
668
|
+
'of their own at those paths: the code saw that instead.'
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def run_error(result)
|
|
672
|
+
return 'The code ran past its time limit and was stopped' if result.timed_out?
|
|
673
|
+
|
|
674
|
+
limit = result.limit_exceeded if result.respond_to?(:limit_exceeded)
|
|
675
|
+
LIMIT_ERRORS[limit] if limit
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def truncate(text)
|
|
679
|
+
text = text.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
680
|
+
return text if text.length <= MAX_OUTPUT
|
|
681
|
+
|
|
682
|
+
half = MAX_OUTPUT / 2
|
|
683
|
+
"#{text[0, half]}\n[#{text.length - MAX_OUTPUT} characters omitted]\n#{text[-half..]}"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def language_list
|
|
687
|
+
labels = languages.map { |name| Language[name].label }
|
|
688
|
+
labels.size > 1 ? "#{labels[0..-2].join(', ')} or #{labels.last}" : labels.first
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def files_note
|
|
692
|
+
store = @file_store.call
|
|
693
|
+
if store&.local_root
|
|
694
|
+
["Code runs in the folder that holds this chat's files, the ones list_files shows, and reads them " \
|
|
695
|
+
'there by their paths, files attached to the conversation included.',
|
|
696
|
+
'What it writes, changes, or deletes there changes the chat\'s files at once, for read_file and later runs.']
|
|
697
|
+
elsif store
|
|
698
|
+
['Each call gets a new run directory. Files attached to the conversation are in ./attachments (read-only), ' \
|
|
699
|
+
'at the paths list_files shows.',
|
|
700
|
+
'To change one of this chat\'s files, or use one that is not in ./attachments, name it in files and it is ' \
|
|
701
|
+
'copied in at the same path; a folder copies everything in it.',
|
|
702
|
+
'Files the code writes or changes in the run directory are saved to the chat\'s files when it ends, where ' \
|
|
703
|
+
'read_file and later runs find them. Deleting a file there does not delete it from the chat\'s files.']
|
|
704
|
+
else
|
|
705
|
+
['Each call gets a new empty run directory, and nothing carries over between calls.',
|
|
706
|
+
"Files attached to the conversation, and files earlier calls returned, are in ./#{Workspace::ATTACHMENTS} " \
|
|
707
|
+
'(read-only). Write files to the run directory to return them to the user.']
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def mounts_note
|
|
712
|
+
names = mounts.keys
|
|
713
|
+
"These folders are available read-only: #{names.map { |name| "./#{name}" }.join(', ')}." if names.any?
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def packages_note
|
|
717
|
+
notes = []
|
|
718
|
+
gems = @packages.gem_names
|
|
719
|
+
python = @packages.python_names
|
|
720
|
+
npm = @packages.npm_names
|
|
721
|
+
notes << "Ruby #{RUBY_VERSION} has these gems: #{gems.join(', ')}." if gems.any?
|
|
722
|
+
notes << "Python has these packages: #{python.join(', ')}." if python.any?
|
|
723
|
+
notes << "JavaScript has these npm packages: #{npm.join(', ')}." if npm.any?
|
|
724
|
+
notes.join(' ') unless notes.empty?
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def directory_note(runner)
|
|
728
|
+
directory = setting(runner, :directory)
|
|
729
|
+
return unless directory
|
|
730
|
+
|
|
731
|
+
"Code starts in #{File.expand_path(directory)}, not in the run directory: read the conversation's files " \
|
|
732
|
+
"and write files to return under $#{RUN_DIR}."
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def escalations_note
|
|
736
|
+
return if escalations.empty?
|
|
737
|
+
|
|
738
|
+
sandboxes = escalation_sandboxes.map do |name, sandbox|
|
|
739
|
+
runner = resolve(sandbox)
|
|
740
|
+
"#{name}: #{[directory_note(runner), describe_sandbox(runner, directory: false)].compact.join(' ')}"
|
|
741
|
+
end
|
|
742
|
+
'When the code needs more than this sandbox allows, set sandbox to one of these and explain why in reason. ' \
|
|
743
|
+
'The user approves each such call, and the next call runs in the default sandbox again. ' \
|
|
744
|
+
"#{sandboxes.join(' ')}"
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def describe_sandbox(sandbox, directory: true)
|
|
748
|
+
runner = sandbox.respond_to?(:run) ? sandbox : resolve(sandbox)
|
|
749
|
+
[(directory_sentence(runner) if directory), paths_sentence('Can read', setting(runner, :readable)),
|
|
750
|
+
paths_sentence('Can change', setting(runner, :writable)), network_sentence(setting(runner, :network)),
|
|
751
|
+
limits_sentence(setting(runner, :limits)), "Stops after #{timeout_for(runner)} seconds."].compact.join(' ')
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def directory_sentence(runner)
|
|
755
|
+
directory = setting(runner, :directory)
|
|
756
|
+
"Starts in #{File.expand_path(directory)}." if directory
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def paths_sentence(label, paths)
|
|
760
|
+
paths = Array(paths).map { |path| File.expand_path(path) }
|
|
761
|
+
"#{label} #{paths.join(', ')}." if paths.any?
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def limits_sentence(limits)
|
|
765
|
+
limits = limits.to_h
|
|
766
|
+
seconds = limits[:cpu_time].to_f.ceil if limits[:cpu_time]
|
|
767
|
+
parts = [("#{seconds} #{seconds == 1 ? 'second' : 'seconds'} of CPU time" if seconds),
|
|
768
|
+
("#{(limits[:memory].to_i / (1024.0**2)).ceil} MB of memory" if limits[:memory]),
|
|
769
|
+
("#{limits[:processes]} processes at once" if limits[:processes])].compact
|
|
770
|
+
"Each process may use #{parts.join(' and ')}." if parts.any?
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def network_sentence(network)
|
|
774
|
+
case network
|
|
775
|
+
when true then 'Can reach the network.'
|
|
776
|
+
when Hash then "Can reach only #{Array(network[:allow]).join(', ')}."
|
|
777
|
+
else 'No network access.'
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
end
|