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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Encapsulates the result of a tool execution.
6
+ class Result
7
+ attr_reader :name, :call_id, :output, :status, :error, :session_id,
8
+ :metrics, :error_code, :artifact
9
+
10
+ # `error_code` is an optional Symbol surface for callers (UI badges,
11
+ # automation, future contract tests) that want to branch on the
12
+ # failure mode without parsing the human-facing error string. Today
13
+ # the canonical signal is still the output text — the symbol is a
14
+ # belt-and-suspenders next to it, not a replacement.
15
+ #
16
+ # `artifact` is an optional Hash carrying { path:, filename:,
17
+ # content_type:, byte_size: } when a tool produced a downloadable
18
+ # user-facing file. The agent loop reads this and emits an
19
+ # ARTIFACT_CREATED bus event so SSE consumers (the web UI, the CLI)
20
+ # can offer a download.
21
+ def initialize(name:, call_id:, output:, status:, error: nil,
22
+ metrics: nil, error_code: nil, artifact: nil)
23
+ @name = name
24
+ @call_id = call_id
25
+ @output = output
26
+ @status = status
27
+ @error = error
28
+ @metrics = metrics
29
+ @error_code = error_code
30
+ @artifact = artifact
31
+ @session_id = nil
32
+ end
33
+
34
+ def success?
35
+ @status == :success
36
+ end
37
+
38
+ def failed?
39
+ @status == :error
40
+ end
41
+
42
+ def denied?
43
+ @status == :denied
44
+ end
45
+
46
+ # True when this result represents a failure for DISPLAY purposes, even
47
+ # when the tool didn't raise. Many tools (read, edit, …) report a soft
48
+ # failure by RETURNING an "Error: …" string (status stays :success) or by
49
+ # setting an error_code, instead of raising. The CLI used to render those
50
+ # as a green "✓ done" because it only checked #success?. This is the
51
+ # single predicate the UI uses so an errored tool shows "✗" regardless of
52
+ # which failure convention the tool used.
53
+ def errorish?
54
+ return true unless success?
55
+ return true unless @error_code.nil?
56
+
57
+ @output.to_s.start_with?("Error:")
58
+ end
59
+
60
+ # Returns a truncated preview for display
61
+ def truncated_preview(max_length: 80)
62
+ text = @output.to_s
63
+ text.length > max_length ? "#{text[0...max_length]}..." : text
64
+ end
65
+
66
+ # Substituted when a tool legitimately produces no output (e.g. `touch`).
67
+ # The string survives persistence and load_history, where nil/"" would
68
+ # be dropped and leave a tool_call orphaned — the provider then 400s
69
+ # the next turn for a tool_call with no matching tool_result.
70
+ EMPTY_OUTPUT_PLACEHOLDER = "(no output)"
71
+
72
+ # Factory methods
73
+ def self.success(name:, call_id:, output:, metrics: nil, error_code: nil, artifact: nil)
74
+ new(name: name, call_id: call_id, output: normalize_output(output),
75
+ status: :success, metrics: metrics, error_code: error_code, artifact: artifact)
76
+ end
77
+
78
+ def self.error(name:, call_id:, error:, error_code: nil)
79
+ msg = error.to_s
80
+ msg = "unknown error" if msg.empty?
81
+ new(name: name, call_id: call_id, output: "Error: #{msg}", status: :error,
82
+ error: error, error_code: error_code)
83
+ end
84
+
85
+ # Model-facing text per denial reason (#143). Only a real human decision
86
+ # may read "denied by user" — an automatic denial must name the policy
87
+ # that fired, otherwise a child agent reports (and propagates upward)
88
+ # that "the user denied my tools" when no human ever decided anything.
89
+ DENIED_OUTPUTS = {
90
+ user: "Tool execution denied by user.",
91
+ policy: "Tool execution denied by policy (not by the user).",
92
+ hardline: "Tool execution blocked by policy (hardline safety floor, not by the user): " \
93
+ "this command is never allowed.",
94
+ permission_rule: "Tool execution blocked by policy (a configured permissions deny rule, " \
95
+ "not by the user).",
96
+ doom_loop: "Tool execution blocked by the doom-loop guard (policy, not by the user): " \
97
+ "this exact call was already made repeatedly. Change strategy instead of " \
98
+ "retrying it — e.g. wait for the background-task completion notice instead " \
99
+ "of polling."
100
+ }.freeze
101
+
102
+ def self.denied(name:, call_id:, reason: :user)
103
+ key = DENIED_OUTPUTS.key?(reason) ? reason : :policy
104
+ new(name: name, call_id: call_id, output: DENIED_OUTPUTS[key], status: :denied)
105
+ end
106
+
107
+ def self.normalize_output(output)
108
+ text = output.to_s
109
+ text.empty? ? EMPTY_OUTPUT_PLACEHOLDER : text
110
+ end
111
+ end
112
+ end
113
+ end
Binary file
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # Full-text search across the agent's own message history, backed by the
8
+ # `messages_fts` FTS5 index. Lets the model recall prior conversations
9
+ # without forcing the user to paste them back in.
10
+ #
11
+ # Returns a JSON array of match hits with a highlighted snippet so the
12
+ # model can decide whether to follow up with /v1/sessions/:id.
13
+ class SessionSearchTool < Base
14
+ DEFAULT_LIMIT = 20
15
+ MAX_LIMIT = 100
16
+
17
+ def name
18
+ "session_search"
19
+ end
20
+
21
+ def description
22
+ "Full-text search across past session messages. " \
23
+ "Returns matched messages with highlighted snippets and the owning session id. " \
24
+ "Use to recall earlier conversations or look up what a tool returned previously."
25
+ end
26
+
27
+ def input_schema
28
+ {
29
+ type: "object",
30
+ properties: {
31
+ query: {
32
+ type: "string",
33
+ description: "Free-text search query (FTS5 MATCH)."
34
+ },
35
+ since: {
36
+ type: "string",
37
+ description: "ISO8601 lower bound on message created_at."
38
+ },
39
+ until: {
40
+ type: "string",
41
+ description: "ISO8601 upper bound on message created_at."
42
+ },
43
+ role: {
44
+ type: "string",
45
+ enum: %w[user assistant tool],
46
+ description: "Restrict to a single message role."
47
+ },
48
+ tool: {
49
+ type: "string",
50
+ description: "Restrict to a specific tool_name (when role=tool)."
51
+ },
52
+ limit: {
53
+ type: "integer",
54
+ description: "Max results to return (default 20, max 100)."
55
+ }
56
+ },
57
+ required: %w[query]
58
+ }
59
+ end
60
+
61
+ def risk_level
62
+ :low
63
+ end
64
+
65
+ def call(arguments)
66
+ query = arguments["query"] || arguments[:query]
67
+ return "Error: query is required" if query.nil? || query.to_s.strip.empty?
68
+
69
+ limit = (arguments["limit"] || arguments[:limit] || DEFAULT_LIMIT).to_i
70
+ limit = DEFAULT_LIMIT if limit <= 0
71
+ limit = MAX_LIMIT if limit > MAX_LIMIT
72
+
73
+ rows = store.search(
74
+ query: query,
75
+ since: arguments["since"] || arguments[:since],
76
+ until_: arguments["until"] || arguments[:until],
77
+ role: arguments["role"] || arguments[:role],
78
+ tool: arguments["tool"] || arguments[:tool],
79
+ limit: limit
80
+ )
81
+
82
+ results = rows.map do |row|
83
+ {
84
+ session_id: row[:session_id],
85
+ run_id: row[:run_id],
86
+ message_id: row[:message_id],
87
+ role: row[:role],
88
+ snippet: row[:snippet],
89
+ created_at: row[:created_at]
90
+ }
91
+ end
92
+
93
+ JSON.generate(results)
94
+ end
95
+
96
+ private
97
+
98
+ def store
99
+ @store ||= Rubino::Session::Store.new
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Feeds input to a background shell's stdin (registered by ShellTool when
6
+ # run_in_background: true). This is how the agent answers an interactive
7
+ # prompt a running command emits — Y/N confirmations, "select region"
8
+ # menus, apt-style questions — without having to pre-bake the answer at
9
+ # spawn time (`echo y | cmd`, `-y`, heredoc).
10
+ #
11
+ # Typical loop: shell(run_in_background: true) → shell_output (see the
12
+ # prompt) → shell_input(run_id:, text: "y") → shell_output (see the result).
13
+ #
14
+ # By default a newline is appended (like pressing Enter). Pass
15
+ # `enter: false` to send raw bytes without a newline. Pass `eof: true` to
16
+ # close stdin (send EOF) after writing — for commands that read until EOF.
17
+ #
18
+ # Works for line-oriented prompts. Full-screen TTY programs (vim, REPLs
19
+ # that require a real terminal) are out of scope: the background shell uses
20
+ # a plain pipe, not a pseudo-terminal.
21
+ class ShellInputTool < Base
22
+ def name
23
+ "shell_input"
24
+ end
25
+
26
+ def description
27
+ "Send input to a background shell started via `shell` with " \
28
+ "run_in_background: true — answer an interactive prompt (Y/N, menu " \
29
+ "selection, password) of a running command. A newline is appended by " \
30
+ "default (like pressing Enter); pass enter: false for raw bytes, or " \
31
+ "eof: true to close stdin (EOF). Read the prompt and the result with " \
32
+ "`shell_output`."
33
+ end
34
+
35
+ def input_schema
36
+ {
37
+ type: "object",
38
+ properties: {
39
+ run_id: {
40
+ type: "string",
41
+ description: "The run_id returned by `shell` when launched in background"
42
+ },
43
+ text: {
44
+ type: "string",
45
+ description: "The text to write to the process's stdin (e.g. \"y\", \"2\")"
46
+ },
47
+ enter: {
48
+ type: "boolean",
49
+ description: "Append a newline like pressing Enter (default true)"
50
+ },
51
+ eof: {
52
+ type: "boolean",
53
+ description: "Close stdin / send EOF after writing (default false)"
54
+ }
55
+ },
56
+ required: %w[run_id]
57
+ }
58
+ end
59
+
60
+ def risk_level
61
+ :medium
62
+ end
63
+
64
+ def call(arguments)
65
+ run_id = arguments["run_id"] || arguments[:run_id]
66
+ text = arguments["text"] || arguments[:text] || ""
67
+ enter = arguments.fetch("enter", arguments.fetch(:enter, true))
68
+ eof = arguments["eof"] || arguments[:eof] || false
69
+
70
+ return "Error: run_id is required" if run_id.nil? || run_id.to_s.empty?
71
+
72
+ registry = ShellRegistry.instance
73
+ entry = registry.find(run_id)
74
+ return "Error: no background shell with run_id=#{run_id}" unless entry
75
+
76
+ unless entry.wait_thr.alive?
77
+ return "Error: [#{run_id}] already exited (exit=#{registry.exit_code(entry)}) — cannot send input"
78
+ end
79
+
80
+ written =
81
+ begin
82
+ registry.write_input(entry, text, enter: enter)
83
+ rescue IOError, Errno::EPIPE => e
84
+ return "Error: [#{run_id}] stdin is closed (#{e.message})"
85
+ end
86
+
87
+ registry.close_stdin(entry) if eof
88
+
89
+ msg = "[#{run_id}] wrote #{written} byte#{"s" unless written == 1} to stdin"
90
+ msg << " (EOF sent)" if eof
91
+ msg << "\nRead the result: shell_output run_id=#{run_id}"
92
+ msg
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Terminates a background shell. Sends SIGTERM to the whole process
6
+ # group first; if the process is still alive after a 2s grace period,
7
+ # follows up with SIGKILL.
8
+ class ShellKillTool < Base
9
+ GRACE_SECONDS = 2
10
+
11
+ def name
12
+ "shell_kill"
13
+ end
14
+
15
+ def description
16
+ "Terminate a background shell started via `shell` with run_in_background: true. " \
17
+ "Sends SIGTERM to the process group, waits #{GRACE_SECONDS}s, then SIGKILL if " \
18
+ "the process is still alive."
19
+ end
20
+
21
+ def input_schema
22
+ {
23
+ type: "object",
24
+ properties: {
25
+ run_id: {
26
+ type: "string",
27
+ description: "The run_id returned by `shell` when launched in background"
28
+ }
29
+ },
30
+ required: %w[run_id]
31
+ }
32
+ end
33
+
34
+ def risk_level
35
+ :medium
36
+ end
37
+
38
+ def call(arguments)
39
+ run_id = arguments["run_id"] || arguments[:run_id]
40
+ return "Error: run_id is required" if run_id.nil? || run_id.to_s.empty?
41
+
42
+ registry = ShellRegistry.instance
43
+ entry = registry.find(run_id)
44
+ return "Error: no background shell with run_id=#{run_id}" unless entry
45
+
46
+ unless entry.wait_thr.alive?
47
+ registry.remove(run_id)
48
+ return "[#{run_id}] already exited (exit=#{registry.exit_code(entry)})"
49
+ end
50
+
51
+ send_signal(entry.pgid, "TERM")
52
+ GRACE_SECONDS.times do
53
+ break unless entry.wait_thr.alive?
54
+
55
+ sleep 1
56
+ end
57
+
58
+ if entry.wait_thr.alive?
59
+ send_signal(entry.pgid, "KILL")
60
+ sleep 0.1
61
+ end
62
+
63
+ registry.remove(run_id)
64
+ "[#{run_id}] terminated (SIGTERM" + (entry.wait_thr.alive? ? "+SIGKILL" : "") + ")"
65
+ end
66
+
67
+ private
68
+
69
+ def send_signal(pgid, signal)
70
+ Process.kill(signal, -pgid)
71
+ rescue Errno::ESRCH, Errno::EPERM
72
+ # Already dead or not ours.
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Reads stdout/stderr accumulated by a background shell (registered by
6
+ # ShellTool when run_in_background: true).
7
+ #
8
+ # By default returns only the bytes produced since the last call —
9
+ # repeated polling shows incremental progress like `tail -F`. Pass
10
+ # `mode: "all"` for the full buffer (bounded by ShellRegistry::RING_BYTES).
11
+ class ShellOutputTool < Base
12
+ def name
13
+ "shell_output"
14
+ end
15
+
16
+ def description
17
+ "Read output from a background shell started via `shell` with " \
18
+ "run_in_background: true. By default returns only new bytes since " \
19
+ "the previous read. Pass mode: 'all' for the full buffered output."
20
+ end
21
+
22
+ def input_schema
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ run_id: {
27
+ type: "string",
28
+ description: "The run_id returned by `shell` when launched in background"
29
+ },
30
+ mode: {
31
+ type: "string",
32
+ enum: %w[new all],
33
+ description: "'new' (default) = bytes since last read; 'all' = full buffer"
34
+ }
35
+ },
36
+ required: %w[run_id]
37
+ }
38
+ end
39
+
40
+ def risk_level
41
+ :low
42
+ end
43
+
44
+ def call(arguments)
45
+ run_id = arguments["run_id"] || arguments[:run_id]
46
+ mode = (arguments["mode"] || arguments[:mode] || "new").to_s
47
+
48
+ return "Error: run_id is required" if run_id.nil? || run_id.to_s.empty?
49
+
50
+ registry = ShellRegistry.instance
51
+ entry = registry.find(run_id)
52
+ return "Error: no background shell with run_id=#{run_id}" unless entry
53
+
54
+ body = mode == "all" ? registry.read_all(entry) : registry.read_new(entry)
55
+ status = registry.status(entry)
56
+ exit_code = registry.exit_code(entry)
57
+
58
+ header = "[#{run_id}] status=#{status}"
59
+ header << " exit=#{exit_code}" if exit_code
60
+ header << " (#{body.bytesize} bytes #{mode == "all" ? "total" : "new"})"
61
+
62
+ registry.remove(run_id) unless status == :running
63
+
64
+ if body.empty?
65
+ status == :running ? "#{header}\n(no new output)" : header
66
+ else
67
+ "#{header}\n#{body}"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "open3"
5
+
6
+ module Rubino
7
+ module Tools
8
+ # Process-wide registry for shell commands started with `run_in_background`.
9
+ # Each entry owns a pgid (process group), a reader thread that drains
10
+ # stdout+stderr into an in-memory ring buffer, and the wait_thr for exit.
11
+ #
12
+ # The registry survives a single CLI/server process — it is intentionally
13
+ # NOT persisted to disk. Background shells die with the agent process.
14
+ class ShellRegistry
15
+ RING_BYTES = 256 * 1024 # cap per run; older bytes are dropped
16
+
17
+ Entry = Struct.new(
18
+ :id, :command, :cwd, :pid, :pgid, :wait_thr, :reader_thr,
19
+ :buffer, :mutex, :started_at, :read_offset, :stdin,
20
+ keyword_init: true
21
+ )
22
+
23
+ class << self
24
+ def instance
25
+ @instance ||= new
26
+ end
27
+ end
28
+
29
+ def initialize
30
+ @entries = {}
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ # Spawns `command` detached in its own process group so a single kill
35
+ # takes out the whole subtree. Returns the new entry.
36
+ def spawn(command:, cwd:)
37
+ rd, wr = IO.pipe
38
+ # Writable stdin pipe: the agent feeds answers to interactive prompts
39
+ # (Y/N, "select region", apt-style) via the `shell_input` tool, which
40
+ # writes to `in_wr`. Line-oriented `read`/prompt commands consume this
41
+ # fine; full-screen TTY programs (vim, REPLs that require [ -t 0 ]) are
42
+ # out of scope for a plain pipe.
43
+ in_rd, in_wr = IO.pipe
44
+ # pgroup: true → child becomes leader of a new process group whose
45
+ # pgid == child pid. Lets shell_kill send SIGTERM to the whole tree.
46
+ # bash -o pipefail keeps this path consistent with the foreground
47
+ # shell: a mid-pipeline crash surfaces as the exit status (#156).
48
+ pid = Process.spawn("bash", "-o", "pipefail", "-c", command,
49
+ chdir: cwd, pgroup: true, in: in_rd, out: wr, err: wr)
50
+ wr.close
51
+ in_rd.close
52
+
53
+ entry = Entry.new(
54
+ id: new_id,
55
+ command: command,
56
+ cwd: cwd,
57
+ pid: pid,
58
+ pgid: pid,
59
+ wait_thr: Process.detach(pid),
60
+ buffer: +"",
61
+ mutex: Mutex.new,
62
+ started_at: Time.now,
63
+ read_offset: 0,
64
+ stdin: in_wr
65
+ )
66
+ entry.reader_thr = Thread.new { drain_into(entry, rd) }
67
+
68
+ @mutex.synchronize { @entries[entry.id] = entry }
69
+ entry
70
+ end
71
+
72
+ def find(id)
73
+ @mutex.synchronize { @entries[id] }
74
+ end
75
+
76
+ def remove(id)
77
+ entry = @mutex.synchronize { @entries.delete(id) }
78
+ close_stdin(entry) if entry
79
+ entry
80
+ end
81
+
82
+ # Writes `text` to the background process's stdin (with a trailing
83
+ # newline unless `enter: false`) — the "press Enter to answer a prompt"
84
+ # path. Returns the number of bytes written, or raises if stdin is gone.
85
+ def write_input(entry, text, enter: true)
86
+ io = entry.stdin
87
+ raise IOError, "stdin already closed" if io.nil? || io.closed?
88
+
89
+ payload = enter ? "#{text}\n" : text.to_s
90
+ io.write(payload)
91
+ io.flush
92
+ payload.bytesize
93
+ end
94
+
95
+ # Closes the write end of the child's stdin (sends EOF). Idempotent.
96
+ def close_stdin(entry)
97
+ io = entry&.stdin
98
+ io.close if io && !io.closed?
99
+ rescue IOError
100
+ # already closed
101
+ end
102
+
103
+ # Reads accumulated bytes since the last `read_new` call. Returns the
104
+ # full snapshot if `since` is nil. Thread-safe.
105
+ def read_new(entry)
106
+ entry.mutex.synchronize do
107
+ snapshot = entry.buffer.byteslice(entry.read_offset..) || ""
108
+ entry.read_offset = entry.buffer.bytesize
109
+ snapshot
110
+ end
111
+ end
112
+
113
+ def read_all(entry)
114
+ entry.mutex.synchronize { entry.buffer.dup }
115
+ end
116
+
117
+ def status(entry)
118
+ return :running if entry.wait_thr.alive?
119
+
120
+ code = entry.wait_thr.value.exitstatus
121
+ code && ShellTool.success_exit?(code) ? :completed : :failed
122
+ end
123
+
124
+ def exit_code(entry)
125
+ return nil if entry.wait_thr.alive?
126
+
127
+ entry.wait_thr.value.exitstatus
128
+ end
129
+
130
+ private
131
+
132
+ def new_id
133
+ "bg_#{SecureRandom.hex(4)}"
134
+ end
135
+
136
+ # Single-reader pattern: only this thread writes to entry.buffer, the
137
+ # mutex protects only against concurrent reads from shell_output_tool.
138
+ def drain_into(entry, rd)
139
+ rd.each_line do |chunk|
140
+ entry.mutex.synchronize do
141
+ entry.buffer << chunk
142
+ overflow = entry.buffer.bytesize - RING_BYTES
143
+ if overflow.positive?
144
+ entry.buffer = entry.buffer.byteslice(overflow..) || +""
145
+ # Reset read_offset proportionally so the next read still sees
146
+ # only fresh bytes, not whatever survived the trim.
147
+ entry.read_offset = [entry.read_offset - overflow, 0].max
148
+ end
149
+ end
150
+ end
151
+ rescue IOError, Errno::EBADF
152
+ # pipe closed — process exited
153
+ ensure
154
+ rd.close unless rd.closed?
155
+ end
156
+ end
157
+ end
158
+ end