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,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
module Tools
|
|
6
|
+
# The +edit_file+ tool: replaces exact spans of text files, one or
|
|
7
|
+
# several in a call, all or none.
|
|
8
|
+
class Edit < Base # :nodoc:
|
|
9
|
+
Change = Struct.new(:label, :path, :old_text, :new_text, :replace_all) # :nodoc:
|
|
10
|
+
|
|
11
|
+
def self.tool_name
|
|
12
|
+
'edit_file'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def description
|
|
16
|
+
'Changes text files you have read by replacing old_text, which must appear exactly once, with ' \
|
|
17
|
+
'new_text. A text file attached to the question counts as read, at its file name. Include ' \
|
|
18
|
+
'surrounding text to make old_text unique, or set replace_all. Pass edits for several changes in ' \
|
|
19
|
+
'one call, to one file or several: they apply in order, all or none.'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parameters_schema
|
|
23
|
+
{
|
|
24
|
+
'type' => 'object',
|
|
25
|
+
'properties' => {
|
|
26
|
+
'path' => { 'type' => %w[string null], 'description' => 'The file to change' },
|
|
27
|
+
'old_text' => { 'type' => %w[string null], 'description' => 'The exact text to replace' },
|
|
28
|
+
'new_text' => { 'type' => %w[string null], 'description' => 'The text to put in its place' },
|
|
29
|
+
'replace_all' => { 'type' => %w[boolean null], 'description' => 'Replace every occurrence' },
|
|
30
|
+
'edits' => { 'type' => %w[array null], 'description' => 'Several changes; each may name a path',
|
|
31
|
+
'items' => edit_schema }
|
|
32
|
+
},
|
|
33
|
+
'additionalProperties' => false
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute(path: nil, old_text: nil, new_text: nil, replace_all: false, edits: nil)
|
|
38
|
+
guarded_write do |write|
|
|
39
|
+
raise ToolError, 'Pass old_text and new_text, or edits, not both.' if edits && (old_text || new_text)
|
|
40
|
+
|
|
41
|
+
changes = changes_for(path, old_text, new_text, replace_all, edits)
|
|
42
|
+
store.with_lock do
|
|
43
|
+
files = apply(changes)
|
|
44
|
+
write_all(files, write) || report(files)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def edit_schema
|
|
52
|
+
{
|
|
53
|
+
'type' => 'object',
|
|
54
|
+
'properties' => {
|
|
55
|
+
'path' => { 'type' => %w[string null] },
|
|
56
|
+
'old_text' => { 'type' => 'string' },
|
|
57
|
+
'new_text' => { 'type' => 'string' },
|
|
58
|
+
'replace_all' => { 'type' => %w[boolean null] }
|
|
59
|
+
},
|
|
60
|
+
'required' => %w[old_text new_text],
|
|
61
|
+
'additionalProperties' => false
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def changes_for(path, old_text, new_text, replace_all, edits)
|
|
66
|
+
return batch(path, edits) unless edits.nil?
|
|
67
|
+
raise ToolError, 'Pass path.' if path.to_s.strip.empty?
|
|
68
|
+
|
|
69
|
+
[Change.new('', path, old_text, new_text, replace_all)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def batch(path, edits)
|
|
73
|
+
raise ToolError, 'edits is empty.' if Array(edits).empty?
|
|
74
|
+
|
|
75
|
+
Array(edits).each_with_index.map do |edit, index|
|
|
76
|
+
edit = edit.to_h.transform_keys(&:to_s)
|
|
77
|
+
target = edit['path'] || path
|
|
78
|
+
if target.to_s.strip.empty?
|
|
79
|
+
raise ToolError,
|
|
80
|
+
"Edit #{index + 1} has no path. Pass path, or a path in each edit."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Change.new("Edit #{index + 1}: ", target, edit['old_text'], edit['new_text'], edit['replace_all'])
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def apply(changes)
|
|
88
|
+
changes.each_with_object({}) do |change, files|
|
|
89
|
+
path = labelled(change) { path_for(change.path) }
|
|
90
|
+
files[path] ||= labelled(change) { [contents(path), 0] }
|
|
91
|
+
content, count = labelled(change) { replace(files[path].first, path, change) }
|
|
92
|
+
files[path] = [content, files[path].last + count]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns the error once a write fails after others landed, instead
|
|
97
|
+
# of raising, so a store's transaction keeps what the answer reports.
|
|
98
|
+
def write_all(files, write)
|
|
99
|
+
changed = []
|
|
100
|
+
files.each do |path, (content, _count)|
|
|
101
|
+
write.call(path, content)
|
|
102
|
+
changed << path
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
rescue FileStoreFullError, SystemCallError => e
|
|
106
|
+
raise if changed.empty?
|
|
107
|
+
|
|
108
|
+
reason = e.is_a?(SystemCallError) ? SystemCallError.new(e.errno).message : e.message
|
|
109
|
+
{ error: "#{reason} These files already changed: #{changed.join(', ')}." }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def labelled(change)
|
|
113
|
+
yield
|
|
114
|
+
rescue ToolError => e
|
|
115
|
+
raise ToolError, "#{change.label}#{e.message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def contents(path)
|
|
119
|
+
file = store[path] || raise(ToolError, "There is no file at #{path}. #{missing_hint(path)}")
|
|
120
|
+
fresh!(file)
|
|
121
|
+
text_of(file)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def replace(content, path, change)
|
|
125
|
+
raise ToolError, 'Pass new_text, or an empty string to delete old_text.' if change.new_text.nil?
|
|
126
|
+
|
|
127
|
+
old_text = change.old_text.to_s
|
|
128
|
+
count = old_text.empty? ? 0 : content.scan(old_text).size
|
|
129
|
+
raise ToolError, "old_text does not appear in #{path}." if count.zero?
|
|
130
|
+
if count > 1 && !change.replace_all
|
|
131
|
+
raise ToolError, "old_text appears #{count} times in #{path}. Add the text around it to make " \
|
|
132
|
+
'it unique, or set replace_all.'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
[content.public_send(change.replace_all ? :gsub : :sub, old_text) { change.new_text.to_s }, count]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def report(files)
|
|
139
|
+
count = files.values.sum(&:last)
|
|
140
|
+
replaced = "Replaced #{count} #{count == 1 ? 'occurrence' : 'occurrences'}"
|
|
141
|
+
return "#{replaced} in #{files.keys.first}." if files.one?
|
|
142
|
+
|
|
143
|
+
"#{replaced} in #{files.size} files: #{files.keys.join(', ')}."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def text_of(file)
|
|
147
|
+
content = store.read(file.path).dup.force_encoding(Encoding::UTF_8)
|
|
148
|
+
return content if content.valid_encoding? && !content.include?("\0")
|
|
149
|
+
|
|
150
|
+
raise ToolError, "#{file.path} is not a text file, so edit_file cannot change it."
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
module Tools
|
|
6
|
+
# The +list_files+ tool: the chat's files, those in one folder, or
|
|
7
|
+
# those matching a glob, with their sizes and types.
|
|
8
|
+
class List < Base # :nodoc:
|
|
9
|
+
MAX_FILES = 200
|
|
10
|
+
|
|
11
|
+
def self.tool_name
|
|
12
|
+
'list_files'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def description
|
|
16
|
+
'Lists the files, with sizes and types. Pass path for one folder, and pattern for a glob: ' \
|
|
17
|
+
'*.csv matches names in any folder, reports/**/*.csv matches paths.'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parameters_schema
|
|
21
|
+
{
|
|
22
|
+
'type' => 'object',
|
|
23
|
+
'properties' => {
|
|
24
|
+
'path' => { 'type' => %w[string null], 'description' => 'A folder to list' },
|
|
25
|
+
'pattern' => { 'type' => %w[string null], 'description' => 'A glob, like *.md or reports/**/*.csv' }
|
|
26
|
+
},
|
|
27
|
+
'additionalProperties' => false
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def execute(path: nil, pattern: nil)
|
|
32
|
+
guarded do
|
|
33
|
+
folder = folder_for(path)
|
|
34
|
+
files = store.list(folder)
|
|
35
|
+
pattern = pattern.to_s.strip.then { |glob| glob unless glob.empty? }
|
|
36
|
+
files = files.select { |file| FileStore.match?(file.path, pattern, inside: folder) } if pattern
|
|
37
|
+
next empty(folder, pattern) if files.empty?
|
|
38
|
+
|
|
39
|
+
lines = files.first(MAX_FILES).map { |file| "#{file.path}\t#{file.human_size}\t#{file.content_type}" }
|
|
40
|
+
if files.size > MAX_FILES
|
|
41
|
+
lines << "[#{files.size - MAX_FILES} more files. Narrow the list with path or pattern.]"
|
|
42
|
+
end
|
|
43
|
+
lines.join("\n")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def empty(folder, pattern)
|
|
50
|
+
return "No files#{" in #{folder}" if folder} match #{pattern}." if pattern
|
|
51
|
+
|
|
52
|
+
folder ? "There are no files in #{folder}." : 'There are no files yet.'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
module Tools
|
|
6
|
+
# The +read_file+ tool: a range of a file's text, by lines or by
|
|
7
|
+
# characters, with the text a converter makes of other files. Images,
|
|
8
|
+
# and PDFs no converter reads, come back as an attachment the model
|
|
9
|
+
# sees.
|
|
10
|
+
class Read < Base # :nodoc:
|
|
11
|
+
MAX_LINES = 1_000
|
|
12
|
+
MAX_CHARACTERS = 20_000
|
|
13
|
+
MAX_LINE = 2_000
|
|
14
|
+
UNITS = %w[lines characters].freeze
|
|
15
|
+
|
|
16
|
+
def self.tool_name
|
|
17
|
+
'read_file'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def description
|
|
21
|
+
"Reads a file. Text comes back with line numbers, #{MAX_LINES} lines or #{MAX_CHARACTERS} characters " \
|
|
22
|
+
'at a time: pass offset and limit to read further, and unit "characters" for very long lines. ' \
|
|
23
|
+
'After search_files, read just the lines around a match with offset and limit. Documents a ' \
|
|
24
|
+
'converter reads come back as text, and other images and PDFs as the file itself.'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parameters_schema
|
|
28
|
+
{
|
|
29
|
+
'type' => 'object',
|
|
30
|
+
'properties' => {
|
|
31
|
+
'path' => { 'type' => 'string', 'description' => 'The file to read' },
|
|
32
|
+
'offset' => { 'type' => %w[integer null],
|
|
33
|
+
'description' => 'The line to start at, from 1, or the character, from 0' },
|
|
34
|
+
'limit' => { 'type' => %w[integer null], 'description' => 'How many lines or characters to read' },
|
|
35
|
+
'unit' => { 'type' => %w[string null], 'enum' => [*UNITS, nil],
|
|
36
|
+
'description' => 'lines (the default) or characters' }
|
|
37
|
+
},
|
|
38
|
+
'required' => %w[path],
|
|
39
|
+
'additionalProperties' => false
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute(path:, offset: nil, limit: nil, unit: nil)
|
|
44
|
+
seen = nil
|
|
45
|
+
result = guarded do
|
|
46
|
+
file = file_at(path)
|
|
47
|
+
seen = [file.path, digest_of(file)]
|
|
48
|
+
unit = (unit || 'lines').to_s
|
|
49
|
+
raise ToolError, "unit is lines or characters, not #{unit}." unless UNITS.include?(unit)
|
|
50
|
+
next "#{file.path} is empty." if file.byte_size.zero?
|
|
51
|
+
|
|
52
|
+
text = store.text(file.path, config:)
|
|
53
|
+
next unreadable(file) if text.nil?
|
|
54
|
+
|
|
55
|
+
unit == 'characters' ? characters(file, text, offset, limit) : lines(file, text, offset, limit)
|
|
56
|
+
end
|
|
57
|
+
store.record_read(*seen) if seen && !result.is_a?(Hash)
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def lines(file, text, offset, limit)
|
|
64
|
+
all = text.lines
|
|
65
|
+
start = [offset.to_i, 1].max
|
|
66
|
+
raise ToolError, "#{file.path} has #{all.size} lines; offset #{start} is past the end." if
|
|
67
|
+
start > all.size && !all.empty?
|
|
68
|
+
|
|
69
|
+
count = limit.to_i.positive? ? [limit.to_i, MAX_LINES].min : MAX_LINES
|
|
70
|
+
shown = []
|
|
71
|
+
size = 0
|
|
72
|
+
all.drop(start - 1).first(count).each_with_index do |line, index|
|
|
73
|
+
line = shorten(line.chomp)
|
|
74
|
+
break if size + line.size > MAX_CHARACTERS && shown.any?
|
|
75
|
+
|
|
76
|
+
shown << format('%<number>6d %<line>s', number: start + index, line:)
|
|
77
|
+
size += line.size
|
|
78
|
+
end
|
|
79
|
+
last = start + shown.size - 1
|
|
80
|
+
[header(file, "lines #{start}-#{last} of #{all.size}"), *shown, more_lines(all.size, last)].compact.join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def characters(file, text, offset, limit)
|
|
84
|
+
start = [offset.to_i, 0].max
|
|
85
|
+
count = limit.to_i.positive? ? [limit.to_i, MAX_CHARACTERS].min : MAX_CHARACTERS
|
|
86
|
+
part = text[start, count].to_s
|
|
87
|
+
finish = start + part.size
|
|
88
|
+
more = "[#{text.size - finish} more characters. Continue with offset #{finish}.]" if finish < text.size
|
|
89
|
+
[header(file, "characters #{start}-#{finish} of #{text.size}"), part, more].compact.join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def header(file, range)
|
|
93
|
+
source = " (text read from #{file.content_type})" unless file.text?
|
|
94
|
+
"#{file.path}#{source}, #{range}:"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def more_lines(total, last)
|
|
98
|
+
"[#{total - last} more lines. Continue with offset #{last + 1}.]" if last < total
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def shorten(line)
|
|
102
|
+
return line if line.size <= MAX_LINE
|
|
103
|
+
|
|
104
|
+
"#{line[0, MAX_LINE]} [line cut: #{line.size - MAX_LINE} more characters, read them with unit characters]"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def unreadable(file)
|
|
108
|
+
type = Files::MimeType
|
|
109
|
+
if type.image?(file.content_type) || type.pdf?(file.content_type)
|
|
110
|
+
return ["#{file.path} (#{file.content_type}, #{file.human_size}) is attached to this result.",
|
|
111
|
+
store.attachment(file.path, file:)]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
{ error: "#{file.path} is #{file.content_type} and has no text to read. Work with it in code if you " \
|
|
115
|
+
'can run code, or describe it from its name.' }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
module Tools
|
|
6
|
+
# The +search_files+ tool: lines matching a regular expression across
|
|
7
|
+
# the text of the chat's files, or the files that have one, like grep.
|
|
8
|
+
class Search < Base # :nodoc:
|
|
9
|
+
MAX_MATCHES = 100
|
|
10
|
+
MAX_CHARACTERS = 20_000
|
|
11
|
+
MAX_CONTEXT = 10
|
|
12
|
+
MAX_LINE = 300
|
|
13
|
+
MAX_FILE_SIZE = 50 * 1024 * 1024
|
|
14
|
+
MAX_SECONDS = 10
|
|
15
|
+
|
|
16
|
+
def self.tool_name
|
|
17
|
+
'search_files'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def description
|
|
21
|
+
'Searches the text of the files for lines matching a regular expression, like grep, and returns ' \
|
|
22
|
+
'path:line: text. Start with files_only to see which files match, then read them with read_file. ' \
|
|
23
|
+
'Files whose text read_file has not extracted yet are skipped and named.'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parameters_schema
|
|
27
|
+
{
|
|
28
|
+
'type' => 'object',
|
|
29
|
+
'properties' => {
|
|
30
|
+
'query' => { 'type' => 'string', 'description' => 'A regular expression, or plain words' },
|
|
31
|
+
'path' => { 'type' => %w[string null], 'description' => 'A file or folder to search' },
|
|
32
|
+
'glob' => { 'type' => %w[string null], 'description' => 'Only files matching this, like *.md' },
|
|
33
|
+
'context' => { 'type' => %w[integer null], 'description' => 'Lines to show around each match' },
|
|
34
|
+
'case_sensitive' => { 'type' => %w[boolean null], 'description' => 'Match case. Default: false' },
|
|
35
|
+
'files_only' => { 'type' => %w[boolean null], 'description' => 'Return only the matching paths' }
|
|
36
|
+
},
|
|
37
|
+
'required' => %w[query],
|
|
38
|
+
'additionalProperties' => false
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def execute(query:, path: nil, glob: nil, context: nil, case_sensitive: false, files_only: false)
|
|
43
|
+
guarded do
|
|
44
|
+
pattern = pattern_for(query.to_s, case_sensitive)
|
|
45
|
+
glob = nil if glob.to_s.strip.empty?
|
|
46
|
+
files = scope(path, glob)
|
|
47
|
+
next "No files match #{glob}." if files.empty? && glob
|
|
48
|
+
|
|
49
|
+
Run.new(pattern, context: context.to_i.clamp(0, MAX_CONTEXT), files_only:).call(files) do |file|
|
|
50
|
+
searchable_text(file)
|
|
51
|
+
end.report(query)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def scope(path, glob)
|
|
58
|
+
folder = folder_for(path)
|
|
59
|
+
files = folder ? files_at(folder) : store.list
|
|
60
|
+
return files unless glob
|
|
61
|
+
|
|
62
|
+
files.select { |file| FileStore.match?(file.path, glob, inside: folder) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def files_at(folder)
|
|
66
|
+
file = store[folder]
|
|
67
|
+
files = file ? [file] : store.list(folder)
|
|
68
|
+
raise ToolError, "There is no file or folder at #{folder}." if files.empty?
|
|
69
|
+
|
|
70
|
+
files
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def pattern_for(query, case_sensitive)
|
|
74
|
+
raise ToolError, 'query is empty.' if query.strip.empty?
|
|
75
|
+
|
|
76
|
+
options = case_sensitive ? 0 : Regexp::IGNORECASE
|
|
77
|
+
begin
|
|
78
|
+
Regexp.new(query, options, timeout: 1.0)
|
|
79
|
+
rescue RegexpError
|
|
80
|
+
Regexp.new(Regexp.escape(query), options, timeout: 1.0)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Converters can cost money, like OCR, so search reads only text
|
|
85
|
+
# files and conversions already made.
|
|
86
|
+
def searchable_text(file)
|
|
87
|
+
return '' if file.byte_size.zero?
|
|
88
|
+
return if file.byte_size > MAX_FILE_SIZE
|
|
89
|
+
|
|
90
|
+
cached = store.cached_text(file)
|
|
91
|
+
return cached if cached
|
|
92
|
+
return Converters::Text.call(store.attachment(file.path, file:)) if file.text?
|
|
93
|
+
|
|
94
|
+
:unextracted if Converters.for_type(file.content_type, config:).any?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# One search: walks the files, collects what it shows, and stops at
|
|
98
|
+
# its limits.
|
|
99
|
+
class Run # :nodoc:
|
|
100
|
+
def initialize(pattern, context:, files_only:)
|
|
101
|
+
@pattern = pattern
|
|
102
|
+
@context = context
|
|
103
|
+
@files_only = files_only
|
|
104
|
+
@lines = []
|
|
105
|
+
@size = 0
|
|
106
|
+
@matches = 0
|
|
107
|
+
@skipped = { none: [], unextracted: [] }
|
|
108
|
+
@stopped = nil
|
|
109
|
+
@deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MAX_SECONDS
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def call(files)
|
|
113
|
+
files.each do |file|
|
|
114
|
+
break if @stopped
|
|
115
|
+
|
|
116
|
+
text = yield(file)
|
|
117
|
+
next @skipped[text.is_a?(Symbol) ? text : :none] << file.path unless text.is_a?(String)
|
|
118
|
+
|
|
119
|
+
search(file.path, text.lines)
|
|
120
|
+
end
|
|
121
|
+
self
|
|
122
|
+
rescue Regexp::TimeoutError
|
|
123
|
+
raise Base::ToolError, 'The regular expression took too long. Use a simpler one.'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def report(query)
|
|
127
|
+
lines = @lines.empty? ? ["No lines match #{query}."] : @lines.dup
|
|
128
|
+
lines << notice if @stopped
|
|
129
|
+
if @skipped[:unextracted].any?
|
|
130
|
+
unextracted = @skipped[:unextracted]
|
|
131
|
+
lines << "[Not searched, text not extracted yet: #{unextracted.join(', ')}. " \
|
|
132
|
+
"read_file extracts #{unextracted.one? ? 'it' : 'them'}.]"
|
|
133
|
+
end
|
|
134
|
+
lines << "[Not searched, no text: #{@skipped[:none].join(', ')}]" if @skipped[:none].any?
|
|
135
|
+
lines.join("\n")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def search(path, lines)
|
|
141
|
+
matched = []
|
|
142
|
+
lines.each_with_index do |line, index|
|
|
143
|
+
return stop(:time) if late?
|
|
144
|
+
next unless @pattern.match?(line)
|
|
145
|
+
return show([path]) if @files_only
|
|
146
|
+
|
|
147
|
+
matched << index
|
|
148
|
+
end
|
|
149
|
+
show_matches(path, lines, matched)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def show_matches(path, lines, matched)
|
|
153
|
+
hits = matched.to_set
|
|
154
|
+
windows(matched, lines.size).each_with_index.all? do |(first, last), index|
|
|
155
|
+
(index.zero? || @context.zero? || show(['--'], match: false)) &&
|
|
156
|
+
(first..last).all? do |number|
|
|
157
|
+
mark = hits.include?(number) ? ':' : '-'
|
|
158
|
+
show(["#{path}#{mark}#{number + 1}#{mark} #{clean(lines[number])}"], match: mark == ':')
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def windows(matched, size)
|
|
164
|
+
matched.each_with_object([]) do |index, windows|
|
|
165
|
+
first = [index - @context, 0].max
|
|
166
|
+
last = [index + @context, size - 1].min
|
|
167
|
+
next windows.last[1] = last if windows.any? && first <= windows.last[1] + 1
|
|
168
|
+
|
|
169
|
+
windows << [first, last]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def show(lines, match: true)
|
|
174
|
+
text = lines.join("\n")
|
|
175
|
+
return stop(:size) if @size + text.size > MAX_CHARACTERS && @lines.any?
|
|
176
|
+
return stop(:size) if match && @matches >= MAX_MATCHES
|
|
177
|
+
|
|
178
|
+
@lines.concat(lines)
|
|
179
|
+
@size += text.size + 1
|
|
180
|
+
@matches += 1 if match
|
|
181
|
+
true
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def clean(line)
|
|
185
|
+
line.chomp.strip[0, MAX_LINE]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def late?
|
|
189
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) > @deadline
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def stop(reason)
|
|
193
|
+
@stopped ||= reason
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def notice
|
|
198
|
+
if @stopped == :time
|
|
199
|
+
'[The search took too long and stopped. Narrow it with path or glob.]'
|
|
200
|
+
else
|
|
201
|
+
'[More matches. Narrow the query, path, or glob.]'
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
module Tools
|
|
6
|
+
# The +write_file+ tool: creates a text file in the chat's files, or
|
|
7
|
+
# replaces one.
|
|
8
|
+
class Write < Base # :nodoc:
|
|
9
|
+
def self.tool_name
|
|
10
|
+
'write_file'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def description
|
|
14
|
+
'Writes a text file, replacing any file at path. Read an existing file before you replace it. ' \
|
|
15
|
+
'To change part of a file, use edit_file.'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parameters_schema
|
|
19
|
+
{
|
|
20
|
+
'type' => 'object',
|
|
21
|
+
'properties' => {
|
|
22
|
+
'path' => { 'type' => 'string', 'description' => 'Where to write, such as notes/summary.md' },
|
|
23
|
+
'content' => { 'type' => 'string', 'description' => 'The complete content of the file' }
|
|
24
|
+
},
|
|
25
|
+
'required' => %w[path content],
|
|
26
|
+
'additionalProperties' => false
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def execute(path:, content:)
|
|
31
|
+
guarded_write do |write|
|
|
32
|
+
path = path_for(path)
|
|
33
|
+
file = changing(path) { write.call(path, content.to_s) }
|
|
34
|
+
"Wrote #{file.path} (#{file.human_size})."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
# The tools Chat#with_file_store gives the model, by the names its
|
|
6
|
+
# +tools:+ option takes: +:read+ for +list_files+, +read_file+, and
|
|
7
|
+
# +search_files+, +:write+ for +write_file+, +:edit+ for +edit_file+,
|
|
8
|
+
# and +:delete+ for +delete_file+.
|
|
9
|
+
module Tools # :nodoc:
|
|
10
|
+
GROUPS = { read: %i[List Read Search], write: %i[Write], edit: %i[Edit], delete: %i[Delete] }.freeze
|
|
11
|
+
DEFAULT = %i[read write edit].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Returns +tools+, a name or an Array of them, as Symbols in the
|
|
15
|
+
# order of GROUPS. Raises ArgumentError for a name it does not know,
|
|
16
|
+
# and without +:read+.
|
|
17
|
+
def names(tools)
|
|
18
|
+
names = Array(tools).map { |name| name.to_s.to_sym }
|
|
19
|
+
unknown = names - GROUPS.keys
|
|
20
|
+
if unknown.any?
|
|
21
|
+
raise ArgumentError,
|
|
22
|
+
"tools: takes :read, :write, :edit, and :delete, got #{unknown.map(&:inspect).join(', ')}"
|
|
23
|
+
end
|
|
24
|
+
unless names.include?(:read)
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
'tools: needs :read, since the model reads its files with it'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
GROUPS.keys & names
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the tool classes for +names+.
|
|
33
|
+
def classes(names)
|
|
34
|
+
names.flat_map { |name| GROUPS.fetch(name) }.map { |name| const_get(name) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|