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,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # The degenerate-response recovery ladder — a faithful, rung-by-rung port of
6
+ # the `if not agent._has_content_after_think_block(final_response):` block in
7
+ # the reference conversation loop.
8
+ #
9
+ # This is the load-bearing machinery that cures MiniMax's "completed but
10
+ # empty" / thinking-only responses: a structurally-valid text response whose
11
+ # visible content is empty once the <think> reasoning is stripped. Rather than
12
+ # surfacing that as a finished (empty) turn, the ladder walks seven rungs IN
13
+ # ORDER, each a cheaper-or-smarter recovery than giving up:
14
+ #
15
+ # 1. partial-stream recovery — content already streamed to the user before
16
+ # the turn went degenerate? Use it.
17
+ # 2. prior-turn content — the previous turn already delivered a real
18
+ # answer alongside HOUSEKEEPING tools? Reuse it.
19
+ # 3. post-tool empty nudge — empty right after a tool round? Append a
20
+ # user-level "continue" hint and re-issue.
21
+ # 4. thinking-only prefill ×2 — the model reasoned (<think>) but never spoke?
22
+ # Re-issue the SAME request with an assistant
23
+ # PREFILL seed so it continues into visible
24
+ # text. THE key MiniMax cure.
25
+ # 5. empty-content retry ×3 — truly empty (no text, no reasoning)? Retry.
26
+ # 6. empty → fallback — retries exhausted? Hand to FallbackChain.
27
+ # NOT BUILT — Slice 7 seam, falls through.
28
+ # 7. terminal — still stuck? Raise EmptyModelResponseError.
29
+ # (We DROP the reference "(empty)" sentinel-replay
30
+ # machinery and raise,
31
+ # so Run::Executor maps it to FAILED, never
32
+ # completed-but-empty.)
33
+ #
34
+ # OWNS the two per-turn counters the reference keeps on the agent — prefill
35
+ # attempts (≤2) and empty-content retries (≤3). A fresh instance is built per
36
+ # model call (per ModelCallRunner#call!), so the counters reset exactly where
37
+ # the reference resets them to 0 on a successful content turn.
38
+ #
39
+ # The ladder needs a little turn state the bare AdapterResponse does not carry
40
+ # (what streamed before the drop, the prior assistant turn, whether a tool
41
+ # round just ran). That is threaded in via RecoveryState, NOT re-derived here.
42
+ class DegenerateResponseRecovery
43
+ # Per-turn recovery state the ladder reads. Built by the runner/loop and
44
+ # handed to #recover with each degenerate response.
45
+ #
46
+ # response : the degenerate AdapterResponse just received
47
+ # streamed_text : visible text already streamed to the user this
48
+ # call (rung 1); "" when nothing/streamed off
49
+ # messages : the live api-messages array for this turn —
50
+ # the SAME reference the loop owns, so a rung-3
51
+ # nudge appended here is seen on re-issue
52
+ # prior_turn_content : last assistant content delivered alongside
53
+ # tool calls in a PRIOR turn (rung 2), or nil
54
+ # prior_tools_all_housekeeping : true only when every tool in that prior
55
+ # turn was housekeeping (memory/todo). The gem
56
+ # has no housekeeping taxonomy yet, so this is
57
+ # false today and rung 2 is a faithful no-op.
58
+ RecoveryState = Struct.new(
59
+ :response, :streamed_text, :messages,
60
+ :prior_turn_content, :prior_tools_all_housekeeping,
61
+ keyword_init: true
62
+ )
63
+
64
+ # A directive the runner acts on. `kind` is one of:
65
+ # :use — return `content` as the final answer (rungs 1, 2)
66
+ # :nudge — request.messages was mutated; re-issue the same request (rung 3)
67
+ # :prefill — re-issue carrying `seed` as request.prefill (rung 4)
68
+ # :retry — re-issue the same request unchanged (rung 5)
69
+ # :raise — terminal: raise EmptyModelResponseError (rungs 6→7)
70
+ # `attempt` is the 1-based retry index on a :retry, so the runner can
71
+ # escalate its invalid-response backoff across the ≤3 retries.
72
+ Directive = Struct.new(:kind, :content, :seed, :attempt, keyword_init: true)
73
+
74
+ DEFAULT_PREFILL_MAX = 2
75
+ DEFAULT_EMPTY_MAX = 3
76
+
77
+ # The user-level hint appended after an empty post-tool turn (rung 3),
78
+ # verbatim from the reference implementation.
79
+ NUDGE_TEXT =
80
+ "You just executed tool calls but returned an empty response. " \
81
+ "Please process the tool results above and continue with the task."
82
+
83
+ def initialize(validator: ResponseValidator.new, ui: nil,
84
+ prefill_max: DEFAULT_PREFILL_MAX, empty_max: DEFAULT_EMPTY_MAX)
85
+ @validator = validator
86
+ @ui = ui
87
+ @prefill_max = prefill_max
88
+ @empty_max = empty_max
89
+ @prefill_attempts = 0
90
+ @empty_attempts = 0
91
+ # _post_tool_empty_retried — the nudge fires at most once per turn.
92
+ @nudged = false
93
+ end
94
+
95
+ # Walk the ladder for one degenerate response and return a Directive.
96
+ # Mirrors the reference conversation loop rung for rung, in order.
97
+ def recover(state)
98
+ # ── Rung 1: partial-stream recovery ──────────────────
99
+ # If real content was streamed to the user before the turn came back
100
+ # degenerate, deliver it instead of wasting calls on retries.
101
+ if content_after_think?(state.streamed_text)
102
+ note("↻ Stream interrupted — using delivered content as final response")
103
+ return Directive.new(kind: :use, content: strip_think(state.streamed_text))
104
+ end
105
+
106
+ # ── Rung 2: prior-turn content fallback ──────────────
107
+ # The previous turn already delivered a real answer alongside
108
+ # HOUSEKEEPING-only tools; the model has nothing more to say. Reuse it
109
+ # rather than retrying. Guarded on all-housekeeping so mid-task
110
+ # narration ("I'll scan the directory…") falls through to the nudge.
111
+ if state.prior_turn_content && state.prior_tools_all_housekeeping
112
+ note("↻ Empty response after tool calls — using earlier content as final answer")
113
+ return Directive.new(kind: :use, content: strip_think(state.prior_turn_content))
114
+ end
115
+
116
+ has_inline_thinking = inline_thinking?(state.response)
117
+
118
+ # ── Rung 3: post-tool empty nudge ────────────────────
119
+ # Empty right after a tool round (and NOT a thinking-only response —
120
+ # that routes to prefill below). Append the empty assistant turn then a
121
+ # user-level nudge so the sequence stays valid (tool → assistant →
122
+ # user), and re-issue. Fires at most once per turn.
123
+ if prior_was_tool?(state.messages) && !@nudged && !has_inline_thinking
124
+ @nudged = true
125
+ note("⚠️ Model returned empty after tool calls — nudging to continue")
126
+ append_nudge!(state.messages, state.response)
127
+ return Directive.new(kind: :nudge)
128
+ end
129
+
130
+ # ── Rung 4: thinking-only prefill-to-continue ×2 ─────
131
+ # The model produced reasoning (structured thinking field OR inline
132
+ # <think>) but no visible text. Re-issue the SAME request seeded with an
133
+ # assistant PREFILL so the model continues from its own reasoning into
134
+ # the visible answer. THE MiniMax cure.
135
+ if has_structured?(state.response) && @prefill_attempts < @prefill_max
136
+ @prefill_attempts += 1
137
+ note("↻ Thinking-only response — prefilling to continue " \
138
+ "(#{@prefill_attempts}/#{@prefill_max})")
139
+ return Directive.new(kind: :prefill, seed: prefill_seed(state.response))
140
+ end
141
+
142
+ # ── Rung 5: empty-content retry ×3 ───────────────────
143
+ # Truly empty (nothing usable once <think> is stripped), OR a reasoning
144
+ # model that has now exhausted its prefill attempts. Plain retry.
145
+ truly_empty = strip_think(state.response.content).empty?
146
+ prefill_exhausted = has_structured?(state.response) && @prefill_attempts >= @prefill_max
147
+ if truly_empty && (!has_structured?(state.response) || prefill_exhausted) &&
148
+ @empty_attempts < @empty_max
149
+ @empty_attempts += 1
150
+ note("⚠️ Empty response from model — retrying (#{@empty_attempts}/#{@empty_max})")
151
+ return Directive.new(kind: :retry, attempt: @empty_attempts)
152
+ end
153
+
154
+ # ── Rung 6: empty → fallback ─────────────────────────
155
+ # SLICE-7 seam. The reference here tries _try_activate_fallback() and, on a
156
+ # successful switch, resets _empty_content_retries to 0 and continues on
157
+ # the new provider. FallbackChain is not built yet (Slice 7), so there
158
+ # is no provider to switch to — fall straight through to rung 7. When
159
+ # FallbackChain lands, attempt the switch here and return :retry on
160
+ # success (zeroing @empty_attempts).
161
+
162
+ # ── Rung 7: terminal ─────────────────────────────────
163
+ # Exhausted every rung. We DROP the reference "(empty)" sentinel-replay
164
+ # machinery: the runner raises EmptyModelResponseError so the
165
+ # run is marked FAILED, never completed-but-empty.
166
+ Directive.new(kind: :raise)
167
+ end
168
+
169
+ private
170
+
171
+ # Append the empty assistant turn then the user nudge, so the on-the-wire
172
+ # sequence stays valid: tool(result) → assistant("(empty)") → user(nudge).
173
+ # A bare tool → user is rejected by most strict providers. Mirrors the
174
+ # reference implementation.
175
+ def append_nudge!(messages, response)
176
+ messages << {
177
+ role: "assistant",
178
+ content: response.content.to_s.empty? ? "(empty)" : response.content,
179
+ tool_calls: response.has_tool_calls? ? response.tool_calls : nil
180
+ }
181
+ messages << { role: "user", content: NUDGE_TEXT }
182
+ end
183
+
184
+ # The assistant-seed text for prefill-to-continue. The reference re-appends the
185
+ # model's own interim (thinking) message and lets the model continue from
186
+ # it; on our boundary the equivalent is seeding the next assistant turn
187
+ # with the reasoning the model already produced, so it continues into the
188
+ # visible answer. Prefer the structured thinking field; fall back to the
189
+ # inline <think> content. Returns "" only if nothing is recoverable (the
190
+ # boundary still sends a continuation prompt — an empty prefill is a plain
191
+ # re-issue, harmless).
192
+ def prefill_seed(response)
193
+ seed = response.thinking.to_s
194
+ seed = think_only(response.content) if seed.strip.empty?
195
+ seed.to_s
196
+ end
197
+
198
+ # True when the response carries reasoning by ANY channel the reference checks:
199
+ # a structured thinking field OR an inline <think>/<thinking>/<reasoning>
200
+ # block in the content (Ollama/Qwen put it there).
201
+ def has_structured?(response)
202
+ return true if response.thinking.to_s.strip != ""
203
+
204
+ inline_thinking?(response)
205
+ end
206
+
207
+ # Inline-thinking detector — matches the reference _has_inline_thinking regex.
208
+ def inline_thinking?(response)
209
+ !!(response.content.to_s =~ /<think>|<thinking>|<reasoning>/i)
210
+ end
211
+
212
+ # Any recent message a tool result? The reference checks the last 5 messages.
213
+ def prior_was_tool?(messages)
214
+ Array(messages).last(5).any? { |m| (m[:role] || m["role"]).to_s == "tool" }
215
+ end
216
+
217
+ # True when visible text survives stripping the <think> block — the gem's
218
+ # ResponseValidator already owns this judgement, so reuse it on a synthetic
219
+ # content-only response rather than duplicating the filter.
220
+ def content_after_think?(text)
221
+ return false if text.to_s.strip.empty?
222
+
223
+ !@validator.degenerate?(content_probe(text))
224
+ end
225
+
226
+ def strip_think(text)
227
+ think_only(text).empty? ? collapse(text) : visible_after_think(text)
228
+ end
229
+
230
+ # The visible content with <think> blocks removed, stripped.
231
+ def visible_after_think(text)
232
+ visible = +""
233
+ filter = LLM::InlineThinkFilter.new
234
+ emit = ->(type, str) { visible << str if type == :content }
235
+ filter.feed(text.to_s, &emit)
236
+ filter.flush(&emit)
237
+ visible.strip
238
+ end
239
+
240
+ # Just the think-block contents, for the prefill seed.
241
+ def think_only(text)
242
+ thinking = +""
243
+ filter = LLM::InlineThinkFilter.new
244
+ emit = ->(type, str) { thinking << str if type == :thinking }
245
+ filter.feed(text.to_s, &emit)
246
+ filter.flush(&emit)
247
+ thinking.strip
248
+ end
249
+
250
+ def collapse(text)
251
+ text.to_s.strip
252
+ end
253
+
254
+ # Minimal AdapterResponse-shaped probe so we can reuse ResponseValidator
255
+ # #degenerate? on a raw streamed string.
256
+ def content_probe(text)
257
+ LLM::AdapterResponse.new(
258
+ content: text.to_s, tool_calls: [], input_tokens: 0, output_tokens: 0,
259
+ model_id: nil
260
+ )
261
+ end
262
+
263
+ def note(text)
264
+ @ui&.note(text)
265
+ rescue StandardError
266
+ # UI may be a Null/test double without #note — never let status text
267
+ # abort recovery.
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # The provider/model fallback chain — a faithful port of the reference
6
+ # `_fallback_chain` + `try_activate_fallback`
7
+ # and the per-turn `_restore_primary_runtime`.
8
+ #
9
+ # WHAT IT DOES. The primary backend is index 0; `agent.fallback_models` lists
10
+ # the ordered fallbacks. When the primary keeps failing — invalid/empty
11
+ # responses (eager fallback), rate-limit/overload, or an exhausted
12
+ # retry budget, or empty-after-retries — the runner
13
+ # / recovery ladder calls #activate_next! to rotate to the next backend and
14
+ # rebuild the adapter. At the TOP of each new turn ConversationLoop#run calls
15
+ # #restore_primary! so every turn gets a fresh attempt with the preferred
16
+ # model.
17
+ #
18
+ # DEDUP. An entry that resolves to the CURRENT provider/model/base_url is
19
+ # skipped — falling back to the backend that just failed only loops the
20
+ # failure. We keep advancing past skipped entries in a
21
+ # single #activate_next! call, exactly like the reference recursive
22
+ # `return agent._try_activate_fallback()`.
23
+ #
24
+ # GLOBAL-CONFIG ISOLATION (the heart of this slice).
25
+ # `RubyLLM.configure` is process-global; a naive provider swap would corrupt
26
+ # concurrent sessions on the API/server path. So fallback adapters are built
27
+ # with `isolate_config: true`: each scopes its provider config (base_url /
28
+ # api_key / timeout) into a per-adapter `RubyLLM::Context` and NEVER writes
29
+ # the global. The primary adapter is passed in as-is (it already configured
30
+ # the global at construction, exactly as before), so a single-provider setup
31
+ # — and the no-fallback case — is byte-identical to pre-Slice-7 behaviour.
32
+ #
33
+ # NO-OP WHEN UNCONFIGURED. With an empty `fallback_models` the chain holds
34
+ # only the primary: #activate_next! is always false and #current_adapter is
35
+ # always the primary. Nothing is rebuilt, nothing is mutated.
36
+ class FallbackChain
37
+ # One backend in the chain. provider/model are required to be usable; an
38
+ # entry missing either is treated as invalid and skipped on advance.
39
+ Entry = Struct.new(:provider, :model, :base_url, :api_key, keyword_init: true) do
40
+ def usable?
41
+ !provider.to_s.strip.empty? && !model.to_s.strip.empty?
42
+ end
43
+ end
44
+
45
+ # primary_adapter : the already-built primary LLM adapter (index 0). The
46
+ # chain never rebuilds it — restore just points back to it.
47
+ # config : the live Configuration (reads agent.fallback_models and
48
+ # the providers.* blocks the fallback entries inherit).
49
+ # adapter_builder : injectable seam for tests; defaults to AdapterFactory.
50
+ def initialize(primary_adapter:, config:, ui: nil, event_bus: nil,
51
+ tool_executor: nil, cancel_token: nil,
52
+ adapter_builder: LLM::AdapterFactory)
53
+ @primary = primary_adapter
54
+ @config = config
55
+ @ui = ui
56
+ @event_bus = event_bus
57
+ @tool_executor = tool_executor
58
+ @cancel_token = cancel_token
59
+ @adapter_builder = adapter_builder
60
+
61
+ @entries = build_entries
62
+ @index = 0
63
+ @active = @primary
64
+ end
65
+
66
+ # The adapter the loop/runner should issue calls against right now.
67
+ def current_adapter
68
+ @active
69
+ end
70
+
71
+ # True once a fallback has been activated this turn — lets callers emit the
72
+ # "switched to fallback" status only when something actually changed.
73
+ def active?
74
+ @index.positive?
75
+ end
76
+
77
+ # Advance to the next usable, non-duplicate fallback and rebuild the
78
+ # adapter. Returns true if it actually switched, false when the chain is
79
+ # exhausted (or empty). Mirrors try_activate_fallback (helpers.py:1020):
80
+ # skip invalid entries and entries that resolve to the current backend,
81
+ # advancing past them within this one call.
82
+ def activate_next!
83
+ loop do
84
+ return false if @index >= @entries.size
85
+
86
+ entry = @entries[@index]
87
+ @index += 1
88
+
89
+ next unless entry.usable?
90
+ next if duplicate_of_current?(entry)
91
+
92
+ @active = build_adapter(entry)
93
+ return true
94
+ end
95
+ end
96
+
97
+ # Reset to the primary at the top of each turn. No-op cost when
98
+ # we never left the primary; rebuilds nothing (the primary adapter is the
99
+ # one handed in at construction).
100
+ def restore_primary!
101
+ @index = 0
102
+ @active = @primary
103
+ end
104
+
105
+ private
106
+
107
+ # The fallback entries (NOT including the implicit primary at index 0).
108
+ def build_entries
109
+ Array(@config.dig("agent", "fallback_models")).filter_map do |raw|
110
+ next unless raw.is_a?(Hash)
111
+
112
+ Entry.new(
113
+ provider: fetch(raw, "provider"),
114
+ model: fetch(raw, "model"),
115
+ base_url: fetch(raw, "base_url"),
116
+ api_key: fetch(raw, "api_key")
117
+ )
118
+ end
119
+ end
120
+
121
+ def fetch(hash, key)
122
+ value = hash[key] || hash[key.to_sym]
123
+ value.to_s.strip.empty? ? nil : value.to_s
124
+ end
125
+
126
+ # Skip an entry whose RESOLVED provider+model (or base_url+model) matches the
127
+ # active adapter — falling back to the same backend just loops the failure.
128
+ def duplicate_of_current?(entry)
129
+ resolved = LLM::ProviderResolver.resolve(entry.model, explicit_provider: entry.provider)
130
+ cur_provider = @active.provider.to_s.strip.downcase
131
+ cur_model = @active.model_id.to_s.strip
132
+
133
+ return true if resolved.to_s.strip.downcase == cur_provider && entry.model.to_s.strip == cur_model
134
+
135
+ entry_base = normalize_url(entry.base_url)
136
+ cur_base = normalize_url(current_base_url)
137
+ !entry_base.empty? && !cur_base.empty? &&
138
+ entry_base == cur_base && entry.model.to_s.strip == cur_model
139
+ end
140
+
141
+ # The base_url the active adapter is pointed at (its provider's config
142
+ # base_url), for the dedup comparison.
143
+ def current_base_url
144
+ @config.provider_config(@active.provider)["base_url"]
145
+ end
146
+
147
+ def normalize_url(url)
148
+ url.to_s.strip.sub(%r{/+\z}, "").downcase
149
+ end
150
+
151
+ # Rebuild the adapter for a fallback entry. The entry's base_url/api_key
152
+ # override the providers.<name> block for THIS adapter only; everything is
153
+ # scoped into a per-call RubyLLM::Context via isolate_config: true so the
154
+ # process-global RubyLLM.configure is never mutated.
155
+ def build_adapter(entry)
156
+ @adapter_builder.build(
157
+ model_id: entry.model,
158
+ provider: entry.provider,
159
+ config: config_for(entry),
160
+ ui: @ui,
161
+ event_bus: @event_bus,
162
+ tool_executor: @tool_executor,
163
+ cancel_token: @cancel_token,
164
+ isolate_config: true
165
+ )
166
+ end
167
+
168
+ # A per-entry Configuration whose providers.<provider> block carries the
169
+ # entry's base_url/api_key overrides, leaving the shared config untouched
170
+ # (deep-dup of the provider section only — nothing else is copied or
171
+ # mutated). The adapter reads base_url/api_key from here.
172
+ def config_for(entry)
173
+ overrides = {}
174
+ overrides["base_url"] = entry.base_url if entry.base_url
175
+ overrides["api_key"] = entry.api_key if entry.api_key
176
+ return @config if overrides.empty?
177
+
178
+ raw = deep_dup(@config.raw)
179
+ provider = entry.provider.to_s
180
+ raw["providers"] ||= {}
181
+ raw["providers"][provider] = (raw["providers"][provider] || {}).merge(overrides)
182
+ Config::Configuration.new(raw: raw)
183
+ end
184
+
185
+ def deep_dup(obj)
186
+ case obj
187
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
188
+ when Array then obj.map { |v| deep_dup(v) }
189
+ else obj
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Manages turn and iteration budgets to prevent runaway loops.
6
+ class IterationBudget
7
+ def initialize(config: nil, max_tool_iterations: nil)
8
+ @config = config || Rubino.configuration
9
+ @max_turns = @config.agent_max_turns
10
+ # An explicit override (the CLI `--max-turns N` flag, threaded through
11
+ # Runner → Lifecycle) wins over the config default so the documented
12
+ # control knob actually caps tool iterations (#141). A nil/blank
13
+ # override falls back to the configured budget, unchanged.
14
+ @max_tool_iterations = positive_int(max_tool_iterations) || @config.agent_max_tool_iterations
15
+ @max_turn_seconds = @config.agent_max_turn_seconds
16
+ @turn_started_at = Time.now
17
+ end
18
+
19
+ # Returns true if the agent can continue iterating
20
+ def can_continue?(iteration)
21
+ within_iteration_limit?(iteration) && within_time_limit?
22
+ end
23
+
24
+ private
25
+
26
+ # Coerce an override to a positive Integer, or nil if it's absent/garbage
27
+ # (so the config default is used). Accepts the numeric Thor option, which
28
+ # arrives as a Float, and rejects 0/negative values as "no cap given".
29
+ def positive_int(value)
30
+ return nil if value.nil?
31
+
32
+ n = Integer(value, exception: false) || Float(value, exception: false)&.to_i
33
+ n if n && n.positive?
34
+ end
35
+
36
+ # A nil cap means "unbounded": never stop on that dimension rather than
37
+ # crashing the turn comparing a number with nil (#139).
38
+ def within_iteration_limit?(iteration)
39
+ @max_tool_iterations.nil? || iteration <= @max_tool_iterations
40
+ end
41
+
42
+ def within_time_limit?
43
+ return true if @max_turn_seconds.nil?
44
+
45
+ elapsed = Time.now - @turn_started_at
46
+ elapsed < @max_turn_seconds
47
+ end
48
+ end
49
+ end
50
+ end