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,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Runs the workspace project's test suite and returns a STRUCTURED result
6
+ # instead of the raw toolchain firehose the `shell` tool emits.
7
+ #
8
+ # Why this exists (issue #101): to run tests the model used to drive `shell`
9
+ # and reason its way through the whole Ruby toolchain — bundler version
10
+ # mismatches, missing gems, which command to use. On real tasks that burned
11
+ # several tool calls and twice sent the agent chasing toolchain errors
12
+ # (bundler `GemNotFound`, an `undefined method 'untaint'` crash from an old
13
+ # pinned bundler) instead of the user's actual request; one earlier run even
14
+ # drifted toward `gem uninstall bundler` / `rm -rf …`. This tool:
15
+ #
16
+ # - auto-detects the framework (rspec / minitest / rake) and the right
17
+ # invocation, preferring `bundle exec` when a Gemfile is present and the
18
+ # bundle is usable, falling back to the bare runner when it is not (so a
19
+ # stale lockfile degrades gracefully rather than making the model fight
20
+ # bundler),
21
+ # - returns pass/fail counts, the failing examples (name + file:line +
22
+ # short message) parsed from the runner output, and a short raw tail —
23
+ # not the full backtrace,
24
+ # - distinguishes "the suite could not even start" (toolchain error) from
25
+ # "the suite ran and N failed", via the structured `error_code`.
26
+ #
27
+ # Execution mirrors ShellTool's foreground path: own process group, SIGTERM
28
+ # on timeout/cancel, cwd = workspace root (same resolution as ruby/shell).
29
+ class TestTool < Base
30
+ DEFAULT_TIMEOUT = 300
31
+ MAX_TIMEOUT = 600
32
+ TICK = 0.05
33
+ # Lines of raw runner output to keep for context. Enough to show the
34
+ # tail of a failure dump without dragging the full backtrace into context.
35
+ RAW_TAIL_LINES = 40
36
+
37
+ def name
38
+ "run_tests"
39
+ end
40
+
41
+ def description
42
+ "Run the workspace project's test suite and return a structured result " \
43
+ "(framework, command, exit status, example/failure counts, and the " \
44
+ "failing examples with file:line and message). Auto-detects RSpec, " \
45
+ "Minitest, or a Rakefile default task; prefers `bundle exec` when a " \
46
+ "Gemfile is present and falls back to the bare runner if the bundle is " \
47
+ "broken. Optional `path` runs a single file or pattern; optional " \
48
+ "`framework` (rspec/minitest/rake) overrides detection. Use this " \
49
+ "instead of driving `shell` by hand to run tests."
50
+ end
51
+
52
+ def input_schema
53
+ {
54
+ type: "object",
55
+ properties: {
56
+ path: {
57
+ type: "string",
58
+ description: "Optional file or pattern to run a subset (e.g. " \
59
+ "'spec/models/user_spec.rb' or 'spec/models/'). " \
60
+ "Runs the whole suite when omitted."
61
+ },
62
+ framework: {
63
+ type: "string",
64
+ enum: %w[rspec minitest rake],
65
+ description: "Override framework detection. Omit to auto-detect."
66
+ },
67
+ timeout: {
68
+ type: "integer",
69
+ description: "Timeout in seconds (default #{DEFAULT_TIMEOUT}, max #{MAX_TIMEOUT})."
70
+ }
71
+ },
72
+ required: []
73
+ }
74
+ end
75
+
76
+ # Runs project code (the test suite), so gated like `ruby`: not
77
+ # destructive, but it does execute arbitrary code. :medium → asks in
78
+ # manual mode, auto-allowed in auto mode.
79
+ def risk_level
80
+ :medium
81
+ end
82
+
83
+ def call(arguments)
84
+ args = arguments.is_a?(Hash) ? arguments : {}
85
+ path = args["path"] || args[:path]
86
+ override = args["framework"] || args[:framework]
87
+ timeout = (args["timeout"] || args[:timeout] || DEFAULT_TIMEOUT).to_i
88
+ timeout = [[timeout, 1].max, MAX_TIMEOUT].min
89
+
90
+ root = resolve_workspace
91
+ return { output: "Error: cannot access workspace directory", error_code: :workspace_error } unless root
92
+
93
+ framework = (override && !override.to_s.empty? ? override.to_s : detect_framework(root))
94
+ unless framework
95
+ return { output: "Error: no test setup detected in #{root} — looked for " \
96
+ "spec/ (.rspec), test/, and a Rakefile. Pass `framework` " \
97
+ "to override, or use the shell tool for a custom command.",
98
+ error_code: :no_test_setup }
99
+ end
100
+
101
+ command = build_command(root, framework, path)
102
+ run = execute(command, root, timeout)
103
+
104
+ build_result(framework, command, run)
105
+ end
106
+
107
+ private
108
+
109
+ # Same cwd resolution as ruby_tool/shell_tool: terminal.cwd or Dir.pwd,
110
+ # fully resolved through symlinks. nil if it can't be reached.
111
+ def resolve_workspace
112
+ candidate = Rubino::Workspace.primary_root
113
+ path = File.realpath(File.expand_path(candidate))
114
+ File.directory?(path) ? path : nil
115
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
116
+ nil
117
+ end
118
+
119
+ # Detection order mirrors the issue: RSpec first (most common in gems),
120
+ # then Minitest, then a bare Rakefile default task.
121
+ def detect_framework(root)
122
+ return "rspec" if rspec?(root)
123
+ return "minitest" if minitest?(root)
124
+ return "rake" if File.exist?(File.join(root, "Rakefile"))
125
+
126
+ nil
127
+ end
128
+
129
+ def rspec?(root)
130
+ File.exist?(File.join(root, ".rspec")) ||
131
+ File.directory?(File.join(root, "spec"))
132
+ end
133
+
134
+ def minitest?(root)
135
+ return false unless File.directory?(File.join(root, "test"))
136
+
137
+ # A `test/` dir alone is the signal; rake/rails drive it. We don't try
138
+ # to grep for `require "minitest"` — too fragile across layouts.
139
+ true
140
+ end
141
+
142
+ def gemfile?(root)
143
+ File.exist?(File.join(root, "Gemfile"))
144
+ end
145
+
146
+ # Prefer `bundle exec` when a Gemfile is present AND the bundle resolves;
147
+ # otherwise fall back to the bare runner. The fallback is the whole point
148
+ # of #101: a stale/pinned lockfile must not make the model fight bundler.
149
+ def build_command(root, framework, path)
150
+ bundle = gemfile?(root) && bundle_usable?(root)
151
+ prefix = bundle ? "bundle exec " : ""
152
+
153
+ case framework
154
+ when "rspec"
155
+ target = path && !path.to_s.empty? ? " #{shellescape(path)}" : ""
156
+ "#{prefix}rspec#{target}"
157
+ when "minitest"
158
+ build_minitest_command(root, prefix, path)
159
+ when "rake"
160
+ "#{prefix}rake"
161
+ end
162
+ end
163
+
164
+ # `rake test` is the canonical entry for a Minitest project (it sets up
165
+ # $LOAD_PATH and picks up test/**). When the model wants a single file we
166
+ # can't go through rake's task, so run it with ruby -Itest -Ilib directly.
167
+ def build_minitest_command(root, prefix, path)
168
+ if path && !path.to_s.empty?
169
+ "#{prefix}ruby -Itest -Ilib #{shellescape(path)}"
170
+ elsif rails?(root)
171
+ "#{prefix}bin/rails test"
172
+ else
173
+ "#{prefix}rake test"
174
+ end
175
+ end
176
+
177
+ def rails?(root)
178
+ File.exist?(File.join(root, "bin", "rails"))
179
+ end
180
+
181
+ # Cheap, non-mutating bundle check: `bundle check` exits 0 only when the
182
+ # gems in the lockfile are installed and satisfiable. Catches the #101
183
+ # cases (version-mismatched / pinned-bundler lockfiles) before we commit
184
+ # to `bundle exec`, so we degrade to the bare runner instead of letting
185
+ # the model watch a bundler backtrace scroll by. Capped tight so a slow
186
+ # `bundle check` never dominates the call.
187
+ def bundle_usable?(root)
188
+ _, status = Open3.capture2e(
189
+ { "BUNDLE_GEMFILE" => File.join(root, "Gemfile") },
190
+ "bundle", "check",
191
+ chdir: root
192
+ )
193
+ status&.success?
194
+ rescue StandardError
195
+ # bundler not installed, or it crashed (the untaint-style failure):
196
+ # treat the bundle as unusable and fall back to the bare runner.
197
+ false
198
+ end
199
+
200
+ def shellescape(str)
201
+ require "shellwords"
202
+ Shellwords.escape(str.to_s)
203
+ end
204
+
205
+ # Foreground exec in its own process group, SIGTERM on timeout/cancel.
206
+ # Merged stdout+stderr — the runners interleave results and warnings, and
207
+ # we parse the combined stream anyway. Returns a structured run hash.
208
+ def execute(command, cwd, timeout)
209
+ require "open3"
210
+ rd, wr = IO.pipe
211
+ pid = Process.spawn(command, chdir: cwd, pgroup: true, out: wr, err: wr)
212
+ pgid = pid
213
+ wr.close
214
+
215
+ buf = +""
216
+ reader = Thread.new do
217
+ rd.each_line do |line|
218
+ buf << line
219
+ emit_chunk(line)
220
+ end
221
+ rescue IOError, Errno::EBADF
222
+ # pipe closed — process exited
223
+ ensure
224
+ rd.close unless rd.closed?
225
+ end
226
+
227
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
228
+ deadline = Time.now + timeout
229
+ status = nil
230
+
231
+ loop do
232
+ wpid, status = Process.waitpid2(pid, Process::WNOHANG)
233
+ break if wpid
234
+
235
+ if cancellation_requested?
236
+ terminate_group(pgid)
237
+ reader.join(0.5)
238
+ begin
239
+ Process.kill("KILL", -pgid)
240
+ rescue StandardError
241
+ nil
242
+ end
243
+ begin
244
+ Process.waitpid2(pid)
245
+ rescue StandardError
246
+ nil
247
+ end
248
+ return { output: buf.dup, exit_code: nil, cancelled: true, timed_out: false,
249
+ duration_ms: elapsed_ms(started) }
250
+ end
251
+
252
+ if Time.now >= deadline
253
+ terminate_group(pgid)
254
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
255
+ unless status
256
+ reader.join(2)
257
+ begin
258
+ Process.kill("KILL", -pgid)
259
+ rescue StandardError
260
+ nil
261
+ end
262
+ _, status = Process.waitpid2(pid)
263
+ end
264
+ reader.join(0.5)
265
+ return { output: buf.dup, exit_code: nil, cancelled: false, timed_out: true,
266
+ duration_ms: elapsed_ms(started) }
267
+ end
268
+
269
+ sleep TICK
270
+ end
271
+
272
+ reader.join
273
+ { output: buf, exit_code: status&.exitstatus, cancelled: false, timed_out: false,
274
+ duration_ms: elapsed_ms(started) }
275
+ rescue StandardError => e
276
+ { output: "Error launching tests: #{e.message}", exit_code: nil, cancelled: false,
277
+ timed_out: false, started_error: true, duration_ms: 0 }
278
+ ensure
279
+ rd.close if rd && !rd.closed?
280
+ end
281
+
282
+ def terminate_group(pgid)
283
+ Process.kill("TERM", -pgid)
284
+ rescue Errno::ESRCH, Errno::EPERM
285
+ # already gone / not ours
286
+ end
287
+
288
+ def elapsed_ms(started)
289
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
290
+ end
291
+
292
+ # Turns the run hash into the model-facing structured Result. Parses the
293
+ # combined output into counts + failing examples, classifies the outcome,
294
+ # and keeps a short raw tail for context.
295
+ def build_result(framework, command, run)
296
+ raw = run[:output].to_s
297
+ parsed = parse_output(framework, raw)
298
+ ran = parsed[:ran]
299
+ tail = tail_lines(raw)
300
+
301
+ outcome, error_code = classify(run, parsed)
302
+
303
+ summary = build_summary(framework, command, run, parsed, outcome)
304
+ body = [summary, "", "--- raw output (tail) ---", tail].join("\n")
305
+
306
+ {
307
+ output: body,
308
+ body: summary,
309
+ body_kind: :plain,
310
+ metrics: "#{outcome} · #{format_ms(run[:duration_ms])}",
311
+ error_code: error_code,
312
+ # Structured fields, so the executor / future contract tests can
313
+ # branch without re-parsing the text.
314
+ framework: framework,
315
+ command: command,
316
+ exit_code: run[:exit_code],
317
+ ran: ran,
318
+ examples: parsed[:examples],
319
+ failures: parsed[:failures],
320
+ failing: parsed[:failing]
321
+ }
322
+ end
323
+
324
+ # outcome label + error_code symbol. The critical distinction (#101):
325
+ # the suite NOT starting (toolchain error) vs. running with failures.
326
+ def classify(run, parsed)
327
+ return ["cancelled", :cancelled] if run[:cancelled]
328
+ return ["timeout", :timeout] if run[:timed_out]
329
+ return ["could not start", :test_runner_error] if run[:started_error] || !parsed[:ran]
330
+ return ["#{parsed[:failures]} failed", :tests_failed] if parsed[:failures].to_i.positive?
331
+ return ["nonzero exit", :exit_nonzero] if run[:exit_code] && run[:exit_code] != 0
332
+
333
+ ["passed", nil]
334
+ end
335
+
336
+ def build_summary(framework, command, run, parsed, outcome)
337
+ lines = []
338
+ lines << "framework: #{framework}"
339
+ lines << "command: #{command}"
340
+ lines << "exit: #{run[:exit_code].nil? ? "(none)" : run[:exit_code]}"
341
+ lines << "outcome: #{outcome}"
342
+ if parsed[:ran]
343
+ lines << "examples: #{parsed[:examples].nil? ? "?" : parsed[:examples]}"
344
+ lines << "failures: #{parsed[:failures].nil? ? "?" : parsed[:failures]}"
345
+ unless parsed[:failing].empty?
346
+ lines << "failing:"
347
+ parsed[:failing].each do |f|
348
+ loc = f[:location] ? " (#{f[:location]})" : ""
349
+ desc = f[:description].to_s
350
+ msg = f[:message].to_s.empty? ? "" : " — #{f[:message]}"
351
+ lines << " - #{desc}#{loc}#{msg}"
352
+ end
353
+ end
354
+ else
355
+ lines << "note: the suite did not run (toolchain/setup error) — " \
356
+ "see the raw tail below"
357
+ end
358
+ lines.join("\n")
359
+ end
360
+
361
+ def tail_lines(raw)
362
+ lines = raw.lines.map(&:chomp)
363
+ return raw.chomp if lines.size <= RAW_TAIL_LINES
364
+
365
+ ["… [#{lines.size - RAW_TAIL_LINES} earlier lines omitted] …"]
366
+ .concat(lines.last(RAW_TAIL_LINES)).join("\n")
367
+ end
368
+
369
+ def parse_output(framework, raw)
370
+ case framework
371
+ when "rspec" then parse_rspec(raw)
372
+ when "minitest" then parse_minitest(raw)
373
+ else parse_generic(raw)
374
+ end
375
+ end
376
+
377
+ # RSpec: "N examples, M failures[, K pending]" summary line, and the
378
+ # "Failures:" block with "rspec ./path:line # description".
379
+ def parse_rspec(raw)
380
+ summary = raw.match(/(\d+)\s+examples?,\s+(\d+)\s+failures?/)
381
+ return parse_generic(raw) unless summary
382
+
383
+ examples = summary[1].to_i
384
+ failures = summary[2].to_i
385
+
386
+ failing = []
387
+ # The rerun lines RSpec prints at the bottom give location +
388
+ # description; the numbered Failures: block gives the message.
389
+ messages = rspec_failure_messages(raw)
390
+ raw.scan(%r{^rspec\s+(\.?/?\S+:\d+)\s+#\s+(.+)$}).each_with_index do |(loc, desc), i|
391
+ failing << { description: desc.strip, location: loc.strip, message: messages[i] }
392
+ end
393
+
394
+ { ran: true, examples: examples, failures: failures, failing: failing }
395
+ end
396
+
397
+ # Pulls the first line of each numbered failure block in RSpec's
398
+ # "Failures:" section: " 1) Some description\n Failure/Error: ...\n
399
+ # <message>". We grab the message line(s) after Failure/Error.
400
+ def rspec_failure_messages(raw)
401
+ section = raw[/^Failures:\n(.*?)(?:\n\nFinished|\n\n\d+ examples?)/m, 1]
402
+ return [] unless section
403
+
404
+ section.split(/^\s*\d+\)\s/).reject(&:empty?).map do |block|
405
+ msg = block[%r{Failure/Error:.*?\n\s*\n?\s*(.+)}m, 1] ||
406
+ block[%r{Failure/Error:\s*(.+)}, 1]
407
+ msg.to_s.lines.first.to_s.strip
408
+ end
409
+ end
410
+
411
+ # Minitest: "N runs, M assertions, F failures, E errors, S skips".
412
+ # Failures/errors print as numbered blocks headed by
413
+ # "TestClass#test_name [file:line]:".
414
+ def parse_minitest(raw)
415
+ summary = raw.match(/(\d+)\s+runs?,\s+(\d+)\s+assertions?,\s+(\d+)\s+failures?,\s+(\d+)\s+errors?/)
416
+ return parse_generic(raw) unless summary
417
+
418
+ runs = summary[1].to_i
419
+ failures = summary[3].to_i + summary[4].to_i # failures + errors
420
+
421
+ failing = []
422
+ raw.scan(/^\s*\d+\)\s+(?:Failure|Error):\n\s*(\S+)\s*\[([^\]]+)\]:\n(.+)/).each do |name, loc, msg|
423
+ failing << { description: name.strip, location: loc.strip, message: msg.to_s.lines.first.to_s.strip }
424
+ end
425
+ # Some minitest reporters omit the "Failure:/Error:" label line.
426
+ if failing.empty?
427
+ raw.scan(/^\s*\d+\)\s+(\S+#\S+)\s*\[([^\]]+)\]:\n(.+)/).each do |name, loc, msg|
428
+ failing << { description: name.strip, location: loc.strip, message: msg.to_s.lines.first.to_s.strip }
429
+ end
430
+ end
431
+
432
+ { ran: true, examples: runs, failures: failures, failing: failing }
433
+ end
434
+
435
+ # No recognizable summary line: we can't trust counts. Treat as "ran" only
436
+ # if there's a hint the runner produced test output; otherwise leave ran
437
+ # to the exit-code classifier (started_error / nonzero) upstream.
438
+ def parse_generic(raw)
439
+ ran = raw.match?(/\d+\s+(examples?|runs?|tests?)/) ||
440
+ raw.match?(/Finished in/)
441
+ { ran: ran, examples: nil, failures: nil, failing: [] }
442
+ end
443
+
444
+ def format_ms(ms)
445
+ if ms < 1000 then "#{ms}ms"
446
+ elsif ms < 60_000 then "#{(ms / 1000.0).round(1)}s"
447
+ else
448
+ mins, rem = ms.divmod(60_000)
449
+ "#{mins}m#{(rem / 1000.0).round}s"
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Tool for managing a task/todo list during a session.
6
+ # Allows the agent to track progress on complex multi-step tasks.
7
+ class TodoTool < Base
8
+ def name
9
+ "todowrite"
10
+ end
11
+
12
+ def description
13
+ "Create and manage a structured task list for the current session. " \
14
+ "Use this to track progress on complex multi-step tasks. " \
15
+ "Tasks have content, status (pending/in_progress/completed/cancelled), and priority."
16
+ end
17
+
18
+ def input_schema
19
+ {
20
+ type: "object",
21
+ properties: {
22
+ todos: {
23
+ type: "array",
24
+ items: {
25
+ type: "object",
26
+ properties: {
27
+ content: { type: "string", description: "Brief description of the task" },
28
+ status: {
29
+ type: "string",
30
+ enum: %w[pending in_progress completed cancelled],
31
+ description: "Current task status"
32
+ },
33
+ priority: {
34
+ type: "string",
35
+ enum: %w[high medium low],
36
+ description: "Task priority level"
37
+ }
38
+ },
39
+ required: %w[content status priority]
40
+ },
41
+ description: "The complete updated todo list"
42
+ }
43
+ },
44
+ required: %w[todos]
45
+ }
46
+ end
47
+
48
+ def risk_level
49
+ :low
50
+ end
51
+
52
+ def call(arguments)
53
+ todos = arguments["todos"] || arguments[:todos]
54
+ return "Error: No todos provided" unless todos.is_a?(Array)
55
+
56
+ format_todo_summary(todos)
57
+ end
58
+
59
+ private
60
+
61
+ def format_todo_summary(todos)
62
+ completed = todos.count { |t| t["status"] == "completed" || t[:status] == "completed" }
63
+ in_progress = todos.count { |t| t["status"] == "in_progress" || t[:status] == "in_progress" }
64
+ pending = todos.count { |t| t["status"] == "pending" || t[:status] == "pending" }
65
+ cancelled = todos.count { |t| t["status"] == "cancelled" || t[:status] == "cancelled" }
66
+
67
+ lines = ["Todo list updated (#{todos.size} items):"]
68
+ lines << " Completed: #{completed}" if completed > 0
69
+ lines << " In Progress: #{in_progress}" if in_progress > 0
70
+ lines << " Pending: #{pending}" if pending > 0
71
+ lines << " Cancelled: #{cancelled}" if cancelled > 0
72
+ lines << ""
73
+
74
+ todos.each do |todo|
75
+ content = todo["content"] || todo[:content]
76
+ status = todo["status"] || todo[:status]
77
+ priority = todo["priority"] || todo[:priority]
78
+
79
+ icon = case status
80
+ when "completed" then "[x]"
81
+ when "in_progress" then "[>]"
82
+ when "cancelled" then "[-]"
83
+ else "[ ]"
84
+ end
85
+
86
+ lines << " #{icon} #{content} (#{priority})"
87
+ end
88
+
89
+ lines.join("\n")
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module Tools
8
+ # Persists tool call audit records to the database.
9
+ # Extracted from Agent::ToolExecutor to respect the separation between
10
+ # domain execution logic and storage concerns.
11
+ class ToolCallRepository
12
+ # Persists a tool call record. Failures are swallowed so that a
13
+ # database outage never causes a tool call to fail.
14
+ def record(name:, call_id:, arguments:, result:, status:, error: nil)
15
+ now = Time.now.utc.iso8601
16
+ Rubino.database.db[:tool_calls].insert(
17
+ id: call_id || SecureRandom.uuid,
18
+ session_id: result.session_id,
19
+ tool_name: name,
20
+ input_json: JSON.generate(arguments),
21
+ output: result.output,
22
+ status: status,
23
+ started_at: now,
24
+ finished_at: now,
25
+ error: error
26
+ )
27
+ rescue StandardError
28
+ # Don't fail the tool call just because audit persistence failed.
29
+ nil
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../llm/auxiliary_client"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # Delegates image-understanding to a multimodal aux model so a text-only
8
+ # primary can still "see" what the user uploaded. Implements the
9
+ # agent-as-tool semantics from the OpenAI Agents SDK: the primary stays
10
+ # in control, calls this tool with a focused question, and receives a
11
+ # structured (text) reply — no conversation handoff, no shared history.
12
+ #
13
+ # The aux model is resolved from `auxiliary.vision` in config. When the
14
+ # primary already supports vision (per Configuration#model_supports_vision?)
15
+ # AND no aux is configured, Registry hides this tool — there's no useful
16
+ # delegation to perform.
17
+ class VisionTool < Base
18
+ def name
19
+ "vision"
20
+ end
21
+
22
+ def description
23
+ "Ask a multimodal model to describe or interpret an image. " \
24
+ "Use when you need to understand visual content (charts, screenshots, " \
25
+ "diagrams, photos). Provide an optional focused question to direct the " \
26
+ "analysis; default is a full markdown description."
27
+ end
28
+
29
+ def input_schema
30
+ {
31
+ type: "object",
32
+ properties: {
33
+ file_path: {
34
+ type: "string",
35
+ description: "Absolute path to an image file (.png .jpg .jpeg .webp .gif .bmp)"
36
+ },
37
+ question: {
38
+ type: "string",
39
+ description: "Optional focused question. Default: 'Describe what you see in markdown.'"
40
+ }
41
+ },
42
+ required: %w[file_path]
43
+ }
44
+ end
45
+
46
+ def risk_level
47
+ :low
48
+ end
49
+
50
+ def call(arguments)
51
+ path = (arguments["file_path"] || arguments[:file_path]).to_s
52
+ question = (arguments["question"] || arguments[:question] ||
53
+ "Describe what you see in markdown.").to_s
54
+
55
+ return "Error: file_path is required" if path.empty?
56
+
57
+ expanded = File.expand_path(path)
58
+ return "Error: file not found: #{path}" unless File.exist?(expanded)
59
+ return "Error: not a regular file: #{path}" unless File.file?(expanded)
60
+
61
+ ext = File.extname(expanded).downcase
62
+ unless LLM::ContentBuilder::SUPPORTED_IMAGE_TYPES.include?(ext)
63
+ return "Error: unsupported image extension '#{ext}'. " \
64
+ "Supported: #{LLM::ContentBuilder::SUPPORTED_IMAGE_TYPES.join(", ")}"
65
+ end
66
+
67
+ # Pass the image through ruby_llm's native `with:` slot (image_paths),
68
+ # NOT as an OpenAI-style content array. ruby_llm's `ask` stringifies an
69
+ # array content, so the base64 bytes would reach the model as TEXT and
70
+ # it hallucinates (prod sessions 38/41: M3 saw the image perfectly when
71
+ # called directly, but got a text blob through this path). image_paths
72
+ # attaches the file as a real multimodal part — same route the primary
73
+ # uses for native vision.
74
+ response = LLM::AuxiliaryClient.new.call(
75
+ task: :vision,
76
+ messages: [{ role: "user", content: question }],
77
+ image_paths: [expanded]
78
+ )
79
+ response.content.to_s
80
+ rescue StandardError => e
81
+ "Error calling vision model: #{e.class}: #{e.message}"
82
+ end
83
+ end
84
+ end
85
+ end