rubino-agent 0.3.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# The degenerate-response recovery ladder — a faithful, rung-by-rung port of
|
|
6
|
+
# the `if not agent._has_content_after_think_block(final_response):` block in
|
|
7
|
+
# the reference conversation loop.
|
|
8
|
+
#
|
|
9
|
+
# This is the load-bearing machinery that cures MiniMax's "completed but
|
|
10
|
+
# empty" / thinking-only responses: a structurally-valid text response whose
|
|
11
|
+
# visible content is empty once the <think> reasoning is stripped. Rather than
|
|
12
|
+
# surfacing that as a finished (empty) turn, the ladder walks seven rungs IN
|
|
13
|
+
# ORDER, each a cheaper-or-smarter recovery than giving up:
|
|
14
|
+
#
|
|
15
|
+
# 1. partial-stream recovery — content already streamed to the user before
|
|
16
|
+
# the turn went degenerate? Use it.
|
|
17
|
+
# 2. prior-turn content — the previous turn already delivered a real
|
|
18
|
+
# answer alongside HOUSEKEEPING tools? Reuse it.
|
|
19
|
+
# 3. post-tool empty nudge — empty right after a tool round? Append a
|
|
20
|
+
# user-level "continue" hint and re-issue.
|
|
21
|
+
# 4. thinking-only prefill ×2 — the model reasoned (<think>) but never spoke?
|
|
22
|
+
# Re-issue the SAME request with an assistant
|
|
23
|
+
# PREFILL seed so it continues into visible
|
|
24
|
+
# text. THE key MiniMax cure.
|
|
25
|
+
# 5. empty-content retry ×3 — truly empty (no text, no reasoning)? Retry.
|
|
26
|
+
# 6. empty → fallback — retries exhausted? Hand to FallbackChain.
|
|
27
|
+
# NOT BUILT — Slice 7 seam, falls through.
|
|
28
|
+
# 7. terminal — still stuck? Raise EmptyModelResponseError.
|
|
29
|
+
# (We DROP the reference "(empty)" sentinel-replay
|
|
30
|
+
# machinery and raise,
|
|
31
|
+
# so Run::Executor maps it to FAILED, never
|
|
32
|
+
# completed-but-empty.)
|
|
33
|
+
#
|
|
34
|
+
# OWNS the two per-turn counters the reference keeps on the agent — prefill
|
|
35
|
+
# attempts (≤2) and empty-content retries (≤3). A fresh instance is built per
|
|
36
|
+
# model call (per ModelCallRunner#call!), so the counters reset exactly where
|
|
37
|
+
# the reference resets them to 0 on a successful content turn.
|
|
38
|
+
#
|
|
39
|
+
# The ladder needs a little turn state the bare AdapterResponse does not carry
|
|
40
|
+
# (what streamed before the drop, the prior assistant turn, whether a tool
|
|
41
|
+
# round just ran). That is threaded in via RecoveryState, NOT re-derived here.
|
|
42
|
+
class DegenerateResponseRecovery
|
|
43
|
+
# Per-turn recovery state the ladder reads. Built by the runner/loop and
|
|
44
|
+
# handed to #recover with each degenerate response.
|
|
45
|
+
#
|
|
46
|
+
# response : the degenerate AdapterResponse just received
|
|
47
|
+
# streamed_text : visible text already streamed to the user this
|
|
48
|
+
# call (rung 1); "" when nothing/streamed off
|
|
49
|
+
# messages : the live api-messages array for this turn —
|
|
50
|
+
# the SAME reference the loop owns, so a rung-3
|
|
51
|
+
# nudge appended here is seen on re-issue
|
|
52
|
+
# prior_turn_content : last assistant content delivered alongside
|
|
53
|
+
# tool calls in a PRIOR turn (rung 2), or nil
|
|
54
|
+
# prior_tools_all_housekeeping : true only when every tool in that prior
|
|
55
|
+
# turn was housekeeping (memory/todo). The gem
|
|
56
|
+
# has no housekeeping taxonomy yet, so this is
|
|
57
|
+
# false today and rung 2 is a faithful no-op.
|
|
58
|
+
RecoveryState = Struct.new(
|
|
59
|
+
:response, :streamed_text, :messages,
|
|
60
|
+
:prior_turn_content, :prior_tools_all_housekeeping,
|
|
61
|
+
keyword_init: true
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# A directive the runner acts on. `kind` is one of:
|
|
65
|
+
# :use — return `content` as the final answer (rungs 1, 2)
|
|
66
|
+
# :nudge — request.messages was mutated; re-issue the same request (rung 3)
|
|
67
|
+
# :prefill — re-issue carrying `seed` as request.prefill (rung 4)
|
|
68
|
+
# :retry — re-issue the same request unchanged (rung 5)
|
|
69
|
+
# :raise — terminal: raise EmptyModelResponseError (rungs 6→7)
|
|
70
|
+
# `attempt` is the 1-based retry index on a :retry, so the runner can
|
|
71
|
+
# escalate its invalid-response backoff across the ≤3 retries.
|
|
72
|
+
Directive = Struct.new(:kind, :content, :seed, :attempt, keyword_init: true)
|
|
73
|
+
|
|
74
|
+
DEFAULT_PREFILL_MAX = 2
|
|
75
|
+
DEFAULT_EMPTY_MAX = 3
|
|
76
|
+
|
|
77
|
+
# The user-level hint appended after an empty post-tool turn (rung 3),
|
|
78
|
+
# verbatim from the reference implementation.
|
|
79
|
+
NUDGE_TEXT =
|
|
80
|
+
"You just executed tool calls but returned an empty response. " \
|
|
81
|
+
"Please process the tool results above and continue with the task."
|
|
82
|
+
|
|
83
|
+
def initialize(validator: ResponseValidator.new, ui: nil,
|
|
84
|
+
prefill_max: DEFAULT_PREFILL_MAX, empty_max: DEFAULT_EMPTY_MAX)
|
|
85
|
+
@validator = validator
|
|
86
|
+
@ui = ui
|
|
87
|
+
@prefill_max = prefill_max
|
|
88
|
+
@empty_max = empty_max
|
|
89
|
+
@prefill_attempts = 0
|
|
90
|
+
@empty_attempts = 0
|
|
91
|
+
# _post_tool_empty_retried — the nudge fires at most once per turn.
|
|
92
|
+
@nudged = false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Walk the ladder for one degenerate response and return a Directive.
|
|
96
|
+
# Mirrors the reference conversation loop rung for rung, in order.
|
|
97
|
+
def recover(state)
|
|
98
|
+
# ── Rung 1: partial-stream recovery ──────────────────
|
|
99
|
+
# If real content was streamed to the user before the turn came back
|
|
100
|
+
# degenerate, deliver it instead of wasting calls on retries.
|
|
101
|
+
if content_after_think?(state.streamed_text)
|
|
102
|
+
note("↻ Stream interrupted — using delivered content as final response")
|
|
103
|
+
return Directive.new(kind: :use, content: strip_think(state.streamed_text))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ── Rung 2: prior-turn content fallback ──────────────
|
|
107
|
+
# The previous turn already delivered a real answer alongside
|
|
108
|
+
# HOUSEKEEPING-only tools; the model has nothing more to say. Reuse it
|
|
109
|
+
# rather than retrying. Guarded on all-housekeeping so mid-task
|
|
110
|
+
# narration ("I'll scan the directory…") falls through to the nudge.
|
|
111
|
+
if state.prior_turn_content && state.prior_tools_all_housekeeping
|
|
112
|
+
note("↻ Empty response after tool calls — using earlier content as final answer")
|
|
113
|
+
return Directive.new(kind: :use, content: strip_think(state.prior_turn_content))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
has_inline_thinking = inline_thinking?(state.response)
|
|
117
|
+
|
|
118
|
+
# ── Rung 3: post-tool empty nudge ────────────────────
|
|
119
|
+
# Empty right after a tool round (and NOT a thinking-only response —
|
|
120
|
+
# that routes to prefill below). Append the empty assistant turn then a
|
|
121
|
+
# user-level nudge so the sequence stays valid (tool → assistant →
|
|
122
|
+
# user), and re-issue. Fires at most once per turn.
|
|
123
|
+
if prior_was_tool?(state.messages) && !@nudged && !has_inline_thinking
|
|
124
|
+
@nudged = true
|
|
125
|
+
note("⚠️ Model returned empty after tool calls — nudging to continue")
|
|
126
|
+
append_nudge!(state.messages, state.response)
|
|
127
|
+
return Directive.new(kind: :nudge)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ── Rung 4: thinking-only prefill-to-continue ×2 ─────
|
|
131
|
+
# The model produced reasoning (structured thinking field OR inline
|
|
132
|
+
# <think>) but no visible text. Re-issue the SAME request seeded with an
|
|
133
|
+
# assistant PREFILL so the model continues from its own reasoning into
|
|
134
|
+
# the visible answer. THE MiniMax cure.
|
|
135
|
+
if has_structured?(state.response) && @prefill_attempts < @prefill_max
|
|
136
|
+
@prefill_attempts += 1
|
|
137
|
+
note("↻ Thinking-only response — prefilling to continue " \
|
|
138
|
+
"(#{@prefill_attempts}/#{@prefill_max})")
|
|
139
|
+
return Directive.new(kind: :prefill, seed: prefill_seed(state.response))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ── Rung 5: empty-content retry ×3 ───────────────────
|
|
143
|
+
# Truly empty (nothing usable once <think> is stripped), OR a reasoning
|
|
144
|
+
# model that has now exhausted its prefill attempts. Plain retry.
|
|
145
|
+
truly_empty = strip_think(state.response.content).empty?
|
|
146
|
+
prefill_exhausted = has_structured?(state.response) && @prefill_attempts >= @prefill_max
|
|
147
|
+
if truly_empty && (!has_structured?(state.response) || prefill_exhausted) &&
|
|
148
|
+
@empty_attempts < @empty_max
|
|
149
|
+
@empty_attempts += 1
|
|
150
|
+
note("⚠️ Empty response from model — retrying (#{@empty_attempts}/#{@empty_max})")
|
|
151
|
+
return Directive.new(kind: :retry, attempt: @empty_attempts)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ── Rung 6: empty → fallback ─────────────────────────
|
|
155
|
+
# SLICE-7 seam. The reference here tries _try_activate_fallback() and, on a
|
|
156
|
+
# successful switch, resets _empty_content_retries to 0 and continues on
|
|
157
|
+
# the new provider. FallbackChain is not built yet (Slice 7), so there
|
|
158
|
+
# is no provider to switch to — fall straight through to rung 7. When
|
|
159
|
+
# FallbackChain lands, attempt the switch here and return :retry on
|
|
160
|
+
# success (zeroing @empty_attempts).
|
|
161
|
+
|
|
162
|
+
# ── Rung 7: terminal ─────────────────────────────────
|
|
163
|
+
# Exhausted every rung. We DROP the reference "(empty)" sentinel-replay
|
|
164
|
+
# machinery: the runner raises EmptyModelResponseError so the
|
|
165
|
+
# run is marked FAILED, never completed-but-empty.
|
|
166
|
+
Directive.new(kind: :raise)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# Append the empty assistant turn then the user nudge, so the on-the-wire
|
|
172
|
+
# sequence stays valid: tool(result) → assistant("(empty)") → user(nudge).
|
|
173
|
+
# A bare tool → user is rejected by most strict providers. Mirrors the
|
|
174
|
+
# reference implementation.
|
|
175
|
+
def append_nudge!(messages, response)
|
|
176
|
+
messages << {
|
|
177
|
+
role: "assistant",
|
|
178
|
+
content: response.content.to_s.empty? ? "(empty)" : response.content,
|
|
179
|
+
tool_calls: response.has_tool_calls? ? response.tool_calls : nil
|
|
180
|
+
}
|
|
181
|
+
messages << { role: "user", content: NUDGE_TEXT }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The assistant-seed text for prefill-to-continue. The reference re-appends the
|
|
185
|
+
# model's own interim (thinking) message and lets the model continue from
|
|
186
|
+
# it; on our boundary the equivalent is seeding the next assistant turn
|
|
187
|
+
# with the reasoning the model already produced, so it continues into the
|
|
188
|
+
# visible answer. Prefer the structured thinking field; fall back to the
|
|
189
|
+
# inline <think> content. Returns "" only if nothing is recoverable (the
|
|
190
|
+
# boundary still sends a continuation prompt — an empty prefill is a plain
|
|
191
|
+
# re-issue, harmless).
|
|
192
|
+
def prefill_seed(response)
|
|
193
|
+
seed = response.thinking.to_s
|
|
194
|
+
seed = think_only(response.content) if seed.strip.empty?
|
|
195
|
+
seed.to_s
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# True when the response carries reasoning by ANY channel the reference checks:
|
|
199
|
+
# a structured thinking field OR an inline <think>/<thinking>/<reasoning>
|
|
200
|
+
# block in the content (Ollama/Qwen put it there).
|
|
201
|
+
def has_structured?(response)
|
|
202
|
+
return true if response.thinking.to_s.strip != ""
|
|
203
|
+
|
|
204
|
+
inline_thinking?(response)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Inline-thinking detector — matches the reference _has_inline_thinking regex.
|
|
208
|
+
def inline_thinking?(response)
|
|
209
|
+
!!(response.content.to_s =~ /<think>|<thinking>|<reasoning>/i)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Any recent message a tool result? The reference checks the last 5 messages.
|
|
213
|
+
def prior_was_tool?(messages)
|
|
214
|
+
Array(messages).last(5).any? { |m| (m[:role] || m["role"]).to_s == "tool" }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# True when visible text survives stripping the <think> block — the gem's
|
|
218
|
+
# ResponseValidator already owns this judgement, so reuse it on a synthetic
|
|
219
|
+
# content-only response rather than duplicating the filter.
|
|
220
|
+
def content_after_think?(text)
|
|
221
|
+
return false if text.to_s.strip.empty?
|
|
222
|
+
|
|
223
|
+
!@validator.degenerate?(content_probe(text))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def strip_think(text)
|
|
227
|
+
think_only(text).empty? ? collapse(text) : visible_after_think(text)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The visible content with <think> blocks removed, stripped.
|
|
231
|
+
def visible_after_think(text)
|
|
232
|
+
visible = +""
|
|
233
|
+
filter = LLM::InlineThinkFilter.new
|
|
234
|
+
emit = ->(type, str) { visible << str if type == :content }
|
|
235
|
+
filter.feed(text.to_s, &emit)
|
|
236
|
+
filter.flush(&emit)
|
|
237
|
+
visible.strip
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Just the think-block contents, for the prefill seed.
|
|
241
|
+
def think_only(text)
|
|
242
|
+
thinking = +""
|
|
243
|
+
filter = LLM::InlineThinkFilter.new
|
|
244
|
+
emit = ->(type, str) { thinking << str if type == :thinking }
|
|
245
|
+
filter.feed(text.to_s, &emit)
|
|
246
|
+
filter.flush(&emit)
|
|
247
|
+
thinking.strip
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def collapse(text)
|
|
251
|
+
text.to_s.strip
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Minimal AdapterResponse-shaped probe so we can reuse ResponseValidator
|
|
255
|
+
# #degenerate? on a raw streamed string.
|
|
256
|
+
def content_probe(text)
|
|
257
|
+
LLM::AdapterResponse.new(
|
|
258
|
+
content: text.to_s, tool_calls: [], input_tokens: 0, output_tokens: 0,
|
|
259
|
+
model_id: nil
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def note(text)
|
|
264
|
+
@ui&.note(text)
|
|
265
|
+
rescue StandardError
|
|
266
|
+
# UI may be a Null/test double without #note — never let status text
|
|
267
|
+
# abort recovery.
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# The provider/model fallback chain — a faithful port of the reference
|
|
6
|
+
# `_fallback_chain` + `try_activate_fallback`
|
|
7
|
+
# and the per-turn `_restore_primary_runtime`.
|
|
8
|
+
#
|
|
9
|
+
# WHAT IT DOES. The primary backend is index 0; `agent.fallback_models` lists
|
|
10
|
+
# the ordered fallbacks. When the primary keeps failing — invalid/empty
|
|
11
|
+
# responses (eager fallback), rate-limit/overload, or an exhausted
|
|
12
|
+
# retry budget, or empty-after-retries — the runner
|
|
13
|
+
# / recovery ladder calls #activate_next! to rotate to the next backend and
|
|
14
|
+
# rebuild the adapter. At the TOP of each new turn ConversationLoop#run calls
|
|
15
|
+
# #restore_primary! so every turn gets a fresh attempt with the preferred
|
|
16
|
+
# model.
|
|
17
|
+
#
|
|
18
|
+
# DEDUP. An entry that resolves to the CURRENT provider/model/base_url is
|
|
19
|
+
# skipped — falling back to the backend that just failed only loops the
|
|
20
|
+
# failure. We keep advancing past skipped entries in a
|
|
21
|
+
# single #activate_next! call, exactly like the reference recursive
|
|
22
|
+
# `return agent._try_activate_fallback()`.
|
|
23
|
+
#
|
|
24
|
+
# GLOBAL-CONFIG ISOLATION (the heart of this slice).
|
|
25
|
+
# `RubyLLM.configure` is process-global; a naive provider swap would corrupt
|
|
26
|
+
# concurrent sessions on the API/server path. So fallback adapters are built
|
|
27
|
+
# with `isolate_config: true`: each scopes its provider config (base_url /
|
|
28
|
+
# api_key / timeout) into a per-adapter `RubyLLM::Context` and NEVER writes
|
|
29
|
+
# the global. The primary adapter is passed in as-is (it already configured
|
|
30
|
+
# the global at construction, exactly as before), so a single-provider setup
|
|
31
|
+
# — and the no-fallback case — is byte-identical to pre-Slice-7 behaviour.
|
|
32
|
+
#
|
|
33
|
+
# NO-OP WHEN UNCONFIGURED. With an empty `fallback_models` the chain holds
|
|
34
|
+
# only the primary: #activate_next! is always false and #current_adapter is
|
|
35
|
+
# always the primary. Nothing is rebuilt, nothing is mutated.
|
|
36
|
+
class FallbackChain
|
|
37
|
+
# One backend in the chain. provider/model are required to be usable; an
|
|
38
|
+
# entry missing either is treated as invalid and skipped on advance.
|
|
39
|
+
Entry = Struct.new(:provider, :model, :base_url, :api_key, keyword_init: true) do
|
|
40
|
+
def usable?
|
|
41
|
+
!provider.to_s.strip.empty? && !model.to_s.strip.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# primary_adapter : the already-built primary LLM adapter (index 0). The
|
|
46
|
+
# chain never rebuilds it — restore just points back to it.
|
|
47
|
+
# config : the live Configuration (reads agent.fallback_models and
|
|
48
|
+
# the providers.* blocks the fallback entries inherit).
|
|
49
|
+
# adapter_builder : injectable seam for tests; defaults to AdapterFactory.
|
|
50
|
+
def initialize(primary_adapter:, config:, ui: nil, event_bus: nil,
|
|
51
|
+
tool_executor: nil, cancel_token: nil,
|
|
52
|
+
adapter_builder: LLM::AdapterFactory)
|
|
53
|
+
@primary = primary_adapter
|
|
54
|
+
@config = config
|
|
55
|
+
@ui = ui
|
|
56
|
+
@event_bus = event_bus
|
|
57
|
+
@tool_executor = tool_executor
|
|
58
|
+
@cancel_token = cancel_token
|
|
59
|
+
@adapter_builder = adapter_builder
|
|
60
|
+
|
|
61
|
+
@entries = build_entries
|
|
62
|
+
@index = 0
|
|
63
|
+
@active = @primary
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The adapter the loop/runner should issue calls against right now.
|
|
67
|
+
def current_adapter
|
|
68
|
+
@active
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# True once a fallback has been activated this turn — lets callers emit the
|
|
72
|
+
# "switched to fallback" status only when something actually changed.
|
|
73
|
+
def active?
|
|
74
|
+
@index.positive?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Advance to the next usable, non-duplicate fallback and rebuild the
|
|
78
|
+
# adapter. Returns true if it actually switched, false when the chain is
|
|
79
|
+
# exhausted (or empty). Mirrors try_activate_fallback (helpers.py:1020):
|
|
80
|
+
# skip invalid entries and entries that resolve to the current backend,
|
|
81
|
+
# advancing past them within this one call.
|
|
82
|
+
def activate_next!
|
|
83
|
+
loop do
|
|
84
|
+
return false if @index >= @entries.size
|
|
85
|
+
|
|
86
|
+
entry = @entries[@index]
|
|
87
|
+
@index += 1
|
|
88
|
+
|
|
89
|
+
next unless entry.usable?
|
|
90
|
+
next if duplicate_of_current?(entry)
|
|
91
|
+
|
|
92
|
+
@active = build_adapter(entry)
|
|
93
|
+
return true
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Reset to the primary at the top of each turn. No-op cost when
|
|
98
|
+
# we never left the primary; rebuilds nothing (the primary adapter is the
|
|
99
|
+
# one handed in at construction).
|
|
100
|
+
def restore_primary!
|
|
101
|
+
@index = 0
|
|
102
|
+
@active = @primary
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# The fallback entries (NOT including the implicit primary at index 0).
|
|
108
|
+
def build_entries
|
|
109
|
+
Array(@config.dig("agent", "fallback_models")).filter_map do |raw|
|
|
110
|
+
next unless raw.is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
Entry.new(
|
|
113
|
+
provider: fetch(raw, "provider"),
|
|
114
|
+
model: fetch(raw, "model"),
|
|
115
|
+
base_url: fetch(raw, "base_url"),
|
|
116
|
+
api_key: fetch(raw, "api_key")
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def fetch(hash, key)
|
|
122
|
+
value = hash[key] || hash[key.to_sym]
|
|
123
|
+
value.to_s.strip.empty? ? nil : value.to_s
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Skip an entry whose RESOLVED provider+model (or base_url+model) matches the
|
|
127
|
+
# active adapter — falling back to the same backend just loops the failure.
|
|
128
|
+
def duplicate_of_current?(entry)
|
|
129
|
+
resolved = LLM::ProviderResolver.resolve(entry.model, explicit_provider: entry.provider)
|
|
130
|
+
cur_provider = @active.provider.to_s.strip.downcase
|
|
131
|
+
cur_model = @active.model_id.to_s.strip
|
|
132
|
+
|
|
133
|
+
return true if resolved.to_s.strip.downcase == cur_provider && entry.model.to_s.strip == cur_model
|
|
134
|
+
|
|
135
|
+
entry_base = normalize_url(entry.base_url)
|
|
136
|
+
cur_base = normalize_url(current_base_url)
|
|
137
|
+
!entry_base.empty? && !cur_base.empty? &&
|
|
138
|
+
entry_base == cur_base && entry.model.to_s.strip == cur_model
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The base_url the active adapter is pointed at (its provider's config
|
|
142
|
+
# base_url), for the dedup comparison.
|
|
143
|
+
def current_base_url
|
|
144
|
+
@config.provider_config(@active.provider)["base_url"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def normalize_url(url)
|
|
148
|
+
url.to_s.strip.sub(%r{/+\z}, "").downcase
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Rebuild the adapter for a fallback entry. The entry's base_url/api_key
|
|
152
|
+
# override the providers.<name> block for THIS adapter only; everything is
|
|
153
|
+
# scoped into a per-call RubyLLM::Context via isolate_config: true so the
|
|
154
|
+
# process-global RubyLLM.configure is never mutated.
|
|
155
|
+
def build_adapter(entry)
|
|
156
|
+
@adapter_builder.build(
|
|
157
|
+
model_id: entry.model,
|
|
158
|
+
provider: entry.provider,
|
|
159
|
+
config: config_for(entry),
|
|
160
|
+
ui: @ui,
|
|
161
|
+
event_bus: @event_bus,
|
|
162
|
+
tool_executor: @tool_executor,
|
|
163
|
+
cancel_token: @cancel_token,
|
|
164
|
+
isolate_config: true
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A per-entry Configuration whose providers.<provider> block carries the
|
|
169
|
+
# entry's base_url/api_key overrides, leaving the shared config untouched
|
|
170
|
+
# (deep-dup of the provider section only — nothing else is copied or
|
|
171
|
+
# mutated). The adapter reads base_url/api_key from here.
|
|
172
|
+
def config_for(entry)
|
|
173
|
+
overrides = {}
|
|
174
|
+
overrides["base_url"] = entry.base_url if entry.base_url
|
|
175
|
+
overrides["api_key"] = entry.api_key if entry.api_key
|
|
176
|
+
return @config if overrides.empty?
|
|
177
|
+
|
|
178
|
+
raw = deep_dup(@config.raw)
|
|
179
|
+
provider = entry.provider.to_s
|
|
180
|
+
raw["providers"] ||= {}
|
|
181
|
+
raw["providers"][provider] = (raw["providers"][provider] || {}).merge(overrides)
|
|
182
|
+
Config::Configuration.new(raw: raw)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def deep_dup(obj)
|
|
186
|
+
case obj
|
|
187
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
188
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
189
|
+
else obj
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# Manages turn and iteration budgets to prevent runaway loops.
|
|
6
|
+
class IterationBudget
|
|
7
|
+
def initialize(config: nil, max_tool_iterations: nil)
|
|
8
|
+
@config = config || Rubino.configuration
|
|
9
|
+
@max_turns = @config.agent_max_turns
|
|
10
|
+
# An explicit override (the CLI `--max-turns N` flag, threaded through
|
|
11
|
+
# Runner → Lifecycle) wins over the config default so the documented
|
|
12
|
+
# control knob actually caps tool iterations (#141). A nil/blank
|
|
13
|
+
# override falls back to the configured budget, unchanged.
|
|
14
|
+
@max_tool_iterations = positive_int(max_tool_iterations) || @config.agent_max_tool_iterations
|
|
15
|
+
@max_turn_seconds = @config.agent_max_turn_seconds
|
|
16
|
+
@turn_started_at = Time.now
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns true if the agent can continue iterating
|
|
20
|
+
def can_continue?(iteration)
|
|
21
|
+
within_iteration_limit?(iteration) && within_time_limit?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Coerce an override to a positive Integer, or nil if it's absent/garbage
|
|
27
|
+
# (so the config default is used). Accepts the numeric Thor option, which
|
|
28
|
+
# arrives as a Float, and rejects 0/negative values as "no cap given".
|
|
29
|
+
def positive_int(value)
|
|
30
|
+
return nil if value.nil?
|
|
31
|
+
|
|
32
|
+
n = Integer(value, exception: false) || Float(value, exception: false)&.to_i
|
|
33
|
+
n if n && n.positive?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A nil cap means "unbounded": never stop on that dimension rather than
|
|
37
|
+
# crashing the turn comparing a number with nil (#139).
|
|
38
|
+
def within_iteration_limit?(iteration)
|
|
39
|
+
@max_tool_iterations.nil? || iteration <= @max_tool_iterations
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def within_time_limit?
|
|
43
|
+
return true if @max_turn_seconds.nil?
|
|
44
|
+
|
|
45
|
+
elapsed = Time.now - @turn_started_at
|
|
46
|
+
elapsed < @max_turn_seconds
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|