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,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Run
5
+ # Synchronizes async HTTP decisions (approvals, clarifications) with the
6
+ # in-thread run loop. The run loop calls #await(id) and blocks; an HTTP
7
+ # endpoint calls #decide(id, value) to unblock it. One gate per run,
8
+ # owned by Executor and published in GateRegistry.
9
+ #
10
+ # Implementation: one +Queue+ per +id+, lazily created under a mutex.
11
+ # Each id must first be issued via #register before #decide will accept
12
+ # it — this prevents a stray POST with an arbitrary or replayed
13
+ # approval_id from unblocking an awaiting call. Decided ids are
14
+ # remembered with their resolved value so duplicate POSTs are
15
+ # idempotent (same decision returned, queue not pushed twice).
16
+ #
17
+ # Id namespace is shared per run: approval ids and clarify ids are
18
+ # both UUIDs minted by UI::API and routed through the same registry
19
+ # entry.
20
+ #
21
+ # Bounded wait (W1): #await never parks on a bare, effectively-infinite
22
+ # +queue.pop+. It loops over short, interruptible +pop(timeout:)+ ticks,
23
+ # re-checking the cancelled flag and an absolute deadline each tick, so:
24
+ # * an explicit #cancel! (run stop/teardown) wakes it within one tick,
25
+ # * an abandoned approval (client closed the tab, no decision ever) is
26
+ # released at the configured deadline instead of holding the worker
27
+ # thread for 24h and exhausting the server pool.
28
+ # On deadline expiry #await returns the EXPIRED sentinel (never an
29
+ # approve) and emits +approval.expired+; UI::API maps that to a safe DENY.
30
+ class ApprovalGate
31
+ # Default human-wait bound (seconds) used only when the caller passes
32
+ # none AND no config is reachable. The real default comes from
33
+ # approvals.wait_timeout_seconds. This is a SANE bound (15 minutes), not
34
+ # the old 24h: an unanswered approval must free its worker thread in
35
+ # minutes, not a day. nil = wait forever (opt-in, discouraged on servers).
36
+ DEFAULT_TIMEOUT = 900
37
+
38
+ # How long a single interruptible +pop(timeout:)+ tick blocks before the
39
+ # loop re-checks the cancelled flag / deadline. Small enough that a
40
+ # #cancel! is observed promptly even if its sentinel push raced; large
41
+ # enough not to spin. The sentinel push in #cancel! is the fast path;
42
+ # this tick is the safety net that bounds the worst-case wake latency.
43
+ WAKE_TICK = 0.25
44
+ private_constant :WAKE_TICK
45
+
46
+ # Pushed into a pending queue by #cancel! to wake a blocked #await; the
47
+ # awaiter sees it and raises Interrupted instead of returning a decision.
48
+ # A private object so it can never collide with a real decision value.
49
+ CANCELLED = Object.new.freeze
50
+ private_constant :CANCELLED
51
+
52
+ # Returned by #await when the human-wait deadline elapses with no
53
+ # decision. A distinct, non-approve sentinel: UI::API recognizes it and
54
+ # resolves the approval to a safe DENY (never an approve) and the
55
+ # clarification to nil — the abandoned-run safe default.
56
+ EXPIRED = Object.new.freeze
57
+
58
+ def initialize
59
+ @queues = {}
60
+ @issued = {} # id => recorder (or nil) — ids the gate will accept decisions for
61
+ @decided = {} # id => decision — first-write-wins, used for idempotency
62
+ @pending = {} # id => true while a thread is blocked in #await for it
63
+ @cancelled = false # set by #cancel!; makes future/in-flight awaits raise
64
+ @mutex = Mutex.new
65
+ end
66
+
67
+ # True when at least one #await call is currently blocked waiting for a
68
+ # decision. The SSE idle watchdog consults this (via GateRegistry) so it
69
+ # never reaps a run that is legitimately parked on a human answer.
70
+ def pending?
71
+ @mutex.synchronize { @pending.any? }
72
+ end
73
+
74
+ # Marks +id+ as a valid target for a future #decide call, optionally
75
+ # binding a recorder used to emit +approval.decided+ once a decision
76
+ # lands. Must be called before #decide; otherwise #decide rejects
77
+ # the id as unknown. Idempotent: re-registering an id is a no-op.
78
+ def register(id, recorder: nil)
79
+ @mutex.synchronize do
80
+ @issued[id] = recorder unless @issued.key?(id)
81
+ end
82
+ end
83
+
84
+ # Blocks until #decide is called for +id+, returns the decision value.
85
+ # Loops over short interruptible pops so a #cancel! or the deadline wakes
86
+ # it within one WAKE_TICK rather than parking on a bare pop.
87
+ #
88
+ # @param timeout [Numeric, :config, nil] seconds before giving up.
89
+ # :config (default) reads approvals.wait_timeout_seconds; nil waits
90
+ # forever (still interruptible by #cancel!).
91
+ # @return the decision value, or EXPIRED if the deadline elapses first.
92
+ # @raise [Rubino::Interrupted] if the gate is #cancel!-ed (run stopped)
93
+ # while this call is parked, so the worker thread unwinds at once.
94
+ def await(id, timeout: :config)
95
+ timeout = configured_timeout if timeout == :config
96
+ queue = queue_for(id)
97
+ # Lose the wake-up race safely: if #cancel! already fired, raise now
98
+ # rather than park on a queue nothing will ever push to.
99
+ raise Rubino::Interrupted if mark_pending(id)
100
+
101
+ deadline = timeout && (monotonic_now + timeout)
102
+ begin
103
+ loop do
104
+ decision = pop_tick(id, queue, deadline)
105
+ next if decision.equal?(:tick) # woke on a tick boundary; re-check
106
+
107
+ raise Rubino::Interrupted if decision.equal?(CANCELLED)
108
+
109
+ return decision # a real decision, or EXPIRED on deadline
110
+ end
111
+ ensure
112
+ @mutex.synchronize do
113
+ @pending.delete(id)
114
+ @queues.delete(id)
115
+ end
116
+ end
117
+ end
118
+
119
+ # Wakes every thread currently parked in #await (and any that park later)
120
+ # so they raise Interrupted and the worker thread unwinds. Called when a
121
+ # run is cancelled/stopped while parked on a human decision — without it
122
+ # the gate's pop blocks until the deadline and holds a Solid Queue worker
123
+ # thread for the whole window. One-shot, like CancelToken: once cancelled
124
+ # the gate stays cancelled.
125
+ def cancel!
126
+ @mutex.synchronize do
127
+ @cancelled = true
128
+ @pending.each_key { |id| (@queues[id] ||= Queue.new) << CANCELLED }
129
+ end
130
+ end
131
+
132
+ # Records a decision for +id+.
133
+ # @return [:ok, :duplicate, :unknown]
134
+ # * +:ok+ — first decision for a registered id; queue pushed.
135
+ # * +:duplicate+ — id was already decided (a real decision OR an
136
+ # auto-expiry); previous value preserved, queue NOT pushed again.
137
+ # * +:unknown+ — id was never #register-ed; nothing recorded.
138
+ # On +:ok+, emits an +approval.decided+ event through the recorder
139
+ # captured at #register time (when one was provided) so the SSE
140
+ # client can confirm receipt.
141
+ def decide(id, decision)
142
+ recorder = nil
143
+ status = @mutex.synchronize do
144
+ if !@issued.key?(id)
145
+ :unknown
146
+ elsif @decided.key?(id)
147
+ :duplicate
148
+ else
149
+ @decided[id] = decision
150
+ recorder = @issued[id]
151
+ :ok
152
+ end
153
+ end
154
+
155
+ if status == :ok
156
+ queue_for(id) << decision
157
+ recorder&.emit("approval.decided", { approval_id: id, decision: decision })
158
+ end
159
+ status
160
+ end
161
+
162
+ # Decision previously resolved for +id+, or nil if none. May be the
163
+ # EXPIRED sentinel when the wait deadline elapsed before any #decide.
164
+ def decision_for(id)
165
+ @mutex.synchronize { @decided[id] }
166
+ end
167
+
168
+ private
169
+
170
+ # One interruptible wait step for +id+. Returns the popped value (a real
171
+ # decision or CANCELLED), +:tick+ when the per-tick pop simply timed out
172
+ # (loop should re-evaluate), or EXPIRED when the absolute deadline passed.
173
+ def pop_tick(id, queue, deadline)
174
+ wait = WAKE_TICK
175
+ if deadline
176
+ remaining = deadline - monotonic_now
177
+ return expire(id, queue) if remaining <= 0
178
+
179
+ wait = remaining if remaining < wait
180
+ end
181
+
182
+ value = queue.pop(timeout: wait)
183
+ return :tick if value.nil? # tick boundary: no decision yet
184
+
185
+ value
186
+ end
187
+
188
+ # Resolves +id+ to EXPIRED exactly once and announces it. Guarded by
189
+ # @decided so a #decide that landed in the same instant still wins if it
190
+ # got there first (then we return that real decision); otherwise records
191
+ # EXPIRED and emits +approval.expired+ via the recorder captured at
192
+ # #register so SSE clients observe the auto-deny.
193
+ def expire(id, queue)
194
+ recorder = nil
195
+ won = @mutex.synchronize do
196
+ if @decided.key?(id)
197
+ false
198
+ else
199
+ @decided[id] = EXPIRED
200
+ recorder = @issued[id]
201
+ true
202
+ end
203
+ end
204
+ # A real decision beat us to it — deliver it instead of EXPIRED.
205
+ return queue.pop(timeout: 0) || EXPIRED unless won
206
+
207
+ recorder&.emit("approval.expired", { approval_id: id })
208
+ EXPIRED
209
+ end
210
+
211
+ # Registers the awaiter as pending and reports whether the gate was
212
+ # already cancelled — done under the same lock as #cancel! so a cancel
213
+ # that fires concurrently either is seen here (return true → raise) or
214
+ # sees this id in @pending and pushes the sentinel. No lost wake-ups.
215
+ def mark_pending(id)
216
+ @mutex.synchronize do
217
+ @pending[id] = true
218
+ @cancelled
219
+ end
220
+ end
221
+
222
+ # The configured human-wait bound (approvals.wait_timeout_seconds).
223
+ # Falls back to DEFAULT_TIMEOUT when no configuration is reachable (unit
224
+ # tests that build a bare gate). nil means "wait forever".
225
+ def configured_timeout
226
+ cfg = Rubino.configuration if defined?(Rubino) && Rubino.respond_to?(:configuration)
227
+ return DEFAULT_TIMEOUT unless cfg.respond_to?(:approvals_wait_timeout)
228
+
229
+ cfg.approvals_wait_timeout
230
+ rescue StandardError
231
+ DEFAULT_TIMEOUT
232
+ end
233
+
234
+ def monotonic_now
235
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
236
+ end
237
+
238
+ def queue_for(id)
239
+ @mutex.synchronize { @queues[id] ||= Queue.new }
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Rubino
8
+ module Run
9
+ # Fetches the URLs passed as `attachments` on a run and saves them
10
+ # under <workspace>/uploads/<run_id>/. The runner then tells the
11
+ # model "you have these local files" instead of forcing it to do
12
+ # tool calls (webfetch was crashing on binaries — see v0.2.5 fix —
13
+ # and even when it worked the model paid context for the bytes).
14
+ #
15
+ # SSRF guard: only URLs whose host appears in attachments.allowed_hosts
16
+ # (config) or ENV["ALLOWED_FILE_URL_HOSTS"] (comma-separated) are
17
+ # fetched. Empty config + empty env = block everything. The list is
18
+ # case-insensitive and matched exactly against the URI host (no port,
19
+ # no path, no subdomain magic) so an admin knows exactly what is
20
+ # allowed without re-reading regex semantics.
21
+ class AttachmentDownloader
22
+ MAX_BYTES_PER_FILE = 50 * 1024 * 1024 # 50 MB hard cap, matches uploads
23
+ HTTP_TIMEOUT = 30
24
+
25
+ # When an HTTP client is co-located on the same host as the agent,
26
+ # attachment URLs are loopback (http://localhost:3000/...).
27
+ # These are always allowed IN ADDITION to attachments.allowed_hosts so
28
+ # the common case works out of the box without opening the guard to
29
+ # arbitrary external hosts. SSRF risk is bounded: only the local host is
30
+ # reachable, which the agent could already talk to via the shell.
31
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
32
+
33
+ def initialize(workspace_root: nil, allowed_hosts: nil)
34
+ @workspace_root = workspace_root || Rubino::Workspace.primary_root
35
+ @allowed_hosts = normalize_hosts(allowed_hosts || default_allowed_hosts)
36
+ end
37
+
38
+ # @return [Array<String>] absolute paths of successfully saved files.
39
+ def fetch_all(run_id:, urls:)
40
+ list = Array(urls).reject { |u| u.to_s.strip.empty? }
41
+ return [] if list.empty?
42
+
43
+ dir = File.join(@workspace_root, "uploads", run_id.to_s)
44
+ FileUtils.mkdir_p(dir)
45
+ list.filter_map { |url| fetch_one(dir, url) }
46
+ end
47
+
48
+ private
49
+
50
+ def fetch_one(dir, url)
51
+ uri = parse_uri(url)
52
+ return nil unless uri
53
+
54
+ unless host_allowed?(uri.host)
55
+ log_warn(url, "host #{uri.host.inspect} not in attachments.allowed_hosts")
56
+ return nil
57
+ end
58
+
59
+ filename = filename_for(uri)
60
+ path = File.join(dir, filename)
61
+
62
+ http = Net::HTTP.new(uri.host, uri.port)
63
+ http.use_ssl = (uri.scheme == "https")
64
+ http.open_timeout = HTTP_TIMEOUT
65
+ http.read_timeout = HTTP_TIMEOUT
66
+
67
+ request = Net::HTTP::Get.new(uri.request_uri)
68
+ request["Accept"] = "*/*"
69
+
70
+ saved = nil
71
+ http.request(request) do |response|
72
+ unless response.is_a?(Net::HTTPSuccess)
73
+ log_warn(url, "HTTP #{response.code}")
74
+ return nil
75
+ end
76
+
77
+ # Prefer the server-supplied filename when available — beats
78
+ # whatever the URL path happened to encode.
79
+ if (real = filename_from_content_disposition(response["content-disposition"]))
80
+ path = File.join(dir, real)
81
+ end
82
+
83
+ total = 0
84
+ File.open(path, "wb") do |f|
85
+ response.read_body do |chunk|
86
+ total += chunk.bytesize
87
+ if total > MAX_BYTES_PER_FILE
88
+ log_warn(url, "exceeded #{MAX_BYTES_PER_FILE} bytes, aborted")
89
+ f.close
90
+ File.delete(path) if File.exist?(path)
91
+ return nil
92
+ end
93
+ f.write(chunk)
94
+ end
95
+ end
96
+ saved = path
97
+ end
98
+
99
+ saved
100
+ rescue StandardError => e
101
+ log_warn(url, "#{e.class}: #{e.message}")
102
+ nil
103
+ end
104
+
105
+ def default_allowed_hosts
106
+ cfg = Array(Rubino.configuration&.dig("attachments", "allowed_hosts"))
107
+ env = ENV["ALLOWED_FILE_URL_HOSTS"].to_s.split(",").map(&:strip).reject(&:empty?)
108
+ cfg + env
109
+ end
110
+
111
+ def normalize_hosts(list)
112
+ Array(list).map { |h| h.to_s.strip.downcase }.reject(&:empty?).to_set
113
+ end
114
+
115
+ def host_allowed?(host)
116
+ # URI#host wraps IPv6 literals in brackets (`[::1]`); strip them so
117
+ # the comparison against LOOPBACK_HOSTS matches.
118
+ normalized = host.to_s.downcase.delete_prefix("[").delete_suffix("]")
119
+ return false if normalized.empty?
120
+
121
+ LOOPBACK_HOSTS.include?(normalized) || @allowed_hosts.include?(normalized)
122
+ end
123
+
124
+ def parse_uri(url)
125
+ uri = URI.parse(url.to_s)
126
+ return nil unless %w[http https].include?(uri.scheme)
127
+ return nil if uri.host.to_s.empty?
128
+
129
+ uri
130
+ rescue URI::InvalidURIError
131
+ nil
132
+ end
133
+
134
+ def filename_for(uri)
135
+ raw = uri.path.to_s
136
+ base = raw.empty? ? "attachment" : File.basename(raw)
137
+ sanitize_filename(base)
138
+ end
139
+
140
+ # `Content-Disposition: attachment; filename="foo.pdf"` or
141
+ # `filename*=UTF-8''foo%20bar.pdf`. We extract whichever is present.
142
+ def filename_from_content_disposition(header)
143
+ return nil if header.nil? || header.empty?
144
+
145
+ if (m = header.match(/filename\*=UTF-8''([^;]+)/i))
146
+ decoded = URI.decode_www_form_component(m[1])
147
+ return sanitize_filename(decoded)
148
+ end
149
+ if (m = header.match(/filename="?([^";]+)"?/i))
150
+ return sanitize_filename(m[1])
151
+ end
152
+
153
+ nil
154
+ end
155
+
156
+ def sanitize_filename(name)
157
+ cleaned = name.to_s.tr("\\/", "_").gsub(/[^A-Za-z0-9._-]/, "_")
158
+ cleaned.empty? ? "attachment" : cleaned[-200..] || cleaned
159
+ end
160
+
161
+ def log_warn(url, reason)
162
+ Rubino.logger&.warn(event: "attachment.fetch_failed", url: url, reason: reason)
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module Run
8
+ # Persists per-run events for SSE replay (Last-Event-ID) and audit.
9
+ #
10
+ # +seq+ is monotonic per +session_id+ (computed under a transaction as
11
+ # +max(seq) + 1+) so a single Session can stream across multiple Runs
12
+ # without seq collisions; SSE handlers send +seq+ as the event id and
13
+ # clients resume with +after_seq+.
14
+ #
15
+ # Reads order primarily by +seq+; +#for_run+ inherits that ordering.
16
+ # When two inserts land in the same wall-clock second, the
17
+ # +(created_at, rowid)+ tuple is the implicit tiebreaker for any
18
+ # consumer scanning by timestamp (Repository#last_for_session uses
19
+ # the same trick).
20
+ class EventStore
21
+ def initialize(db: nil)
22
+ @db = db || Rubino.database.db
23
+ end
24
+
25
+ def append(session_id:, run_id:, type:, payload:)
26
+ @db.transaction do
27
+ next_seq = (@db[:events].where(session_id: session_id).max(:seq) || 0) + 1
28
+ row = {
29
+ id: SecureRandom.uuid,
30
+ session_id: session_id,
31
+ run_id: run_id,
32
+ type: type.to_s,
33
+ payload_json: JSON.generate(scrub_for_json(payload)),
34
+ seq: next_seq,
35
+ created_at: Time.now.utc.iso8601
36
+ }
37
+ @db[:events].insert(row)
38
+ row
39
+ end
40
+ end
41
+
42
+ # Recursively replaces invalid UTF-8 bytes so JSON.generate never raises
43
+ # JSON::GeneratorError on the event boundary. A tool that returns binary
44
+ # data (e.g. ReadTool on a misdetected PDF) would otherwise blow up here,
45
+ # propagate out of emit_finished, and kill the entire run — the model
46
+ # would never receive a tool error result and couldn't recover.
47
+ def scrub_for_json(value)
48
+ case value
49
+ when String
50
+ if value.encoding == Encoding::UTF_8
51
+ value.valid_encoding? ? value : value.scrub("?")
52
+ else
53
+ value.dup.force_encoding(Encoding::UTF_8).scrub("?")
54
+ end
55
+ when Hash then value.transform_values { |v| scrub_for_json(v) }
56
+ when Array then value.map { |v| scrub_for_json(v) }
57
+ else value
58
+ end
59
+ end
60
+
61
+ # @param after_seq [Integer, nil] when given, returns only events with
62
+ # +seq > after_seq+ (used to honour SSE Last-Event-ID on reconnect).
63
+ def for_run(run_id, after_seq: nil)
64
+ ds = @db[:events].where(run_id: run_id).order(:seq)
65
+ ds = ds.where { seq > after_seq } if after_seq
66
+ ds.all
67
+ end
68
+
69
+ def last_seq_for_session(session_id)
70
+ @db[:events].where(session_id: session_id).max(:seq) || 0
71
+ end
72
+ end
73
+ end
74
+ end