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,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "net/http"
|
|
6
|
+
|
|
7
|
+
module Rubino
|
|
8
|
+
module LLM
|
|
9
|
+
# Why an API call failed — determines recovery strategy. A faithful subset
|
|
10
|
+
# of the reference FailoverReason: only the CORE reasons
|
|
11
|
+
# this gem can actually act on are ported. Provider-niche reasons
|
|
12
|
+
# (thinking_signature, llama_cpp_grammar, encrypted_content,
|
|
13
|
+
# long_context_tier, image_too_large, …) are intentionally dropped.
|
|
14
|
+
#
|
|
15
|
+
# The load-bearing default is `unknown → retryable`:
|
|
16
|
+
# an unclassifiable provider blip backs off and retries rather than aborting.
|
|
17
|
+
module FailoverReason
|
|
18
|
+
AUTH = :auth # 401/403 — invalid credential, don't retry as-is
|
|
19
|
+
BILLING = :billing # 402 / credit exhaustion — don't retry
|
|
20
|
+
RATE_LIMIT = :rate_limit # 429 — backoff then retry
|
|
21
|
+
OVERLOADED = :overloaded # 503/529 — provider overloaded, backoff
|
|
22
|
+
SERVER_ERROR = :server_error # 500/502 — internal server error, retry
|
|
23
|
+
TIMEOUT = :timeout # connection/read timeout / transport drop — retry
|
|
24
|
+
CONTEXT_OVERFLOW = :context_overflow # context too large — compress, not failover
|
|
25
|
+
MODEL_NOT_FOUND = :model_not_found # 404 / invalid model — fallback to another model
|
|
26
|
+
FORMAT_ERROR = :format_error # 400 bad request — abort + fallback
|
|
27
|
+
UNKNOWN = :unknown # unclassifiable — retry with backoff
|
|
28
|
+
|
|
29
|
+
ALL = [
|
|
30
|
+
AUTH, BILLING, RATE_LIMIT, OVERLOADED, SERVER_ERROR, TIMEOUT,
|
|
31
|
+
CONTEXT_OVERFLOW, MODEL_NOT_FOUND, FORMAT_ERROR, UNKNOWN
|
|
32
|
+
].freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Structured classification of an API error with recovery hints, mirroring
|
|
36
|
+
# the reference ClassifiedError. The retry loop checks
|
|
37
|
+
# these hints instead of re-classifying the error itself.
|
|
38
|
+
#
|
|
39
|
+
# `should_rotate_credential` is recorded for fidelity but is a NO-OP in this
|
|
40
|
+
# gem: there is no credential pool to rotate. `should_fallback` is likewise
|
|
41
|
+
# advisory until the FallbackChain lands (Slice 7).
|
|
42
|
+
ClassifiedError = Data.define(
|
|
43
|
+
:reason, :status_code, :message,
|
|
44
|
+
:retryable, :should_compress, :should_rotate_credential, :should_fallback
|
|
45
|
+
) do
|
|
46
|
+
def auth?
|
|
47
|
+
reason == FailoverReason::AUTH
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Centralized API-error classifier — the single source of truth for "is this
|
|
52
|
+
# error worth a retry?", replacing the adapter's boolean transient_error?.
|
|
53
|
+
# Port of the reference classify_api_error, reduced to
|
|
54
|
+
# the structural signals ruby_llm actually surfaces: a typed error class and
|
|
55
|
+
# the wrapped HTTP status. We do NOT port the giant message-pattern tables
|
|
56
|
+
# (billing/rate-limit/context phrase lists) — ruby_llm raises typed classes,
|
|
57
|
+
# so status + class carry the same information without the brittle matching.
|
|
58
|
+
# The one message-based branch kept is the MiniMax "unknown error" (code
|
|
59
|
+
# 999/1000) blip, which arrives statusless and must stay in the retryable
|
|
60
|
+
# `unknown` bucket.
|
|
61
|
+
module ErrorClassifier
|
|
62
|
+
# Transport-level drops that surface mid-request and never reach an HTTP
|
|
63
|
+
# status — always retryable. faraday-net_http re-raises IOError/EOFError
|
|
64
|
+
# (and friends) as Faraday::ConnectionFailed, the type we actually see for
|
|
65
|
+
# an upstream socket close; the rest are defensive.
|
|
66
|
+
STREAM_DROP_ERRORS = [
|
|
67
|
+
Faraday::ConnectionFailed, Faraday::TimeoutError,
|
|
68
|
+
Net::OpenTimeout, Net::ReadTimeout,
|
|
69
|
+
EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
# ruby_llm 1.15 raises a typed error per HTTP status. Map the classes we
|
|
73
|
+
# can name directly; everything else falls through to status-based then
|
|
74
|
+
# unknown classification.
|
|
75
|
+
RETRYABLE_HTTP = ->(status) { status && (status >= 500 || status == 429) }.freeze
|
|
76
|
+
|
|
77
|
+
# Body/message fragments identifying a transient provider "unknown error"
|
|
78
|
+
# (MiniMax api_error 999/1000 on the Anthropic-compatible endpoint). Kept
|
|
79
|
+
# narrow and provider-blip-specific. Moved here from the adapter so the
|
|
80
|
+
# classifier is the single source of truth (folds Slice 0(b)).
|
|
81
|
+
UNKNOWN_PROVIDER_ERROR_PATTERNS = [
|
|
82
|
+
"unknown error",
|
|
83
|
+
"api_error 999",
|
|
84
|
+
"api_error 1000",
|
|
85
|
+
"\"code\":999",
|
|
86
|
+
"\"code\": 999",
|
|
87
|
+
"\"code\":1000",
|
|
88
|
+
"\"code\": 1000",
|
|
89
|
+
"code 999",
|
|
90
|
+
"code 1000"
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
93
|
+
# Last-resort transport-drop phrases for statusless errors that never
|
|
94
|
+
# surfaced as a typed transport class.
|
|
95
|
+
TRANSIENT_TRANSPORT_PATTERNS = [
|
|
96
|
+
"timeout", "timed out", "connection reset",
|
|
97
|
+
"connection refused", "broken pipe", "end of file reached"
|
|
98
|
+
].freeze
|
|
99
|
+
|
|
100
|
+
# Local Ruby PROGRAMMING errors — unambiguous bugs in our own code (or a
|
|
101
|
+
# caller's), not provider/API blips. These must NEVER be retried: a retry
|
|
102
|
+
# storm would mask the bug behind backoff (the very thing that turned a
|
|
103
|
+
# mid-turn `NoMethodError` from the UI into three `llm.retry` warnings).
|
|
104
|
+
# They reach `classify` only because ModelCallRunner rescues StandardError
|
|
105
|
+
# broadly around the boundary call; the reference classify_api_error never sees
|
|
106
|
+
# them because it only ever runs at the API layer. So we short-circuit them
|
|
107
|
+
# to NON-retryable (reason stays :unknown) BEFORE the unknown→retryable
|
|
108
|
+
# fallback, surfacing the bug immediately. The set is curated by CLASS, not
|
|
109
|
+
# message: every entry is a clear local bug. RuntimeError is deliberately
|
|
110
|
+
# EXCLUDED — it is too generic (ruby_llm/providers raise it for transient
|
|
111
|
+
# conditions), so it stays on the message-based path and keeps its
|
|
112
|
+
# provider-blip retryability.
|
|
113
|
+
LOCAL_PROGRAMMING_ERRORS = [
|
|
114
|
+
NoMethodError, NameError, NoMatchingPatternError, NoMatchingPatternKeyError,
|
|
115
|
+
ArgumentError, TypeError, NotImplementedError, FrozenError,
|
|
116
|
+
LocalJumpError, ThreadError, FiberError
|
|
117
|
+
].freeze
|
|
118
|
+
|
|
119
|
+
module_function
|
|
120
|
+
|
|
121
|
+
# Classify an error into a ClassifiedError with reason + recovery hints.
|
|
122
|
+
# Priority mirrors the reference pipeline: typed/transport class → HTTP status →
|
|
123
|
+
# statusless provider-unknown / transport → unknown (retryable default).
|
|
124
|
+
def classify(error)
|
|
125
|
+
status = http_status(error)
|
|
126
|
+
|
|
127
|
+
result = classify_missing_credential(error) ||
|
|
128
|
+
classify_invalid_credential(error) ||
|
|
129
|
+
classify_transport(error) ||
|
|
130
|
+
classify_invalid_media(error) ||
|
|
131
|
+
classify_typed(error) ||
|
|
132
|
+
(status && classify_by_status(status, error)) ||
|
|
133
|
+
classify_statusless(error)
|
|
134
|
+
return result if result
|
|
135
|
+
|
|
136
|
+
# A genuine local Ruby bug (NoMethodError, ArgumentError, …) is NOT a
|
|
137
|
+
# retryable provider blip — propagate it immediately instead of letting
|
|
138
|
+
# the unknown→retryable default mask it behind a backoff storm.
|
|
139
|
+
return result_for(FailoverReason::UNKNOWN, status, error, retryable: false) if local_programming_error?(error)
|
|
140
|
+
|
|
141
|
+
result_for(FailoverReason::UNKNOWN, status, error, retryable: true)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Convenience: just the boolean the adapter's retry loop needs.
|
|
145
|
+
def retryable?(error)
|
|
146
|
+
classify(error).retryable
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── classification stages ────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
# A missing / unconfigured credential — raised BEFORE any HTTP call, so it
|
|
152
|
+
# carries no status and would otherwise fall through to the unknown→
|
|
153
|
+
# retryable default and trigger an ~80s retry storm that exits empty (#93).
|
|
154
|
+
# ruby_llm raises RubyLLM::ConfigurationError ("Missing configuration for
|
|
155
|
+
# OpenRouter: openrouter_api_key") when a provider's key is unset; our own
|
|
156
|
+
# adapter raises Rubino::Error ("Missing API key for provider ..."). A
|
|
157
|
+
# missing key is a credential problem the user must fix — classify it as a
|
|
158
|
+
# NON-retryable AUTH error so the runner surfaces it immediately.
|
|
159
|
+
MISSING_CREDENTIAL_PATTERNS = [
|
|
160
|
+
"missing configuration for",
|
|
161
|
+
"missing api key",
|
|
162
|
+
"no api key",
|
|
163
|
+
"api key is not set",
|
|
164
|
+
"_api_key"
|
|
165
|
+
].freeze
|
|
166
|
+
|
|
167
|
+
def classify_missing_credential(error)
|
|
168
|
+
is_config_error =
|
|
169
|
+
defined?(RubyLLM::ConfigurationError) && error.is_a?(RubyLLM::ConfigurationError)
|
|
170
|
+
msg = error.message.to_s.downcase
|
|
171
|
+
return unless is_config_error || MISSING_CREDENTIAL_PATTERNS.any? { |p| msg.include?(p) }
|
|
172
|
+
|
|
173
|
+
result_for(FailoverReason::AUTH, http_status(error), error,
|
|
174
|
+
retryable: false, should_rotate_credential: true, should_fallback: true)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A PRESENT but INVALID credential rejected by the provider via a
|
|
178
|
+
# statusless / untyped error body (MiniMax's Anthropic-compatible
|
|
179
|
+
# endpoint says "login fail" with no 401), which used to fall through to
|
|
180
|
+
# the unknown→retryable default and burn ~60-90s of silent retries on a
|
|
181
|
+
# deterministic auth failure (#126). Same deal as a typed 401/403:
|
|
182
|
+
# NON-retryable AUTH, surfaced immediately. Patterns are the literal
|
|
183
|
+
# provider phrasings, kept narrow.
|
|
184
|
+
INVALID_CREDENTIAL_PATTERNS = [
|
|
185
|
+
"login fail",
|
|
186
|
+
"invalid api key",
|
|
187
|
+
"incorrect api key",
|
|
188
|
+
"invalid x-api-key",
|
|
189
|
+
"authentication_error",
|
|
190
|
+
"authentication failed"
|
|
191
|
+
].freeze
|
|
192
|
+
|
|
193
|
+
def classify_invalid_credential(error)
|
|
194
|
+
msg = error.message.to_s.downcase
|
|
195
|
+
return unless INVALID_CREDENTIAL_PATTERNS.any? { |p| msg.include?(p) }
|
|
196
|
+
|
|
197
|
+
result_for(FailoverReason::AUTH, http_status(error), error,
|
|
198
|
+
retryable: false, should_rotate_credential: true, should_fallback: true)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Transport drops (Faraday::ConnectionFailed for the MiniMax EOF, read/
|
|
202
|
+
# connect timeouts, …) are retryable regardless of message — they never
|
|
203
|
+
# reach an HTTP status. STREAM_DROP_ERRORS lives on the adapter.
|
|
204
|
+
def classify_transport(error)
|
|
205
|
+
return unless STREAM_DROP_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
206
|
+
|
|
207
|
+
result_for(FailoverReason::TIMEOUT, nil, error, retryable: true)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Provider media/image validation rejections — a PERMANENT 4xx-class
|
|
211
|
+
# complaint about the attachment itself, which some providers (MiniMax
|
|
212
|
+
# Anthropic-compat) surface statusless so it used to fall through to the
|
|
213
|
+
# unknown→retryable default and burn the whole retry budget (~80s) on a
|
|
214
|
+
# bad image (#98). The same attachment fails identically on every retry,
|
|
215
|
+
# so fail fast. Patterns are the literal provider phrasings, kept narrow.
|
|
216
|
+
INVALID_MEDIA_PATTERNS = [
|
|
217
|
+
"media exceeds size limit",
|
|
218
|
+
"invalid image content",
|
|
219
|
+
"image: unknown format",
|
|
220
|
+
"could not process image"
|
|
221
|
+
].freeze
|
|
222
|
+
|
|
223
|
+
def classify_invalid_media(error)
|
|
224
|
+
msg = error.message.to_s.downcase
|
|
225
|
+
return unless INVALID_MEDIA_PATTERNS.any? { |p| msg.include?(p) }
|
|
226
|
+
|
|
227
|
+
result_for(FailoverReason::FORMAT_ERROR, http_status(error), error,
|
|
228
|
+
retryable: false, should_fallback: true)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Typed ruby_llm errors we can name without a status lookup.
|
|
232
|
+
def classify_typed(error)
|
|
233
|
+
case error
|
|
234
|
+
when RubyLLM::ContextLengthExceededError
|
|
235
|
+
result_for(FailoverReason::CONTEXT_OVERFLOW, http_status(error), error,
|
|
236
|
+
retryable: false, should_compress: true)
|
|
237
|
+
when RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError
|
|
238
|
+
result_for(FailoverReason::AUTH, http_status(error), error,
|
|
239
|
+
retryable: false, should_rotate_credential: true, should_fallback: true)
|
|
240
|
+
when RubyLLM::PaymentRequiredError
|
|
241
|
+
result_for(FailoverReason::BILLING, http_status(error), error,
|
|
242
|
+
retryable: false, should_rotate_credential: true, should_fallback: true)
|
|
243
|
+
when RubyLLM::RateLimitError
|
|
244
|
+
result_for(FailoverReason::RATE_LIMIT, http_status(error) || 429, error,
|
|
245
|
+
retryable: true, should_rotate_credential: true, should_fallback: true)
|
|
246
|
+
when RubyLLM::OverloadedError, RubyLLM::ServiceUnavailableError
|
|
247
|
+
result_for(FailoverReason::OVERLOADED, http_status(error), error, retryable: true)
|
|
248
|
+
when RubyLLM::ServerError
|
|
249
|
+
result_for(FailoverReason::SERVER_ERROR, http_status(error), error, retryable: true)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# HTTP status classification with message-aware refinement, mirroring
|
|
254
|
+
# _classify_by_status (error_classifier.py:725) for the CORE reasons.
|
|
255
|
+
def classify_by_status(status, error)
|
|
256
|
+
case status
|
|
257
|
+
when 401, 403
|
|
258
|
+
result_for(FailoverReason::AUTH, status, error,
|
|
259
|
+
retryable: false, should_rotate_credential: true, should_fallback: true)
|
|
260
|
+
when 402
|
|
261
|
+
result_for(FailoverReason::BILLING, status, error,
|
|
262
|
+
retryable: false, should_rotate_credential: true, should_fallback: true)
|
|
263
|
+
when 404
|
|
264
|
+
# Generic 404 with no "model not found" signal is treated as unknown
|
|
265
|
+
# (retryable) per the reference: a misconfigured
|
|
266
|
+
# endpoint or proxy glitch shouldn't masquerade as a missing model.
|
|
267
|
+
if model_not_found?(error)
|
|
268
|
+
result_for(FailoverReason::MODEL_NOT_FOUND, status, error,
|
|
269
|
+
retryable: false, should_fallback: true)
|
|
270
|
+
else
|
|
271
|
+
result_for(FailoverReason::UNKNOWN, status, error, retryable: true)
|
|
272
|
+
end
|
|
273
|
+
when 429
|
|
274
|
+
result_for(FailoverReason::RATE_LIMIT, status, error,
|
|
275
|
+
retryable: true, should_rotate_credential: true, should_fallback: true)
|
|
276
|
+
when 503, 529
|
|
277
|
+
result_for(FailoverReason::OVERLOADED, status, error, retryable: true)
|
|
278
|
+
when 400
|
|
279
|
+
if context_overflow?(error)
|
|
280
|
+
result_for(FailoverReason::CONTEXT_OVERFLOW, status, error,
|
|
281
|
+
retryable: false, should_compress: true)
|
|
282
|
+
elsif model_not_found?(error)
|
|
283
|
+
result_for(FailoverReason::MODEL_NOT_FOUND, status, error,
|
|
284
|
+
retryable: false, should_fallback: true)
|
|
285
|
+
else
|
|
286
|
+
result_for(FailoverReason::FORMAT_ERROR, status, error,
|
|
287
|
+
retryable: false, should_fallback: true)
|
|
288
|
+
end
|
|
289
|
+
else
|
|
290
|
+
if status >= 500
|
|
291
|
+
result_for(FailoverReason::SERVER_ERROR, status, error, retryable: true)
|
|
292
|
+
elsif status >= 400
|
|
293
|
+
result_for(FailoverReason::FORMAT_ERROR, status, error,
|
|
294
|
+
retryable: false, should_fallback: true)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# No decisive status: the MiniMax "unknown error" blip and bare transport
|
|
300
|
+
# drops. A permanent 4xx never reaches here (returned above), so the
|
|
301
|
+
# provider-unknown net stays narrow — mirrors the reference unknown→retryable.
|
|
302
|
+
def classify_statusless(error)
|
|
303
|
+
msg = error.message.to_s.downcase
|
|
304
|
+
if UNKNOWN_PROVIDER_ERROR_PATTERNS.any? { |p| msg.include?(p) }
|
|
305
|
+
return result_for(FailoverReason::UNKNOWN, nil, error, retryable: true)
|
|
306
|
+
end
|
|
307
|
+
if TRANSIENT_TRANSPORT_PATTERNS.any? { |p| msg.include?(p) }
|
|
308
|
+
return result_for(FailoverReason::TIMEOUT, nil, error, retryable: true)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# ── helpers ──────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
def result_for(reason, status, error, retryable:, should_compress: false,
|
|
317
|
+
should_rotate_credential: false, should_fallback: false)
|
|
318
|
+
ClassifiedError.new(
|
|
319
|
+
reason: reason,
|
|
320
|
+
status_code: status,
|
|
321
|
+
message: error.respond_to?(:message) ? error.message.to_s[0, 500] : error.to_s[0, 500],
|
|
322
|
+
retryable: retryable,
|
|
323
|
+
should_compress: should_compress,
|
|
324
|
+
should_rotate_credential: should_rotate_credential,
|
|
325
|
+
should_fallback: should_fallback
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# HTTP status from a typed RubyLLM::Error's wrapped Faraday response, or nil.
|
|
330
|
+
def http_status(error)
|
|
331
|
+
return unless error.respond_to?(:response) && error.response.respond_to?(:status)
|
|
332
|
+
|
|
333
|
+
status = error.response.status
|
|
334
|
+
status if status.is_a?(Integer)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
CONTEXT_OVERFLOW_PATTERNS = [
|
|
338
|
+
"context length", "context window", "maximum context",
|
|
339
|
+
"token limit", "too many tokens", "prompt is too long", "max_tokens"
|
|
340
|
+
].freeze
|
|
341
|
+
|
|
342
|
+
MODEL_NOT_FOUND_PATTERNS = [
|
|
343
|
+
"is not a valid model", "invalid model", "model not found",
|
|
344
|
+
"model_not_found", "does not exist", "no such model", "unknown model"
|
|
345
|
+
].freeze
|
|
346
|
+
|
|
347
|
+
def context_overflow?(error)
|
|
348
|
+
return true if error.is_a?(RubyLLM::ContextLengthExceededError)
|
|
349
|
+
|
|
350
|
+
msg = error.message.to_s.downcase
|
|
351
|
+
CONTEXT_OVERFLOW_PATTERNS.any? { |p| msg.include?(p) }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def model_not_found?(error)
|
|
355
|
+
msg = error.message.to_s.downcase
|
|
356
|
+
MODEL_NOT_FOUND_PATTERNS.any? { |p| msg.include?(p) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def local_programming_error?(error)
|
|
360
|
+
LOCAL_PROGRAMMING_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter_response"
|
|
4
|
+
require_relative "scenario_loader"
|
|
5
|
+
require_relative "scenario_selector"
|
|
6
|
+
|
|
7
|
+
module Rubino
|
|
8
|
+
module LLM
|
|
9
|
+
# Dev-only LLM adapter that replays a pre-recorded YAML scenario instead
|
|
10
|
+
# of hitting a real provider. The public surface mirrors RubyLLMAdapter
|
|
11
|
+
# so Agent::Loop can swap it in without further plumbing changes.
|
|
12
|
+
#
|
|
13
|
+
# Selection:
|
|
14
|
+
# - model_id starting with "fake/" pins the scenario (suffix is the name).
|
|
15
|
+
# - otherwise ScenarioSelector.resolve(last_user_message_content) chooses
|
|
16
|
+
# one based on keyword routing, falling back to "happy-path".
|
|
17
|
+
#
|
|
18
|
+
# Streaming:
|
|
19
|
+
# - "content" → yield { type: :content, text: ... }
|
|
20
|
+
# - "thinking" → yield { type: :thinking, text: ... } (gated by
|
|
21
|
+
# display.show_reasoning, mirroring RubyLLMAdapter)
|
|
22
|
+
# - "tool_call" → buffered onto the final AdapterResponse (NOT yielded
|
|
23
|
+
# mid-stream; this matches RubyLLMAdapter and is what Loop
|
|
24
|
+
# expects).
|
|
25
|
+
# - "delay_seconds" → cancellable sleep between events.
|
|
26
|
+
# - unknown → logged and skipped.
|
|
27
|
+
#
|
|
28
|
+
# Cancellation is checked between each event so Esc / Ctrl+C lands within
|
|
29
|
+
# one tick instead of waiting for the full scenario to drain.
|
|
30
|
+
class FakeProvider
|
|
31
|
+
attr_reader :model_id, :provider
|
|
32
|
+
|
|
33
|
+
DEFAULT_DELAY = 0.1
|
|
34
|
+
|
|
35
|
+
def initialize(model_id: nil, provider: nil, config: nil, ui: nil, event_bus: nil,
|
|
36
|
+
tool_executor: nil, cancel_token: nil)
|
|
37
|
+
@config = config || Rubino.configuration
|
|
38
|
+
@model_id = model_id || @config.model_default || "fake/happy-path"
|
|
39
|
+
@provider = provider || "fake"
|
|
40
|
+
@ui = ui
|
|
41
|
+
@event_bus = event_bus
|
|
42
|
+
@tool_executor = tool_executor
|
|
43
|
+
@cancel_token = cancel_token
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# LLM boundary entry: dispatch an LLM::Request to the
|
|
47
|
+
# streaming vs non-streaming transport. Mirrors RubyLLMAdapter#call so Loop
|
|
48
|
+
# can drive the fake through the same seam.
|
|
49
|
+
def call(request, &)
|
|
50
|
+
if request.stream?
|
|
51
|
+
stream(messages: request.messages, tools: request.tools,
|
|
52
|
+
image_paths: request.image_paths, &)
|
|
53
|
+
else
|
|
54
|
+
chat(messages: request.messages, tools: request.tools,
|
|
55
|
+
image_paths: request.image_paths)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Non-streaming entry point. Plays the scenario with a no-op block and
|
|
60
|
+
# returns the accumulated AdapterResponse.
|
|
61
|
+
def chat(messages:, tools: nil, response_format: nil, image_paths: nil)
|
|
62
|
+
stream(messages: messages, tools: tools, response_format: response_format,
|
|
63
|
+
image_paths: image_paths) { |_chunk| }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Streaming entry point. Yields chunk hashes shaped exactly like
|
|
67
|
+
# RubyLLMAdapter:
|
|
68
|
+
# { type: :content, text: String }
|
|
69
|
+
# { type: :thinking, text: String }
|
|
70
|
+
# Returns AdapterResponse with concatenated content, accumulated
|
|
71
|
+
# tool_calls, zero usage tokens, and the model id.
|
|
72
|
+
def stream(messages:, tools: nil, response_format: nil, image_paths: nil, &block)
|
|
73
|
+
# image_paths is accepted for signature parity with RubyLLMAdapter
|
|
74
|
+
# (Loop passes it on every call). FakeProvider plays back recorded
|
|
75
|
+
# scenarios verbatim, so it has nothing to do with attachments.
|
|
76
|
+
_ = image_paths
|
|
77
|
+
# If the runner is calling us back after a tool result, replaying the
|
|
78
|
+
# original scenario would re-emit the same tool_call indefinitely
|
|
79
|
+
# (FakeProvider has no inter-turn state). Detect the post-tool turn
|
|
80
|
+
# and emit a short closing message instead so the run terminates.
|
|
81
|
+
events =
|
|
82
|
+
if post_tool_turn?(messages)
|
|
83
|
+
closing_events
|
|
84
|
+
else
|
|
85
|
+
scenario_name = pick_scenario(messages)
|
|
86
|
+
ScenarioLoader.load(scenario_name, scenarios_dir: scenarios_dir_from_config)
|
|
87
|
+
end
|
|
88
|
+
# {{input}} is the only placeholder scenarios currently use. The reference
|
|
89
|
+
# had a richer template system, but in practice every scenario only
|
|
90
|
+
# interpolated the user input. Keep it simple until a scenario actually
|
|
91
|
+
# needs more (e.g. {{session_id}}).
|
|
92
|
+
@scenario_vars = { "input" => extract_last_user_text(messages).to_s }
|
|
93
|
+
|
|
94
|
+
buffered = +""
|
|
95
|
+
tool_calls = []
|
|
96
|
+
|
|
97
|
+
events.each do |event|
|
|
98
|
+
@cancel_token&.check!
|
|
99
|
+
dispatch_event(event, buffered: buffered, tool_calls: tool_calls, &block)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
AdapterResponse.new(
|
|
103
|
+
content: buffered,
|
|
104
|
+
tool_calls: tool_calls,
|
|
105
|
+
input_tokens: 0,
|
|
106
|
+
output_tokens: 0,
|
|
107
|
+
model_id: @model_id
|
|
108
|
+
)
|
|
109
|
+
rescue Rubino::Interrupted
|
|
110
|
+
# Mirror RubyLLMAdapter: surface whatever was buffered as a clean
|
|
111
|
+
# AdapterResponse instead of swallowing the partial output.
|
|
112
|
+
AdapterResponse.new(
|
|
113
|
+
content: buffered || "",
|
|
114
|
+
tool_calls: tool_calls || [],
|
|
115
|
+
input_tokens: 0,
|
|
116
|
+
output_tokens: 0,
|
|
117
|
+
model_id: @model_id
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def model_info
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def context_window
|
|
126
|
+
@config.model_context_length || 128_000
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Convenience: returns the scenario name FakeProvider would pick for
|
|
130
|
+
# this set of messages. Useful in specs and the doctor command.
|
|
131
|
+
def resolve_scenario(messages)
|
|
132
|
+
pick_scenario(messages)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def dispatch_event(event, buffered:, tool_calls:, &block)
|
|
138
|
+
type = event["type"] || event[:type]
|
|
139
|
+
case type.to_s
|
|
140
|
+
when "content"
|
|
141
|
+
text = interpolate(event["text"] || event[:text])
|
|
142
|
+
return if text.nil? || text.empty?
|
|
143
|
+
|
|
144
|
+
buffered << text
|
|
145
|
+
# Single buffered scenario turn ⇒ one content block ⇒ message_id 0,
|
|
146
|
+
# matching the uniform chunk contract every adapter emits.
|
|
147
|
+
safe_yield(block, type: :content, text: text, message_id: 0)
|
|
148
|
+
when "thinking"
|
|
149
|
+
text = interpolate(event["text"] || event[:text])
|
|
150
|
+
return if text.nil? || text.empty?
|
|
151
|
+
return if reasoning_hidden?
|
|
152
|
+
|
|
153
|
+
safe_yield(block, type: :thinking, text: text, message_id: 0)
|
|
154
|
+
when "tool_call"
|
|
155
|
+
tool_calls << build_tool_call(event)
|
|
156
|
+
when "delay_seconds"
|
|
157
|
+
seconds = event["value"] || event[:value] || DEFAULT_DELAY
|
|
158
|
+
cancellable_sleep(seconds.to_f)
|
|
159
|
+
else
|
|
160
|
+
log_safely(event: "llm.fake.unknown_event", type: type.to_s)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Substitutes {{var}} placeholders using @scenario_vars. Returns the
|
|
165
|
+
# text unchanged when nothing matches so scenario authors can mix
|
|
166
|
+
# static and templated chunks freely.
|
|
167
|
+
def interpolate(text)
|
|
168
|
+
return text if text.nil? || text.empty? || @scenario_vars.nil?
|
|
169
|
+
|
|
170
|
+
@scenario_vars.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def extract_last_user_text(messages)
|
|
174
|
+
return "" unless messages.is_a?(Array)
|
|
175
|
+
|
|
176
|
+
last = messages.reverse.find { |m| (m[:role] || m["role"]).to_s == "user" }
|
|
177
|
+
return "" unless last
|
|
178
|
+
|
|
179
|
+
content = last[:content] || last["content"]
|
|
180
|
+
case content
|
|
181
|
+
when String then content
|
|
182
|
+
when Array
|
|
183
|
+
content.filter_map { |part| part.is_a?(Hash) ? (part[:text] || part["text"]) : nil }.join(" ")
|
|
184
|
+
else
|
|
185
|
+
content.to_s
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def safe_yield(block, payload)
|
|
190
|
+
return unless block
|
|
191
|
+
|
|
192
|
+
block.call(payload)
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
# UI hiccups must not abort the stream. Mirror RubyLLMAdapter#emit.
|
|
195
|
+
log_safely(event: "llm.fake.emit_error", error: e.message, type: payload[:type])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_tool_call(event)
|
|
199
|
+
id = event["id"] || event[:id] || "fake_call_#{SecureRandom.hex(4)}"
|
|
200
|
+
name = event["name"] || event[:name] || event["tool"] || event[:tool]
|
|
201
|
+
arguments = event["arguments"] || event[:arguments] || {}
|
|
202
|
+
|
|
203
|
+
# Loop / ToolBridge expect string-keyed arguments. Normalise here so
|
|
204
|
+
# scenario authors can use either symbol or string keys in the YAML.
|
|
205
|
+
normalised_args =
|
|
206
|
+
if arguments.is_a?(Hash)
|
|
207
|
+
arguments.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
208
|
+
else
|
|
209
|
+
arguments
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
{ id: id, name: name, arguments: normalised_args }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def pick_scenario(messages)
|
|
216
|
+
if @model_id.to_s.start_with?("fake/")
|
|
217
|
+
suffix = @model_id.to_s.sub(%r{\Afake/}, "")
|
|
218
|
+
return suffix unless suffix.empty?
|
|
219
|
+
end
|
|
220
|
+
ScenarioSelector.resolve(last_user_message_content(messages))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# True when the runner is calling us back IN THE SAME TURN, right
|
|
224
|
+
# after a tool result — i.e. the very last message has role "tool".
|
|
225
|
+
# In a multi-turn session there are usually older tool results from
|
|
226
|
+
# previous runs in the history; those must NOT flip us into the
|
|
227
|
+
# closing-content path, only an immediately-preceding tool result
|
|
228
|
+
# does. Checking just the tail handles both cases.
|
|
229
|
+
def post_tool_turn?(messages)
|
|
230
|
+
return false unless messages.is_a?(Array)
|
|
231
|
+
|
|
232
|
+
last = messages.last
|
|
233
|
+
return false unless last.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
(last[:role] || last["role"]).to_s == "tool"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A minimal closing turn: a short content chunk and nothing else. Returns
|
|
239
|
+
# the events array the scenario dispatcher expects.
|
|
240
|
+
def closing_events
|
|
241
|
+
[{ "type" => "content", "text" => "Done." }]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def last_user_message_content(messages)
|
|
245
|
+
return "" if messages.nil? || messages.empty?
|
|
246
|
+
|
|
247
|
+
last_user = messages.reverse.find do |m|
|
|
248
|
+
role = (m[:role] || m["role"]).to_s
|
|
249
|
+
role == "user"
|
|
250
|
+
end
|
|
251
|
+
last_user ||= messages.last
|
|
252
|
+
last_user[:content] || last_user["content"] || ""
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def reasoning_hidden?
|
|
256
|
+
Config::ReasoningPrefs.mode(@config) == :hidden
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Pulls the override scenarios directory off the adapter's own config
|
|
260
|
+
# so tests (which build a one-off configuration via test_configuration)
|
|
261
|
+
# don't have to mutate the global Rubino.configuration.
|
|
262
|
+
def scenarios_dir_from_config
|
|
263
|
+
@config.dig("fake_provider", "scenarios_dir") ||
|
|
264
|
+
@config.dig("providers", "fake", "scenarios_dir")
|
|
265
|
+
rescue StandardError
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def cancellable_sleep(seconds)
|
|
270
|
+
return if seconds <= 0
|
|
271
|
+
|
|
272
|
+
deadline = monotonic_now + seconds
|
|
273
|
+
while (remaining = deadline - monotonic_now).positive?
|
|
274
|
+
@cancel_token&.check!
|
|
275
|
+
sleep([0.05, remaining].min)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def monotonic_now
|
|
280
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def log_safely(**fields)
|
|
284
|
+
Rubino.logger.warn(**fields)
|
|
285
|
+
rescue StandardError
|
|
286
|
+
# nothing to do
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
require "securerandom"
|