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,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"