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,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "attachment_downloader"
5
+ require_relative "../llm/content_builder"
6
+ require_relative "../tools/vision_tool"
7
+
8
+ module Rubino
9
+ module Run
10
+ # Runs an Agent::Runner in a background thread, persisting per-run events
11
+ # via Recorder. Returns immediately so the HTTP handler can respond 201.
12
+ #
13
+ # Per-run wiring done inside the spawned thread:
14
+ # - Recorder.attach! to mirror EventBus into EventStore + live queue.
15
+ # - A fresh ApprovalGate per run, published in GateRegistry under run_id
16
+ # so HTTP decision endpoints can resolve it.
17
+ # - UI::API instantiated with the gate and recorder so the runner can
18
+ # ask for approvals / clarifications.
19
+ # - +ensure+ block always detaches the recorder, unregisters the gate,
20
+ # and fires the +on_complete+ callback (used by Jobs::Scheduler to
21
+ # trigger webhook delivery).
22
+ #
23
+ # Metrics: +runs_total+ is incremented once per #start (tagged with
24
+ # +source+, defaulting to +"api"+); +runs_completed_total+ is incremented
25
+ # in the ensure block, tagged with the final +status+ (+completed+ or
26
+ # +failed+).
27
+ #
28
+ # Stop is cooperative via Run::Repository#stop_requested?. The worker
29
+ # spawns a short-tick watcher (#spawn_stop_watcher) that polls that flag
30
+ # and, on observing it, flips the runner's CancelToken via runner.cancel!.
31
+ # The token is the single halt mechanism: the agent loop / LLM stream
32
+ # poll it (CancelToken#check!) and raise Interrupted, which unwinds the
33
+ # turn the same way a chat Ctrl+C does. No second kill path.
34
+ class Executor
35
+ # How often the stop watcher polls the DB stop flag (seconds).
36
+ STOP_POLL_INTERVAL = 0.25
37
+ # Prompt sent to the auxiliary vision model when pre-describing an image
38
+ # for a text-only primary. Verbatim from the reference — broad
39
+ # enough that the description is useful regardless of the user's question.
40
+ VISION_ANALYSIS_PROMPT =
41
+ "Describe everything visible in this image in thorough detail. " \
42
+ "Include any text, code, data, objects, people, layout, colors, " \
43
+ "and any other notable visual information."
44
+
45
+ def initialize(repository: nil, recorder_factory: nil, vision_describer: nil)
46
+ @repository = repository || Repository.new
47
+ @recorder_factory = recorder_factory ||
48
+ lambda { |run_id:, session_id:, event_bus:|
49
+ Recorder.new(run_id: run_id, session_id: session_id, event_bus: event_bus)
50
+ }
51
+ # Callable(path) -> description String (or an "Error…" String on
52
+ # failure). Injectable so unit tests don't hit the aux model.
53
+ @vision_describer = vision_describer || method(:default_vision_describe)
54
+ end
55
+
56
+ # Parses Run row's persisted attachments_json column (a JSON array of
57
+ # URL strings as sent on the CreateRun body). Returns [] on any
58
+ # malformed input so a broken attachment list never blocks the run.
59
+ def parse_attachment_urls(attachments_json)
60
+ return [] if attachments_json.nil? || attachments_json.to_s.empty?
61
+
62
+ parsed = JSON.parse(attachments_json)
63
+ parsed.is_a?(Array) ? parsed : []
64
+ rescue JSON::ParserError
65
+ []
66
+ end
67
+
68
+ # Pre-pends an "you have these local files" header to the user input so
69
+ # the model knows the attachments are on disk and doesn't try to webfetch
70
+ # them (binaries crash webfetch — v0.2.5). Pure function (no network) —
71
+ # any vision pre-description is computed upstream and passed in via
72
+ # +descriptions+. Putting the header FIRST anchors small models (MiniMax
73
+ # in particular) — a trailing block was ignored in prod session 33.
74
+ #
75
+ # Per file, mirroring the reference image-routing logic:
76
+ # - image sent natively (primary sees pixels): a [Image attached at: …]
77
+ # handle so the model can reference it in follow-up tool calls.
78
+ # - image on a text-only primary WITH a pre-description: inline the
79
+ # description so the model has the content without having to choose
80
+ # to call a tool (the prod failure mode — M2.7 said "no image" / ran
81
+ # `shell ls`). +descriptions+ maps such paths to their aux output.
82
+ # - image on a text-only primary WITHOUT a description (aux missing or
83
+ # errored): an explicit imperative to call `vision`, not shell.
84
+ # - non-image file: generic pointer; the preamble (PDF → markitdown)
85
+ # and tool descriptions tell the model which tool fits.
86
+ def augment_input_with_attachments(input_text, paths, native_image_paths: [], descriptions: {})
87
+ return input_text.to_s if paths.nil? || paths.empty?
88
+
89
+ native = Array(native_image_paths)
90
+ user_text = input_text.to_s
91
+ if user_text.strip.empty? && paths.any? { |p| LLM::ContentBuilder.image_file?(p) }
92
+ user_text = "What do you see in this image?"
93
+ end
94
+
95
+ aux_vision = !Rubino.configuration.auxiliary_vision_config["model"].to_s.empty?
96
+ blocks = paths.filter_map do |p|
97
+ if LLM::ContentBuilder.image_file?(p) && image_by_magic?(p)
98
+ if native.include?(p)
99
+ "[Image attached at: #{p}]"
100
+ elsif descriptions[p]
101
+ "[The user attached an image. Here's what it contains:\n#{descriptions[p]}]\n" \
102
+ "[If you need a closer look, call the `vision` tool with file_path: #{p}.]"
103
+ elsif aux_vision
104
+ # Aux configured but pre-description failed: keep the on-demand
105
+ # `vision` imperative (the tool stays exposed in this case).
106
+ "[The user attached an image at #{p}. Call the `vision` tool with " \
107
+ "file_path: #{p} to see it — do not use shell/ls.]"
108
+ else
109
+ # Gap A: no native vision, no aux vision => the `vision` tool is
110
+ # HIDDEN from the model (Registry#aux_dependency_satisfied?). Do
111
+ # NOT instruct calling a hidden tool; warn instead.
112
+ cls = Attachments::Classify.call(p)
113
+ Attachments::Preamble.no_multimodal_warning(p, cls.mime || "image")
114
+ end
115
+ else
116
+ # Gap B + universal handling + MIME-spoof egress guard: classify by
117
+ # content (magic wins) and render a typed preamble. A file with an
118
+ # image extension whose magic is NOT an image (e.g. a .png-named
119
+ # zip) lands here too via #image_by_magic? above, so it is demoted
120
+ # to its real kind instead of being shipped to native/aux vision.
121
+ # Unsafe/oversize/disallowed => skip+warn.
122
+ attachment_block(p)
123
+ end
124
+ end
125
+
126
+ "[Uploaded files — already in your workspace. Do not re-fetch the URLs.]\n" \
127
+ "#{blocks.join("\n\n")}\n\n" \
128
+ "#{user_text}"
129
+ end
130
+
131
+ # True only when a file that LOOKS like an image by extension ALSO sniffs
132
+ # as a real image by content (Attachments::Classify, magic wins). Gates
133
+ # the native/aux-vision egress branch: a .png-named zip/text/binary fails
134
+ # here and is demoted to attachment_block (its real typed preamble), so a
135
+ # spoofed extension can never ship raw bytes to the native vision model or
136
+ # the EXTERNAL auxiliary vision model. The safety pipeline (lstat /
137
+ # realpath-confine / size cap) runs inside Classify, so image-extension
138
+ # files now get the same fail-closed checks as every other attachment.
139
+ def image_by_magic?(path)
140
+ cls = Attachments::Classify.call(path)
141
+ cls.safe && cls.kind == :image
142
+ end
143
+
144
+ # Classifies a non-image attachment by content (Attachments::Classify --
145
+ # magic wins, fail-closed safety pipeline) and renders the typed preamble
146
+ # (Attachments::Preamble). Returns nil to SKIP the attachment (with a
147
+ # warn) when the safety pipeline rejects it or its kind is disallowed by
148
+ # policy -- never inline/execute an unsafe file. Closes Gap B (archives /
149
+ # documents / binaries get typed guidance instead of a bare `- file:`).
150
+ def attachment_block(path)
151
+ cls = Attachments::Classify.call(path)
152
+ unless cls.safe
153
+ Rubino.logger.warn(event: "run.attachment_skipped", path: path.to_s, reason: cls.reason)
154
+ return nil
155
+ end
156
+ unless Attachments::Policy.allow_kind?(cls.kind)
157
+ Rubino.logger.warn(event: "run.attachment_skipped", path: path.to_s,
158
+ reason: "kind #{cls.kind} not in allow_kinds")
159
+ return nil
160
+ end
161
+ Attachments::Preamble.for(cls)
162
+ end
163
+
164
+ # Spawns the worker thread and returns it immediately.
165
+ # @param run [Hash] row from Run::Repository; +:id+, +:session_id+,
166
+ # +:input_text+, +:model+, +:provider+ are read.
167
+ # @param on_complete [#call, nil] invoked from the +ensure+ block with
168
+ # +run_id:+, +session_id:+, +status:+; runs even when the run failed.
169
+ # @return [Thread] the worker thread (caller typically discards it).
170
+ def start(run, on_complete: nil)
171
+ Thread.new do
172
+ run_id = run[:id]
173
+ session_id = run[:session_id]
174
+ # A FRESH bus per run is the isolation boundary: the Recorder and the
175
+ # Runner share THIS instance only, so a run's emit reaches only its own
176
+ # recorder and its detach!/off only removes its own listeners. Without
177
+ # it every run bound the process-global bus and cross-contaminated
178
+ # peers' events/output (architecture audit A1).
179
+ bus = Interaction::EventBus.new
180
+ recorder = @recorder_factory.call(run_id: run_id, session_id: session_id, event_bus: bus)
181
+ gate = ApprovalGate.new
182
+ GateRegistry.register(run_id, gate)
183
+ recorder.attach!
184
+ final_status = "completed"
185
+ stopped = false
186
+ stop_watcher = nil
187
+ ::Rubino::Metrics.counter(:runs_total, source: run[:source] || "api").increment
188
+ begin
189
+ @repository.mark_running!(run_id)
190
+ # Bind this run's gated UI as the thread-scoped Rubino.ui for the
191
+ # whole worker thread, so tools that look up the global adapter
192
+ # (QuestionTool#ask → clarify.required, TaskTool) hit THIS run's
193
+ # gate/recorder instead of the gate-less process global — without it
194
+ # the `question` tool's prompt is silently dropped and the web run
195
+ # hangs on an unanswerable question.
196
+ ui = UI::API.new(gate: gate, recorder: recorder, session_id: session_id)
197
+ runner = Agent::Runner.new(
198
+ session_id: session_id,
199
+ model_override: run[:model],
200
+ provider_override: run[:provider],
201
+ ui: ui,
202
+ event_bus: bus
203
+ )
204
+ # Bridge the cooperative HTTP stop flag to the runner's cancel
205
+ # token: poll #stop_requested? on a short tick and flip the token
206
+ # so the in-flight loop/stream unwinds via Interrupted. The flag in
207
+ # the closure lets the ensure record the run as "stopped" rather
208
+ # than "completed"/"failed".
209
+ stop_watcher = spawn_stop_watcher(run_id, runner) { stopped = true }
210
+ # Agent::Runner swallows Interrupted and StandardError internally
211
+ # and emits INTERACTION_FAILED on the bus, which Recorder maps to
212
+ # "run.failed". The lifecycle emits INTERACTION_FINISHED on the
213
+ # happy path → "run.completed". Don't re-emit either terminal
214
+ # event here or every run would broadcast two terminal frames
215
+ # (and the web UI would enqueue two title-generation jobs).
216
+ downloaded_paths = AttachmentDownloader.new.fetch_all(
217
+ run_id: run_id,
218
+ urls: parse_attachment_urls(run[:attachments_json])
219
+ )
220
+ # Emit a recorded event so SSE consumers (and post-hoc forensics)
221
+ # can confirm the augment fired and which paths the model saw.
222
+ # Only when something was actually downloaded — a plain chat with
223
+ # no upload has nothing to report, and emitting an empty event just
224
+ # rendered as noise in the timeline. Direct recorder.emit bypasses
225
+ # EventBus, same pattern as approval.required.
226
+ recorder.emit("run.attachments_downloaded", paths: downloaded_paths) if downloaded_paths.any?
227
+ # When the primary model supports vision, image files are passed
228
+ # natively (via ruby_llm `with:`) so the model can ingest the bytes
229
+ # directly. When the primary is text-only, image_paths stays empty
230
+ # and we pre-describe each image with the vision aux NOW, inlining
231
+ # the description into the prompt — so the model has the content
232
+ # without depending on choosing to call the `vision` tool (the prod
233
+ # failure mode in sessions 36/37). The tool stays exposed for
234
+ # on-demand re-inspection either way. Mirrors the reference text-mode
235
+ # _enrich_message_with_vision.
236
+ image_paths_for_native = native_image_paths(downloaded_paths)
237
+ descriptions = preprocess_images_with_vision(
238
+ downloaded_paths, image_paths_for_native, recorder
239
+ )
240
+ Rubino.with_ui(ui) do
241
+ runner.run!(
242
+ augment_input_with_attachments(
243
+ run[:input_text], downloaded_paths,
244
+ native_image_paths: image_paths_for_native,
245
+ descriptions: descriptions
246
+ ),
247
+ image_paths: image_paths_for_native
248
+ )
249
+ end
250
+ @repository.mark_completed!(run_id)
251
+ rescue Rubino::Interrupted
252
+ # Cooperative stop won the race: the watcher flipped the token and
253
+ # the loop unwound via Interrupted. Record "stopped", not "failed"
254
+ # — this was a user-requested halt, not an error. Re-raise to a
255
+ # failed terminal state only if the token flipped for some other
256
+ # reason than a stop request (shouldn't happen in the API path).
257
+ if stopped || @repository.stop_requested?(run_id)
258
+ final_status = "stopped"
259
+ @repository.mark_stopped!(run_id)
260
+ recorder.emit("run.stopped", {})
261
+ else
262
+ final_status = "failed"
263
+ safe_mark_failed(run_id, "interrupted")
264
+ safe_emit_failed(recorder, "interrupted")
265
+ end
266
+ rescue SystemExit, Interrupt, SignalException
267
+ # Process is shutting down — re-raise so systemd / Puma can drain.
268
+ # Mark the run as failed first so it isn't left stuck in "running".
269
+ final_status = "failed"
270
+ safe_mark_failed(run_id, "agent process terminated")
271
+ safe_emit_failed(recorder, "agent process terminated")
272
+ raise
273
+ rescue Exception => e # rubocop:disable Lint/RescueException
274
+ # Catch Exception (not just StandardError) — user-tool LoadError /
275
+ # SyntaxError / NoMemoryError can propagate from threads inside the
276
+ # runner via Thread#join, and without this the worker silently dies
277
+ # and the run is left as "running" forever (the recorder never sees
278
+ # INTERACTION_FAILED so the SSE stream also never gets a terminal
279
+ # frame). Emit run.failed directly via the recorder as a safety net
280
+ # in case the lifecycle didn't get a chance to.
281
+ final_status = "failed"
282
+ Rubino.logger.error(event: "run.exception", run_id: run_id, error: e.class.name, message: e.message)
283
+ safe_mark_failed(run_id, "#{e.class}: #{e.message}")
284
+ safe_emit_failed(recorder, "#{e.class}: #{e.message}")
285
+ ensure
286
+ stop_watcher&.kill
287
+ recorder.detach!
288
+ GateRegistry.unregister(run_id)
289
+ ::Rubino::Metrics.counter(:runs_completed_total, status: final_status).increment
290
+ on_complete&.call(run_id: run_id, session_id: session_id, status: final_status)
291
+ end
292
+ end
293
+ end
294
+
295
+ private
296
+
297
+ # Polls the run's stop flag on a short tick and, on observing it, flips
298
+ # the runner's CancelToken (the single halt mechanism). Yields once after
299
+ # the cancel so the caller can record that this was a stop, then exits.
300
+ # Returns the watcher Thread; the worker kills it in its ensure block.
301
+ def spawn_stop_watcher(run_id, runner)
302
+ Thread.new do
303
+ loop do
304
+ sleep STOP_POLL_INTERVAL
305
+ next unless @repository.stop_requested?(run_id)
306
+
307
+ runner.cancel!
308
+ # The CancelToken only halts the loop/stream at a poll point. If the
309
+ # worker is parked inside ApprovalGate#await (queue.pop, up to the
310
+ # configured wait bound — default 15 min) it never reaches one, so
311
+ # wake the gate too — it raises Interrupted in the awaiting thread
312
+ # and frees the worker. Without this a cancelled/abandoned approval
313
+ # holds a Solid Queue thread for the whole wait window (W1).
314
+ GateRegistry.fetch(run_id)&.cancel!
315
+ yield if block_given?
316
+ break
317
+ end
318
+ rescue StandardError => e
319
+ # A DB hiccup in the watcher must never take down the run; the worst
320
+ # case is the stop is observed a tick later or not at all.
321
+ Rubino.logger.error(event: "run.stop_watcher_error", run_id: run_id,
322
+ error: e.class.name, message: e.message)
323
+ end
324
+ end
325
+
326
+ # Returns the subset of paths that are images AND can be ingested
327
+ # natively by the current primary model. Empty when either condition
328
+ # fails — in which case the `vision` tool path takes over.
329
+ def native_image_paths(paths)
330
+ return [] if paths.nil? || paths.empty?
331
+ return [] unless Rubino.configuration.model_supports_vision?
332
+
333
+ paths.select { |p| LLM::ContentBuilder.image_file?(p) }
334
+ end
335
+
336
+ # For images NOT sent natively (text-only primary), ask the vision aux to
337
+ # describe each up-front. Returns { path => description } for the ones that
338
+ # succeeded; the augment inlines them. No-op (empty hash) when no aux
339
+ # vision model is configured — the augment then falls back to an explicit
340
+ # "call the `vision` tool" imperative instead. Emits forensic events so a
341
+ # missing/failed pre-description is visible post-hoc (same reason
342
+ # run.attachments_downloaded exists).
343
+ def preprocess_images_with_vision(paths, native, recorder)
344
+ return {} if Rubino.configuration.auxiliary_vision_config["model"].to_s.empty?
345
+
346
+ text_only_images = paths.select { |p| LLM::ContentBuilder.image_file?(p) } - Array(native)
347
+ text_only_images.each_with_object({}) do |path, acc|
348
+ result = @vision_describer.call(path).to_s
349
+ if result.start_with?("Error")
350
+ recorder&.emit("run.vision_preprocess_failed", path: path, error: result.slice(0, 300))
351
+ else
352
+ recorder&.emit("run.vision_preprocessed", path: path, chars: result.length)
353
+ acc[path] = result
354
+ end
355
+ end
356
+ end
357
+
358
+ # Default describer: routes through the same VisionTool the model can call
359
+ # on demand, so pre-description and on-demand inspection share one path.
360
+ def default_vision_describe(path)
361
+ Tools::VisionTool.new.call("file_path" => path, "question" => VISION_ANALYSIS_PROMPT)
362
+ end
363
+
364
+ # mark_failed! can itself raise (DB locked, etc). The whole point of the
365
+ # outer rescue is to leave the row in a terminal state — if even that
366
+ # fails, log and move on; the watchdog in EventsOperation will catch it.
367
+ def safe_mark_failed(run_id, message)
368
+ @repository.mark_failed!(run_id, error: message.to_s.slice(0, 500))
369
+ rescue StandardError => e
370
+ Rubino.logger.error(event: "run.mark_failed_error", run_id: run_id, error: e.class.name, message: e.message)
371
+ end
372
+
373
+ # Recorder may already be detached (race with the ensure block) — emit is
374
+ # best-effort. The DB row is the authoritative source of truth; SSE is a
375
+ # convenience.
376
+ def safe_emit_failed(recorder, message)
377
+ recorder&.emit("run.failed", error: message.to_s.slice(0, 500))
378
+ rescue StandardError => e
379
+ Rubino.logger.error(event: "run.emit_failed_error", error: e.class.name, message: e.message)
380
+ end
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Run
5
+ # Process-wide registry of ApprovalGate instances, keyed by run_id.
6
+ # Module-level state (no instance): one hash + one mutex held at the
7
+ # module singleton class.
8
+ #
9
+ # Lifecycle: Executor#start calls +register+ when a run begins and
10
+ # +unregister+ in its +ensure+ block; HTTP decision endpoints call
11
+ # +fetch+ to resolve the gate before forwarding a decision.
12
+ #
13
+ # Single-process only: the gate lives in the Ruby heap, so this does
14
+ # not survive multi-process scaling (Puma workers, forked servers).
15
+ # Decisions routed to the wrong worker silently fail #fetch.
16
+ module GateRegistry
17
+ @gates = {}
18
+ @mutex = Mutex.new
19
+
20
+ class << self
21
+ def register(run_id, gate)
22
+ @mutex.synchronize { @gates[run_id] = gate }
23
+ end
24
+
25
+ def fetch(run_id)
26
+ @mutex.synchronize { @gates[run_id] }
27
+ end
28
+
29
+ def unregister(run_id)
30
+ @mutex.synchronize { @gates.delete(run_id) }
31
+ end
32
+
33
+ def reset!
34
+ @mutex.synchronize { @gates.clear }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Run
5
+ # Bridges Interaction::EventBus to per-run persisted events. Subscribes
6
+ # to the bus, translates internal symbols to API event names via
7
+ # +EVENT_MAP+, and writes one row per emission through EventStore.
8
+ #
9
+ # +EVENT_MAP+ is the single source of truth for the internal-to-API
10
+ # event-type translation; anything not in the map is dropped on the
11
+ # floor (callers that need to bypass the bus, e.g. +approval.required+
12
+ # / +clarify.required+, must call #emit directly).
13
+ #
14
+ # Lifecycle:
15
+ # recorder = Recorder.new(run_id:, session_id:)
16
+ # recorder.attach!
17
+ # ... run loop ...
18
+ # recorder.detach!
19
+ class Recorder
20
+ EVENT_MAP = {
21
+ Interaction::Events::MODEL_STREAM => "message.delta",
22
+ Interaction::Events::MESSAGE_COMPLETED => "message.completed",
23
+ Interaction::Events::TOOL_STARTED => "tool.started",
24
+ Interaction::Events::TOOL_PROGRESS => "tool.progress",
25
+ Interaction::Events::TOOL_FINISHED => "tool.completed",
26
+ Interaction::Events::ARTIFACT_CREATED => "artifact.created",
27
+ Interaction::Events::INPUT_INJECTED => "input.injected",
28
+ Interaction::Events::SKILL_LOADED => "skill.loaded",
29
+ Interaction::Events::SUBAGENT_SPAWNED => "subagent.spawned",
30
+ Interaction::Events::SUBAGENT_COMPLETED => "subagent.completed",
31
+ Interaction::Events::SUBAGENT_FAILED => "subagent.failed",
32
+ Interaction::Events::INTERACTION_FINISHED => "run.completed",
33
+ Interaction::Events::INTERACTION_FAILED => "run.failed"
34
+ }.freeze
35
+
36
+ def initialize(run_id:, session_id:, event_bus: nil, store: nil)
37
+ @run_id = run_id
38
+ @session_id = session_id
39
+ @event_bus = event_bus || Rubino.event_bus
40
+ @store = store || EventStore.new
41
+ @subscribers = []
42
+ end
43
+
44
+ def attach!
45
+ EVENT_MAP.each do |internal_type, api_type|
46
+ handler = ->(payload) { record(api_type, payload) }
47
+ @event_bus.on(internal_type, &handler)
48
+ @subscribers << [internal_type, handler]
49
+ end
50
+ end
51
+
52
+ def detach!
53
+ @subscribers.each { |type, _| @event_bus.off(type) }
54
+ @subscribers.clear
55
+ end
56
+
57
+ # Direct emission bypassing EventBus (used for API-only events like approval.required).
58
+ def emit(api_type, payload)
59
+ record(api_type, payload)
60
+ end
61
+
62
+ private
63
+
64
+ def record(api_type, payload)
65
+ @store.append(session_id: @session_id, run_id: @run_id, type: api_type, payload: payload)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module Run
8
+ # Repository for Run CRUD. A Run is one user-input -> assistant-response
9
+ # cycle within a Session, exposed as a first-class resource over the HTTP
10
+ # API and the only persistence point for cooperative cancellation.
11
+ #
12
+ # Status transitions are driven by the executor:
13
+ # queued -> running (#mark_running!)
14
+ # -> completed (#mark_completed!)
15
+ # -> failed (#mark_failed!)
16
+ # -> stopped (#mark_stopped!)
17
+ #
18
+ # Cooperative stop pattern:
19
+ # - +POST /v1/runs/:id/stop+ calls #request_stop! which flips the
20
+ # +stop_requested+ boolean on the row.
21
+ # - The run loop is expected to poll #stop_requested? between turns
22
+ # and bail out, then call #mark_stopped!. The flag is a hint, not
23
+ # a hard kill — the worker thread keeps the CPU until it observes
24
+ # it. In the current Executor the in-loop poll is not yet wired,
25
+ # so the flag is recorded and surfaced to clients but does not
26
+ # actually halt an in-flight run; downstream agents should add the
27
+ # check inside Agent::Runner.
28
+ #
29
+ # +last_for_session+ uses a (created_at DESC, rowid DESC) tuple to
30
+ # disambiguate rows created in the same second.
31
+ class Repository
32
+ def initialize(db: nil)
33
+ @db = db || Rubino.database.db
34
+ end
35
+
36
+ def create(session_id:, input_text:, attachments: [], skills: [], model: nil, provider: nil, cron_job_id: nil)
37
+ now = Time.now.utc.iso8601
38
+ id = SecureRandom.uuid
39
+
40
+ @db[:runs].insert(
41
+ id: id,
42
+ session_id: session_id,
43
+ status: "queued",
44
+ input_text: input_text,
45
+ attachments_json: JSON.generate(attachments),
46
+ skills_json: JSON.generate(skills),
47
+ model: model,
48
+ provider: provider,
49
+ cron_job_id: cron_job_id,
50
+ stop_requested: false,
51
+ created_at: now,
52
+ updated_at: now
53
+ )
54
+ find(id)
55
+ end
56
+
57
+ def find(id)
58
+ @db[:runs].where(id: id).first
59
+ end
60
+
61
+ def list_for_session(session_id)
62
+ @db[:runs].where(session_id: session_id).order(:created_at).all
63
+ end
64
+
65
+ def last_for_session(session_id)
66
+ @db[:runs]
67
+ .where(session_id: session_id)
68
+ .order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid")))
69
+ .first
70
+ end
71
+
72
+ def mark_running!(id)
73
+ now = Time.now.utc.iso8601
74
+ @db[:runs].where(id: id).update(status: "running", started_at: now, updated_at: now)
75
+ end
76
+
77
+ def mark_completed!(id, tokens_input: 0, tokens_output: 0)
78
+ now = Time.now.utc.iso8601
79
+ @db[:runs].where(id: id).update(
80
+ status: "completed",
81
+ finished_at: now,
82
+ tokens_input: tokens_input,
83
+ tokens_output: tokens_output,
84
+ updated_at: now
85
+ )
86
+ end
87
+
88
+ def mark_failed!(id, error:)
89
+ now = Time.now.utc.iso8601
90
+ @db[:runs].where(id: id).update(status: "failed", error: error, finished_at: now, updated_at: now)
91
+ end
92
+
93
+ def mark_stopped!(id)
94
+ now = Time.now.utc.iso8601
95
+ @db[:runs].where(id: id).update(status: "stopped", finished_at: now, updated_at: now)
96
+ end
97
+
98
+ # Signals a cooperative stop. The run loop must observe this on its
99
+ # own; nothing in this class interrupts an in-flight thread.
100
+ def request_stop!(id)
101
+ @db[:runs].where(id: id).update(stop_requested: true, updated_at: Time.now.utc.iso8601)
102
+ end
103
+
104
+ def stop_requested?(id)
105
+ @db[:runs].where(id: id).get(:stop_requested) == true
106
+ end
107
+
108
+ # Cascades: deletes the run's persisted events before the run row,
109
+ # in a single transaction (FKs are not declared at the schema level).
110
+ def destroy!(id)
111
+ @db.transaction do
112
+ @db[:events].where(run_id: id).delete
113
+ @db[:runs].where(id: id).delete
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end