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,622 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Delegates a bounded sub-task to a specialized subagent (the "agents-as-tools"
6
+ # pattern). Modeled on Claude Code's Task/Agent tool, which runs subagents in
7
+ # the BACKGROUND via `run_in_background` — here background is the DEFAULT:
8
+ #
9
+ # - background (default): spawn the subagent on its own thread and return
10
+ # IMMEDIATELY with a task id (`sa_…`). The subagent works while the parent
11
+ # keeps going. On completion the parent is NOTIFIED — a `[background-task]`
12
+ # message is injected into its live turn (via the parent's InputQueue, the
13
+ # same channel mid-turn steering uses) — and the result is also fetchable
14
+ # with `task_result(<id>)` or stoppable with `task_stop(<id>)`. This is
15
+ # the SendMessage/poll/notify trio Claude Code exposes for background
16
+ # agents, mapped onto the gem's existing async substrate.
17
+ # - synchronous (`background: false`): the legacy path — run the nested turn
18
+ # to completion inline and return ONLY the subagent's final message as the
19
+ # tool result. For callers that cannot proceed without the answer now.
20
+ #
21
+ # Isolation contract (unchanged, both paths):
22
+ # - the nested run gets a FRESH session seeded with ONLY the `prompt`
23
+ # string — the parent transcript never leaks into the child;
24
+ # - each background child gets its OWN Interaction::EventBus (like
25
+ # Run::Executor does per top-level run) so its tool events never pollute
26
+ # the parent recorder;
27
+ # - the only parent→child channel is the `prompt`, so the parent model must
28
+ # put any needed file paths / errors into it.
29
+ #
30
+ # Scoped nesting (S1): a subagent CAN now spawn its own subagents (the
31
+ # delegation tools are no longer stripped from a subagent's tool list). The
32
+ # tree is bounded in ONE place — BackgroundTasks#reserve — by three caps:
33
+ # max nesting depth (tasks.max_depth), per-owner live children
34
+ # (tasks.max_children_per_node), and a global live ceiling
35
+ # (tasks.max_concurrent_total). When a cap is hit reserve returns nil and this
36
+ # tool surfaces a clear, reason-specific message (#capacity_message) so the
37
+ # model knows whether to retry later, do the work inline, or report back.
38
+ class TaskTool < Base
39
+ # Suffix of the placeholder a subagent run lands on when it produced no
40
+ # final assistant text — a no-op or a fully-denied run (every tool denied,
41
+ # nothing said). Used as the single signal that a completion was a no-op so
42
+ # both the background completion line and the foreground delegation row can
43
+ # show a neutral indicator instead of a misleading green ✓ (#16).
44
+ NOOP_RESULT_SUFFIX = "returned no output)"
45
+
46
+ # True when a subagent's final result text is the no-op placeholder, i.e.
47
+ # the run did nothing / was denied. Shared by completion_summary so the
48
+ # background path mirrors the foreground delegation row.
49
+ def self.noop_result?(text)
50
+ text.to_s.strip.end_with?(NOOP_RESULT_SUFFIX)
51
+ end
52
+
53
+ def name
54
+ "task"
55
+ end
56
+
57
+ # `task` is the config gate; absent from config ⇒ enabled (opt-out model),
58
+ # same as every other tool.
59
+ def config_key
60
+ "task"
61
+ end
62
+
63
+ # The "NEVER claim a task was started…" sentence is the #149 guardrail:
64
+ # the model was observed confirming a spawn ("Started: sa_…") with a task
65
+ # id RECYCLED from earlier context, without calling this tool at all. The
66
+ # ids are unguessable (SecureRandom), so the only honest source of a NEW
67
+ # id is this tool's own return value — the description says so explicitly.
68
+ # Prompt-level by design: a render-time transcript scanner would be a far
69
+ # bigger surface for a model-behavior bug the user can already audit via
70
+ # the turn footer (0 tools) and /agents.
71
+ def description
72
+ "Delegate a bounded sub-task to a specialized subagent. By DEFAULT the " \
73
+ "subagent runs in the BACKGROUND: this call returns immediately with a " \
74
+ "task id and the subagent keeps working while you continue with other " \
75
+ "tools or reasoning — do NOT wait for it. When it finishes you will " \
76
+ "automatically receive a `[background-task] <id> completed` message with " \
77
+ "its result; you can also fetch the result anytime with `task_result(<id>)` " \
78
+ "or stop it with `task_stop(<id>)`. Set `background: false` ONLY when you " \
79
+ "cannot proceed without the subagent's answer in this same step (this " \
80
+ "blocks until it finishes and returns the result inline). The subagent " \
81
+ "runs in an isolated fresh context (it does NOT see this conversation) and " \
82
+ "returns only its final message — put every file path / error / detail it " \
83
+ "needs into `prompt`. NEVER claim a task was started unless THIS call just " \
84
+ "returned its id in the current turn — `sa_…` ids from earlier in the " \
85
+ "conversation belong to old tasks and must not be reported as new ones. " \
86
+ "Available subagents: #{available_subagents_description}."
87
+ end
88
+
89
+ def input_schema
90
+ {
91
+ type: "object",
92
+ properties: {
93
+ subagent: { type: "string",
94
+ description: "Name of the subagent to delegate to (#{available_subagent_names.join(", ")})" },
95
+ prompt: { type: "string",
96
+ description: "The full self-contained task for the subagent (the only context it receives)" },
97
+ background: {
98
+ type: "boolean",
99
+ description: "Run the subagent in the background (default true). " \
100
+ "true = return immediately with a task id, keep working, get " \
101
+ "notified on completion. false = block until the subagent " \
102
+ "finishes and return its result inline."
103
+ }
104
+ },
105
+ required: %w[subagent prompt]
106
+ }
107
+ end
108
+
109
+ # Spawns a gated nested run, not a destructive op — the nested tools carry
110
+ # their own approval/risk gates. Low risk keeps it auto-available so the
111
+ # model can auto-delegate from the description.
112
+ def risk_level
113
+ :low
114
+ end
115
+
116
+ def call(arguments)
117
+ subagent = (arguments["subagent"] || arguments[:subagent]).to_s.strip
118
+ prompt = (arguments["prompt"] || arguments[:prompt]).to_s
119
+ background = background_arg(arguments)
120
+
121
+ return "Error: subagent is required" if subagent.empty?
122
+ return "Error: prompt is required" if prompt.strip.empty?
123
+
124
+ definition = registry.find(subagent)
125
+ unless definition&.subagent?
126
+ return "Error: unknown subagent '#{subagent}'. " \
127
+ "Valid subagents: #{available_subagent_names.join(", ")}."
128
+ end
129
+
130
+ if background
131
+ run_background(definition, prompt)
132
+ else
133
+ run_subagent(definition, prompt)
134
+ end
135
+ rescue StandardError => e
136
+ "Error: subagent '#{subagent}' failed: #{e.message}"
137
+ end
138
+
139
+ private
140
+
141
+ # background defaults to TRUE (Claude-Code-style background-by-default).
142
+ # Absent ⇒ true; only an explicit false (bool or "false") opts into the
143
+ # synchronous path. A nil from a caller that omitted the key stays true.
144
+ def background_arg(arguments)
145
+ raw = arguments.key?("background") ? arguments["background"] : arguments[:background]
146
+ return true if raw.nil?
147
+
148
+ ![false, "false", 0, "0"].include?(raw)
149
+ end
150
+
151
+ # Background spawn (the default). Reserves a registry slot, builds the
152
+ # child Runner with its OWN EventBus (isolation), launches it on a thread,
153
+ # and returns a handle string IMMEDIATELY — the parent never blocks.
154
+ #
155
+ # On completion the worker's `ensure` records terminal state, emits a
156
+ # SUBAGENT_COMPLETED/FAILED event (so the CLI/web can surface it), and
157
+ # pushes a `[background-task]` notice onto the captured parent sink (the
158
+ # parent's InputQueue) so the parent folds the result in at its next
159
+ # iteration boundary — the Claude Code "auto-notify on completion" contract.
160
+ def run_background(definition, prompt)
161
+ registry_bg = BackgroundTasks.instance
162
+ # Ownership link (S1): when THIS run is itself a subagent, the thread-local
163
+ # current-subagent id is the spawner — the new child's owner. nil ⇒ the
164
+ # human / top-level agent is spawning (depth 0). The owner's depth is what
165
+ # reserve uses to stamp the child (owner.depth + 1); we pass 0 only as the
166
+ # human-spawned default. reserve recomputes depth from the owner entry, so
167
+ # this is just the top-level base case.
168
+ owner_id = Rubino.current_subagent_id
169
+ entry = registry_bg.reserve(
170
+ subagent: definition.name, prompt: prompt,
171
+ owner_subagent_id: owner_id, depth: 0
172
+ )
173
+ return capacity_message(registry_bg) unless entry
174
+
175
+ # Captured on the PARENT thread, before we spawn — the child thread has
176
+ # no access to the parent's thread-locals. The sink is the parent's
177
+ # InputQueue (completion notice), event_bus is the turn-scoped bus (so
178
+ # SSE/recorder sees the lifecycle), parent_ui is the parent's CLI view
179
+ # (so completion surfaces as a line, like background-shell does).
180
+ sink = Rubino.background_sink
181
+ event_bus = Rubino.active_event_bus
182
+ parent_ui = Rubino.ui
183
+ # Stash the spawn-captured sink on the entry so a tool running on the
184
+ # CHILD's thread (ask_parent) can notify the parent MODEL without
185
+ # reading the child's own thread-local sink — which is the child's own
186
+ # steer_queue, not the parent's queue (#195).
187
+ entry.parent_sink = sink
188
+ # Build the child UI on the PARENT thread so the collapsed-card view is
189
+ # wired with this run's entry id + the parent CLI (whose live region hosts
190
+ # the card) + the approval handler. In card mode the child's per-tool
191
+ # activity feeds the registry instead of flooding $stdout (#124).
192
+ child_ui = nested_ui_for(entry, parent_ui)
193
+ runner = build_background_runner(definition, child_ui)
194
+
195
+ thread = Thread.new do
196
+ run_child_thread(entry, runner, prompt, sink, event_bus, parent_ui, child_ui)
197
+ end
198
+ registry_bg.attach(entry, thread: thread, runner: runner)
199
+
200
+ event_bus&.emit(Interaction::Events::SUBAGENT_SPAWNED,
201
+ task_id: entry.id, subagent: definition.name,
202
+ prompt: Rubino::Util::Output.elide(prompt, 200))
203
+ # Paint the collapsed card for this just-spawned subagent immediately so
204
+ # it shows "running · 0 tools" the instant delegation starts, not only
205
+ # after its first child tool fires.
206
+ repaint_parent_cards(parent_ui)
207
+
208
+ spawn_handle(entry, definition)
209
+ end
210
+
211
+ # The child worker body. Runs the nested loop under the child UI, then —
212
+ # ALWAYS, even on a child exception (Exception net like Run::Executor) —
213
+ # records terminal state, notifies the parent, and emits the lifecycle
214
+ # event. A child LoadError/SyntaxError must not wedge the task as
215
+ # "running" forever.
216
+ def run_child_thread(entry, runner, prompt, sink, event_bus, parent_ui, child_ui = nil)
217
+ # The runner already renders through the card-mode child UI (wired at
218
+ # spawn); with_ui binds that SAME instance thread-locally so any global
219
+ # Rubino.ui lookup inside the nested loop also resolves to it.
220
+ ui_for_child = child_ui || nested_ui_for(entry, parent_ui)
221
+ # Wire the child Loop with the entry's OWN steering queue (parent->child
222
+ # `steer` channel) and bind the current-subagent id so a tool the child
223
+ # invokes (ask_parent) can find its own registry entry. The steer queue
224
+ # is the SAME InputQueue the human uses to steer the parent: the parent
225
+ # pushes a note via BackgroundTasks#steer, the child folds it in at its
226
+ # next iteration boundary (Loop#inject_steered_input).
227
+ result = Rubino.with_current_subagent_id(entry.id) do
228
+ Rubino.with_ui(ui_for_child) do
229
+ runner.run!(prompt, input_queue: entry.steer_queue)
230
+ end
231
+ end
232
+ text = result.to_s.strip
233
+ text = "(subagent '#{entry.subagent}' #{NOOP_RESULT_SUFFIX}" if text.empty?
234
+
235
+ record_completion(entry, text, sink, parent_ui)
236
+ repaint_parent_cards(parent_ui)
237
+ event_bus&.emit(Interaction::Events::SUBAGENT_COMPLETED,
238
+ task_id: entry.id, subagent: entry.subagent,
239
+ status: "completed", output: Rubino::Util::Output.elide(text, 400))
240
+ rescue Exception => e # rubocop:disable Lint/RescueException
241
+ BackgroundTasks.instance.complete(entry, status: :failed, error: e.message)
242
+ # A failure landing on a stop-requested entry was recorded as :stopped
243
+ # (BackgroundTasks#complete): a deliberate /agents --stop / task_stop
244
+ # must not surface as a ✗ "failed" notice (#108/#13).
245
+ if entry.status == :stopped
246
+ notify(sink, stopped_notice(entry))
247
+ surface_completion(parent_ui, "▸ #{entry.id} · #{entry.subagent} · stopped at your request",
248
+ id: entry.id, status: "stopped")
249
+ else
250
+ notify(sink, failure_notice(entry, e.message))
251
+ surface_completion(parent_ui, "▸ #{entry.id} · #{entry.subagent} · failed: #{e.message}",
252
+ id: entry.id, status: "failed")
253
+ end
254
+ repaint_parent_cards(parent_ui)
255
+ event_bus&.emit(Interaction::Events::SUBAGENT_FAILED,
256
+ task_id: entry.id, subagent: entry.subagent,
257
+ status: entry.status == :stopped ? "stopped" : "failed",
258
+ error: e.message)
259
+ end
260
+
261
+ # Records the terminal :completed state and notifies the parent.
262
+ # Deliver-or-report for /agents steer (#140): a parked note the child
263
+ # never got another turn to fold in would otherwise vanish silently —
264
+ # the user believes the child was steered when it wasn't. Drain what's
265
+ # left NOW (the child is done; nothing can consume it anymore) and say
266
+ # so, on the parent UI and in the completion notice.
267
+ def record_completion(entry, text, sink, parent_ui)
268
+ undelivered = entry.steer_queue&.drain || []
269
+ BackgroundTasks.instance.complete(entry, status: :completed, result: text)
270
+ notify(sink, completion_notice(entry, text, undelivered: undelivered))
271
+ unless undelivered.empty?
272
+ surface_completion(parent_ui,
273
+ "⚠ #{entry.id} · steer note not delivered (task completed first): " \
274
+ "#{Rubino::Util::Output.elide(undelivered.join(" | "), 80)}")
275
+ end
276
+ surface_completion(parent_ui, completion_summary(entry, text),
277
+ id: entry.id, status: self.class.noop_result?(text) ? "no-op" : "done",
278
+ report: text)
279
+ end
280
+
281
+ # One committed summary line for a finished subagent, folded above the
282
+ # prompt by #surface_completion (the card itself clears when the registry
283
+ # snapshot no longer lists it as running). Reuses the LIVE-CARD row shape
284
+ # (P6) so the lifecycle reads in one grammar:
285
+ # ▸ sa_e488 · explore · completed · 1 tool · 12s
286
+ # The status word reflects the OUTCOME: a no-op / fully-denied run (final
287
+ # text is the no-op placeholder) says "no-op" — a denied subagent that
288
+ # did nothing must not read as a success (#16). The full report travels
289
+ # SEPARATELY (surface_completion report:) so the CLI can render it whole
290
+ # under `↳ report:` instead of an amputated one-line head.
291
+ def completion_summary(entry, text)
292
+ count = entry.tool_count.to_i
293
+ tools = "#{count} tool#{"s" if count != 1}"
294
+ word = self.class.noop_result?(text) ? "no-op" : "completed"
295
+ ["▸ #{entry.id}", entry.subagent, word, tools, entry_elapsed(entry)].compact.join(" · ")
296
+ end
297
+
298
+ # Human elapsed time for the lifecycle row, or nil when unknown.
299
+ def entry_elapsed(entry)
300
+ return nil unless entry.started_at
301
+
302
+ Util::Duration.human_duration((entry.finished_at || Time.now) - entry.started_at)
303
+ end
304
+
305
+ # Rings the parent's attention notifier (bell/command hook) for a child
306
+ # parked on an approval — the same best-effort contract as the card
307
+ # repaint. No-op off the CLI (Null/API expose no notifier).
308
+ def ring_parent_attention(entry, preview)
309
+ parent_ui = entry_parent_ui
310
+ return unless parent_ui.respond_to?(:notifier)
311
+
312
+ parent_ui.notifier.needs_approval("subagent #{entry.id} needs approval: #{preview}")
313
+ rescue StandardError
314
+ nil
315
+ end
316
+
317
+ # Repaints the parent's collapsed card block from the registry snapshot.
318
+ # Best-effort: cosmetic, never breaks the worker. No-op off the CLI.
319
+ def repaint_parent_cards(parent_ui)
320
+ parent_ui.set_subagent_cards if parent_ui.respond_to?(:set_subagent_cards)
321
+ rescue StandardError
322
+ nil
323
+ end
324
+
325
+ # Renders a one-line completion notice on the parent's CLI view, parallel
326
+ # to how a background shell's exit surfaces. DISPLAY-ONLY (a note on the
327
+ # parent UI) — the authoritative delivery to the MODEL is the InputQueue
328
+ # notice + the registry. No-op on Null/API (note is a quiet annotation).
329
+ # A terminal-state notice (id + status given) goes through the CLI's
330
+ # #subagent_finished so a completion landing at turn end folds into the
331
+ # turn footer instead of stacking a second `┄ ┄` rail (P4).
332
+ def surface_completion(parent_ui, line, id: nil, status: nil, report: nil)
333
+ return unless parent_ui.is_a?(UI::CLI)
334
+
335
+ if id && parent_ui.respond_to?(:subagent_finished)
336
+ parent_ui.subagent_finished(line, id: id, status: status || "done", report: report)
337
+ else
338
+ parent_ui.note(line)
339
+ end
340
+ rescue StandardError
341
+ # A UI hiccup must never wedge the worker's terminal-state bookkeeping.
342
+ end
343
+
344
+ # Parks the notice on the parent's InputQueue if one is wired — as a
345
+ # NOTICE, not a typed line: the parent loop folds it in at an iteration
346
+ # boundary of a live turn, or at the start of the NEXT real turn, never
347
+ # as a standalone synthetic user turn at the idle prompt (#13). When no
348
+ # sink exists (API/server, or the parent turn already ended) the result
349
+ # still lives in the registry and is reachable via `task_result`.
350
+ def notify(sink, text)
351
+ sink&.push_notice(text)
352
+ end
353
+
354
+ def completion_notice(entry, text, undelivered: [])
355
+ notice = "[background-task] Task #{entry.id} (subagent '#{entry.subagent}') completed.\n" \
356
+ "Result:\n#{Rubino::Util::Output.elide(text, 4000)}\n" \
357
+ "(full result via task_result(\"#{entry.id}\"))"
358
+ return notice if undelivered.empty?
359
+
360
+ notice + "\nNote: a steer note was NOT delivered (the task completed first): " \
361
+ "#{Rubino::Util::Output.elide(undelivered.join(" | "), 200)}"
362
+ end
363
+
364
+ def failure_notice(entry, message)
365
+ "[background-task] Task #{entry.id} (subagent '#{entry.subagent}') failed: #{message}"
366
+ end
367
+
368
+ # The stopped notice must carry the ground truth about PARTIAL progress:
369
+ # "no action needed" with zero detail led the parent model to assert that
370
+ # nothing was produced while completed side effects (an approved write,
371
+ # …) were already on disk (#150). Include the tool count + the activity
372
+ # tail from the registry entry so neither the model nor the human is misled.
373
+ def stopped_notice(entry)
374
+ base = "[background-task] Task #{entry.id} (subagent '#{entry.subagent}') was stopped " \
375
+ "at the user's request"
376
+ count = entry.tool_count.to_i
377
+ return "#{base} before it ran any tools — no action needed." if count.zero?
378
+
379
+ recent = Array(entry.activity_log).last(3).join("; ")
380
+ detail = recent.empty? ? "" : " (recent: #{recent})"
381
+ "#{base} after #{count} tool#{"s" if count != 1} had already run#{detail} — " \
382
+ "completed tools' side effects may exist."
383
+ end
384
+
385
+ def spawn_handle(entry, definition)
386
+ "Started background subagent '#{definition.name}' as task #{entry.id}. " \
387
+ "It is running now — keep working on other things. You'll receive a " \
388
+ "`[background-task]` message when it finishes; or call " \
389
+ "task_result(\"#{entry.id}\") to check on it, task_stop(\"#{entry.id}\") to cancel."
390
+ end
391
+
392
+ # Turns a nil reserve into a clear, reason-specific model-facing string. The
393
+ # registry records WHY it refused (last_refusal_reason) so the three caps —
394
+ # max nesting depth, per-owner fan-out, global total — read distinctly
395
+ # instead of one undifferentiated "at capacity". The message must NOT
396
+ # recommend `background: false`: the sync path enforces the same ceilings
397
+ # (#196), so it is not an escape hatch.
398
+ def capacity_message(registry_bg)
399
+ case registry_bg.last_refusal_reason
400
+ when :depth
401
+ "Max nesting depth reached: subagents can only nest #{BackgroundTasks::MAX_DEPTH} " \
402
+ "levels deep. This subagent is too deep to delegate further — do the work " \
403
+ "directly, or report back so a shallower agent can split it up."
404
+ when :per_owner
405
+ "At capacity: this agent already has #{BackgroundTasks::MAX_CHILDREN_PER_NODE} " \
406
+ "subagents running. Wait for one to finish (you'll get a " \
407
+ "`[background-task]` message), check it with task_result, or do the work " \
408
+ "directly."
409
+ else # :global (or any future ceiling)
410
+ "At capacity: the maximum number of subagents " \
411
+ "(#{BackgroundTasks::MAX_CONCURRENT_TOTAL}) are already running across all " \
412
+ "agents. Wait for one to finish (you'll get a `[background-task]` message), " \
413
+ "check it with task_result, or do the work directly."
414
+ end
415
+ end
416
+
417
+ # Background children get their OWN fresh EventBus so their inner tool
418
+ # events stay off the parent recorder (the result-only isolation contract).
419
+ # Built directly here (not via @runner_factory, which tests use to inject a
420
+ # stub for the SYNC path) so the bus wiring is always honored.
421
+ def build_background_runner(definition, child_ui)
422
+ if @runner_factory
423
+ @runner_factory.call(definition)
424
+ else
425
+ Agent::Runner.new(
426
+ session_id: nil,
427
+ model_override: definition.resolved_model,
428
+ max_turns: definition.max_turns,
429
+ ui: child_ui,
430
+ agent_definition: definition,
431
+ event_bus: Interaction::EventBus.new
432
+ )
433
+ end
434
+ end
435
+
436
+ # Builds the child UI for a BACKGROUND run. In the interactive CLI it's a
437
+ # COLLAPSED-CARD SubagentView wired with this run's entry id (so its tool
438
+ # activity feeds the registry/card instead of flooding $stdout), the parent
439
+ # CLI (whose live region hosts the card), and the approval handler that
440
+ # surfaces a needed approval on the card + parks the child on a per-entry
441
+ # gate (Option 2). Off the CLI it's Null (headless/API stays silent and
442
+ # auto-approves as before).
443
+ def nested_ui_for(entry, parent_ui)
444
+ if parent_ui.is_a?(UI::CLI)
445
+ UI::SubagentView.new(
446
+ agent_name: entry.subagent,
447
+ entry_id: entry.id,
448
+ parent_ui: parent_ui,
449
+ approve: approval_handler_for(entry)
450
+ )
451
+ else
452
+ UI::Null.new
453
+ end
454
+ end
455
+
456
+ # The approval handler the card-mode SubagentView calls when a background
457
+ # child's tool needs approval. It flips the entry to :needs_approval (the
458
+ # card now shows `● needs approval: <command>` + a parent note), registers a
459
+ # per-entry Run::ApprovalGate, and BLOCKS the child thread on the gate's
460
+ # bounded interruptible wait (15min → auto-deny; a /agents <id> --stop
461
+ # cancel wakes it to a deny). The user's /agents <id> decision resolves the
462
+ # gate; this returns the boolean to the child's tool. "Approve always" is
463
+ # persisted by the parent decision path (the existing allowlist), so here we
464
+ # only need the boolean.
465
+ def approval_handler_for(entry)
466
+ lambda do |question, scope: nil, command: nil, **_context|
467
+ gate = Run::ApprovalGate.new
468
+ approval_id = entry.id
469
+ gate.register(approval_id)
470
+ cmd = command && !command.to_s.empty? ? command.to_s : scope.to_s
471
+ BackgroundTasks.instance.begin_approval(
472
+ entry.id, gate: gate, approval_id: approval_id,
473
+ question: question, command: cmd
474
+ )
475
+ # The committed parent note shows a ONE-LINE elided preview. A
476
+ # multi-line command (ruby code, often starting with a blank line)
477
+ # truncated by raw character count committed its first code lines as
478
+ # bare unframed rows under the card — and an empty first line left
479
+ # "needs approval:" with no body at all (#141). Fall back to the
480
+ # question when the command has no usable line.
481
+ preview = approval_preview(cmd, question)
482
+ surface_completion(entry_parent_ui,
483
+ "● #{entry.id} · #{entry.subagent} · needs approval: #{preview} — /agents #{entry.id}")
484
+ repaint_parent_cards(entry_parent_ui)
485
+ ring_parent_attention(entry, preview)
486
+ begin
487
+ decision = gate.await(approval_id)
488
+ approved = decision_to_bool(decision)
489
+ rescue Rubino::Interrupted
490
+ approved = false # a stop/cancel while parked → deny and unwind
491
+ ensure
492
+ BackgroundTasks.instance.end_approval(entry.id)
493
+ repaint_parent_cards(entry_parent_ui)
494
+ end
495
+ approved
496
+ end
497
+ end
498
+
499
+ # The parent CLI captured for repaints inside the approval handler. The
500
+ # handler runs on the CHILD thread, where Rubino.ui is the child's
501
+ # SubagentView (bound by with_ui); the real parent CLI is the process-global
502
+ # adapter, which is what hosts the live region.
503
+ def entry_parent_ui
504
+ Rubino.instance_variable_get(:@ui)
505
+ end
506
+
507
+ # Maps a gate decision to the boolean the child tool expects. EXPIRED (the
508
+ # 15-min bound elapsed with no answer) is a safe DENY, mirroring UI::API.
509
+ def decision_to_bool(decision)
510
+ return false if decision.equal?(Run::ApprovalGate::EXPIRED)
511
+
512
+ !!decision
513
+ end
514
+
515
+ # One-line approval preview for the parent note (#141): the first
516
+ # NON-BLANK line of the command (elided), falling back to the question.
517
+ def approval_preview(cmd, question)
518
+ line = Rubino::Util::Output.first_nonblank_line(cmd)
519
+ line = Rubino::Util::Output.first_nonblank_line(question) if line.empty?
520
+ Rubino::Util::Output.elide(line, 80)
521
+ end
522
+
523
+ # Runs a FRESH nested agent turn for the given subagent definition and
524
+ # returns its final assistant message as the tool result string.
525
+ #
526
+ # The nested run uses a brand-new session (session_id: nil ⇒ created
527
+ # fresh) so the parent transcript never leaks. It runs synchronously —
528
+ # the parent waits — and is capped by the subagent's own `max_turns`.
529
+ # The nested loop's own tool events fire on the child's executor only;
530
+ # the parent recorder sees just this tool's start/complete boundary.
531
+ #
532
+ # GOVERNED LIKE THE BACKGROUND PATH (#196): a sync child goes through the
533
+ # SAME single enforcement point — BackgroundTasks#reserve — so all three
534
+ # nesting caps apply and it counts toward the live totals for the whole
535
+ # inline run; and it runs under with_current_subagent_id(entry.id) so
536
+ # anything IT spawns is stamped with the right owner/depth. Without this,
537
+ # `background: false` was an uncapped escape hatch that also corrupted
538
+ # ownership/depth stamping for its entire subtree.
539
+ def run_subagent(definition, prompt)
540
+ registry_bg = BackgroundTasks.instance
541
+ entry = registry_bg.reserve(
542
+ subagent: definition.name, prompt: prompt,
543
+ owner_subagent_id: Rubino.current_subagent_id, depth: 0
544
+ )
545
+ return capacity_message(registry_bg) unless entry
546
+
547
+ runner = build_runner(definition)
548
+ registry_bg.attach(entry, thread: Thread.current, runner: runner)
549
+ result = Rubino.with_current_subagent_id(entry.id) { runner.run!(prompt) }
550
+ text = result.to_s.strip
551
+ text = "(subagent '#{definition.name}' #{NOOP_RESULT_SUFFIX}" if text.empty?
552
+ registry_bg.complete(entry, status: :completed, result: text)
553
+ text
554
+ rescue StandardError => e
555
+ # Release the reserved slot on ANY failure so a raising sync child can
556
+ # never wedge a live-slot leak; #call's rescue phrases the message.
557
+ registry_bg.complete(entry, status: :failed, error: e.message) if entry
558
+ raise
559
+ end
560
+
561
+ # Builds the nested Runner. Injectable via the constructor for tests
562
+ # (so a FakeLLMAdapter can drive the child loop); defaults to a real
563
+ # Runner wired with the subagent's resolved model / max_turns and a
564
+ # fresh ephemeral session. The child UI is chosen by #nested_ui: a
565
+ # live nested view in the interactive CLI, silent (Null) everywhere else.
566
+ def build_runner(definition)
567
+ if @runner_factory
568
+ @runner_factory.call(definition)
569
+ else
570
+ Agent::Runner.new(
571
+ session_id: nil,
572
+ model_override: definition.resolved_model,
573
+ max_turns: definition.max_turns,
574
+ ui: nested_ui(definition),
575
+ agent_definition: definition
576
+ )
577
+ end
578
+ end
579
+
580
+ # The UI the child loop renders through.
581
+ #
582
+ # Interactive CLI → UI::SubagentView: the subagent's tool activity shows
583
+ # INLINE, nested + colored under the parent's "● delegated → X" row (the
584
+ # only "watch live" that fits our scroll-native + bottom-composer model).
585
+ # It is DISPLAY-ONLY — it writes to $stdout and never touches the parent
586
+ # loop's messages or recorder, so the result-only contract holds.
587
+ #
588
+ # API / headless / tests (UI::Null, UI::API, …) → UI::Null: the child
589
+ # stays silent so the boundary-only contract for SSE consumers and the
590
+ # non-interactive paths is unchanged (the web nested view is a separate
591
+ # follow-up).
592
+ def nested_ui(definition)
593
+ if Rubino.ui.is_a?(UI::CLI)
594
+ UI::SubagentView.new(agent_name: definition.name)
595
+ else
596
+ UI::Null.new
597
+ end
598
+ end
599
+
600
+ # Optional injection point for tests — a callable taking the resolved
601
+ # Definition and returning something that responds to #run!(prompt).
602
+ def initialize(runner_factory: nil)
603
+ @runner_factory = runner_factory
604
+ end
605
+
606
+ def registry
607
+ Rubino.agent_registry
608
+ end
609
+
610
+ def available_subagent_names
611
+ registry.subagents.map(&:name)
612
+ end
613
+
614
+ def available_subagents_description
615
+ registry.subagents.map do |a|
616
+ desc = a.description.to_s.strip
617
+ desc.empty? ? a.name : "#{a.name} (#{desc})"
618
+ end.join("; ")
619
+ end
620
+ end
621
+ end
622
+ end