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
data/install.sh ADDED
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # rubino installer
4
+ #
5
+ # curl -fsSL https://raw.githubusercontent.com/Jhonnyr97/rubino-agent/main/install.sh | bash
6
+ #
7
+ # What it does (all in user space, no sudo):
8
+ # 1. Provisions a Ruby toolchain:
9
+ # - Linux: via `rv` (https://github.com/spinel-coop/rv), a fast Ruby
10
+ # version manager that fetches a precompiled Ruby (no build step).
11
+ # - macOS: if Homebrew is present you're asked whether to use Homebrew
12
+ # (`brew install ruby`) or rv; if Homebrew is absent it uses rv directly.
13
+ # 2. Installs the `rubino-agent` gem under that Ruby. If a published gem with
14
+ # the CLI isn't available yet, it falls back to building from this repo.
15
+ # 3. Prints the exact PATH line for the `rubino` executable.
16
+ #
17
+ # Non-interactive override: set RUBINO_INSTALL_METHOD=brew|rv to skip the prompt.
18
+ #
19
+ # Security note: you are piping a script from the internet into a shell.
20
+ # Review it first: curl -fsSL <url> -o install.sh && less install.sh && bash install.sh
21
+ #
22
+ # Re-running is safe: every step is idempotent.
23
+
24
+ set -euo pipefail
25
+
26
+ # --- configuration ----------------------------------------------------------
27
+
28
+ REPO_OWNER="Jhonnyr97"
29
+ REPO_NAME="rubino-agent"
30
+ REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}.git"
31
+ REPO_RAW="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main"
32
+
33
+ # Ruby to install via rv. Matches the gem's .ruby-version; the gem itself
34
+ # requires >= 3.1.0, so any 3.1+ works, but we pin a known-good precompiled one.
35
+ # (Homebrew installs its current `ruby` formula instead; the gem supports both.)
36
+ RUBY_VERSION="${RUBINO_RUBY_VERSION:-3.3.3}"
37
+
38
+ # The gem name on RubyGems (rubino-agent) vs. the executable it ships (rubino).
39
+ GEM_NAME="rubino-agent"
40
+ BIN_NAME="rubino"
41
+
42
+ # Optional: brew | rv. When unset on macOS with Homebrew present, we prompt.
43
+ INSTALL_METHOD="${RUBINO_INSTALL_METHOD:-}"
44
+
45
+ # --- output helpers ---------------------------------------------------------
46
+
47
+ if [ -t 1 ]; then
48
+ BOLD=$(printf '\033[1m'); GREEN=$(printf '\033[32m'); YELLOW=$(printf '\033[33m')
49
+ RED=$(printf '\033[31m'); DIM=$(printf '\033[2m'); RESET=$(printf '\033[0m')
50
+ else
51
+ BOLD=""; GREEN=""; YELLOW=""; RED=""; DIM=""; RESET=""
52
+ fi
53
+
54
+ info() { printf '%s==>%s %s\n' "$BOLD" "$RESET" "$*"; }
55
+ ok() { printf '%s==>%s %s\n' "$GREEN" "$RESET" "$*"; }
56
+ warn() { printf '%s==>%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
57
+ die() { printf '%serror:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; }
58
+
59
+ # --- preflight: OS / arch ---------------------------------------------------
60
+
61
+ OS="$(uname -s)"
62
+ ARCH="$(uname -m)"
63
+
64
+ case "$OS" in
65
+ Linux) PLATFORM="linux" ;;
66
+ Darwin) PLATFORM="macos" ;;
67
+ *) die "unsupported OS: ${OS}. rubino's installer supports Linux and macOS (x86_64/arm64)." ;;
68
+ esac
69
+
70
+ case "$ARCH" in
71
+ x86_64|amd64) ;;
72
+ aarch64|arm64) ;;
73
+ *) die "unsupported architecture: ${ARCH}. Supported: x86_64, arm64." ;;
74
+ esac
75
+
76
+ need() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1 (please install it and re-run)"; }
77
+ need curl
78
+ need uname
79
+
80
+ # --- choose install method (macOS may use Homebrew or rv) -------------------
81
+
82
+ # Decide how we get Ruby. Linux always uses rv. macOS: honor an explicit
83
+ # RUBINO_INSTALL_METHOD; else if Homebrew is present, ask (when a terminal is
84
+ # available); else fall back to rv. The prompt reads from /dev/tty so it works
85
+ # even under `curl ... | bash`, where stdin is the script itself.
86
+ choose_method() {
87
+ if [ "$PLATFORM" = "linux" ]; then
88
+ printf 'rv\n'; return 0
89
+ fi
90
+
91
+ case "$INSTALL_METHOD" in
92
+ brew) printf 'brew\n'; return 0 ;;
93
+ rv) printf 'rv\n'; return 0 ;;
94
+ "") ;;
95
+ *) die "RUBINO_INSTALL_METHOD must be 'brew' or 'rv' (got '${INSTALL_METHOD}')." ;;
96
+ esac
97
+
98
+ if ! command -v brew >/dev/null 2>&1; then
99
+ # No Homebrew → rv directly, as requested.
100
+ printf 'rv\n'; return 0
101
+ fi
102
+
103
+ # Homebrew present. Ask, if we have a terminal to ask on.
104
+ if [ -r /dev/tty ] && [ -w /dev/tty ]; then
105
+ {
106
+ printf '\n%sHomebrew detected.%s How should Ruby be installed?\n' "$BOLD" "$RESET"
107
+ printf ' %s1)%s Homebrew %s(brew install ruby)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
108
+ printf ' %s2)%s rv %s(fast, self-contained, no Homebrew)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
109
+ printf 'Choose %s[1/2]%s (default 1): ' "$BOLD" "$RESET"
110
+ } >/dev/tty
111
+ local ans=""
112
+ read -r ans </dev/tty || ans=""
113
+ case "$ans" in
114
+ 2|rv|RV) printf 'rv\n' ;;
115
+ ""|1|brew) printf 'brew\n' ;;
116
+ *) printf 'brew\n' ;;
117
+ esac
118
+ return 0
119
+ fi
120
+
121
+ # Homebrew present but no terminal to prompt on → default to Homebrew
122
+ # (the native macOS expectation). Override with RUBINO_INSTALL_METHOD=rv.
123
+ warn "Homebrew detected but no interactive terminal; defaulting to Homebrew. Set RUBINO_INSTALL_METHOD=rv to use rv instead."
124
+ printf 'brew\n'
125
+ }
126
+
127
+ METHOD="$(choose_method)"
128
+
129
+ # `rubyx <cmd...>` runs a command (gem/bundle/rake/ruby) under the Ruby we set
130
+ # up, regardless of method. Each setup_* defines it plus RUBY_LABEL.
131
+ rubyx() { die "internal: ruby toolchain not initialized"; }
132
+
133
+ # --- ruby toolchain: rv -----------------------------------------------------
134
+
135
+ setup_ruby_rv() {
136
+ # rv's installer (cargo-dist) drops the binary in $CARGO_HOME/bin or
137
+ # $HOME/.cargo/bin. We pass RV_NO_MODIFY_PATH=1 so it doesn't edit the user's
138
+ # shell rc, and locate the binary ourselves.
139
+ locate_rv() {
140
+ if command -v rv >/dev/null 2>&1; then command -v rv; return 0; fi
141
+ for d in "${CARGO_HOME:-}/bin" "$HOME/.cargo/bin" "$HOME/.local/bin"; do
142
+ [ -n "$d" ] && [ -x "$d/rv" ] && { printf '%s\n' "$d/rv"; return 0; }
143
+ done
144
+ return 1
145
+ }
146
+
147
+ local rv_bin
148
+ if rv_bin="$(locate_rv)"; then
149
+ ok "rv already installed: ${rv_bin}"
150
+ else
151
+ info "Installing rv (fast Ruby version manager)..."
152
+ RV_NO_MODIFY_PATH=1 curl -fsSL https://rv.dev/install | sh
153
+ rv_bin="$(locate_rv)" || die "rv install completed but the rv binary wasn't found on PATH or in ~/.cargo/bin."
154
+ ok "Installed rv: ${rv_bin}"
155
+ fi
156
+ export PATH="$(dirname "$rv_bin"):${PATH}"
157
+
158
+ info "Installing Ruby ${RUBY_VERSION} via rv (precompiled, no build step)..."
159
+ "$rv_bin" ruby install "${RUBY_VERSION}" # idempotent
160
+ local ruby_bin
161
+ ruby_bin="$("$rv_bin" ruby find "${RUBY_VERSION}")"
162
+ [ -x "$ruby_bin" ] || die "rv reported Ruby ${RUBY_VERSION} installed but its ruby binary wasn't found."
163
+ RUBY_BIN_DIR="$(dirname "$ruby_bin")"
164
+ RUBY_LABEL="Ruby ${RUBY_VERSION} (rv)"
165
+
166
+ rubyx() { "$rv_bin" run --ruby "${RUBY_VERSION}" "$@"; }
167
+ ok "${RUBY_LABEL} ready: ${RUBY_BIN_DIR}"
168
+ }
169
+
170
+ # --- ruby toolchain: Homebrew ----------------------------------------------
171
+
172
+ setup_ruby_brew() {
173
+ need brew
174
+ if brew list --formula ruby >/dev/null 2>&1; then
175
+ ok "Homebrew Ruby already installed."
176
+ else
177
+ info "Installing Ruby via Homebrew (brew install ruby)..."
178
+ brew install ruby || die "brew install ruby failed."
179
+ fi
180
+ local prefix
181
+ prefix="$(brew --prefix ruby 2>/dev/null)" || die "could not resolve 'brew --prefix ruby'."
182
+ RUBY_BIN_DIR="${prefix}/bin"
183
+ [ -x "${RUBY_BIN_DIR}/ruby" ] || die "Homebrew ruby not found at ${RUBY_BIN_DIR}."
184
+ local ver
185
+ ver="$("${RUBY_BIN_DIR}/ruby" -e 'print RUBY_VERSION' 2>/dev/null || echo '?')"
186
+ RUBY_LABEL="Ruby ${ver} (Homebrew)"
187
+
188
+ # Run gem/bundle/rake from Homebrew's keg-only ruby bin without relinking.
189
+ rubyx() { PATH="${RUBY_BIN_DIR}:${PATH}" "$@"; }
190
+ ok "${RUBY_LABEL} ready: ${RUBY_BIN_DIR}"
191
+ }
192
+
193
+ info "Detected ${OS} ${ARCH}. Installing rubino via ${METHOD}."
194
+
195
+ case "$METHOD" in
196
+ rv) setup_ruby_rv ;;
197
+ brew) setup_ruby_brew ;;
198
+ *) die "internal: unknown method '${METHOD}'." ;;
199
+ esac
200
+
201
+ # Where gem-installed executables land for the chosen Ruby. `gem environment
202
+ # gembindir` is correct for both rv and Homebrew; fall back to the ruby bin dir.
203
+ GEM_BIN_DIR="$(rubyx gem environment gembindir 2>/dev/null | tail -n1)" || GEM_BIN_DIR=""
204
+ [ -n "${GEM_BIN_DIR:-}" ] && [ -d "$GEM_BIN_DIR" ] || GEM_BIN_DIR="$RUBY_BIN_DIR"
205
+
206
+ # --- install the rubino gem -------------------------------------------------
207
+
208
+ gem_bin_present() { [ -x "${GEM_BIN_DIR}/${BIN_NAME}" ]; }
209
+
210
+ install_published() {
211
+ info "Trying published gem: gem install ${GEM_NAME}..."
212
+ if rubyx gem install "${GEM_NAME}" >/dev/null 2>&1; then
213
+ if gem_bin_present; then
214
+ ok "Installed ${GEM_NAME} from RubyGems."
215
+ return 0
216
+ fi
217
+ warn "A '${GEM_NAME}' gem was installed but it doesn't provide the '${BIN_NAME}' CLI; building from source instead."
218
+ rubyx gem uninstall "${GEM_NAME}" -aIx >/dev/null 2>&1 || true
219
+ fi
220
+ return 1
221
+ }
222
+
223
+ install_from_git() {
224
+ warn "Building ${GEM_NAME} from ${REPO_URL} (the CLI gem isn't on RubyGems yet)."
225
+ need git
226
+ local work
227
+ work="$(mktemp -d)"
228
+ trap 'rm -rf "$work"' RETURN
229
+ git clone --depth 1 "$REPO_URL" "$work/${REPO_NAME}" >/dev/null 2>&1 \
230
+ || die "git clone of ${REPO_URL} failed."
231
+ (
232
+ cd "$work/${REPO_NAME}"
233
+ info "Resolving dependencies (bundle install)..."
234
+ rubyx bundle install >/dev/null 2>&1 || die "bundle install failed."
235
+ info "Building the gem (rake build)..."
236
+ rubyx rake build >/dev/null 2>&1 || die "rake build failed."
237
+ local pkg
238
+ pkg="$(ls -1 pkg/${GEM_NAME}-*.gem 2>/dev/null | head -n1)"
239
+ [ -n "$pkg" ] || die "rake build produced no gem in pkg/."
240
+ info "Installing ${pkg}..."
241
+ rubyx gem install "$pkg" >/dev/null 2>&1 || die "gem install of the built package failed."
242
+ )
243
+ gem_bin_present || die "built and installed ${GEM_NAME} but the '${BIN_NAME}' executable is missing."
244
+ ok "Installed ${GEM_NAME} from source."
245
+ }
246
+
247
+ if gem_bin_present; then
248
+ CURRENT_VER="$("${GEM_BIN_DIR}/${BIN_NAME}" version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
249
+ ok "${BIN_NAME} ${CURRENT_VER:+v$CURRENT_VER }is already installed (re-run safe)."
250
+ elif ! install_published; then
251
+ install_from_git
252
+ fi
253
+
254
+ # --- PATH guidance + success ------------------------------------------------
255
+
256
+ printf '\n'
257
+ ok "rubino installed (${RUBY_LABEL})."
258
+ printf '\n'
259
+
260
+ if command -v "${BIN_NAME}" >/dev/null 2>&1 && [ "$(command -v "${BIN_NAME}")" = "${GEM_BIN_DIR}/${BIN_NAME}" ]; then
261
+ PATH_OK=1
262
+ else
263
+ PATH_OK=0
264
+ fi
265
+
266
+ if [ "$PATH_OK" -ne 1 ]; then
267
+ printf '%sAdd this line to your shell profile%s (~/.bashrc, ~/.zshrc, ~/.profile):\n' "$BOLD" "$RESET"
268
+ printf '\n %sexport PATH="%s:$PATH"%s\n\n' "$DIM" "${GEM_BIN_DIR}" "$RESET"
269
+ printf 'Then open a new shell (or run the export above) so %s%s%s is on your PATH.\n\n' "$BOLD" "${BIN_NAME}" "$RESET"
270
+ fi
271
+
272
+ printf '%sNext step:%s\n\n' "$BOLD" "$RESET"
273
+ printf ' %s%s setup%s %s# guided first-run: pick a provider, paste a key%s\n\n' "$GREEN" "${BIN_NAME}" "$RESET" "$DIM" "$RESET"
274
+
275
+ printf 'Run: %s%s setup%s\n' "$BOLD" "${BIN_NAME}" "$RESET"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ # In-process switch holding the ONE skill the user has pinned active for the
5
+ # session (MVP: one at a time). Mirrors Rubino::Modes: a process-level slot,
6
+ # set via `/skills <name>` (the completion-dropdown picker) and cleared via
7
+ # `/skills none`. The active skill is force-loaded into the system prompt each
8
+ # turn (Context::PromptAssembler), so the model actually uses it — not just a
9
+ # cosmetic chip.
10
+ #
11
+ # Lives at the process level intentionally — alpha rule: no premature
12
+ # persistence. A fresh `rubino chat` boots with NO active skill; an explicit
13
+ # `/skills <name>` takes effect for the rest of that process. We can move it
14
+ # onto Session later if users want it sticky across restarts.
15
+ #
16
+ # The sentinel "none" (and the `✗ none` dropdown entry) clears the slot.
17
+ module ActiveSkill
18
+ # The dropdown/CLI sentinel that clears the active skill.
19
+ NONE = "none"
20
+
21
+ class << self
22
+ # The active skill name (String), or nil when none is pinned.
23
+ attr_reader :current
24
+
25
+ # Pins +name+ as the active skill. A nil/empty/"none" clears it. Returns
26
+ # the new value (the name String, or nil when cleared). The caller is
27
+ # responsible for validating the name against the registry BEFORE calling
28
+ # this — ActiveSkill is a dumb slot, like Modes.
29
+ def set(name)
30
+ normalized = name.to_s.strip
31
+ @current = normalized.empty? || normalized.casecmp?(NONE) ? nil : normalized
32
+ end
33
+
34
+ # Clears the active skill (the `/skills none` / `✗ none` path).
35
+ def clear
36
+ @current = nil
37
+ end
38
+
39
+ # True when a skill is pinned.
40
+ def active?
41
+ !@current.nil?
42
+ end
43
+
44
+ # Test/teardown hook. Not part of the public API.
45
+ def reset!
46
+ @current = nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Registry of all defined agents (primary, sub, utility).
6
+ # Default agent system prompts are stored in agent/prompts/*.txt so they
7
+ # can be edited without modifying Ruby source.
8
+ class AgentRegistry
9
+ PROMPTS_DIR = File.expand_path("prompts", __dir__)
10
+
11
+ def initialize
12
+ @agents = {}
13
+ register_defaults!
14
+ end
15
+
16
+ # Returns all primary agents.
17
+ def primary_agents
18
+ @agents.values.select(&:primary?)
19
+ end
20
+
21
+ # Returns all visible subagents (for @mention).
22
+ def subagents
23
+ @agents.values.select { |a| a.subagent? && !a.hidden? }
24
+ end
25
+
26
+ # Returns all agents including hidden.
27
+ def all
28
+ @agents.values
29
+ end
30
+
31
+ # Finds an agent by name.
32
+ def find(name)
33
+ @agents[name.to_s]
34
+ end
35
+
36
+ # Registers a custom agent definition.
37
+ def register(definition)
38
+ @agents[definition.name] = definition
39
+ end
40
+
41
+ # Returns the default primary agent.
42
+ def default
43
+ find("build") || primary_agents.first
44
+ end
45
+
46
+ private
47
+
48
+ def register_defaults!
49
+ register(Definition.new(
50
+ name: "build",
51
+ type: :primary,
52
+ description: "Full-access development agent with all tools",
53
+ system_prompt: load_prompt("build"),
54
+ tools: :all
55
+ ))
56
+
57
+ register(Definition.new(
58
+ name: "plan",
59
+ type: :primary,
60
+ description: "Read-only analysis and planning agent",
61
+ system_prompt: load_prompt("plan"),
62
+ tools: :read_only,
63
+ permissions: { "edit *" => "ask", "shell *" => "ask" }
64
+ ))
65
+
66
+ register(Definition.new(
67
+ name: "explore",
68
+ type: :subagent,
69
+ description: "Fast read-only codebase exploration",
70
+ system_prompt: load_prompt("explore"),
71
+ tools: :read_only,
72
+ max_turns: 20
73
+ ))
74
+
75
+ register(Definition.new(
76
+ name: "general",
77
+ type: :subagent,
78
+ description: "General-purpose agent for complex multi-step tasks",
79
+ system_prompt: load_prompt("general"),
80
+ tools: :all,
81
+ max_turns: 50
82
+ ))
83
+
84
+ register(Definition.new(
85
+ name: "compaction",
86
+ type: :utility,
87
+ description: "Compresses long contexts",
88
+ system_prompt: load_prompt("compaction"),
89
+ hidden: true,
90
+ tools: []
91
+ ))
92
+
93
+ register(Definition.new(
94
+ name: "title",
95
+ type: :utility,
96
+ description: "Generates session titles",
97
+ system_prompt: "Generate a concise title (max 6 words) for this conversation based on the first user message.",
98
+ hidden: true,
99
+ tools: []
100
+ ))
101
+ end
102
+
103
+ # Loads a prompt for a role. Checks the customer config for an
104
+ # explicit override first (prompts.overrides.<role>) and falls back
105
+ # to the built-in agent/prompts/<role>.txt. Missing files resolve
106
+ # to an empty string so a stripped-down distribution doesn't crash
107
+ # the registry at boot.
108
+ def load_prompt(name)
109
+ override = Rubino.configuration.prompts_override_for(name)
110
+ return override if override
111
+
112
+ path = File.join(PROMPTS_DIR, "#{name}.txt")
113
+ File.exist?(path) ? File.read(path).strip : ""
114
+ rescue StandardError
115
+ path = File.join(PROMPTS_DIR, "#{name}.txt")
116
+ File.exist?(path) ? File.read(path).strip : ""
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Jittered exponential backoff for retries, a faithful port of the
6
+ # reference jittered_backoff:
7
+ #
8
+ # delay = min(base * 2^(attempt-1), max)
9
+ # result = delay + uniform(0, jitter_ratio * delay) # jitter_ratio = 0.5
10
+ #
11
+ # Jitter decorrelates concurrent retries so multiple sessions hitting the
12
+ # same rate-limited provider don't all retry at the same instant.
13
+ #
14
+ # Deviation from the reference (intentional): the reference
15
+ # seeds a fresh RNG from a process-global monotonic counter + time on every
16
+ # call to stay decorrelated across threads with a coarse clock. We use
17
+ # Ruby's `rand`, whose Mersenne-Twister default RNG is already per-process
18
+ # and well-distributed — no global counter, no lock, less code. The
19
+ # decorrelation property (jitter spread over [0, 0.5*delay]) is preserved.
20
+ #
21
+ # Two presets mirror the conversation loop's two backoff sites:
22
+ # * INVALID_RESPONSE — base 5s, cap 120s
23
+ # * ERROR_PATH — base 2s, cap 60s
24
+ class BackoffPolicy
25
+ JITTER_RATIO = 0.5
26
+
27
+ # Preset = [base_delay, max_delay] in seconds.
28
+ INVALID_RESPONSE = { base: 5.0, max: 120.0 }.freeze
29
+ ERROR_PATH = { base: 2.0, max: 60.0 }.freeze
30
+
31
+ # Retry-After header values larger than this are clamped, matching the
32
+ # reference 2-minute cap.
33
+ RETRY_AFTER_CAP = 120.0
34
+
35
+ # cancel_token: an Interaction::CancelToken (or anything answering #check!)
36
+ # so a backoff wait aborts promptly on Ctrl+C instead of blocking for the
37
+ # full delay. Optional — nil means a plain (still sliced) sleep.
38
+ def initialize(cancel_token: nil)
39
+ @cancel_token = cancel_token
40
+ end
41
+
42
+ # Jittered delay in seconds for a 1-based attempt. `base`/`max` default to
43
+ # the error-path preset; pass a preset hash's values for the other site.
44
+ def jittered(attempt, base: ERROR_PATH[:base], max: ERROR_PATH[:max])
45
+ exponent = [0, attempt - 1].max
46
+ delay = base <= 0 || exponent >= 63 ? max : [base * (2**exponent), max].min
47
+ delay + (rand * JITTER_RATIO * delay)
48
+ end
49
+
50
+ # The wait to honour for a retry. When the upstream sent a Retry-After we
51
+ # respect it (clamped to RETRY_AFTER_CAP), exactly as the reference does on the
52
+ # rate-limited path; otherwise fall back
53
+ # to the jittered backoff.
54
+ def wait_seconds(attempt, base:, max:, retry_after: nil)
55
+ ra = parse_retry_after(retry_after)
56
+ return [ra, RETRY_AFTER_CAP].min if ra
57
+
58
+ jittered(attempt, base: base, max: max)
59
+ end
60
+
61
+ # Sleep `seconds`, sliced into 100ms ticks, polling the cancel token
62
+ # between ticks so Ctrl+C aborts within ~100ms instead of blocking the
63
+ # whole wait. On cancel, CancelToken#check! raises Interrupted. Mirrors
64
+ # the adapter's former cancellable_sleep and the reference incremental sleep
65
+ # loop.
66
+ def sleep(seconds)
67
+ deadline = monotonic_now + seconds
68
+ while (remaining = deadline - monotonic_now).positive?
69
+ @cancel_token&.check!
70
+ Kernel.sleep([0.1, remaining].min)
71
+ end
72
+ end
73
+
74
+ # Pull a Retry-After value from a raw header value (String/Numeric) or a
75
+ # typed error carrying a Faraday response. Returns Float seconds or nil.
76
+ #
77
+ # NOTE: only the delta-seconds form (e.g. "30") is parsed. The HTTP-date
78
+ # form of Retry-After is not handled — no provider this gem targets sends
79
+ # it, and the reference likewise only parses the numeric form. TODO: handle the
80
+ # date form if a provider ever needs it.
81
+ def parse_retry_after(value)
82
+ return if value.nil?
83
+
84
+ raw =
85
+ if value.is_a?(Numeric) || value.is_a?(String)
86
+ value
87
+ else
88
+ retry_after_header(value)
89
+ end
90
+ return if raw.nil?
91
+
92
+ f = Float(raw, exception: false)
93
+ f if f&.positive?
94
+ end
95
+
96
+ private
97
+
98
+ # Reach a Retry-After header off a typed error's Faraday response, if
99
+ # present. ruby_llm wraps the Faraday::Response on the error (#response),
100
+ # whose #headers is a case-insensitive hash. Returns nil when unreachable.
101
+ def retry_after_header(error)
102
+ return unless error.respond_to?(:response)
103
+
104
+ response = error.response
105
+ headers = response.respond_to?(:headers) ? response.headers : nil
106
+ return unless headers.respond_to?(:[])
107
+
108
+ headers["retry-after"] || headers["Retry-After"]
109
+ end
110
+
111
+ def monotonic_now
112
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Defines an agent type with its own model, system prompt, permissions, and tools.
6
+ # Agents can be primary (user-facing) or subagents (invokable by other agents).
7
+ class Definition
8
+ attr_reader :name, :type, :model, :system_prompt, :description,
9
+ :permissions, :tools, :hidden
10
+
11
+ # Types: :primary (user-switchable), :subagent (invokable), :utility (hidden)
12
+ TYPES = %i[primary subagent utility].freeze
13
+
14
+ def initialize(attrs = {})
15
+ @name = attrs[:name]
16
+ @type = attrs[:type] || :primary
17
+ @model = attrs[:model]
18
+ @system_prompt = attrs[:system_prompt]
19
+ @description = attrs[:description] || ""
20
+ @permissions = attrs[:permissions] || {}
21
+ @mcp_servers = attrs[:mcp_servers] # :all or array of server names
22
+ @tools = attrs[:tools] || :all # :all, :read_only, or array of tool names
23
+ @hidden = attrs[:hidden] || false
24
+ @max_turns = attrs[:max_turns]
25
+ end
26
+
27
+ def primary?
28
+ @type == :primary
29
+ end
30
+
31
+ def subagent?
32
+ @type == :subagent
33
+ end
34
+
35
+ def utility?
36
+ @type == :utility
37
+ end
38
+
39
+ def hidden?
40
+ @hidden
41
+ end
42
+
43
+ # Which MCP servers this agent may use: :all, or an array of server
44
+ # names. An explicit value passed in code wins; otherwise the
45
+ # `agents.<name>.mcp_servers` block in config.yml applies (#92), and
46
+ # absent both the agent sees every server. YAML has no symbols, so the
47
+ # literal string "all" from config normalizes to :all — the value
48
+ # #resolved_tools compares against.
49
+ def mcp_servers
50
+ return @mcp_servers if @mcp_servers
51
+
52
+ configured = Rubino.configuration.dig("agents", name.to_s, "mcp_servers")
53
+ case configured
54
+ when Array then configured.map(&:to_s)
55
+ else :all
56
+ end
57
+ end
58
+
59
+ # Returns the max turns for this agent (falls back to global config)
60
+ def max_turns
61
+ @max_turns || Rubino.configuration.agent_max_turns
62
+ end
63
+
64
+ # Returns the resolved model (falls back to global default)
65
+ def resolved_model
66
+ @model || Rubino.configuration.model_default
67
+ end
68
+
69
+ # Returns tool list based on the agent's tool configuration.
70
+ #
71
+ # Scoped nesting (S1): a subagent now KEEPS the delegation tools (`task` and
72
+ # its companions `task_result`/`task_stop`) so it can spawn its own
73
+ # subagents. Runaway recursion / fan-out is no longer prevented by hiding
74
+ # the tool here — it is bounded in ONE place, Tools::BackgroundTasks#reserve,
75
+ # by the depth / per-owner / global caps. (DELEGATION_TOOLS is kept as a
76
+ # named set for any reader that still wants to reason about the group.)
77
+ DELEGATION_TOOLS = %w[task task_result task_stop].freeze
78
+
79
+ # Tools that ONLY make sense for a subagent and must be hidden from a
80
+ # primary/top-level agent. ask_parent escalates a question to the PARENT — a
81
+ # top-level agent has no parent, so exposing it there would be a dead tool.
82
+ # Subagents keep it; everyone else drops it. This is the single enforcement
83
+ # point and is UNCHANGED by S1 (re-enabling nesting does not expose
84
+ # ask_parent to top-level agents).
85
+ SUBAGENT_ONLY_TOOLS = %w[ask_parent].freeze
86
+
87
+ def resolved_tools
88
+ tools =
89
+ case @tools
90
+ when :all
91
+ Tools::Registry.enabled_tools
92
+ when :read_only
93
+ Tools::Registry.enabled_tools.select { |t| t.risk_level == :low }
94
+ when Array
95
+ @tools.filter_map { |name| Tools::Registry.find(name) }
96
+ else
97
+ Tools::Registry.enabled_tools
98
+ end
99
+
100
+ # Per-agent MCP scoping (#92/#173): every consumer of this agent's tool
101
+ # set (Lifecycle#load_tools, prompt assembler) goes through here, so
102
+ # filtering MCP wrappers HERE is what actually keeps an out-of-scope
103
+ # server's tools away from the model.
104
+ tools = reject_unscoped_mcp_tools(tools)
105
+
106
+ # ask_parent is subagent-only; a primary/top-level agent has no parent.
107
+ # Nesting is otherwise allowed for everyone — the delegation tools stay.
108
+ if subagent?
109
+ tools
110
+ else
111
+ tools.reject { |t| SUBAGENT_ONLY_TOOLS.include?(t.name) }
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ # Drops MCPToolWrapper instances whose server is not in this agent's
118
+ # mcp_servers allowlist (:all keeps everything). Built-in tools pass
119
+ # through untouched.
120
+ def reject_unscoped_mcp_tools(tools)
121
+ allowed = mcp_servers
122
+ return tools if allowed == :all
123
+
124
+ tools.reject { |t| t.is_a?(MCP::MCPToolWrapper) && !allowed.include?(t.server_name.to_s) }
125
+ end
126
+ end
127
+ end
128
+ end