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.
Files changed (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. 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