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,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rufus-scheduler"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Jobs
|
|
7
|
+
# In-process cron scheduler wrapping rufus-scheduler. Owns one rufus
|
|
8
|
+
# instance per process and is exposed as a process-wide singleton via
|
|
9
|
+
# +Scheduler.instance+; +load_all!+ is called once at server boot to
|
|
10
|
+
# register every enabled job. Resolves jobs from CronJobRepository,
|
|
11
|
+
# fires runs through Run::Executor, dispatches webhooks via
|
|
12
|
+
# WebhookDelivery.
|
|
13
|
+
#
|
|
14
|
+
# Because rufus lives in-process, this scheduler does NOT survive a
|
|
15
|
+
# multi-process scale-out: each worker would run every cron tick.
|
|
16
|
+
#
|
|
17
|
+
# Lifecycle:
|
|
18
|
+
# scheduler = Scheduler.new
|
|
19
|
+
# scheduler.load_all! # on server boot
|
|
20
|
+
# scheduler.schedule(job) # after POST /v1/jobs
|
|
21
|
+
# scheduler.unschedule(job_id) # after DELETE
|
|
22
|
+
# scheduler.trigger(job_id) # one-shot
|
|
23
|
+
# scheduler.shutdown!
|
|
24
|
+
class Scheduler
|
|
25
|
+
# Per-process scheduler instance. The server boots one of these once;
|
|
26
|
+
# tests can inject their own via #instance= or call #reset! between examples.
|
|
27
|
+
class << self
|
|
28
|
+
def instance
|
|
29
|
+
@instance ||= new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_writer :instance
|
|
33
|
+
|
|
34
|
+
def reset!
|
|
35
|
+
@instance&.shutdown!
|
|
36
|
+
rescue StandardError
|
|
37
|
+
# best-effort during teardown
|
|
38
|
+
ensure
|
|
39
|
+
@instance = nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(rufus: nil, cron_job_repository: nil, run_repository: nil, session_repository: nil, executor: nil,
|
|
44
|
+
webhook: nil, logger: nil)
|
|
45
|
+
@rufus = rufus || Rufus::Scheduler.new
|
|
46
|
+
@cron_repo = cron_job_repository || CronJobRepository.new
|
|
47
|
+
@run_repo = run_repository || ::Rubino::Run::Repository.new
|
|
48
|
+
@session_repo = session_repository || ::Rubino::Session::Repository.new
|
|
49
|
+
@executor = executor || ::Rubino::Run::Executor.new
|
|
50
|
+
@webhook = webhook || WebhookDelivery.new
|
|
51
|
+
@logger = logger || Rubino.logger
|
|
52
|
+
@handles = {}
|
|
53
|
+
@mutex = Mutex.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def load_all!
|
|
57
|
+
@cron_repo.list(include_disabled: false).each { |job| schedule(job) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Replays any +webhook_deliveries+ row left in +pending+ by a prior
|
|
61
|
+
# process. Boot-only hook; safe to call multiple times because each
|
|
62
|
+
# row's request_id is the dedup key.
|
|
63
|
+
def resume_pending_webhooks!
|
|
64
|
+
@webhook.resume_pending!
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def schedule(job)
|
|
68
|
+
return unless job[:enabled]
|
|
69
|
+
|
|
70
|
+
unschedule(job[:id])
|
|
71
|
+
handle = @rufus.cron(job[:schedule]) { fire(job[:id]) }
|
|
72
|
+
@mutex.synchronize { @handles[job[:id]] = handle }
|
|
73
|
+
rescue ArgumentError => e
|
|
74
|
+
# A persisted row with a cron string rufus/fugit cannot parse (e.g.
|
|
75
|
+
# written by an older build before the API validated schedules, #164).
|
|
76
|
+
# Skip it and keep going: one poison row must never abort load_all!
|
|
77
|
+
# and take down server boot.
|
|
78
|
+
@logger.warn(event: "cron.invalid_schedule", job_id: job[:id], schedule: job[:schedule], error: e.message)
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def unschedule(job_id)
|
|
83
|
+
handle = @mutex.synchronize { @handles.delete(job_id) }
|
|
84
|
+
@rufus.unschedule(handle) if handle
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Run the job now without waiting for the next cron tick.
|
|
88
|
+
# @return [Hash, nil] the created run row, or nil on failure / unknown job.
|
|
89
|
+
def trigger(job_id)
|
|
90
|
+
fire(job_id)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def shutdown!
|
|
94
|
+
@rufus.shutdown
|
|
95
|
+
@mutex.synchronize { @handles.clear }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Number of currently-registered cron handles. Reads @handles under
|
|
99
|
+
# @mutex so callers (e.g. the health probe) never touch private state.
|
|
100
|
+
def scheduled_count
|
|
101
|
+
@mutex.synchronize { @handles.size }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Builds session + run for a cron tick, stamps cron_job_id on the run,
|
|
107
|
+
# and hands off to Executor with a webhook-delivery callback.
|
|
108
|
+
def fire(job_id)
|
|
109
|
+
job = @cron_repo.find(job_id)
|
|
110
|
+
return unless job
|
|
111
|
+
|
|
112
|
+
session = @session_repo.create(source: "cron", model: job[:model], provider: job[:provider], title: job[:name])
|
|
113
|
+
run = @run_repo.create(session_id: session[:id], input_text: job[:prompt], model: job[:model],
|
|
114
|
+
provider: job[:provider], cron_job_id: job_id)
|
|
115
|
+
@cron_repo.record_run(job_id, run_id: run[:id])
|
|
116
|
+
|
|
117
|
+
@executor.start(run, on_complete: ->(payload) { deliver_if_needed(job, payload) })
|
|
118
|
+
Metrics.counter(:cron_fires_total, job: job[:name], outcome: "ok").increment
|
|
119
|
+
@logger.info(event: "cron.fired", job_id: job_id, run_id: run[:id], session_id: session[:id])
|
|
120
|
+
run
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
Metrics.counter(:cron_fires_total, job: job&.dig(:name) || "unknown", outcome: "error").increment
|
|
123
|
+
@logger.error(event: "cron.fire_failed", job_id: job_id, error: e.class.name, message: e.message)
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def deliver_if_needed(job, payload)
|
|
128
|
+
return unless job[:deliver] == "webhook"
|
|
129
|
+
|
|
130
|
+
@webhook.deliver(
|
|
131
|
+
payload.merge(job_id: job[:id], job_name: job[:name]),
|
|
132
|
+
job_id: job[:id],
|
|
133
|
+
run_id: payload[:run_id]
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "digest"
|
|
8
|
+
require "openssl"
|
|
9
|
+
require "time"
|
|
10
|
+
|
|
11
|
+
module Rubino
|
|
12
|
+
module Jobs
|
|
13
|
+
# POSTs cron-job results to a configured webhook URL with idempotency
|
|
14
|
+
# and persistence guarantees.
|
|
15
|
+
#
|
|
16
|
+
# Every #deliver call is recorded as a row in +webhook_deliveries+ before
|
|
17
|
+
# the HTTP request fires; the row's +request_id+ doubles as the
|
|
18
|
+
# +X-Rubino-Delivery-Id+ header receivers MUST treat as the dedup
|
|
19
|
+
# key. The body is signed with HMAC-SHA256 under +RUBINO_WEBHOOK_SECRET+
|
|
20
|
+
# (or a per-job secret passed via +secret:+) and sent as
|
|
21
|
+
# +X-Rubino-Signature+. When no secret is configured the header is
|
|
22
|
+
# omitted; the receiver is then on its own.
|
|
23
|
+
#
|
|
24
|
+
# Failures retry up to 3 attempts total with exponential backoff
|
|
25
|
+
# (5s, 30s, 5min) using lightweight Thread.new sleeps. The current
|
|
26
|
+
# trade-off is that an agent crash mid-backoff loses the in-flight
|
|
27
|
+
# retry timer, but the persisted row stays +pending+ and #resume_pending!
|
|
28
|
+
# at boot picks it up. A real job queue is overkill for the expected
|
|
29
|
+
# webhook volume; revisit if backlog grows.
|
|
30
|
+
#
|
|
31
|
+
# URL resolution: constructor arg > +RUBINO_WEBHOOK_URL+ env. There
|
|
32
|
+
# is no per-job override yet (alpha).
|
|
33
|
+
class WebhookDelivery
|
|
34
|
+
DEFAULT_TIMEOUT = 10
|
|
35
|
+
# Backoff schedule (seconds) BEFORE attempt N+1. attempt_count after a
|
|
36
|
+
# successful schedule is N; index into BACKOFF_SCHEDULE[N-1] for the
|
|
37
|
+
# delay before the next attempt. After 3 entries we give up.
|
|
38
|
+
BACKOFF_SCHEDULE = [5, 30, 300].freeze
|
|
39
|
+
MAX_ATTEMPTS = 3
|
|
40
|
+
RESUME_SCAN_LIMIT = 1000
|
|
41
|
+
|
|
42
|
+
def initialize(url: nil, logger: nil, timeout: DEFAULT_TIMEOUT, conn: nil, db: nil, secret: nil, clock: nil,
|
|
43
|
+
sleeper: nil)
|
|
44
|
+
@url = url || ENV.fetch("RUBINO_WEBHOOK_URL", nil)
|
|
45
|
+
@logger = logger || Rubino.logger
|
|
46
|
+
@conn = conn || build_conn(timeout)
|
|
47
|
+
@db = db
|
|
48
|
+
@secret = secret || ENV.fetch("RUBINO_WEBHOOK_SECRET", nil)
|
|
49
|
+
@clock = clock || -> { Time.now.utc }
|
|
50
|
+
# Tests inject a synchronous sleeper so backoff doesn't burn wall time.
|
|
51
|
+
@sleeper = sleeper || ->(s) { sleep(s) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param payload [Hash] JSON-serialisable body POSTed as-is.
|
|
55
|
+
# @param job_id [String, nil] persisted on the delivery row.
|
|
56
|
+
# @param run_id [String, nil] persisted on the delivery row.
|
|
57
|
+
# @return [Boolean] true if delivered on this call, false otherwise.
|
|
58
|
+
def deliver(payload, job_id: nil, run_id: nil)
|
|
59
|
+
return false if @url.nil? || @url.empty?
|
|
60
|
+
|
|
61
|
+
body = JSON.generate(payload)
|
|
62
|
+
row_id = persist_pending(body: body, job_id: job_id, run_id: run_id)
|
|
63
|
+
attempt_with_retries(row_id: row_id, body: body)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resume hook called at agent boot. Scans up to RESUME_SCAN_LIMIT
|
|
67
|
+
# pending rows whose scheduled_at has passed and replays them in a
|
|
68
|
+
# background thread. Cap exists to avoid replay storms after a long
|
|
69
|
+
# outage — older entries stay in the table for ops to inspect.
|
|
70
|
+
def resume_pending!
|
|
71
|
+
return 0 unless db
|
|
72
|
+
|
|
73
|
+
now = @clock.call.iso8601
|
|
74
|
+
rows = db[:webhook_deliveries]
|
|
75
|
+
.where(status: "pending")
|
|
76
|
+
.where { scheduled_at <= now }
|
|
77
|
+
.order(:scheduled_at)
|
|
78
|
+
.limit(RESUME_SCAN_LIMIT)
|
|
79
|
+
.all
|
|
80
|
+
rows.each do |row|
|
|
81
|
+
Thread.new { attempt_with_retries(row_id: row[:id], body: row[:payload_json]) }
|
|
82
|
+
end
|
|
83
|
+
rows.size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def attempt_with_retries(row_id:, body:)
|
|
89
|
+
row = db[:webhook_deliveries].where(id: row_id).first if db && row_id
|
|
90
|
+
attempts_done = row ? row[:attempt_count] : 0
|
|
91
|
+
# Without a persisted row we can't ack/idempotently dedupe retries,
|
|
92
|
+
# so we degrade to a single attempt to preserve the pre-persistence
|
|
93
|
+
# contract (one POST, return success bool).
|
|
94
|
+
max = row_id ? MAX_ATTEMPTS : 1
|
|
95
|
+
|
|
96
|
+
loop do
|
|
97
|
+
attempts_done += 1
|
|
98
|
+
ok = post_once(row_id: row_id, body: body, attempt_count: attempts_done)
|
|
99
|
+
return true if ok
|
|
100
|
+
|
|
101
|
+
if attempts_done >= max
|
|
102
|
+
mark_dead(row_id) if row_id
|
|
103
|
+
return false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@sleeper.call(BACKOFF_SCHEDULE[attempts_done - 1])
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def post_once(row_id:, body:, attempt_count:)
|
|
111
|
+
request_id = row_request_id(row_id) || SecureRandom.uuid
|
|
112
|
+
response = @conn.post(@url) do |req|
|
|
113
|
+
req.headers["content-type"] = "application/json"
|
|
114
|
+
req.headers["X-Rubino-Delivery-Id"] = request_id
|
|
115
|
+
req.headers["X-Rubino-Signature"] = "sha256=#{sign(body)}" if @secret && !@secret.empty?
|
|
116
|
+
req.body = body
|
|
117
|
+
end
|
|
118
|
+
success = response.success?
|
|
119
|
+
outcome = success ? "ok" : "http_error"
|
|
120
|
+
Metrics.counter(:webhook_deliveries_total, outcome: outcome).increment
|
|
121
|
+
if success
|
|
122
|
+
mark_delivered(row_id, attempt_count: attempt_count)
|
|
123
|
+
@logger.info(event: "webhook.delivered", url: @url, status: response.status, request_id: request_id)
|
|
124
|
+
else
|
|
125
|
+
mark_failed(row_id, attempt_count: attempt_count, error: "http_#{response.status}")
|
|
126
|
+
@logger.error(event: "webhook.http_error", url: @url, status: response.status, request_id: request_id)
|
|
127
|
+
end
|
|
128
|
+
success
|
|
129
|
+
rescue Faraday::Error => e
|
|
130
|
+
Metrics.counter(:webhook_deliveries_total, outcome: "error").increment
|
|
131
|
+
mark_failed(row_id, attempt_count: attempt_count, error: "#{e.class.name}: #{e.message}")
|
|
132
|
+
@logger.error(event: "webhook.failed", url: @url, error: e.class.name, message: e.message)
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def sign(body)
|
|
137
|
+
OpenSSL::HMAC.hexdigest("SHA256", @secret, body)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def persist_pending(body:, job_id:, run_id:)
|
|
141
|
+
return nil unless db
|
|
142
|
+
|
|
143
|
+
now = @clock.call.iso8601
|
|
144
|
+
id = SecureRandom.uuid
|
|
145
|
+
db[:webhook_deliveries].insert(
|
|
146
|
+
id: id,
|
|
147
|
+
job_id: job_id,
|
|
148
|
+
run_id: run_id,
|
|
149
|
+
target_url: @url,
|
|
150
|
+
request_id: SecureRandom.uuid,
|
|
151
|
+
payload_sha256: Digest::SHA256.hexdigest(body),
|
|
152
|
+
payload_json: body,
|
|
153
|
+
attempt_count: 0,
|
|
154
|
+
status: "pending",
|
|
155
|
+
scheduled_at: now,
|
|
156
|
+
created_at: now,
|
|
157
|
+
updated_at: now
|
|
158
|
+
)
|
|
159
|
+
id
|
|
160
|
+
rescue Sequel::DatabaseError, Sequel::Error => e
|
|
161
|
+
# No webhook_deliveries table → fall back to fire-and-forget so the
|
|
162
|
+
# legacy contract (deliver returns true on 2xx, no persistence) still
|
|
163
|
+
# works in installs that have not migrated yet.
|
|
164
|
+
@logger.warn(event: "webhook.persist_skipped", error: e.class.name, message: e.message)
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def row_request_id(row_id)
|
|
169
|
+
return nil unless db && row_id
|
|
170
|
+
|
|
171
|
+
db[:webhook_deliveries].where(id: row_id).get(:request_id)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def mark_delivered(row_id, attempt_count:)
|
|
175
|
+
return unless db && row_id
|
|
176
|
+
|
|
177
|
+
now = @clock.call.iso8601
|
|
178
|
+
db[:webhook_deliveries].where(id: row_id).update(
|
|
179
|
+
status: "delivered",
|
|
180
|
+
attempt_count: attempt_count,
|
|
181
|
+
delivered_at: now,
|
|
182
|
+
updated_at: now
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def mark_failed(row_id, attempt_count:, error:)
|
|
187
|
+
return unless db && row_id
|
|
188
|
+
|
|
189
|
+
db[:webhook_deliveries].where(id: row_id).update(
|
|
190
|
+
status: "failed",
|
|
191
|
+
attempt_count: attempt_count,
|
|
192
|
+
last_error: error,
|
|
193
|
+
updated_at: @clock.call.iso8601
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def mark_dead(row_id)
|
|
198
|
+
return unless db && row_id
|
|
199
|
+
|
|
200
|
+
db[:webhook_deliveries].where(id: row_id).update(
|
|
201
|
+
status: "dead",
|
|
202
|
+
updated_at: @clock.call.iso8601
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def db
|
|
207
|
+
return @db if defined?(@db_resolved) && @db_resolved
|
|
208
|
+
|
|
209
|
+
@db_resolved = true
|
|
210
|
+
@db ||= begin
|
|
211
|
+
Rubino.database.db
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def build_conn(timeout)
|
|
218
|
+
Faraday.new do |f|
|
|
219
|
+
f.options.timeout = timeout
|
|
220
|
+
f.adapter Faraday.default_adapter
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Jobs
|
|
5
|
+
# Background worker that polls the job queue and executes available jobs.
|
|
6
|
+
# Runs in a loop until interrupted.
|
|
7
|
+
class Worker
|
|
8
|
+
def initialize(config: nil)
|
|
9
|
+
@config = config || Rubino.configuration
|
|
10
|
+
@poll_interval = @config.jobs_poll_interval
|
|
11
|
+
@running = false
|
|
12
|
+
@worker_id = "worker-#{Process.pid}-#{Thread.current.object_id}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Starts the worker loop
|
|
16
|
+
def start
|
|
17
|
+
@running = true
|
|
18
|
+
setup_signal_handlers
|
|
19
|
+
|
|
20
|
+
while @running
|
|
21
|
+
processed = process_batch
|
|
22
|
+
sleep(@poll_interval) if processed.zero?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Stops the worker gracefully
|
|
27
|
+
def stop
|
|
28
|
+
@running = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def running?
|
|
32
|
+
@running
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def process_batch
|
|
38
|
+
queue = Queue.new
|
|
39
|
+
processed = 0
|
|
40
|
+
|
|
41
|
+
loop do
|
|
42
|
+
job = queue.dequeue(worker_id: @worker_id)
|
|
43
|
+
break unless job
|
|
44
|
+
|
|
45
|
+
runner = Runner.new
|
|
46
|
+
runner.run_job(job[:id])
|
|
47
|
+
processed += 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
processed
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def setup_signal_handlers
|
|
54
|
+
trap("INT") { stop }
|
|
55
|
+
trap("TERM") { stop }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ruby_llm_adapter"
|
|
4
|
+
require_relative "fake_provider"
|
|
5
|
+
require_relative "provider_resolver"
|
|
6
|
+
|
|
7
|
+
module Rubino
|
|
8
|
+
module LLM
|
|
9
|
+
# Single seam where Lifecycle (and tests) decide which LLM adapter to
|
|
10
|
+
# instantiate. Keeps the "fake provider" branch out of RubyLLMAdapter
|
|
11
|
+
# so the real adapter stays focused on ruby_llm wiring.
|
|
12
|
+
#
|
|
13
|
+
# Routing:
|
|
14
|
+
# - explicit `provider: "fake"` → FakeProvider
|
|
15
|
+
# - model_id matches the "fake" regex → FakeProvider
|
|
16
|
+
# - everything else → RubyLLMAdapter
|
|
17
|
+
class AdapterFactory
|
|
18
|
+
def self.build(model_id: nil, provider: nil, config: nil, ui: nil, event_bus: nil,
|
|
19
|
+
tool_executor: nil, cancel_token: nil, isolate_config: false)
|
|
20
|
+
# Resolve the provider ONCE here (the single seam) and pass the concrete
|
|
21
|
+
# value down. The caller's provider may be nil/"auto"; fall back to the
|
|
22
|
+
# config default and let ProviderResolver interpret "auto" (including the
|
|
23
|
+
# Bedrock-bearer override) in one place. RubyLLMAdapter then trusts the
|
|
24
|
+
# value it receives and no longer re-runs resolution.
|
|
25
|
+
explicit = provider
|
|
26
|
+
explicit = config&.model_provider if explicit.nil?
|
|
27
|
+
resolved = ProviderResolver.resolve(model_id, explicit_provider: explicit)
|
|
28
|
+
|
|
29
|
+
klass = resolved == "fake" ? FakeProvider : RubyLLMAdapter
|
|
30
|
+
kwargs = {
|
|
31
|
+
model_id: model_id,
|
|
32
|
+
provider: resolved,
|
|
33
|
+
config: config,
|
|
34
|
+
ui: ui,
|
|
35
|
+
event_bus: event_bus,
|
|
36
|
+
tool_executor: tool_executor,
|
|
37
|
+
cancel_token: cancel_token
|
|
38
|
+
}
|
|
39
|
+
# SLICE-7: only the real adapter understands per-call config isolation
|
|
40
|
+
# (RubyLLM::Context). FakeProvider has no global to protect, so it never
|
|
41
|
+
# receives the flag — keeps its constructor signature untouched.
|
|
42
|
+
kwargs[:isolate_config] = isolate_config if klass == RubyLLMAdapter
|
|
43
|
+
klass.new(**kwargs)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module LLM
|
|
5
|
+
# Structured response returned by all LLM adapters — the normalized shape the
|
|
6
|
+
# conversation loop and its recovery layers read, never ruby_llm internals.
|
|
7
|
+
# This is the Ruby side of the reference normalize_response seam:
|
|
8
|
+
# the loop branches only on content / thinking /
|
|
9
|
+
# tool_calls / stop_reason / interrupted?, never on provider types.
|
|
10
|
+
#
|
|
11
|
+
# All recovery-layer fields (thinking, stop_reason, usage, raw) default
|
|
12
|
+
# nil-safely so existing callers that construct only the core fields keep
|
|
13
|
+
# working unchanged.
|
|
14
|
+
class AdapterResponse
|
|
15
|
+
attr_reader :content, :tool_calls, :input_tokens, :output_tokens, :model_id,
|
|
16
|
+
:thinking, :stop_reason, :raw
|
|
17
|
+
|
|
18
|
+
def initialize(content:, tool_calls:, input_tokens:, output_tokens:, model_id:,
|
|
19
|
+
interrupted: false, thinking: nil, stop_reason: nil, raw: nil)
|
|
20
|
+
@content = content
|
|
21
|
+
@tool_calls = tool_calls || []
|
|
22
|
+
@input_tokens = input_tokens || 0
|
|
23
|
+
@output_tokens = output_tokens || 0
|
|
24
|
+
@model_id = model_id
|
|
25
|
+
# True when this response holds only a buffered partial from a stream that
|
|
26
|
+
# was cut before a clean completion (no finish_reason / [DONE]). The Loop
|
|
27
|
+
# must treat it as a turn failure, never as a final answer.
|
|
28
|
+
@interrupted = interrupted
|
|
29
|
+
# Reasoning text/summary if the provider surfaced it (think blocks are
|
|
30
|
+
# already split out of +content+). nil when not surfaced on this path.
|
|
31
|
+
@thinking = thinking
|
|
32
|
+
# Normalized finish reason: :stop | :length | :tool_calls | nil. Drives
|
|
33
|
+
# truncation continuation (later slice). Left nil where unreachable —
|
|
34
|
+
# never fabricated.
|
|
35
|
+
@stop_reason = stop_reason
|
|
36
|
+
# Escape hatch to the underlying provider response. The loop must NOT
|
|
37
|
+
# branch on it; it exists for diagnostics / later-slice needs only.
|
|
38
|
+
@raw = raw
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Token usage as a nil-safe Hash, the shape the recovery layers read.
|
|
42
|
+
def usage
|
|
43
|
+
{ input_tokens: @input_tokens, output_tokens: @output_tokens }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The stream was truncated; +content+ is an incomplete partial, not a
|
|
47
|
+
# finished turn. See AdapterResponse#initialize and Loop#run.
|
|
48
|
+
def interrupted?
|
|
49
|
+
@interrupted
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def has_tool_calls?
|
|
53
|
+
!@tool_calls.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def text_only?
|
|
57
|
+
!has_tool_calls? && !@content.nil? && !@content.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def total_tokens
|
|
61
|
+
@input_tokens + @output_tokens
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter_factory"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module LLM
|
|
7
|
+
# Routes per-task auxiliary LLM calls (vision, compression, approval, …)
|
|
8
|
+
# through AdapterFactory based on the `auxiliary.<task>` config block.
|
|
9
|
+
#
|
|
10
|
+
# Pattern lifted from the reference `call_llm(task: …)`: instead of a
|
|
11
|
+
# single "secondary model" slot, each task has its own block with
|
|
12
|
+
# provider/model/base_url/timeout independently overridable. The
|
|
13
|
+
# `provider: "main"` sentinel reuses the primary's provider so simple
|
|
14
|
+
# setups don't repeat themselves.
|
|
15
|
+
#
|
|
16
|
+
# Returns an AdapterResponse — the caller reads `.content` for text-only
|
|
17
|
+
# delegations (vision tool) or inspects `.tool_calls` if the aux model
|
|
18
|
+
# itself can use tools (compression doesn't, but we don't preclude it).
|
|
19
|
+
class AuxiliaryClient
|
|
20
|
+
def initialize(config: Rubino.configuration)
|
|
21
|
+
@config = config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(task:, messages:, **opts)
|
|
25
|
+
cfg = @config.auxiliary_config(task)
|
|
26
|
+
raise ArgumentError, "No auxiliary config for task=#{task}" if cfg.empty?
|
|
27
|
+
|
|
28
|
+
adapter = build_adapter(cfg)
|
|
29
|
+
adapter.chat(messages: messages, **opts.slice(:tools, :response_format, :image_paths))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build_adapter(cfg)
|
|
35
|
+
provider = cfg["provider"].to_s
|
|
36
|
+
resolved_provider = provider.empty? || provider == "main" ? @config.model_provider : provider
|
|
37
|
+
|
|
38
|
+
AdapterFactory.build(
|
|
39
|
+
model_id: cfg["model"].to_s.empty? ? @config.model_default : cfg["model"],
|
|
40
|
+
provider: resolved_provider,
|
|
41
|
+
config: build_overlay_config(cfg, resolved_provider)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# When the aux task pins a base_url, push it into a shallow config
|
|
46
|
+
# overlay so the adapter sees it. We don't mutate the real configuration
|
|
47
|
+
# — provider_config is read by RubyLLMAdapter.configure_ruby_llm! on
|
|
48
|
+
# construction, so a transient overlay is enough.
|
|
49
|
+
def build_overlay_config(cfg, resolved_provider)
|
|
50
|
+
base_url = cfg["base_url"].to_s
|
|
51
|
+
return @config if base_url.empty?
|
|
52
|
+
|
|
53
|
+
raw = Marshal.load(Marshal.dump(@config.raw))
|
|
54
|
+
raw["providers"] ||= {}
|
|
55
|
+
raw["providers"][resolved_provider] ||= {}
|
|
56
|
+
raw["providers"][resolved_provider]["base_url"] = base_url
|
|
57
|
+
Config::Configuration.new(raw: raw)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|