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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # answer_child — the MODEL-callable answer to a child's ask_parent (S4). The
6
+ # agent-parent counterpart of the human `/reply <id> <answer>`: when a
7
+ # subagent calls ask_parent and it is OWNED by an agent (not the human), the
8
+ # question lands on this parent's steer_queue as a [subagent-question] note;
9
+ # the parent model reads it at its next turn and answers it with this tool.
10
+ #
11
+ # SCOPED AT CALL (like steer/probe, the S1 correction): registered for ALL
12
+ # agents and AUTHORIZED by OWNERSHIP at call time. The caller is the
13
+ # thread-local Rubino.current_subagent_id (nil ⇒ the human / top-level agent).
14
+ # The target must be the caller's OWN DIRECT child (BackgroundTasks.owned_by?)
15
+ # AND it must actually be waiting on an ask (it has an ask_gate). NOT on any
16
+ # strip list.
17
+ #
18
+ # Mechanism reuse: it wraps BackgroundTasks#deliver_answer verbatim — the SAME
19
+ # ONE answer wire the human /reply path uses (decide the gate + push the
20
+ # [parent answer] steer note + clear the blocked state). No new transport.
21
+ #
22
+ # An agent-parent that CANNOT answer from its own context does NOT use this
23
+ # tool: it escalates by calling its OWN ask_parent (recursion up the tree).
24
+ class AnswerChildTool < Base
25
+ def name
26
+ "answer_child"
27
+ end
28
+
29
+ # Gated by the same `tools.task` delegation key — answering a child is
30
+ # meaningless without the delegation substrate. Disabling delegation
31
+ # disables answer_child too.
32
+ def config_key
33
+ "task"
34
+ end
35
+
36
+ def description
37
+ "Answer one of YOUR OWN subagents that asked you a question via " \
38
+ "ask_parent (you will have received it as a [subagent-question] note). " \
39
+ "The answer is delivered into that child's context: it unblocks a child " \
40
+ "that paused for it and folds into a child that kept working. You can " \
41
+ "ONLY answer a subagent you started (your direct child) that is actually " \
42
+ "waiting on you. If you CANNOT answer from your own context, do NOT guess " \
43
+ "— escalate by calling ask_parent yourself."
44
+ end
45
+
46
+ def input_schema
47
+ {
48
+ type: "object",
49
+ properties: {
50
+ task_id: { type: "string", description: "The id (sa_…) of YOUR subagent that asked you." },
51
+ answer: { type: "string",
52
+ description: "Your answer. Be specific and self-contained — it enters the child's context." }
53
+ },
54
+ required: %w[task_id answer]
55
+ }
56
+ end
57
+
58
+ # Answering a child is a low-risk, non-destructive hand-off (the child
59
+ # carries its own approval/risk gates for whatever it does with the answer).
60
+ def risk_level
61
+ :low
62
+ end
63
+
64
+ def call(arguments)
65
+ task_id = (arguments["task_id"] || arguments[:task_id]).to_s.strip
66
+ answer = (arguments["answer"] || arguments[:answer]).to_s.strip
67
+ return "Error: answer is required" if answer.empty?
68
+
69
+ caller_id = Rubino.current_subagent_id
70
+ registry = BackgroundTasks.instance
71
+
72
+ # Ownership: only a DIRECT child of the caller may be answered.
73
+ return "Error: #{task_id} is not one of your subagents." unless registry.owned_by?(caller_id, task_id)
74
+
75
+ # It must actually be waiting on an ask (deliver_answer no-ops without a
76
+ # live ask_gate). Covers a missing/finished/not-blocked child uniformly.
77
+ return "#{task_id} is not waiting on you." unless registry.deliver_answer(task_id, answer)
78
+
79
+ "↳ answered #{task_id}: #{Rubino::Util::Output.elide(answer, 80)}\n✓ #{task_id} resumes at its next turn"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # ask_parent — the child->parent escalation channel (the third mechanism of
6
+ # the parent<->subagent comm design). A subagent calls this when it hits a
7
+ # fork it cannot resolve from its sealed prompt ("sqlite or postgres?").
8
+ #
9
+ # Answerer is MIXED: the parent answers from its own context if it can, else
10
+ # it escalates to the HUMAN. This tool implements the wire; the escalation
11
+ # itself reuses Run::ApprovalGate verbatim (the SAME blocking cross-thread
12
+ # hand-off the Option-2 background-approval path already uses):
13
+ #
14
+ # 1. The tool finds the child\'s own BackgroundTasks entry (via the
15
+ # thread-local Rubino.current_subagent_id set by TaskTool around the
16
+ # child run). No entry ⇒ this run has no parent (top-level / foreground
17
+ # sync) and the tool refuses gracefully — never hangs.
18
+ # 2. It registers a Run::ApprovalGate on the entry (BackgroundTasks#begin_ask),
19
+ # flipping the entry to :blocked_on_human so the parent CLI surfaces the
20
+ # ⛔ blocked banner + the persistent "N subagent waiting on you" marker,
21
+ # and informs the parent loop by pushing a note onto the parent\'s
22
+ # InputQueue (so the parent MODEL sees the question at its next turn and
23
+ # MAY answer it — the "parent answers if it can" half; the parent\'s
24
+ # answer routes back through the SAME gate via /reply or a parent path).
25
+ # 3. blocking:true → the tool BLOCKS on gate.await(timeout: nil) — wait
26
+ # INDEFINITELY, no auto-default (the owner constraint). The human answers
27
+ # via /reply <id>, which decides the gate; the answer is the tool result
28
+ # and enters the child\'s context as the tool message.
29
+ # blocking:false → the tool returns IMMEDIATELY ("asked, keep working");
30
+ # the answer is delivered later as a steer note on the child\'s queue
31
+ # (Loop#inject_steered_input), so the child keeps making progress.
32
+ #
33
+ # SUSPEND/RESUME (the W1/#54 lesson): on the CLI a background subagent runs on
34
+ # its OWN dedicated Thread — NOT a pooled Puma/Solid-Queue worker. Parking
35
+ # that dedicated thread on the gate (blocking:true) therefore holds only the
36
+ # child\'s own thread, never a shared pool, so it cannot freeze the REPL the
37
+ # way a parked Puma worker froze the server (W1). This is exactly how the
38
+ # existing Option-2 approval handler parks the child thread today. A full
39
+ # persist-and-resume suspend (free the thread entirely, rehydrate on answer)
40
+ # is only required for the POOLED web path, which is OUT OF SCOPE here and
41
+ # tracked as a follow-up. A stop (/agents <id> --stop) cancels the gate so a
42
+ # blocking ask unwinds at once instead of waiting forever.
43
+ class AskParentTool < Base
44
+ # Sentinel head used when a non-blocking ask returns to the child: the
45
+ # child keeps working and the real answer arrives later as a steer note.
46
+ NONBLOCKING_ACK = "Question sent to your parent. Keep working with your best " + "judgement; the answer will be delivered to you as a note " + "at your next turn if/when it arrives."
47
+
48
+ # Fallback bound (seconds) for a blocking ask when no configuration is
49
+ # reachable (a bare tool in a unit test). The live value comes from
50
+ # tasks.ask_parent_timeout; this matches the approvals wait-timeout default.
51
+ DEFAULT_ASK_TIMEOUT = 900
52
+
53
+ def name
54
+ "ask_parent"
55
+ end
56
+
57
+ # Gated by the same `tools.task` delegation key — it is meaningless without
58
+ # the delegation substrate (BackgroundTasks/registry). Disabling delegation
59
+ # disables ask_parent too.
60
+ def config_key
61
+ "task"
62
+ end
63
+
64
+ def description
65
+ "Ask YOUR PARENT agent a question when you hit a decision you cannot " + "resolve from the task you were given (e.g. a missing preference, an " + "ambiguous requirement, sqlite-vs-postgres). Your parent answers from " + "its own context if it can, otherwise it asks the human. Use " + "blocking:true when you CANNOT proceed without the answer (you will " + "pause until it arrives); blocking:false (default) when you can keep " + "working and fold the answer in later. Only available to subagents."
66
+ end
67
+
68
+ def input_schema
69
+ {
70
+ type: "object",
71
+ properties: {
72
+ question: { type: "string", description: "The question for your parent. Be specific and self-contained." },
73
+ blocking: {
74
+ type: "boolean",
75
+ description: "true = pause until answered (you cannot proceed without it). " + "false (default) = keep working; the answer is delivered later as a note."
76
+ }
77
+ },
78
+ required: %w[question]
79
+ }
80
+ end
81
+
82
+ def risk_level
83
+ :low
84
+ end
85
+
86
+ def call(arguments)
87
+ question = (arguments["question"] || arguments[:question]).to_s.strip
88
+ blocking = blocking_arg(arguments)
89
+ return "Error: question is required" if question.empty?
90
+
91
+ id = Rubino.current_subagent_id
92
+ entry = id && BackgroundTasks.instance.find(id)
93
+ unless entry
94
+ return "Error: ask_parent is only available to a background subagent " + "(no parent to ask). Resolve this from your task instead."
95
+ end
96
+
97
+ escalate(entry, question, blocking)
98
+ rescue Rubino::Interrupted
99
+ # A /agents <id> --stop (or teardown) cancelled the gate while we were
100
+ # parked. Unwind cleanly: report it as denied/cancelled so the child can
101
+ # finish rather than hang.
102
+ BackgroundTasks.instance.end_ask(entry&.id) if defined?(entry) && entry
103
+ "Your parent question was cancelled (the run is being stopped)."
104
+ end
105
+
106
+ private
107
+
108
+ # blocking defaults to FALSE (the cheap, non-freezing default): the child
109
+ # keeps working and the answer is injected later. Only an explicit true
110
+ # opts into the indefinite blocking wait.
111
+ def blocking_arg(arguments)
112
+ raw = arguments.key?("blocking") ? arguments["blocking"] : arguments[:blocking]
113
+ [true, "true", 1, "1"].include?(raw)
114
+ end
115
+
116
+ def escalate(entry, question, blocking)
117
+ gate = Run::ApprovalGate.new
118
+ ask_id = "ask_#{entry.id}"
119
+ gate.register(ask_id)
120
+ # Route by OWNER (S4): a child with an agent-parent blocks on that PARENT
121
+ # (:blocked_on_parent, answered by the parent model's `answer_child`); a
122
+ # human/top-level-owned child blocks on the HUMAN (:blocked_on_human,
123
+ # answered via /reply). begin_ask records the right status from the owner.
124
+ owner_id = entry.owner_subagent_id
125
+ BackgroundTasks.instance.begin_ask(
126
+ entry.id, gate: gate, ask_id: ask_id, question: question,
127
+ blocking: blocking, owner_id: owner_id
128
+ )
129
+ if owner_id
130
+ notify_agent_parent(owner_id, entry, question)
131
+ else
132
+ surface_and_notify(entry, question)
133
+ end
134
+
135
+ if blocking
136
+ await_human(entry, gate, ask_id)
137
+ else
138
+ # Non-blocking: the child keeps working. The answer arrives later as a
139
+ # steer note via the gate-watcher the CLI installs at /reply time
140
+ # (BackgroundTasks#steer pushes onto the child\'s queue). We do NOT
141
+ # clear the ask state here — the entry stays :blocked_on_human on the
142
+ # card until the human answers, so a non-blocking ask is still visible
143
+ # and answerable; the child simply does not pause for it.
144
+ NONBLOCKING_ACK
145
+ end
146
+ end
147
+
148
+ # Parks the child\'s OWN thread on the gate, BOUNDED by
149
+ # tasks.ask_parent_timeout (S5a — default 900s). On EXPIRED (no answer
150
+ # within the bound) the child self-heals: it stops waiting and proceeds with
151
+ # its best judgement instead of hanging forever. The answer (from /reply or
152
+ # answer_child, both via gate.decide) returns as the tool result so it
153
+ # enters the child\'s context. A cancel (/agents <id> --stop or a
154
+ # stop-cascade from an ancestor) raises Interrupted, handled in #call.
155
+ def await_human(entry, gate, ask_id)
156
+ decision = gate.await(ask_id, timeout: ask_timeout)
157
+ answer = decision.equal?(Run::ApprovalGate::EXPIRED) ? nil : decision.to_s
158
+ BackgroundTasks.instance.end_ask(entry.id)
159
+ if answer.nil? || answer.empty?
160
+ "Your parent did not provide an answer. Proceed with your best judgement."
161
+ else
162
+ "Your parent answered: #{answer}"
163
+ end
164
+ end
165
+
166
+ # The bounded wait for a blocking ask, from config (tasks.ask_parent_timeout)
167
+ # when wired, else the built-in default. Reuses the approval-gate timeout
168
+ # convention (a sane upper bound, never "forever") so an abandoned ask
169
+ # self-heals rather than parking the child\'s thread indefinitely.
170
+ def ask_timeout
171
+ cfg = Rubino.configuration if defined?(Rubino) && Rubino.respond_to?(:configuration)
172
+ val = cfg&.respond_to?(:tasks_ask_parent_timeout) ? cfg.tasks_ask_parent_timeout : nil
173
+ Integer(val)
174
+ rescue StandardError, TypeError, ArgumentError
175
+ DEFAULT_ASK_TIMEOUT
176
+ end
177
+
178
+ # Notifies the AGENT-parent (owner) of a child question by pushing the
179
+ # [subagent-question] note onto the OWNER\'s steer_queue — the same
180
+ # turn-boundary channel a steer rides — so the parent MODEL sees it at its
181
+ # next iteration and can answer with `answer_child` (or escalate up via its
182
+ # own ask_parent). No human surfacing here: a :blocked_on_parent ask is the
183
+ # agent-parent\'s job, not the human\'s. Best-effort.
184
+ def notify_agent_parent(owner_id, entry, question)
185
+ BackgroundTasks.instance.steer(owner_id, agent_parent_notice(entry, question))
186
+ rescue StandardError
187
+ nil
188
+ end
189
+
190
+ def agent_parent_notice(entry, question)
191
+ "[subagent-question] Your subagent #{entry.id} ('#{entry.subagent}') is asking you:\n" \
192
+ "#{question}\n" \
193
+ "Answer it with answer_child(task_id: \"#{entry.id}\", answer: \"…\") if you can. " \
194
+ "If you cannot answer from your own context, escalate by calling ask_parent yourself."
195
+ end
196
+
197
+ # Surfaces the blocked state on the parent CLI (a committed banner in
198
+ # scrollback + a card repaint so the persistent ⛔ marker shows) and pushes
199
+ # a note onto the PARENT\'s InputQueue so the parent MODEL learns of the
200
+ # question at its next turn and may answer it (answer_child). The sink is
201
+ # the one TaskTool captured on the PARENT thread at spawn time
202
+ # (entry.parent_sink) — NEVER the thread-local Rubino.background_sink,
203
+ # which on this (the child\'s) thread is the child\'s own steer_queue and
204
+ # would misroute the question back into the asking child (#195). Pushed as
205
+ # a NOTICE so it rides the parent\'s next real turn instead of firing a
206
+ # standalone synthetic user turn at the idle prompt (#13). DISPLAY/notify
207
+ # only — the authoritative answer delivery is the gate decision
208
+ # (/reply or answer_child).
209
+ def surface_and_notify(entry, question)
210
+ entry.parent_sink&.push_notice(parent_notice(entry, question))
211
+ parent_ui = Rubino.instance_variable_get(:@ui)
212
+ return unless parent_ui.is_a?(UI::CLI)
213
+
214
+ parent_ui.subagent_ask_banner(entry.id, entry.subagent, question) if parent_ui.respond_to?(:subagent_ask_banner)
215
+ parent_ui.set_subagent_cards if parent_ui.respond_to?(:set_subagent_cards)
216
+ rescue StandardError
217
+ nil
218
+ end
219
+
220
+ # The top-level agent has answer_child registered too — the notice must
221
+ # name the MODEL-callable tool, not the human-only /reply command the
222
+ # model cannot invoke (#195).
223
+ def parent_notice(entry, question)
224
+ "[subagent-question] Task #{entry.id} (subagent '#{entry.subagent}') is asking you:\n" \
225
+ "#{question}\n" \
226
+ "Answer it with answer_child(task_id: \"#{entry.id}\", answer: \"…\") if you can. " \
227
+ "If you cannot answer from your own context, tell the user — the human can also " \
228
+ "answer via /reply #{entry.id}."
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # Hands a previously-written file to the surrounding UI as a downloadable
8
+ # artifact. The tool itself does not move bytes — it validates the path
9
+ # against the workspace and the file's existence, then surfaces a
10
+ # structured artifact payload that the agent loop turns into an
11
+ # ARTIFACT_CREATED event. Downstream consumers (the web UI's
12
+ # run job, the CLI) fetch the file separately via GET /v1/files.
13
+ #
14
+ # Why a dedicated tool rather than inferring artifacts from write/edit
15
+ # tool calls: the model writes lots of intermediate files (helper
16
+ # scripts, scratch JSON, downloaded fixtures) that should NOT show up
17
+ # as user-facing downloads. An explicit attach_file call makes that
18
+ # decision intentional and reviewable.
19
+ class AttachFileTool < Base
20
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
21
+
22
+ # Minimal extension → MIME map. Anything not listed falls back to
23
+ # application/octet-stream; the browser will then decide based on
24
+ # filename. Add entries here only when a real run needs a specific
25
+ # type signalled (e.g. inline PDF preview).
26
+ CONTENT_TYPES = {
27
+ "pdf" => "application/pdf",
28
+ "csv" => "text/csv",
29
+ "txt" => "text/plain",
30
+ "md" => "text/markdown",
31
+ "json" => "application/json",
32
+ "html" => "text/html",
33
+ "htm" => "text/html",
34
+ "xml" => "application/xml",
35
+ "png" => "image/png",
36
+ "jpg" => "image/jpeg",
37
+ "jpeg" => "image/jpeg",
38
+ "gif" => "image/gif",
39
+ "svg" => "image/svg+xml",
40
+ "zip" => "application/zip",
41
+ "tar" => "application/x-tar",
42
+ "gz" => "application/gzip",
43
+ "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
44
+ "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
45
+ "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
46
+ }.freeze
47
+
48
+ def name
49
+ "attach_file"
50
+ end
51
+
52
+ def description
53
+ "Attach a previously-written file to the current turn as a downloadable artifact " \
54
+ "for the user. Call this AFTER you have already created the file with write/edit/shell. " \
55
+ "Pass the absolute or workspace-relative path. The tool does not copy or move the file — " \
56
+ "it just registers it as a deliverable. Use for final user-facing outputs " \
57
+ "(PDF, CSV, ZIP, reports) and not for intermediate helper scripts."
58
+ end
59
+
60
+ def input_schema
61
+ {
62
+ type: "object",
63
+ properties: {
64
+ file_path: {
65
+ type: "string",
66
+ description: "Path to the file to attach. Must exist and live inside the workspace."
67
+ },
68
+ filename: {
69
+ type: "string",
70
+ description: "Optional display name; defaults to the basename of file_path."
71
+ }
72
+ },
73
+ required: %w[file_path]
74
+ }
75
+ end
76
+
77
+ def risk_level
78
+ :low
79
+ end
80
+
81
+ def call(arguments)
82
+ file_path = (arguments["file_path"] || arguments[:file_path]).to_s
83
+ return error("file_path is required") if file_path.empty?
84
+
85
+ expanded = File.expand_path(file_path)
86
+ return error("File not found: #{file_path}") unless File.exist?(expanded)
87
+ return error("Not a regular file: #{file_path}") unless File.file?(expanded)
88
+ return error("Path escapes the workspace: #{file_path}") unless within_workspace?(expanded)
89
+
90
+ display = (arguments["filename"] || arguments[:filename]).to_s
91
+ display = File.basename(expanded) if display.empty?
92
+
93
+ size = File.size(expanded)
94
+ artifact = {
95
+ path: expanded,
96
+ filename: display,
97
+ content_type: content_type_for(expanded),
98
+ byte_size: size
99
+ }
100
+
101
+ {
102
+ output: "Attached #{display} (#{size} bytes) as a downloadable artifact.",
103
+ metrics: "#{size} bytes",
104
+ artifact: artifact
105
+ }
106
+ end
107
+
108
+ private
109
+
110
+ def error(message)
111
+ { output: "Error: #{message}", error_code: :attach_failed }
112
+ end
113
+
114
+ def content_type_for(path)
115
+ ext = File.extname(path).to_s.sub(/\A\./, "").downcase
116
+ CONTENT_TYPES[ext] || DEFAULT_CONTENT_TYPE
117
+ end
118
+ end
119
+ end
120
+ end