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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Routes user input to the appropriate agent.
6
+ # Handles @mention syntax for subagent invocation and agent switching.
7
+ class Router
8
+ MENTION_REGEX = /\A@(\w+)\s+(.+)/m
9
+
10
+ def initialize(registry:, ui:)
11
+ @registry = registry
12
+ @ui = ui
13
+ @current_agent = registry.default
14
+ end
15
+
16
+ attr_reader :current_agent
17
+
18
+ # Switches to a different primary agent
19
+ def switch_to(agent_name)
20
+ agent = @registry.find(agent_name)
21
+ unless agent
22
+ @ui.error("unknown agent: #{agent_name}")
23
+ return false
24
+ end
25
+
26
+ unless agent.primary?
27
+ @ui.error("cannot switch to subagent '#{agent_name}'. Use @#{agent_name} to invoke it.")
28
+ return false
29
+ end
30
+
31
+ @current_agent = agent
32
+ @ui.info("Switched to agent: #{agent.name}")
33
+ true
34
+ end
35
+
36
+ # Routes input, returning [agent_definition, cleaned_input]
37
+ def route(input)
38
+ # Check for @mention
39
+ if input.match?(MENTION_REGEX)
40
+ match = input.match(MENTION_REGEX)
41
+ agent_name = match[1]
42
+ actual_input = match[2]
43
+
44
+ agent = @registry.find(agent_name)
45
+ return [agent, actual_input] if agent && (agent.subagent? || agent.primary?)
46
+
47
+ @ui.warning("Unknown agent '#{agent_name}', using current agent")
48
+
49
+ end
50
+
51
+ [@current_agent, input]
52
+ end
53
+
54
+ # Returns available agent names for autocomplete
55
+ def available_mentions
56
+ @registry.subagents.map { |a| "@#{a.name}" }
57
+ end
58
+
59
+ # Returns primary agent names for switching
60
+ def switchable_agents
61
+ @registry.primary_agents.map(&:name)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Top-level orchestrator for a single user interaction.
6
+ # Coordinates session management, the agent loop, and post-turn jobs.
7
+ class Runner
8
+ attr_reader :session
9
+
10
+ # The resolved model id this runner runs against. Read by SubagentProbe so an
11
+ # ephemeral peek uses the child's OWN model, not the global default.
12
+ attr_reader :model_id
13
+
14
+ def initialize(session_id: nil, model_override: nil, provider_override: nil,
15
+ max_turns: nil, ignore_rules: false, ui: nil, agent_definition: nil,
16
+ event_bus: nil, announce_session: true)
17
+ @ui = ui || Rubino.ui
18
+ # An in-chat rewind/fork builds a runner on the child session but has its
19
+ # own purpose-built "┄ rewound to message N — editing ┄" marker, so the
20
+ # generic "Resuming session: <id>…" plumbing line must not also leak into
21
+ # the transcript (#220). Off-rewind callers keep the announcement.
22
+ @announce_session = announce_session
23
+ # Defaults to the process-global bus for the single-run CLI path; the
24
+ # HTTP Executor injects a fresh per-run bus so concurrent runs don't
25
+ # cross-contaminate each other's events/output (architecture audit A1).
26
+ @event_bus = event_bus || Rubino.event_bus
27
+ @config = Rubino.configuration
28
+ @session_repo = Session::Repository.new
29
+ @message_store = Session::Store.new
30
+ @explicit_model_override = model_override
31
+ @model_id = model_override || @config.model_default
32
+ @provider_override = provider_override
33
+ @max_turns = max_turns
34
+ @ignore_rules = ignore_rules
35
+ @agent_definition = agent_definition
36
+ # Pre-instantiate so cancel! is meaningful between turns and during the
37
+ # window between Signal.trap install and run() — a too-early Ctrl+C
38
+ # used to land on a nil token and silently no-op, then the next run
39
+ # started fresh and the user's cancel was lost.
40
+ @cancel_token = Interaction::CancelToken.new
41
+ @session = load_or_create_session(session_id)
42
+ end
43
+
44
+ # Executes a full interaction turn, swallowing failures so CLI callers
45
+ # can stay in the REPL after a model/tool error. The friendly UI
46
+ # message is emitted, but the bus event INTERACTION_FAILED is NOT
47
+ # re-emitted here — Interaction::Lifecycle is the single source of
48
+ # truth for that, and it already emitted before re-raising. Use
49
+ # +run!+ from non-CLI callers (HTTP executor) that need the
50
+ # exception to propagate so the run row can be marked failed.
51
+ def run(input, image_paths: [], input_queue: nil, paste_expansions: [])
52
+ run!(input, image_paths: image_paths, input_queue: input_queue,
53
+ paste_expansions: paste_expansions)
54
+ rescue Interrupted
55
+ # Standardized single interrupt notice: a dim `⎿ interrupted` marker
56
+ # right after the partial answer the Loop already committed via
57
+ # #stream_end. Replaces the old "⚠ interrupted by user" warning so the
58
+ # Ctrl+C path and the interrupt-by-default type-ahead path read the same.
59
+ @ui.turn_interrupted
60
+ nil
61
+ rescue SystemExit, Interrupt, SignalException
62
+ raise
63
+ rescue Exception => e # rubocop:disable Lint/RescueException
64
+ @ui.error(friendly_error_message(e))
65
+ nil
66
+ end
67
+
68
+ # Like +run+ but propagates exceptions to the caller. The HTTP
69
+ # Executor uses this so it can transition the run row to "failed"
70
+ # (instead of mark_completed!) when the lifecycle raises. The
71
+ # ScriptError / Exception net is kept here too so the Executor sees
72
+ # LoadError etc. as a real failure rather than nil-and-completed.
73
+ def run!(input, image_paths: [], input_queue: nil, paste_expansions: [])
74
+ # Each turn gets a fresh token. A CancelToken is one-shot, so reusing a
75
+ # cancelled one would poison every subsequent turn (it would raise
76
+ # Interrupted immediately at the first poll point). The per-turn SIGINT
77
+ # trap (CLI) / stop-watcher (HTTP) is wired to #cancel! against this new
78
+ # token before any LLM/tool work runs, so an in-flight interrupt still
79
+ # cancels the current turn.
80
+ @cancel_token = Interaction::CancelToken.new
81
+
82
+ lifecycle = Interaction::Lifecycle.new(
83
+ session: @session,
84
+ event_bus: @event_bus,
85
+ ui: @ui,
86
+ config: @config,
87
+ ignore_rules: @ignore_rules,
88
+ agent_definition: @agent_definition,
89
+ cancel_token: @cancel_token,
90
+ model_override: @explicit_model_override,
91
+ provider_override: @provider_override,
92
+ max_tool_iterations: @max_turns
93
+ )
94
+
95
+ lifecycle.execute(input, image_paths: image_paths, input_queue: input_queue,
96
+ paste_expansions: paste_expansions)
97
+ end
98
+
99
+ # Flips the current turn's cancel token. Called from the UI thread when
100
+ # the user hits Esc or a second Ctrl+C while the worker is mid-stream.
101
+ # No-op when no turn is in flight.
102
+ def cancel!
103
+ @cancel_token&.cancel!
104
+ end
105
+
106
+ # Switches the LIVE model for this runner (the in-chat `/model <name>`).
107
+ # Lifecycle builds the adapter per turn from
108
+ # `@explicit_model_override || @session[:model]`, and the CLI always
109
+ # passes a model_override at boot — so both fields must move for the
110
+ # NEXT turn to actually hit the new model. The session hash is mutated
111
+ # in place (statusbar and /status read it) and the persisted row is
112
+ # updated so resume/--continue agree; an unpersisted lazy session gets
113
+ # the new value via Repository#persist! on its first message instead.
114
+ def switch_model!(model_id)
115
+ @explicit_model_override = model_id
116
+ @model_id = model_id
117
+ @session[:model] = model_id
118
+ @session[:provider] = @provider_override ||
119
+ LLM::ProviderResolver.resolve(model_id, explicit_provider: @config.model_provider)
120
+ if @session_repo.persisted?(@session[:id])
121
+ @session_repo.update(@session[:id], model: model_id, provider: @session[:provider])
122
+ end
123
+ model_id
124
+ end
125
+
126
+ # Marks the current session ended (#100). Called from the CLI on a clean
127
+ # REPL teardown (and best-effort on terminal close) so a session stops
128
+ # showing as "active" forever and cleanup/list/--continue can tell a
129
+ # finished session from a live one. Best-effort: a failure here must never
130
+ # crash the exit path.
131
+ def end_session!
132
+ # Nothing to end for a session that was never persisted (the user opened
133
+ # chat and left without sending a message, #144) — there's no row.
134
+ return if @session.nil? || (@session[:persisted] == false && !@session_repo.persisted?(@session[:id]))
135
+
136
+ @session_repo.end_session!(@session[:id])
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ private
142
+
143
+ # Translates upstream errors into actionable messages instead of
144
+ # bare stack-trace fragments. (issue #16)
145
+ def friendly_error_message(error)
146
+ msg = error.message.to_s
147
+ case msg
148
+ when /\b401\b|unauthorized|invalid[_ ]?api[_ ]?key/i
149
+ "authentication failed (#{msg}). Check your API key in ~/.rubino/.env " \
150
+ "or run `rubino setup`."
151
+ when /\b404\b|model.*not.*found|invalid[_ ]?model|unknown[_ ]?model/i
152
+ "model '#{@model_id}' not available with the current provider/plan. " \
153
+ "Check `model.default` in config.yml; details: #{msg}"
154
+ when /\b(429|rate[_ ]?limit)\b/i
155
+ "rate-limited by the provider. Wait a moment and retry. Details: #{msg}"
156
+ when /\b(timeout|timed out|connection reset)\b/i
157
+ "network error reaching the LLM (#{msg}). Check connectivity and retry."
158
+ else
159
+ "error: #{msg}"
160
+ end
161
+ end
162
+
163
+ def load_or_create_session(session_id)
164
+ if session_id
165
+ # Support resume by title/first-prompt substring as well as ID
166
+ session = @session_repo.find_by_id_or_title(session_id)
167
+ unless session
168
+ raise SessionError,
169
+ "Session not found: #{session_id}. " \
170
+ "Try `rubino sessions list`, or resume by id prefix."
171
+ end
172
+
173
+ # An existing row is already in the DB; mark it so the lazy-persist
174
+ # path (#144) treats it as persisted and never re-inserts.
175
+ session[:persisted] = true
176
+ @ui.status("Resuming session: #{session[:id][0..7]}...") if @announce_session
177
+ session
178
+ else
179
+ # Build an UNSAVED session: no row is written until the first user
180
+ # message is committed (#144), so opening `chat` and leaving without
181
+ # typing anything never pollutes `/sessions` with empty rows. The
182
+ # record carries a real id so the whole turn pipeline works unchanged;
183
+ # Lifecycle#persist_user_message flips it to a real row on demand.
184
+ session = @session_repo.build(
185
+ source: "cli",
186
+ model: @model_id,
187
+ provider: @provider_override || LLM::ProviderResolver.resolve(@model_id)
188
+ )
189
+ @ui.status("New session: #{session[:id][0..7]}")
190
+ session
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Executes tool calls with approval checks and result formatting.
6
+ class ToolExecutor
7
+ # The Loop registers its count+persist sink here after construction (the
8
+ # executor is built first so the adapter/ToolBridge can share it). See
9
+ # Loop#handle_tool_result.
10
+ attr_writer :on_result
11
+
12
+ def initialize(registry:, approval_policy:, ui:, config:,
13
+ tool_call_repository: Tools::ToolCallRepository.new,
14
+ cancel_token: nil, read_tracker: nil, event_bus: nil,
15
+ on_result: nil)
16
+ @registry = registry
17
+ @approval_policy = approval_policy
18
+ @ui = ui
19
+ @config = config
20
+ @tool_call_repository = tool_call_repository
21
+ @cancel_token = cancel_token
22
+ # Optional sink the Loop registers so a tool that runs on the STREAMING
23
+ # path (ruby_llm dispatches it mid-stream via ToolBridge → straight into
24
+ # #execute, never returning through Loop#execute_tool_calls) is still
25
+ # counted in the turn summary and persisted as a `tool` message. Called
26
+ # once per completed/denied tool with (name:, arguments:, call_id:,
27
+ # result:). The non-streaming path routes through the same sink so the
28
+ # count/persist happens in exactly one place regardless of mode.
29
+ @on_result = on_result
30
+ # Optional event bus so this executor emits TOOL_STARTED/TOOL_FINISHED
31
+ # for the API mode timeline. ToolBridge already emits these when no
32
+ # executor is wired (test/one-shot path); the production path went
33
+ # through here and dropped them, so the web UI timeline never saw
34
+ # the tool call as a discrete event.
35
+ @event_bus = event_bus
36
+ # One tracker shared across every tool call so the read registered by
37
+ # ReadTool is visible to a later EditTool. The production path
38
+ # (Interaction::Lifecycle) injects the SESSION-scoped tracker so the
39
+ # gate spans turns (#151). Default to a fresh tracker if the caller
40
+ # didn't supply one; an isolated unit test can pass
41
+ # `read_tracker: nil` to skip the gate.
42
+ @read_tracker = read_tracker.equal?(false) ? nil : (read_tracker || Tools::ReadTracker.new)
43
+ end
44
+
45
+ # Executes a single tool call, returns a Tools::Result.
46
+ def execute(name:, arguments:, call_id:)
47
+ tool = @registry.find(name)
48
+ raise ToolError, "Unknown tool: #{name}" unless tool
49
+
50
+ case @approval_policy.decide(tool, arguments: arguments)
51
+ when :deny
52
+ # A policy denial must NOT read "denied by user" to the model — the
53
+ # policy records why it fired (#last_deny_reason) and the Result
54
+ # maps it to a reason-specific message, so a child agent never
55
+ # blames the human for an automatic deny (#143).
56
+ denied = Tools::Result.denied(name: name, call_id: call_id, reason: policy_deny_reason)
57
+ record_denied(name: name, call_id: call_id, arguments: arguments,
58
+ result: denied, reason: "policy-denied")
59
+ return finish(name, arguments, call_id, denied)
60
+ when :ask
61
+ unless request_approval(tool, arguments)
62
+ denied = Tools::Result.denied(name: name, call_id: call_id, reason: :user)
63
+ record_denied(name: name, call_id: call_id, arguments: arguments,
64
+ result: denied, reason: "user-denied")
65
+ return finish(name, arguments, call_id, denied)
66
+ end
67
+ end
68
+
69
+ notify_yolo_if_applicable(tool, arguments)
70
+ emit_started(name, arguments)
71
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ result = nil
73
+ begin
74
+ result = run_tool(tool, name: name, arguments: arguments, call_id: call_id)
75
+ ensure
76
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
77
+ emit_artifact(result) if result.respond_to?(:artifact) && result&.artifact
78
+ emit_finished(name, result: result, duration_ms: duration_ms, arguments: arguments)
79
+ end
80
+ finish(name, arguments, call_id, result)
81
+ end
82
+
83
+ private
84
+
85
+ # Single exit point: notifies the Loop's on_result sink (count + persist)
86
+ # for every completed/denied tool, then returns the result unchanged. This
87
+ # is the one place both the streaming (ToolBridge → #execute) and the
88
+ # non-streaming (Loop#execute_tool_calls → #execute) paths funnel through,
89
+ # so the turn-summary count and the `tool` message rows stay accurate
90
+ # regardless of streaming mode. Best-effort: a sink failure must not take
91
+ # down the tool call the model is waiting on.
92
+ def finish(name, arguments, call_id, result)
93
+ @on_result&.call(name: name, arguments: arguments, call_id: call_id, result: result)
94
+ result
95
+ rescue StandardError => e
96
+ Rubino.logger&.warn(event: "tool_executor.on_result_failed", error: e.message)
97
+ result
98
+ end
99
+
100
+ def run_tool(tool, name:, arguments:, call_id:)
101
+ tool.cancel_token = @cancel_token if tool.respond_to?(:cancel_token=)
102
+ tool.read_tracker = @read_tracker if tool.respond_to?(:read_tracker=)
103
+ streamed = false
104
+ last_progress_at = nil
105
+ if tool.respond_to?(:stream_chunk=) && (@ui.respond_to?(:tool_chunk) || @event_bus)
106
+ tool.stream_chunk = lambda do |chunk|
107
+ streamed = true
108
+ @ui.tool_chunk(name, chunk) if @ui.respond_to?(:tool_chunk)
109
+ # Mirror the chunk onto the bus so the API/SSE stream isn't silent
110
+ # during a long tool call: the Recorder maps TOOL_PROGRESS to a
111
+ # `tool.progress` event, which resets the idle watchdog. Without
112
+ # this a busy tool (summarize_file: ~30 sequential aux-LLM calls,
113
+ # no run-events) is killed at the 300s idle timeout. Throttled so a
114
+ # chatty tool (shell streaming thousands of stdout lines) doesn't
115
+ # write a DB row + SSE frame per line — one heartbeat per interval
116
+ # is enough to keep the watchdog satisfied.
117
+ last_progress_at = emit_tool_progress(name, chunk, last_progress_at) if @event_bus
118
+ end
119
+ end
120
+ raw = tool.call(arguments)
121
+ # Tools can return either a String (plain output) or a Hash carrying
122
+ # {output:, metrics:, body:, body_kind:}. The Hash form lets a tool emit
123
+ # - a `metrics` one-liner for the done header ("42 lines · 0.1s")
124
+ # - a `body` block (diff, preview) printed inside the tool box
125
+ # - a `body_kind` (:diff | :plain) selecting the CLI coloring for body
126
+ # without having to reverse-engineer them from the formatted output.
127
+ if raw.is_a?(Hash)
128
+ text = raw[:output] || raw["output"]
129
+ metrics = raw[:metrics] || raw["metrics"]
130
+ body = raw[:body] || raw["body"]
131
+ body_kind = raw[:body_kind] || raw["body_kind"] || :plain
132
+ error_code = raw[:error_code] || raw["error_code"]
133
+ artifact = raw[:artifact] || raw["artifact"]
134
+ else
135
+ text = raw
136
+ metrics = nil
137
+ body = nil
138
+ body_kind = :plain
139
+ error_code = nil
140
+ artifact = nil
141
+ end
142
+ # Skip the body block when the tool already streamed its output line by
143
+ # line via #tool_chunk: `body` is the SAME content (e.g. ShellTool's
144
+ # Util::Output.preview of the captured stdout), so rendering it again
145
+ # would duplicate every line in the timeline. Tools that don't stream
146
+ # (read, grep, edit, glob, github) still render their body here.
147
+ @ui.tool_body(body, kind: body_kind.to_sym) if body && !body.to_s.empty? && !streamed
148
+ result = Tools::Result.success(
149
+ name: name,
150
+ call_id: call_id,
151
+ output: Util::Output.truncate(text, max_bytes: @config.tool_output_max_bytes,
152
+ max_lines: @config.tool_output_max_lines,
153
+ spill: ->(full) { spill_full_output(full, call_id) }),
154
+ metrics: metrics,
155
+ error_code: error_code&.to_sym,
156
+ artifact: artifact
157
+ )
158
+ @tool_call_repository.record(name: name, call_id: call_id, arguments: arguments,
159
+ result: result, status: "completed")
160
+ result
161
+ rescue StandardError => e
162
+ result = Tools::Result.error(name: name, call_id: call_id, error: e.message)
163
+ @tool_call_repository.record(name: name, call_id: call_id, arguments: arguments,
164
+ result: result, status: "failed", error: e.message)
165
+ result
166
+ ensure
167
+ tool.cancel_token = nil if tool.respond_to?(:cancel_token=)
168
+ tool.read_tracker = nil if tool.respond_to?(:read_tracker=)
169
+ tool.stream_chunk = nil if tool.respond_to?(:stream_chunk=)
170
+ end
171
+
172
+ # Cap on per-event size we forward to SSE consumers (the web UI timeline,
173
+ # CLI logs). Tools already truncate their textual output via
174
+ # truncate_output for the model's eyes; this is a second guard so a
175
+ # huge payload doesn't bloat the event bus / DB run_events rows.
176
+ EVENT_PREVIEW_MAX = 4_000
177
+
178
+ # Minimum gap between TOOL_PROGRESS heartbeats forwarded to the bus. Well
179
+ # under the SSE idle watchdog window (300s) so the stream never goes
180
+ # silent, but coarse enough that a chatty per-line tool doesn't flood the
181
+ # event store. The first chunk always emits (nil last-emit time).
182
+ TOOL_PROGRESS_INTERVAL = 5.0
183
+
184
+ # Emits a throttled TOOL_PROGRESS heartbeat on the bus. Returns the
185
+ # monotonic time of this emit (or the unchanged previous time when the
186
+ # chunk was throttled) so the caller can track the cadence.
187
+ def emit_tool_progress(name, chunk, last_at)
188
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
+ return last_at if last_at && (now - last_at) < TOOL_PROGRESS_INTERVAL
190
+
191
+ @event_bus&.emit(Interaction::Events::TOOL_PROGRESS,
192
+ name: name, chunk: truncate_for_event(chunk.to_s))
193
+ now
194
+ end
195
+
196
+ def emit_started(name, arguments)
197
+ sanitized = sanitize_arguments_for_event(arguments)
198
+ @ui.tool_started(name, arguments: arguments) if @ui.respond_to?(:tool_started)
199
+ payload = { name: name, arguments: sanitized }
200
+ # Boundary event for delegation: tag the `task` call with the target
201
+ # subagent name (+ the task prompt) so an SSE consumer (the web UI)
202
+ # can render "delegated to X" without parsing the raw arguments. The
203
+ # subagent's own inner events are NOT streamed in Phase 1 — boundary only.
204
+ payload.merge!(subagent_tag(arguments)) if name == "task"
205
+ @event_bus&.emit(Interaction::Events::TOOL_STARTED, **payload)
206
+ end
207
+
208
+ def emit_finished(name, result: nil, duration_ms: nil, arguments: nil)
209
+ @ui.tool_finished(name, result: result) if @ui.respond_to?(:tool_finished)
210
+ payload = {
211
+ name: name,
212
+ output: truncate_for_event(result&.output.to_s),
213
+ duration_ms: duration_ms,
214
+ error_code: result.respond_to?(:error_code) ? result&.error_code : nil
215
+ }
216
+ # On completion the `output` already carries the subagent's returned
217
+ # summary; tag the subagent name (recovered from the call arguments) so
218
+ # the consumer can render "X answered" and group it with the start.
219
+ if name == "task" && arguments.is_a?(Hash)
220
+ subagent = arguments["subagent"] || arguments[:subagent]
221
+ payload[:subagent] = subagent.to_s unless subagent.nil?
222
+ end
223
+ @event_bus&.emit(Interaction::Events::TOOL_FINISHED, **payload)
224
+ end
225
+
226
+ # Extracts the { subagent:, prompt: } boundary tag from a `task` call's
227
+ # arguments. Nil-tolerant so a malformed call still emits the event.
228
+ def subagent_tag(arguments)
229
+ return {} unless arguments.is_a?(Hash)
230
+
231
+ subagent = arguments["subagent"] || arguments[:subagent]
232
+ prompt = arguments["prompt"] || arguments[:prompt]
233
+ tag = {}
234
+ tag[:subagent] = subagent.to_s unless subagent.nil?
235
+ tag[:prompt] = truncate_for_event(prompt.to_s) unless prompt.nil?
236
+ tag
237
+ end
238
+
239
+ def sanitize_arguments_for_event(arguments)
240
+ return arguments unless arguments.is_a?(Hash)
241
+
242
+ arguments.each_with_object({}) do |(key, value), memo|
243
+ masked = Util::SecretsMask.mask_value(value, key: key)
244
+ memo[key.to_s] = truncate_for_event(masked.to_s)
245
+ end
246
+ rescue StandardError
247
+ # Never block the run because of a serialisation hiccup — drop the
248
+ # arguments rather than crash the tool emission path.
249
+ nil
250
+ end
251
+
252
+ def truncate_for_event(text)
253
+ return text if text.nil? || text.bytesize <= EVENT_PREVIEW_MAX
254
+
255
+ head = text.byteslice(0, EVENT_PREVIEW_MAX).to_s.force_encoding(text.encoding).scrub("")
256
+ "#{head}\n…[truncated at #{EVENT_PREVIEW_MAX} bytes]"
257
+ end
258
+
259
+ # ARTIFACT_CREATED is what SSE consumers (e.g. the web UI) latch onto to
260
+ # render a download card for tools like attach_file. Emit it here so the
261
+ # streaming path (ToolBridge → ToolExecutor, never lands in Loop's
262
+ # execute_tool_calls) propagates the artifact too.
263
+ def emit_artifact(result)
264
+ @event_bus&.emit(Interaction::Events::ARTIFACT_CREATED, **result.artifact)
265
+ end
266
+
267
+ def record_denied(name:, call_id:, arguments:, result:, reason:)
268
+ @tool_call_repository.record(
269
+ name: name,
270
+ call_id: call_id,
271
+ arguments: arguments,
272
+ result: result,
273
+ status: "denied",
274
+ error: reason
275
+ )
276
+ rescue StandardError
277
+ # Don't fail the user's request just because the audit write failed.
278
+ end
279
+
280
+ # The reason behind the policy's :deny, when the policy exposes one
281
+ # (test doubles may not). nil falls back to the generic policy message.
282
+ def policy_deny_reason
283
+ return :policy unless @approval_policy.respond_to?(:last_deny_reason)
284
+
285
+ @approval_policy.last_deny_reason || :policy
286
+ end
287
+
288
+ def request_approval(tool, arguments)
289
+ command = Security::ApprovalPolicy.command_string(tool, arguments)
290
+ _hit, pattern_key, description = Security::DangerousPatterns.detect(command)
291
+ @ui.confirm(
292
+ approval_question(tool, arguments),
293
+ scope: approval_scope(tool, arguments),
294
+ tool: tool.name,
295
+ command: command,
296
+ pattern_key: pattern_key,
297
+ description: description
298
+ )
299
+ end
300
+
301
+ # Build a stable string identifier for (tool, arguments) so the
302
+ # UI layer can short-circuit on a prior "session"/"always"
303
+ # decision. Reuses the same command extractor ApprovalPolicy
304
+ # already uses for pattern-rule matching to keep the granularity
305
+ # consistent — approving `shell ls` will NOT auto-approve
306
+ # `shell rm -rf /`.
307
+ def approval_scope(tool, arguments)
308
+ cmd = Security::ApprovalPolicy.command_string(tool, arguments)
309
+ cmd.empty? ? tool.name.to_s : "#{tool.name}:#{cmd}"
310
+ end
311
+
312
+ # --yolo / approvals.mode: "skip" bypasses request_approval entirely.
313
+ # Without any visual signal the user can't tell that the model just
314
+ # ran (e.g.) `rm -rf` until it's done. Print a single-line warning for
315
+ # risky tools so silence can't mask the auto-approval. Low-risk tools
316
+ # (read, glob, grep) stay quiet — yolo for those is no different from
317
+ # the normal allow path.
318
+ def notify_yolo_if_applicable(tool, arguments)
319
+ return unless @config.dig("approvals", "mode") == "skip"
320
+ return unless tool.respond_to?(:risky?) && tool.risky?
321
+
322
+ preview = if arguments.is_a?(Hash)
323
+ arguments.map { |k, v| "#{k}=#{summarize_yolo_value(v, key: k)}" }.join(" ")
324
+ else
325
+ Util::SecretsMask.mask_inline(arguments.to_s)
326
+ end
327
+ @ui.warning("⚡ yolo: #{tool.name} #{preview}")
328
+ end
329
+
330
+ def summarize_yolo_value(value, key: nil)
331
+ masked = Util::SecretsMask.mask_value(value, key: key).to_s
332
+ masked = masked.lines.first.to_s.rstrip if masked.include?("\n")
333
+ masked.length > 60 ? "#{masked[0, 57]}…" : masked
334
+ end
335
+
336
+ # Multi-line aware args formatter for the approval prompt.
337
+ #
338
+ # arguments.inspect on a Hash with newline values (shell scripts, file
339
+ # contents) collapses everything into one giant line, which the terminal
340
+ # then truncates at the right edge. The user sees "command=\"ls -la"
341
+ # and approves — without ever seeing the trailing `; rm -rf` that the
342
+ # model actually sent. Lay each key out on its own line; clip long
343
+ # values explicitly; tag dropped lines so silence can't mask intent.
344
+ def approval_question(tool, arguments)
345
+ pairs = Array(arguments)
346
+ # No arguments (e.g. a bare run_tests run) ⇒ no dangling "wants:" — a
347
+ # header followed by nothing reads as a truncated/broken card (#109).
348
+ return "#{tool.name} wants to run" if pairs.empty?
349
+
350
+ # The common case — ONE short single-line argument (a shell command, a
351
+ # file path) — inlines onto the header: `shell wants: touch hello.txt`
352
+ # (P7). Multi-arg / multi-line calls keep the per-key layout below.
353
+ if pairs.size == 1
354
+ key, value = pairs.first
355
+ text = Util::SecretsMask.mask_value(value, key: key).to_s
356
+ return "#{tool.name} wants: #{text}" if !text.include?("\n") && text.length <= 120
357
+ end
358
+
359
+ lines = ["#{tool.name} wants:"]
360
+ pairs.each { |key, value| lines.concat(format_arg_pair(key, value)) }
361
+ lines.join("\n")
362
+ end
363
+
364
+ def format_arg_pair(key, value)
365
+ # Mask credentials before any rendering: the approval prompt is the
366
+ # one place a real secret value could land in the user's scrollback
367
+ # if the model passed it through unwrapped.
368
+ text = Util::SecretsMask.mask_value(value, key: key).to_s
369
+ if text.include?("\n")
370
+ body = text.lines
371
+ head = body.first(5).map(&:rstrip)
372
+ tail = body.size > 5 ? [" [… #{body.size - 5} more line(s)]"] : []
373
+ [" #{key}:", *head.map { |l| " #{l}" }, *tail]
374
+ elsif text.length > 120
375
+ [" #{key}: #{text[0, 117]}…"]
376
+ else
377
+ [" #{key}: #{text}"]
378
+ end
379
+ end
380
+
381
+ # Persists the complete (pre-truncation) output to a per-call file under
382
+ # the rubino home so the model can read back whatever the inline
383
+ # head+tail elided (the spill seam Util::Output.truncate calls back into
384
+ # on overflow — Util keeps the pure shaping, the executor keeps the IO).
385
+ # Best-effort: a write failure just yields no path and the marker falls
386
+ # back to its grep/head hint. Returns the path or nil.
387
+ def spill_full_output(text, call_id)
388
+ id = call_id.to_s.gsub(/[^a-zA-Z0-9_.-]/, "_")
389
+ return nil if id.empty?
390
+
391
+ dir = File.join(Rubino.home_path, "tool-results")
392
+ FileUtils.mkdir_p(dir)
393
+ path = File.join(dir, "#{id}.txt")
394
+ File.write(path, text)
395
+ path
396
+ rescue StandardError => e
397
+ Rubino.logger&.warn(event: "tool_output.spill_failed", error: e.message)
398
+ nil
399
+ end
400
+ end
401
+ end
402
+ end