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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Blocking variant of shell_output: waits up to `timeout` seconds for new
6
+ # bytes to arrive on a background shell, then returns them. Returns
7
+ # immediately if bytes are already buffered or if the process has exited.
8
+ #
9
+ # Lets an agent "follow" a long-running job (CI, build, watcher) without
10
+ # busy-polling shell_output. The polling itself is implemented internally
11
+ # as a short-interval loop on ShellRegistry.read_new — switching to a
12
+ # condition variable would shave ~50ms of jitter and is a refactor for
13
+ # later; for v1, 100ms polling under the agent's tool-call latency is
14
+ # invisible.
15
+ class ShellTailTool < Base
16
+ DEFAULT_TIMEOUT = 30
17
+ MAX_TIMEOUT = 300
18
+ POLL_INTERVAL = 0.1
19
+
20
+ def name
21
+ "shell_tail"
22
+ end
23
+
24
+ def description
25
+ "Follow a background shell — block until new stdout/stderr bytes " \
26
+ "arrive on its run_id, the process exits, or `timeout` seconds " \
27
+ "elapse. Default timeout #{DEFAULT_TIMEOUT}s (max #{MAX_TIMEOUT}s). " \
28
+ "Returns the new bytes plus a status header. Use for `tail -F`-style " \
29
+ "following; use shell_output for a one-shot read."
30
+ end
31
+
32
+ def input_schema
33
+ {
34
+ type: "object",
35
+ properties: {
36
+ run_id: { type: "string", description: "run_id from shell run_in_background:true" },
37
+ timeout: { type: "integer",
38
+ description: "Max seconds to block (default #{DEFAULT_TIMEOUT}, max #{MAX_TIMEOUT})" }
39
+ },
40
+ required: %w[run_id]
41
+ }
42
+ end
43
+
44
+ def risk_level
45
+ :low
46
+ end
47
+
48
+ def call(arguments)
49
+ run_id = arguments["run_id"] || arguments[:run_id]
50
+ timeout = (arguments["timeout"] || arguments[:timeout] || DEFAULT_TIMEOUT).to_i
51
+ timeout = timeout.clamp(1, MAX_TIMEOUT)
52
+
53
+ return "Error: run_id is required" if run_id.nil? || run_id.to_s.empty?
54
+
55
+ registry = ShellRegistry.instance
56
+ entry = registry.find(run_id)
57
+ return "Error: no background shell with run_id=#{run_id}" unless entry
58
+
59
+ body = ""
60
+ deadline = Time.now + timeout
61
+
62
+ loop do
63
+ body = registry.read_new(entry)
64
+ break unless body.empty?
65
+
66
+ # Process has exited and no bytes left to drain — return now with
67
+ # whatever the final status says.
68
+ break if registry.status(entry) != :running
69
+
70
+ # User pressed Ctrl+C during a tail. Don't keep blocking — return
71
+ # an empty body with a "cancelled" hint so the model can react.
72
+ if cancellation_requested?
73
+ return { output: tail_header(run_id, registry, entry, body, cancelled: true),
74
+ error_code: :cancelled }
75
+ end
76
+
77
+ break if Time.now >= deadline
78
+
79
+ sleep POLL_INTERVAL
80
+ end
81
+
82
+ status = registry.status(entry)
83
+ exit_code = registry.exit_code(entry)
84
+ registry.remove(run_id) unless status == :running
85
+
86
+ text = if body.empty?
87
+ tail_header(run_id, registry, entry, body)
88
+ else
89
+ "#{tail_header(run_id, registry, entry, body)}\n#{body}"
90
+ end
91
+ { output: text,
92
+ metrics: "#{body.bytesize}B · #{status}",
93
+ exit_code: exit_code,
94
+ error_code: tail_error_code(status, exit_code) }
95
+ end
96
+
97
+ private
98
+
99
+ def tail_header(run_id, registry, entry, body, cancelled: false)
100
+ status = registry.status(entry)
101
+ exit_code = registry.exit_code(entry)
102
+ header = "[#{run_id}] status=#{status}"
103
+ header << " exit=#{exit_code}" if exit_code
104
+ header << " (#{body.bytesize} new bytes)"
105
+ header << " (cancelled by user)" if cancelled
106
+ header << "\n(no new output before deadline)" if body.empty? && status == :running && !cancelled
107
+ header
108
+ end
109
+
110
+ def tail_error_code(status, exit_code)
111
+ return nil if %i[running completed].include?(status)
112
+ return :exit_nonzero if exit_code && exit_code != 0
113
+
114
+ :shell_error
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # Executes shell commands.
8
+ #
9
+ # Modes:
10
+ # - foreground (default): blocks until exit or `timeout` seconds, then
11
+ # SIGTERMs the process group and returns whatever was captured.
12
+ # - background (`run_in_background: true`): registers the process with
13
+ # ShellRegistry, returns a run_id immediately. Read its output later
14
+ # with `shell_output`, terminate it with `shell_kill`.
15
+ #
16
+ # Gatekeeping (allowlist, deny rules, approval prompts) lives in
17
+ # Security::ApprovalPolicy and is enforced by the ToolExecutor before we
18
+ # get here — this class only runs the command and resolves cwd.
19
+ #
20
+ # As defense-in-depth, #call re-checks the command against the hardline
21
+ # blocklist (Security::HardlineGuard — the single source of truth, also
22
+ # used by ApprovalPolicy). yolo skips approvals by design, but the point
23
+ # of yolo is "trust the model to move fast", not "let it wipe the root
24
+ # filesystem if it confuses paths" — so catastrophic, unrecoverable
25
+ # commands are refused here even if the policy was somehow bypassed.
26
+ class ShellTool < Base
27
+ DEFAULT_TIMEOUT = 120
28
+ MAX_TIMEOUT = 600
29
+
30
+ # 128 + SIGPIPE(13): under `pipefail`, a benign early-exit consumer
31
+ # (`cmd | head -1`) makes an upstream stage report SIGPIPE and the
32
+ # pipeline returns 141 even though nothing actually went wrong.
33
+ SIGPIPE_EXIT = 141
34
+
35
+ # Single decision point for "does this exit code count as success?".
36
+ # Used by both the [Exit code: …] suffix and the ✓/✗ presentation
37
+ # (via shell_error_code → Result#errorish?) so the two can't drift.
38
+ def self.success_exit?(code)
39
+ code.zero? || code == SIGPIPE_EXIT
40
+ end
41
+
42
+ def name
43
+ "shell"
44
+ end
45
+
46
+ def description
47
+ "Execute a shell command. " \
48
+ "Foreground: blocks until the command exits or `timeout` seconds elapse " \
49
+ "(default #{DEFAULT_TIMEOUT}s, max #{MAX_TIMEOUT}s). " \
50
+ "Background: pass `run_in_background: true` to fire-and-forget; the tool " \
51
+ "returns a run_id. Use the `shell_output` tool to read its stdout/stderr, " \
52
+ "`shell_input` to answer an interactive prompt it emits (Y/N, menu), " \
53
+ "and `shell_kill` to terminate it."
54
+ end
55
+
56
+ def input_schema
57
+ {
58
+ type: "object",
59
+ properties: {
60
+ command: {
61
+ type: "string",
62
+ description: "The shell command to execute"
63
+ },
64
+ cwd: {
65
+ type: "string",
66
+ description: "Working directory (defaults to current)"
67
+ },
68
+ timeout: {
69
+ type: "integer",
70
+ description: "Foreground timeout in seconds (default #{DEFAULT_TIMEOUT}, max #{MAX_TIMEOUT}). Ignored when run_in_background is true."
71
+ },
72
+ run_in_background: {
73
+ type: "boolean",
74
+ description: "If true, start the command detached and return a run_id immediately."
75
+ }
76
+ },
77
+ required: %w[command]
78
+ }
79
+ end
80
+
81
+ def risk_level
82
+ :high
83
+ end
84
+
85
+ def call(arguments)
86
+ command = arguments["command"] || arguments[:command]
87
+ cwd = arguments["cwd"] || arguments[:cwd]
88
+ background = arguments["run_in_background"] || arguments[:run_in_background] || false
89
+ timeout = arguments["timeout"] || arguments[:timeout] || DEFAULT_TIMEOUT
90
+ timeout = [[timeout.to_i, 1].max, MAX_TIMEOUT].min
91
+
92
+ return "Error: command is required" if command.nil? || command.to_s.empty?
93
+
94
+ if (denied = destructive_pattern_match(command))
95
+ return { output: "Error: refusing to run #{denied} — this is hardcoded as " \
96
+ "destructive and not overridable by --yolo. " \
97
+ "If you genuinely need this, run it manually outside the agent.",
98
+ error_code: :denied_command }
99
+ end
100
+
101
+ working_dir = resolve_cwd(cwd)
102
+ return "Error: cannot access working directory: #{cwd.inspect}" unless working_dir
103
+
104
+ if background
105
+ spawn_background(command, working_dir)
106
+ else
107
+ run = execute_foreground(command, working_dir, timeout)
108
+ # exit_code / timed_out / cancelled are surfaced as structured
109
+ # keys so downstream code (and the model) doesn't have to parse
110
+ # `[Exit code: N]` out of free-form text to know whether the
111
+ # command succeeded. The text suffix stays for visual continuity
112
+ # in the scrollback and for tests that grep for it.
113
+ { output: run[:text],
114
+ metrics: foreground_metric(run),
115
+ body: Util::Output.preview(run[:text]),
116
+ body_kind: :plain,
117
+ exit_code: run[:exit_code],
118
+ timed_out: run[:timed_out],
119
+ cancelled: run[:cancelled],
120
+ error_code: shell_error_code(run) }
121
+ end
122
+ end
123
+
124
+ def shell_error_code(run)
125
+ return :timeout if run[:timed_out]
126
+ return :cancelled if run[:cancelled]
127
+ return :shell_error if run[:shell_error]
128
+ return :exit_nonzero if run[:exit_code] && !self.class.success_exit?(run[:exit_code])
129
+
130
+ nil
131
+ end
132
+
133
+ # One-liner for the `done · shell` header. Reads the structured run
134
+ # fields directly — no regex archaeology on the text suffix.
135
+ def foreground_metric(run)
136
+ status = if run[:timed_out] then "timeout"
137
+ elsif run[:cancelled] then "cancelled"
138
+ elsif run[:shell_error] then "shell error"
139
+ elsif run[:exit_code].nil? then "no exit"
140
+ elsif run[:exit_code].zero? then "exit 0"
141
+ else "exit #{run[:exit_code]}"
142
+ end
143
+ "#{status} · #{format_ms(run[:duration_ms])}"
144
+ end
145
+
146
+ def format_ms(ms)
147
+ if ms < 1000 then "#{ms}ms"
148
+ elsif ms < 60_000 then "#{(ms / 1000.0).round(1)}s"
149
+ else
150
+ mins, rem = ms.divmod(60_000)
151
+ "#{mins}m#{(rem / 1000.0).round}s"
152
+ end
153
+ end
154
+
155
+ private
156
+
157
+ # Defense-in-depth: the ApprovalPolicy already denies hardline commands
158
+ # before we get here, but the tool re-checks against the SAME single
159
+ # source (Security::HardlineGuard) so a future caller that bypasses the
160
+ # policy still can't wipe the host. No divergent inline list.
161
+ def destructive_pattern_match(command)
162
+ Security::HardlineGuard.block_reason(command)
163
+ end
164
+
165
+ # Resolves cwd via realpath so symlinks and "../" are fully expanded;
166
+ # returns nil if the directory does not exist or is unreadable.
167
+ def resolve_cwd(cwd)
168
+ candidate = cwd || Rubino::Workspace.primary_root
169
+ path = File.realpath(File.expand_path(candidate))
170
+ File.directory?(path) ? path : nil
171
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
172
+ nil
173
+ end
174
+
175
+ def spawn_background(command, cwd)
176
+ entry = ShellRegistry.instance.spawn(command: command, cwd: cwd)
177
+ "Started background shell #{entry.id} (pid #{entry.pid})\n " \
178
+ "command: #{command}\n " \
179
+ "cwd: #{cwd}\n" \
180
+ "Read output: shell_output run_id=#{entry.id}\n" \
181
+ "Send input: shell_input run_id=#{entry.id} text=...\n" \
182
+ "Terminate: shell_kill run_id=#{entry.id}"
183
+ rescue StandardError => e
184
+ "Error starting background shell: #{e.message}"
185
+ end
186
+
187
+ # Runs in its own process group so we can SIGTERM the whole subtree on
188
+ # timeout (a bare `kill pid` would leave child processes orphaned).
189
+ # Returns a structured hash — the wrapper builds the model-facing text
190
+ # from the same data, keeping the parse path single-sourced.
191
+ def execute_foreground(command, cwd, timeout)
192
+ rd = nil
193
+ rd, wr = IO.pipe
194
+ # bash -o pipefail (instead of bare `/bin/sh -c`) so a crash in the
195
+ # MIDDLE of a pipeline surfaces as the pipeline's exit status instead
196
+ # of being masked by an innocuous last stage (#156).
197
+ pid = Process.spawn("bash", "-o", "pipefail", "-c", command,
198
+ chdir: cwd, pgroup: true, out: wr, err: wr)
199
+ pgid = pid
200
+ wr.close
201
+
202
+ # Drain the merged stdout+stderr pipe line-by-line so each chunk can
203
+ # be streamed to the UI/event stream as the subprocess writes it,
204
+ # not just at end-of-command. The accumulated string is still the
205
+ # canonical model-facing output. `each_line` only yields on \n or
206
+ # EOF, so a process emitting unterminated progress (`\r`-only) will
207
+ # still buffer until newline — acceptable for v1; live progress
208
+ # bars are a separate problem.
209
+ output_buf = +""
210
+ output_thr = Thread.new do
211
+ begin
212
+ rd.each_line do |line|
213
+ output_buf << line
214
+ emit_chunk(line)
215
+ end
216
+ rescue IOError, Errno::EBADF
217
+ # pipe closed under us — process exited
218
+ ensure
219
+ rd.close unless rd.closed?
220
+ end
221
+ output_buf
222
+ end
223
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
224
+
225
+ begin
226
+ deadline = Time.now + timeout
227
+ status = nil
228
+ loop do
229
+ wpid, status = Process.waitpid2(pid, Process::WNOHANG)
230
+ break if wpid
231
+
232
+ if cancellation_requested?
233
+ terminate_group(pgid)
234
+ sleep 0.5
235
+ begin
236
+ Process.kill("KILL", -pgid)
237
+ rescue StandardError
238
+ nil
239
+ end
240
+ begin
241
+ Process.waitpid2(pid)
242
+ rescue StandardError
243
+ nil
244
+ end
245
+ return foreground_result(
246
+ stdout: output_thr.value,
247
+ suffix: "[Command cancelled by user — SIGTERM sent]",
248
+ cancelled: true,
249
+ duration_ms: elapsed_ms(started_at)
250
+ )
251
+ end
252
+
253
+ if Time.now >= deadline
254
+ terminate_group(pgid)
255
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
256
+ unless status
257
+ sleep 2
258
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
259
+ end
260
+ unless status
261
+ begin
262
+ Process.kill("KILL", -pgid)
263
+ rescue StandardError
264
+ nil
265
+ end
266
+ _, status = Process.waitpid2(pid)
267
+ end
268
+ return foreground_result(
269
+ stdout: output_thr.value,
270
+ suffix: "[Command timed out after #{timeout}s — SIGTERM sent]",
271
+ timed_out: true,
272
+ duration_ms: elapsed_ms(started_at)
273
+ )
274
+ end
275
+ sleep 0.05
276
+ end
277
+
278
+ code = status&.exitstatus
279
+ foreground_result(stdout: output_thr.value,
280
+ suffix: exit_suffix(code),
281
+ exit_code: code,
282
+ duration_ms: elapsed_ms(started_at))
283
+ rescue Errno::ECHILD
284
+ foreground_result(stdout: output_thr.value,
285
+ duration_ms: elapsed_ms(started_at))
286
+ end
287
+ rescue StandardError => e
288
+ { text: "Shell error: #{e.message}", exit_code: nil, timed_out: false,
289
+ cancelled: false, shell_error: true, duration_ms: 0 }
290
+ ensure
291
+ rd.close if rd && !rd.closed?
292
+ end
293
+
294
+ # nil for a clean exit; an honest [Exit code: N] otherwise. 141 keeps
295
+ # the real code in the text but carries the SIGPIPE note so neither
296
+ # the human nor the model reads it as a failure.
297
+ def exit_suffix(code)
298
+ return nil if code.nil? || code.zero?
299
+
300
+ if code == SIGPIPE_EXIT
301
+ "[Exit code: #{code} — SIGPIPE: downstream consumer closed early; treated as success]"
302
+ else
303
+ "[Exit code: #{code}]"
304
+ end
305
+ end
306
+
307
+ def foreground_result(stdout:, duration_ms:, suffix: nil,
308
+ exit_code: nil, timed_out: false, cancelled: false)
309
+ text = stdout.to_s
310
+ text = "#{text}\n#{suffix}" if suffix
311
+ { text: text,
312
+ exit_code: exit_code,
313
+ timed_out: timed_out,
314
+ cancelled: cancelled,
315
+ shell_error: false,
316
+ duration_ms: duration_ms }
317
+ end
318
+
319
+ def elapsed_ms(started_at)
320
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
321
+ end
322
+
323
+ def terminate_group(pgid)
324
+ Process.kill("TERM", -pgid)
325
+ rescue Errno::ESRCH, Errno::EPERM
326
+ # Already dead or not ours — fine.
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # steer — the MODEL-callable parent->child steering note (S2). The model
6
+ # counterpart of the human `/agents <id> steer "..."` affordance: a parent
7
+ # agent parks a note onto one of ITS OWN running children; the note is folded
8
+ # into that child's context at its next turn boundary (Loop#inject_steered_input
9
+ # via the child's steer_queue) and PERSISTS — it changes the child's
10
+ # trajectory, unlike the ephemeral `probe`.
11
+ #
12
+ # SCOPED AT CALL (the S1 correction): steer is registered for ALL agents and
13
+ # authorized by OWNERSHIP at call time. The caller is the thread-local
14
+ # Rubino.current_subagent_id (nil ⇒ the human / top-level agent). The target
15
+ # must be the caller's OWN DIRECT child (BackgroundTasks.owned_by?), so a node
16
+ # with no children simply gets a "not your child" error. This tool does NOT
17
+ # touch the human CLI path (executor.rb's steer_agent stays unscoped) and is
18
+ # NOT on any strip list.
19
+ #
20
+ # Mechanism reuse: it wraps BackgroundTasks#steer verbatim (the SAME wire the
21
+ # human CLI uses) — no new transport, no new state.
22
+ class SteerTool < Base
23
+ def name
24
+ "steer"
25
+ end
26
+
27
+ # Gated by the same `tools.task` delegation key — steering a child is
28
+ # meaningless without the delegation substrate. Disabling delegation
29
+ # disables steer too.
30
+ def config_key
31
+ "task"
32
+ end
33
+
34
+ def description
35
+ "Steer one of YOUR OWN running subagents: park a short note that is " \
36
+ "folded into that child's context at its NEXT turn (it persists and " \
37
+ "changes what the child does). Use it to course-correct a child you " \
38
+ "started — add a constraint, narrow the scope, flag something it missed. " \
39
+ "You can ONLY steer subagents you started (your direct children); you " \
40
+ "cannot steer yourself, a sibling, or a finished child. The note is " \
41
+ "queued, not delivered instantly — the child sees it between turns."
42
+ end
43
+
44
+ def input_schema
45
+ {
46
+ type: "object",
47
+ properties: {
48
+ task_id: { type: "string", description: "The id (sa_…) of YOUR running subagent to steer." },
49
+ note: { type: "string",
50
+ description: "The steering note to fold into the child's next turn. Keep it short and self-contained." }
51
+ },
52
+ required: %w[task_id note]
53
+ }
54
+ end
55
+
56
+ # Steering a child is a low-risk, non-destructive nudge (the child carries
57
+ # its own approval/risk gates for anything it does next).
58
+ def risk_level
59
+ :low
60
+ end
61
+
62
+ def call(arguments)
63
+ task_id = (arguments["task_id"] || arguments[:task_id]).to_s.strip
64
+ note = (arguments["note"] || arguments[:note]).to_s.strip
65
+ return "Error: note is required" if note.empty?
66
+
67
+ caller_id = Rubino.current_subagent_id
68
+ registry = BackgroundTasks.instance
69
+ entry = task_id.empty? ? nil : registry.find(task_id)
70
+
71
+ # No such id at all → it is not a steerable running subagent.
72
+ return "Cannot steer #{task_id} — no such running subagent." unless entry
73
+ # Self-steer is meaningless and would loop a note into your own context.
74
+ return "Error: cannot steer yourself." if task_id == caller_id
75
+ # Ownership: only a DIRECT child of the caller may be steered.
76
+ unless registry.owned_by?(caller_id, task_id)
77
+ return "Error: #{task_id} is not one of your subagents — you can only steer children you started."
78
+ end
79
+ # A finished child has no live loop to fold the note into.
80
+ return "Cannot steer #{task_id} — it already finished (#{entry.status})." unless live?(entry.status)
81
+
82
+ # Wraps the SAME wire the human CLI uses. A false here means the child's
83
+ # queue vanished between checks (a just-finished child) — treat as gone.
84
+ return "Cannot steer #{task_id} — no such running subagent." unless registry.steer(task_id, note)
85
+
86
+ # A child parked on a BLOCKING ask_parent has no next turn until the ask
87
+ # is answered — the note IS queued (deliver-on-unblock), but saying
88
+ # "enters child context next turn" would let the parent believe the
89
+ # redirect took effect (#198). Be honest and point at the one action
90
+ # that unblocks the child.
91
+ if parked_on_ask?(entry)
92
+ return "steer ▸ #{task_id} ← #{Rubino::Util::Output.elide(note, 80)} (queued — but #{task_id} is BLOCKED " \
93
+ "on ask_parent and will NOT see it until you answer its question: " \
94
+ "#{Rubino::Util::Output.elide(entry.ask_question, 120)} — unblock it with " \
95
+ "answer_child(task_id: \"#{task_id}\", answer: \"…\"))"
96
+ end
97
+
98
+ "steer ▸ #{task_id} ← #{Rubino::Util::Output.elide(note, 80)} (parked · enters child context next turn)"
99
+ end
100
+
101
+ private
102
+
103
+ # Mirrors BackgroundTasks#live_status? — a child still holds a loop (its
104
+ # thread is alive) while running, awaiting approval, or blocked on an
105
+ # escalated ask_parent (waiting on the human OR on its agent-parent).
106
+ def live?(status)
107
+ %i[running needs_approval blocked_on_human blocked_on_parent].include?(status)
108
+ end
109
+
110
+ # True when the child's thread is PARKED on a blocking ask_parent gate (a
111
+ # non-blocking ask keeps working, so its steer is consumable as normal).
112
+ def parked_on_ask?(entry)
113
+ entry.ask_gate && entry.ask_blocking &&
114
+ %i[blocked_on_human blocked_on_parent].include?(entry.status)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../llm/adapter_factory"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # probe — the parent\'s EPHEMERAL read-only peek into a running subagent (the
8
+ # second mechanism of the parent<->subagent comm design).
9
+ #
10
+ # Unlike `steer` (a persisted note that changes the child\'s trajectory),
11
+ # `probe` is read-only and DISCARDED: it takes a SNAPSHOT of the child\'s
12
+ # current messages, runs ONE side-inference ([child messages] + question) on
13
+ # the child\'s own model, and returns the answer to the parent. Nothing is
14
+ # appended to the child\'s history, the child\'s loop is never touched, and
15
+ # the Q&A never enters the timeline — so a probe can never alter what the
16
+ # subagent does. The cost is one extra model round-trip that is billed but
17
+ # thrown away (keep probes short).
18
+ #
19
+ # Reuse: the snapshot is just Session::Store#for_session on the child\'s own
20
+ # session id (the child Runner exposes #session); the inference is a one-shot
21
+ # AdapterFactory.build(...).chat — the SAME adapter seam Lifecycle and the
22
+ # auxiliary client use. No new transport, no shared state.
23
+ class SubagentProbe
24
+ # The instruction prepended to the one-shot so the child\'s model answers AS
25
+ # the subagent, from its context-so-far, without trying to continue the task.
26
+ PREAMBLE = "You are the subagent above. Answer the following question from " + "your current context ONLY — do not take any action or continue " + "your task; this is a read-only check. Be brief."
27
+
28
+ # @param adapter_factory [#call] test seam: a callable taking the resolved
29
+ # model id and returning an LLM adapter (anything responding to #chat).
30
+ # Defaults to the real AdapterFactory.build for the child\'s model.
31
+ def initialize(adapter_factory: nil, message_store: nil)
32
+ @adapter_factory = adapter_factory
33
+ @message_store = message_store
34
+ end
35
+
36
+ # Runs the ephemeral peek and returns the answer string. Best-effort: any
37
+ # failure (no session yet, model error) returns a short diagnostic rather
38
+ # than raising — a probe must never break the parent REPL.
39
+ def peek(entry:, question:)
40
+ snapshot = snapshot_messages(entry)
41
+ messages = [{ role: "user", content: PREAMBLE }] + snapshot +
42
+ [{ role: "user", content: question.to_s }]
43
+
44
+ adapter = build_adapter(entry)
45
+ response = adapter.chat(messages: messages)
46
+ text = response.respond_to?(:content) ? response.content.to_s : response.to_s
47
+ text.strip.empty? ? "(no answer)" : text.strip
48
+ rescue StandardError => e
49
+ "(probe failed: #{e.message})"
50
+ end
51
+
52
+ private
53
+
54
+ # The child\'s current transcript as plain {role:, content:} text messages.
55
+ # Tool/assistant rows with no textual content are dropped (the peek only
56
+ # needs the readable context, not the tool_use/result wiring), so the
57
+ # snapshot is a clean prompt the one-shot model can answer from.
58
+ def snapshot_messages(entry)
59
+ session = entry.runner&.session
60
+ return [] unless session && session[:id]
61
+
62
+ store.for_session(session[:id]).filter_map do |m|
63
+ c = m.content.to_s
64
+ next if c.strip.empty?
65
+
66
+ { role: normalize_role(m.role), content: c }
67
+ end
68
+ end
69
+
70
+ # Map persisted roles to the chat roles the adapter expects; a `tool` row
71
+ # becomes a user-visible context line (its content is the tool output).
72
+ def normalize_role(role)
73
+ %w[user assistant].include?(role.to_s) ? role.to_s : "user"
74
+ end
75
+
76
+ def store
77
+ @message_store ||= Session::Store.new
78
+ end
79
+
80
+ def build_adapter(entry)
81
+ model = (entry.runner.respond_to?(:model_id) ? entry.runner.model_id : nil) if entry.runner
82
+ model ||= Rubino.configuration.model_default
83
+ return @adapter_factory.call(model) if @adapter_factory
84
+
85
+ LLM::AdapterFactory.build(model_id: model, config: Rubino.configuration)
86
+ end
87
+ end
88
+ end
89
+ end