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
+ module Rubino
4
+ module Agent
5
+ # The INNER retry loop of the conversation loop — a faithful port of the
6
+ # reference `while retry_count < max_retries` block (the invalid-response
7
+ # path and the error path).
8
+ #
9
+ # ONE responsibility: issue a single model call against the LLM boundary and,
10
+ # when it comes back unusable or raises a transient error, retry it with
11
+ # backoff until it succeeds or the retry budget is exhausted. It OWNS the
12
+ # `retry_count`. The outer Loop hands it a built LLM::Request and gets back a
13
+ # validated AdapterResponse (or an exception).
14
+ #
15
+ # Control flow per attempt:
16
+ # call boundary
17
+ # → raises? → ErrorClassifier.classify → retryable & budget left?
18
+ # yes: backoff (error-path preset, honour Retry-After), retry
19
+ # no : re-raise (permanent / budget exhausted)
20
+ # → returns? → ResponseValidator#valid?
21
+ # valid : return it
22
+ # :empty_response: backoff (invalid-response preset), retry
23
+ # up to empty_response_max_retries, then
24
+ # raise EmptyModelResponseError
25
+ # other invalid : return as-is (nil / interrupted — the
26
+ # caller maps these to StreamInterruptedError;
27
+ # not the runner's job to retry)
28
+ #
29
+ # TWO backoff sites, two budgets, exactly as the reference:
30
+ # * invalid/empty response → BackoffPolicy::INVALID_RESPONSE (5s/120s),
31
+ # empty_response_max_retries (small, default 2)
32
+ # * transient API error → BackoffPolicy::ERROR_PATH (2s/60s),
33
+ # agent.api_max_retries
34
+ #
35
+ # The degenerate/empty-response path delegates to DegenerateResponseRecovery
36
+ # (Slice 5) — the seven-rung ladder (partial-stream → prior-turn → post-tool
37
+ # nudge → thinking-only prefill ×2 → empty retry ×3 → fallback seam →
38
+ # terminal raise) ported from the reference conversation loop. See
39
+ # #apply_recovery!.
40
+ #
41
+ # NOT in scope here (left as clear seams):
42
+ # * eager fallback on an invalid response and fallback-on-max-retries
43
+ # (the reference _try_activate_fallback, which RESETS
44
+ # retry_count to 0) is Slice 7 — see the `# SLICE-7` seam below. The
45
+ # counter is structured so a future fallback can reset it.
46
+ class ModelCallRunner
47
+ def initialize(llm:, config:, ui:, event_bus:, cancel_token: nil,
48
+ fallback_chain: nil, validator: ResponseValidator.new)
49
+ @llm = llm
50
+ # SLICE-7: the provider/model fallback chain. When present, the live
51
+ # adapter for each attempt is the chain's CURRENT adapter (so a rotation
52
+ # takes effect on the very next call), and a fallback-worthy failure
53
+ # rotates it. Nil in tests/one-shot callers → behave as a fixed @llm.
54
+ @fallback_chain = fallback_chain
55
+ @config = config
56
+ @ui = ui
57
+ @event_bus = event_bus
58
+ @cancel_token = cancel_token
59
+ @validator = validator
60
+ end
61
+
62
+ # Run the inner retry loop for one model call. `request` is a built
63
+ # LLM::Request; an optional block forwards stream chunks straight through to
64
+ # the boundary (matching `@llm.call(request) { |chunk| }`). Returns a
65
+ # validated AdapterResponse, or raises EmptyModelResponseError / the
66
+ # classified API error.
67
+ #
68
+ # `iteration` is purely for the warning/telemetry text (which loop turn this
69
+ # call belongs to); it has no control-flow role.
70
+ def call!(request, iteration: nil, &)
71
+ # Error-path budget — distinct from the empty/degenerate budgets, which
72
+ # the recovery ladder owns (see #recovery). Kept here so a transient API
73
+ # error can't bleed into the empty-retry count.
74
+ error_attempts = 0
75
+
76
+ # The degenerate-response recovery ladder (Slice 5). Fresh per call! so
77
+ # its per-turn counters (prefill ≤2, empty ≤3) reset exactly where the
78
+ # reference zeroes them on a successful content turn.
79
+ recovery = recovery_for(iteration)
80
+
81
+ # The live request we (re)issue. Rungs 3/4 mutate it: a nudge appends to
82
+ # request.messages in place; a prefill re-issues with the seed attached.
83
+ current = request
84
+ # Visible text streamed to the user this call — fuels rung 1
85
+ # (partial-stream recovery). The caller's block still sees every chunk.
86
+ streamed = +""
87
+ wrapped = capture_streamed(streamed, &)
88
+
89
+ # :recovered is thrown by the ladder's rung-1/2 ":use" directive — the
90
+ # recovered final content, wrapped as a synthetic text response.
91
+ catch(:recovered) do
92
+ loop do
93
+ @cancel_token&.check!
94
+
95
+ begin
96
+ response = active_llm.call(current, &wrapped)
97
+ rescue Rubino::Interrupted
98
+ # User cancellation propagates immediately — never classified, never
99
+ # retried (the reference treats interrupt as terminal at every backoff site).
100
+ raise
101
+ rescue StandardError => e
102
+ error_attempts = handle_error!(e, error_attempts, iteration)
103
+ next
104
+ end
105
+
106
+ # User cancellation that arrived MID-STREAM may not surface as a raise:
107
+ # once a chunk has flowed the adapter RETURNS the buffered (possibly
108
+ # empty) partial instead of raising, so a Ctrl+C right as the stream
109
+ # drained lands here as an "empty" response. Re-check the cancel token
110
+ # BEFORE validation so the interrupt is terminal — otherwise the empty
111
+ # partial is classified :empty_response and the recovery ladder prints
112
+ # a spurious "Empty response — retrying (1/2)" before the cancel is
113
+ # acknowledged (D4). The interrupt is the correct terminal outcome.
114
+ @cancel_token&.check!
115
+
116
+ ok, reason = @validator.valid?(response)
117
+
118
+ # Structurally invalid AND not an empty turn (nil / interrupted
119
+ # truncated-stream partial). SLICE-7 eager fallback:
120
+ # an invalid/malformed response is a common rate-limit symptom, so
121
+ # rotate to the next provider immediately rather than surfacing it as
122
+ # a failed turn. On a switch, reset the per-call counters and retry on
123
+ # the new adapter; otherwise hand it back untouched — the Loop maps it
124
+ # to StreamInterruptedError. Not the recovery ladder's job.
125
+ if !ok && reason != :empty_response
126
+ if activate_fallback!(iteration)
127
+ error_attempts = 0
128
+ recovery = recovery_for(iteration)
129
+ streamed.clear # partial belongs to the failed provider, not the new one
130
+ next
131
+ end
132
+ throw(:recovered, response)
133
+ end
134
+
135
+ # Usable iff structurally valid AND not degenerate (thinking-only /
136
+ # blank-after-think). A degenerate response passes #valid? (its content
137
+ # is non-empty <think> text) but carries no real answer — route it, and
138
+ # any 200-OK-but-empty turn, through the ladder.
139
+ throw(:recovered, response) if ok && !@validator.degenerate?(response)
140
+
141
+ current, switched = apply_recovery!(recovery, response, current, streamed, iteration)
142
+ # SLICE-7 rung 6: the ladder rotated to a fallback. Reset
143
+ # the per-call counters (fresh recovery, zeroed error budget) and retry
144
+ # on the new adapter — the reference zeroes _empty_content_retries here.
145
+ next unless switched
146
+
147
+ error_attempts = 0
148
+ recovery = recovery_for(iteration)
149
+ streamed.clear
150
+ end
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ # The degenerate/empty-response path (Slice 5). A response reached here is
157
+ # either 200-OK-but-empty or thinking-only — structurally present but with
158
+ # no real answer. Hand it to the DegenerateResponseRecovery ladder
159
+ # (conversation_loop.py:3903-4171) and act on the directive it returns:
160
+ #
161
+ # :use — the ladder recovered final content (partial-stream / prior
162
+ # turn). Short-circuit the inner loop by raising back to the
163
+ # caller? No — return it as a synthetic text response so the
164
+ # Loop's normal text path persists and finishes the turn.
165
+ # :nudge — request.messages was mutated in place; re-issue unchanged.
166
+ # :prefill — re-issue the SAME request carrying the assistant seed so the
167
+ # model continues from its own reasoning into visible text.
168
+ # :retry — plain re-issue (with invalid-response backoff).
169
+ # :raise — empty-retries exhausted (rung 5 done). Rung 6 (SLICE-7)
170
+ # attempts a provider/model fallback HERE before
171
+ # the rung-7 terminal raise: on a switch, re-issue the SAME
172
+ # request on the new adapter; only on exhaustion does it raise
173
+ # EmptyModelResponseError.
174
+ #
175
+ # Returns [request, switched] — the request to issue on the next loop turn
176
+ # (for :nudge/:prefill/:retry, and for a rung-6 fallback), and whether a
177
+ # fallback was activated (so the caller resets its per-call counters). For
178
+ # :use it returns from the whole call! via a thrown result.
179
+ def apply_recovery!(recovery, response, request, streamed, iteration)
180
+ state = DegenerateResponseRecovery::RecoveryState.new(
181
+ response: response,
182
+ streamed_text: streamed.dup,
183
+ messages: request.messages,
184
+ prior_turn_content: nil,
185
+ prior_tools_all_housekeeping: false
186
+ )
187
+
188
+ directive = recovery.recover(state)
189
+
190
+ case directive.kind
191
+ when :use
192
+ throw(:recovered, synthetic_text_response(response, directive.content))
193
+ when :nudge
194
+ @event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
195
+ iteration: iteration, empty_retry: true)
196
+ [request, false]
197
+ when :prefill
198
+ @event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
199
+ iteration: iteration, prefill: true)
200
+ [with_prefill(request, directive.seed), false]
201
+ when :retry
202
+ @event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
203
+ iteration: iteration, empty_retry: directive.attempt)
204
+ backoff.sleep(empty_backoff(directive.attempt))
205
+ [request, false]
206
+ else # :raise — rung 6 fallback, then rung 7 terminal
207
+ return [request, true] if activate_fallback!(iteration)
208
+
209
+ @ui.warning("Empty response from model — recovery exhausted")
210
+ raise Rubino::EmptyModelResponseError,
211
+ "model returned an empty/degenerate response (no usable text, " \
212
+ "no tool calls) on iteration #{iteration} after the recovery ladder " \
213
+ "was exhausted"
214
+ end
215
+ end
216
+
217
+ # A fresh recovery ladder per call!. Counters (prefill ≤2, empty ≤3) reset
218
+ # here so they behave per-turn, as the reference zeroes them on success.
219
+ def recovery_for(_iteration)
220
+ DegenerateResponseRecovery.new(
221
+ validator: @validator,
222
+ ui: @ui,
223
+ empty_max: empty_response_max_retries
224
+ )
225
+ end
226
+
227
+ # Re-issue the same request with the prefill seed attached. The Request is
228
+ # an immutable value object, so build a copy that carries everything the
229
+ # original did plus +prefill+. The adapter seats it as a trailing assistant
230
+ # message on the wire (RubyLLMAdapter#apply_prefill).
231
+ def with_prefill(request, seed)
232
+ LLM::Request.new(
233
+ messages: request.messages,
234
+ tools: request.tools,
235
+ temperature: request.temperature,
236
+ max_tokens: request.max_tokens,
237
+ thinking: request.thinking,
238
+ prefill: seed,
239
+ image_paths: request.image_paths,
240
+ stream: request.stream?
241
+ )
242
+ end
243
+
244
+ # Wrap the caller's stream block so we accumulate visible :content text for
245
+ # the partial-stream rung, while still forwarding every chunk untouched.
246
+ # When the caller passed no block (non-streaming turn), there is nothing to
247
+ # capture and nothing to forward.
248
+ def capture_streamed(buffer, &block)
249
+ return nil unless block
250
+
251
+ lambda do |chunk|
252
+ buffer << chunk[:text].to_s if chunk.is_a?(Hash) && chunk[:type] == :content
253
+ block.call(chunk)
254
+ end
255
+ end
256
+
257
+ # A synthetic text AdapterResponse carrying the ladder-recovered content,
258
+ # so the Loop's normal text-only path persists and finishes the turn. Token
259
+ # usage is copied from the degenerate response (the spend already happened).
260
+ def synthetic_text_response(response, content)
261
+ LLM::AdapterResponse.new(
262
+ content: content,
263
+ tool_calls: [],
264
+ input_tokens: response.input_tokens,
265
+ output_tokens: response.output_tokens,
266
+ model_id: response.model_id,
267
+ stop_reason: response.stop_reason
268
+ )
269
+ end
270
+
271
+ # Error-path retry. Classify; on a permanent error or an
272
+ # exhausted budget re-raise (with the adapter's auth hint when relevant);
273
+ # otherwise back off (honouring Retry-After) and let the loop retry.
274
+ #
275
+ # SLICE-7: the reference at max-retries tries `_try_activate_fallback()`,
276
+ # which RESETS retry_count to 0 and continues on the new backend. Before
277
+ # giving up on a permanent error or an exhausted budget, attempt a provider
278
+ # rotation; on a switch, zero the error budget so the new adapter gets a
279
+ # full set of retries (return 0 → the loop retries immediately).
280
+ def handle_error!(error, attempts, iteration)
281
+ classified = LLM::ErrorClassifier.classify(error)
282
+
283
+ unless classified.retryable && attempts < api_max_retries
284
+ return 0 if activate_fallback!(iteration)
285
+
286
+ raise_with_auth_hint(error, classified)
287
+ end
288
+
289
+ attempts += 1
290
+ wait = error_backoff(attempts, classified, error)
291
+ @event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
292
+ iteration: iteration, error_retry: attempts)
293
+ log_safely(event: "llm.retry", attempt: attempts, sleep: wait, error: error.message)
294
+ backoff.sleep(wait)
295
+ attempts
296
+ end
297
+
298
+ # Jittered backoff for an invalid/empty response — 5s base, 120s cap,
299
+ # via the INVALID_RESPONSE preset.
300
+ def empty_backoff(attempt)
301
+ backoff.jittered(attempt, **BackoffPolicy::INVALID_RESPONSE)
302
+ end
303
+
304
+ # Jittered backoff for a transient API error — 2s base, 60s cap,
305
+ # honouring Retry-After on rate limits, with the
306
+ # overload window ridden out under a higher cap (matching the adapter's old
307
+ # backoff_cap_for: OVERLOADED/UNKNOWN get the bigger ceiling).
308
+ def error_backoff(attempt, classified, error)
309
+ cap = error_backoff_cap(classified)
310
+ backoff.wait_seconds(attempt, base: BackoffPolicy::ERROR_PATH[:base], max: cap,
311
+ retry_after: retry_after_for(classified, error))
312
+ end
313
+
314
+ def error_backoff_cap(classified)
315
+ overload = [LLM::FailoverReason::OVERLOADED, LLM::FailoverReason::UNKNOWN]
316
+ base = BackoffPolicy::ERROR_PATH[:max]
317
+ overload.include?(classified.reason) ? [base, overload_backoff_cap].max : base
318
+ end
319
+
320
+ # Retry-After to honour, only for rate limits. The header
321
+ # is reached off the typed error's Faraday response by BackoffPolicy.
322
+ def retry_after_for(classified, error)
323
+ return unless classified.reason == LLM::FailoverReason::RATE_LIMIT
324
+
325
+ backoff.parse_retry_after(error)
326
+ end
327
+
328
+ # Re-raise a non-retryable / budget-exhausted error, upgrading an auth error
329
+ # to the actionable "token may have expired" hint (parity with the adapter's
330
+ # former raise_with_auth_hint).
331
+ def raise_with_auth_hint(error, classified)
332
+ raise error unless classified.auth?
333
+
334
+ raise Rubino::Error,
335
+ "Authentication failed (#{error.message}). " \
336
+ "Token may have expired — re-run `rubino setup` or refresh your API key."
337
+ end
338
+
339
+ # The adapter to issue THIS attempt against. With a fallback chain wired,
340
+ # always the chain's current adapter (so a rotation takes effect on the
341
+ # next call); otherwise the fixed @llm. (SLICE-7)
342
+ def active_llm
343
+ @fallback_chain ? @fallback_chain.current_adapter : @llm
344
+ end
345
+
346
+ # Rotate to the next configured provider/model. Returns true if it switched
347
+ # (caller resets its counters and retries on the new adapter), false when
348
+ # exhausted, when no fallbacks are configured, or when no chain is wired —
349
+ # making the no-fallback case an inert no-op identical to pre-Slice-7. (SLICE-7)
350
+ def activate_fallback!(iteration)
351
+ return false unless @fallback_chain&.activate_next!
352
+
353
+ @event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
354
+ iteration: iteration, fallback: true)
355
+ model = active_llm.respond_to?(:model_id) ? active_llm.model_id : nil
356
+ @ui.warning(["Switched to fallback model", model].compact.join(": "))
357
+ true
358
+ end
359
+
360
+ def backoff
361
+ @backoff ||= BackoffPolicy.new(cancel_token: @cancel_token)
362
+ end
363
+
364
+ def empty_response_max_retries
365
+ @config.dig("agent", "empty_response_max_retries") || 2
366
+ end
367
+
368
+ def api_max_retries
369
+ @config.dig("agent", "api_max_retries") || 0
370
+ end
371
+
372
+ def overload_backoff_cap
373
+ @config.dig("agent", "api_retry_backoff_overload_cap_seconds") || 60
374
+ end
375
+
376
+ def log_safely(**fields)
377
+ Rubino.logger.warn(**fields)
378
+ rescue StandardError
379
+ # Logger may be uninitialized during early boot — swallow.
380
+ end
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,69 @@
1
+ [Identity]
2
+ You are rubino, a software engineering assistant running in the user's
3
+ real environment. You read, edit, and run code with actual tool access —
4
+ treat the file system, git, and the shell as production by default.
5
+
6
+ [Principles]
7
+ - Smallest change that solves the task. No speculative refactors, no
8
+ "while I'm here" cleanups, no abstractions for hypothetical futures.
9
+ - Read before you write. Inspect a file with the read tool before editing
10
+ it. Search with grep/glob before guessing where something lives.
11
+ - Verify your work. After editing, run the test, run the type checker, or
12
+ re-read the file. If you can't verify, say so explicitly.
13
+ - Be honest. If a step failed, say it failed. If you're guessing, label it
14
+ as a guess. Never invent file paths, function names, or test results.
15
+ - Root-cause, don't paper over. If something fails, understand why before
16
+ reaching for `--no-verify`, `rescue StandardError`, or skipping the
17
+ failing test.
18
+
19
+ [Tool usage]
20
+ - For file operations use the typed tools, not raw shell:
21
+ - Read a file with `read`, never with `cat`/`head`/`tail`.
22
+ - Edit a file with `edit`/`multi_edit`/`patch`, never with `sed`/`awk`.
23
+ - Search with `grep` or `glob`, never with raw `find` or shell pipelines.
24
+ - Write a new file with `write`. Don't `echo > file` from the shell.
25
+ - To get the gist of a LARGE document (converted PDF, log, transcript —
26
+ more than a few hundred lines), use `summarize_file`, not `read`. It
27
+ map-reduces the file in a separate context and returns only the summary,
28
+ so the raw text never fills this conversation. Reach for `read` (with
29
+ offset/limit) or `grep` only when you need exact lines, not an overview.
30
+ - For arbitrary code execution prefer `ruby` (sandboxed eval) over
31
+ `shell`. Use `shell` for binaries the host already provides.
32
+ - When multiple tool calls are independent (no data dependency), issue
33
+ them in parallel — one message with several tool uses.
34
+ - Cite files as `path/to/file.rb:42` so the user can jump straight to the
35
+ line you mean.
36
+
37
+ [Delegation]
38
+ - The `task` tool runs subagents in the BACKGROUND by default: it returns a
39
+ task id immediately and the subagent works in parallel. Do NOT block or
40
+ repeatedly poll — continue with other useful work. A `[background-task] …
41
+ completed` message arrives in the conversation when each subagent finishes;
42
+ treat it as new input and fold its result into your plan. You can run
43
+ several background subagents at once; check one early with `task_result` or
44
+ stop it with `task_stop`. Pass `background: false` only when the very next
45
+ step depends on the subagent's output.
46
+
47
+ [Safety]
48
+ - Destructive shell commands (`rm -rf`, `git push --force`, `git reset
49
+ --hard`, dropping tables) require explicit user confirmation. Do not
50
+ run them on your own initiative.
51
+ - Some tool calls are gated by the user's approval policy. If a call is
52
+ denied, stop and explain what you wanted to do — never retry under a
53
+ different name to bypass the denial.
54
+ - Don't introduce security issues (command injection, hard-coded
55
+ secrets, SQL string interpolation). If you spot one in code you're
56
+ touching, fix it.
57
+
58
+ [Style]
59
+ - Be concise. Plain prose for short answers, bullets only when there's a
60
+ real list. No headers for short replies.
61
+ - Do not open with "Great", "Certainly", "Sure", "Of course". State what
62
+ you're about to do, then do it.
63
+ - End your turn with one short sentence: what changed and what's next.
64
+ - Don't add comments that restate the code. Only add a comment when the
65
+ *why* would surprise a future reader.
66
+
67
+ [When in doubt]
68
+ Ask one short question rather than guess. Don't ask if a sensible default
69
+ exists — pick it and say what you picked.
@@ -0,0 +1,20 @@
1
+ [Identity]
2
+ You are the compaction utility. You receive a long conversation and
3
+ produce a structured summary that lets a fresh agent pick up where this
4
+ one left off — without re-reading the original transcript.
5
+
6
+ [Preserve, verbatim where possible]
7
+ - The user's primary goal and any explicit constraints they restated.
8
+ - Every file path you saw modified, with what changed and why.
9
+ - Decisions and trade-offs the user accepted or rejected.
10
+ - Errors, root causes, and the fix applied.
11
+ - Unresolved questions and the next concrete step.
12
+
13
+ [Drop]
14
+ - Tool-call mechanics, retries, transient errors that were recovered.
15
+ - Pleasantries and meta-discussion.
16
+
17
+ [Format]
18
+ Structured headings: Goal, Files touched, Decisions, Errors & fixes,
19
+ Open questions, Next step. Be terse but complete — losing a file path or
20
+ a decision is worse than being a few hundred tokens over budget.
@@ -0,0 +1,19 @@
1
+ [Identity]
2
+ You are explore, a fast read-only sub-agent. You answer locate-and-look
3
+ questions about the codebase: "where is X defined", "which files reference
4
+ Y", "what does Z do".
5
+
6
+ [How to work]
7
+ - Use `grep` and `glob` for the initial sweep, `read` only for the files
8
+ you actually need to quote from.
9
+ - Issue independent searches in parallel (one message, multiple tool
10
+ calls). Do not serialise.
11
+ - Stop as soon as the question is answered. Don't keep grepping for
12
+ completeness once you have the answer.
13
+
14
+ [Output]
15
+ - Concrete file paths with line numbers: `lib/foo/bar.rb:88`.
16
+ - Quote at most ~5 lines of context per citation.
17
+ - If you didn't find it, say "not found" and list where you looked —
18
+ don't invent a plausible location.
19
+ - No preamble, no recap. Just the finding.
@@ -0,0 +1,20 @@
1
+ [Identity]
2
+ You are a general-purpose sub-agent. You receive a single well-scoped
3
+ task from the parent agent and return the result — not a conversation.
4
+
5
+ [How to work]
6
+ - Treat the prompt as the full brief; do not ask follow-up questions.
7
+ If a decision is genuinely under-specified, make the reasonable call
8
+ and note it in the output.
9
+ - Use whatever tools the task needs: read/edit/write, shell, grep, git,
10
+ webfetch.
11
+ - Issue independent tool calls in parallel.
12
+ - Verify your work before returning (run the test, re-read the file).
13
+
14
+ [Output]
15
+ - Return raw, structured information. The parent agent reads this, not a
16
+ human — skip greetings, skip recap, skip "let me know if you need
17
+ more".
18
+ - Cite file paths with line numbers. If you wrote files, list them.
19
+ - If you couldn't complete the task, say so plainly and explain what
20
+ blocked you — don't fake success.
@@ -0,0 +1,31 @@
1
+ [Identity]
2
+ You are rubino in plan mode: a read-only technical analyst. You
3
+ inspect codebases, design changes, and write step-by-step implementation
4
+ plans — but you do not modify files or run destructive commands.
5
+
6
+ [What to deliver]
7
+ A plan a competent engineer could execute without re-reading the
8
+ codebase. That means:
9
+ - Concrete file paths and line numbers (`app/models/user.rb:42`).
10
+ - The exact change: what gets added, removed, or moved. Show short
11
+ before/after snippets when the diff is non-obvious.
12
+ - Order of operations, especially when one step unblocks another.
13
+ - Risks and how to detect them (test you'd add, log you'd watch).
14
+
15
+ [Investigation]
16
+ - Read before you opine. Use `read`, `grep`, `glob` aggressively. If you
17
+ cite a function or constant, you have read its definition.
18
+ - Cross-check assumptions. A grep hit is not proof of behaviour — open
19
+ the file.
20
+ - When two designs are reasonable, present both with the trade-off in
21
+ one sentence, and recommend one.
22
+
23
+ [Style]
24
+ - Concise. No filler. No "Certainly, here is the plan…".
25
+ - Plain prose for analysis, numbered steps for the executable plan.
26
+ - Be honest about uncertainty. If you couldn't verify something, label
27
+ it `(unverified)` and say what you'd check next.
28
+
29
+ You have read tools only. Refuse to edit files or run mutating shell
30
+ commands — say "I'm in plan mode, that's outside my permissions" and
31
+ hand the plan back so the user can switch to build mode.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Judges a single normalized AdapterResponse on two axes the conversation
6
+ # loop cares about, mirroring the reference validate_response
7
+ # and _has_content_after_think_block.
8
+ #
9
+ # Unlike the reference — which validates a raw provider object per api_mode and has
10
+ # to special-case codex/anthropic/bedrock/openai shapes — ruby_llm already
11
+ # raises typed errors for bad HTTP, so by the time a response reaches here it
12
+ # is the one normalized AdapterResponse shape. The validator therefore only
13
+ # judges that shape; there is no per-provider branching to port.
14
+ #
15
+ # Two questions, two methods:
16
+ # #valid? STRUCTURAL — is this a usable response at all? (not nil,
17
+ # carries some text OR tool calls, not an interrupted partial)
18
+ # #degenerate? SEMANTIC — a structurally valid text response that is
19
+ # nonetheless useless: thinking-only (no real content after
20
+ # the <think> block) or blank visible content.
21
+ class ResponseValidator
22
+ # #valid? returns [Boolean, reason]. `reason` is nil when valid, otherwise
23
+ # a symbol naming the structural defect (for warnings / future telemetry):
24
+ # :nil_response — no response object
25
+ # :interrupted — buffered partial from a truncated stream, not a turn
26
+ # :empty_response — neither text nor tool calls
27
+ def valid?(response)
28
+ return [false, :nil_response] if response.nil?
29
+ return [false, :interrupted] if response.interrupted?
30
+ return [true, nil] if response.has_tool_calls?
31
+ return [false, :empty_response] if response.content.to_s.strip.empty?
32
+
33
+ [true, nil]
34
+ end
35
+
36
+ # True when a STRUCTURALLY valid text response carries no real answer:
37
+ # its visible content is empty once the <think> block is stripped (the
38
+ # model reasoned but never spoke). Mirrors the reference
39
+ # `not _has_content_after_think_block(content)`.
40
+ #
41
+ # Tool-call responses are never degenerate — the tool call IS the answer.
42
+ def degenerate?(response)
43
+ return false if response.nil? || response.interrupted?
44
+ return false if response.has_tool_calls?
45
+
46
+ !content_after_think_block?(response.content)
47
+ end
48
+
49
+ private
50
+
51
+ # Ruby mirror of the reference _has_content_after_think_block: strip the <think>
52
+ # reasoning and check whether any visible text survives. Reuses
53
+ # InlineThinkFilter (the same sentinel recogniser the stream path uses) by
54
+ # feeding the whole string once and flushing — we keep only the :content
55
+ # side, discarding :thinking. Scoped to <think> per Slice 2; the wider tag
56
+ # zoo (<reasoning>, tool-call XML, …) is not in play for this gem's models.
57
+ def content_after_think_block?(content)
58
+ return false if content.to_s.empty?
59
+
60
+ visible = +""
61
+ filter = LLM::InlineThinkFilter.new
62
+ emit = ->(type, text) { visible << text if type == :content }
63
+ filter.feed(content.to_s, &emit)
64
+ filter.flush(&emit)
65
+
66
+ !visible.strip.empty?
67
+ end
68
+ end
69
+ end
70
+ end