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,617 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # The core agent loop that handles LLM calls and tool execution cycles.
6
+ # Runs until the LLM produces a final text response or budget is exhausted.
7
+ class Loop
8
+ # Nudge issued on the final, toolless model call when the iteration/budget
9
+ # ceiling is hit. Mirrors the reference handle_max_iterations summary request
10
+ # — ask the model to wrap up in prose
11
+ # instead of ending the turn with nothing.
12
+ MAX_ITERATIONS_SUMMARY_NUDGE =
13
+ "You've reached the maximum number of tool-calling iterations allowed. " \
14
+ "Please provide a final response summarizing what you've found and " \
15
+ "accomplished so far, without calling any more tools."
16
+
17
+ # Framing for turn-start background notices (#148): tells the model the
18
+ # notices are secondary to the user message that follows them.
19
+ NOTICES_PREAMBLE =
20
+ "[background notices — acknowledge briefly; the user's message AFTER " \
21
+ "these notices is the instruction to act on]"
22
+
23
+ def initialize(session:, llm_adapter:, tool_executor:, message_store:,
24
+ budget:, ui:, event_bus:, config:, cancel_token: nil,
25
+ initial_image_paths: [], input_queue: nil)
26
+ @session = session
27
+ @llm = llm_adapter
28
+ @tool_executor = tool_executor
29
+ @message_store = message_store
30
+ @budget = budget
31
+ @ui = ui
32
+ @event_bus = event_bus
33
+ @config = config
34
+ @cancel_token = cancel_token
35
+ # Optional steering hand-off (Interaction::InputQueue). When present,
36
+ # text the user typed mid-turn is drained at the top of each loop
37
+ # iteration and injected as a user message. Nil for the API/server path
38
+ # and nested subagent runs — they get no injection and behave exactly
39
+ # as before.
40
+ @input_queue = input_queue
41
+ # Consumed once on the first iteration. After the first model call
42
+ # subsequent iterations are tool-result follow-ups — no user input,
43
+ # nothing to re-attach.
44
+ @pending_image_paths = Array(initial_image_paths)
45
+ # Provider/model fallback chain (Slice 7). Primary at index 0; rotates to
46
+ # the next configured backend when the primary keeps failing, and is
47
+ # restored at the top of each turn (#run). With no agent.fallback_models
48
+ # configured the chain holds only the primary and is an inert pass-through,
49
+ # so single-provider setups behave exactly as before.
50
+ @fallback_chain = FallbackChain.new(
51
+ primary_adapter: llm_adapter,
52
+ config: config,
53
+ ui: ui,
54
+ event_bus: event_bus,
55
+ tool_executor: tool_executor,
56
+ cancel_token: cancel_token
57
+ )
58
+ # Owns the inner retry loop (call → validate → classify → backoff →
59
+ # return/raise). The Loop builds each LLM::Request and hands it to the
60
+ # runner, which returns a validated response or raises (empty-exhausted →
61
+ # EmptyModelResponseError; transient-exhausted/permanent → the classified
62
+ # error). The error-classification + backoff retries that used to live in
63
+ # the adapter's with_retries now live here — single owner, no double-retry.
64
+ # The runner issues calls against the chain's CURRENT adapter and can
65
+ # rotate it via the chain on a fallback-worthy failure.
66
+ @model_call_runner = ModelCallRunner.new(
67
+ llm: llm_adapter,
68
+ fallback_chain: @fallback_chain,
69
+ config: config,
70
+ ui: ui,
71
+ event_bus: event_bus,
72
+ cancel_token: cancel_token
73
+ )
74
+ # Single count + persist sink for tool results. The executor invokes it
75
+ # for every tool on BOTH paths: the streaming path (ruby_llm runs the
76
+ # tool mid-stream via ToolBridge → ToolExecutor#execute, never returning
77
+ # through #execute_tool_calls) and the non-streaming path. Registered
78
+ # here rather than passed at construction because the executor is built
79
+ # before the Loop (the adapter/ToolBridge share the same executor).
80
+ @tool_executor.on_result = method(:handle_tool_result) if @tool_executor.respond_to?(:on_result=)
81
+ end
82
+
83
+ # Runs the agent loop, returning the final assistant response content.
84
+ def run(messages:, tools:)
85
+ # Stash the resolved toolset so #streaming? can decide, per run, whether
86
+ # this turn might block on a human (clarify/approval). When it might, we
87
+ # run NON-STREAMING so the LLM HTTP request completes and CLOSES before
88
+ # any tool fires — leaving no upstream socket held open during the gate
89
+ # wait (the wait can now be effectively unbounded; see ApprovalGate).
90
+ @turn_tools = Array(tools)
91
+ iteration = 0
92
+ turn_started_at = monotonic_now
93
+
94
+ # If a previous turn rotated to a fallback, restore the primary backend
95
+ # so this turn gets a fresh attempt with the preferred model
96
+ # (conversation_loop.py:427). No-op when we never left the primary.
97
+ @fallback_chain.restore_primary!
98
+
99
+ # Mutated by the ToolExecutor's on_result sink (see #handle_tool_result),
100
+ # which fires for EVERY tool regardless of streaming mode — including the
101
+ # streaming path where ruby_llm runs the tool mid-stream via ToolBridge
102
+ # and never returns through #execute_tool_calls below. Instance vars (not
103
+ # locals) so the sink closure can update them.
104
+ @tool_count = 0
105
+ @denied_count = 0
106
+ token_total = 0
107
+
108
+ loop do
109
+ iteration += 1
110
+ @cancel_token&.check!
111
+
112
+ # Mid-turn steering boundary. SAFE point: the cancel check has passed
113
+ # and any prior assistant(tool_use) + tool(result) messages from the
114
+ # previous iteration are already appended, so adding a USER message
115
+ # here can never split a tool_use from its results (no orphan pair on
116
+ # strict providers). On iteration 1 the initial user input is already
117
+ # the user turn, so only parked background NOTICES fold in (#13);
118
+ # typed lines stay queued for their own turns.
119
+ inject_steered_input(messages, iteration)
120
+
121
+ unless @budget.can_continue?(iteration)
122
+ @ui.warning("Iteration budget exhausted (#{iteration} turns)")
123
+ return summarize_on_budget_exhausted(messages, iteration,
124
+ turn_started_at, token_total)
125
+ end
126
+
127
+ @event_bus.emit(Interaction::Events::MODEL_CALL_STARTED, iteration: iteration)
128
+ # Show a transient "thinking…" indicator during TTFB. The UI erases
129
+ # it the moment the first chunk lands (any type). Skipped in
130
+ # non-streaming mode — the response arrives in one shot, indicator
131
+ # would flash uselessly.
132
+ @ui.thinking_started if streaming?
133
+ begin
134
+ response = call_model(messages, tools, iteration)
135
+ rescue Rubino::Interrupted
136
+ # The streaming callback (or the per-iteration check above)
137
+ # observed cancellation. Close any open stream box on the UI
138
+ # (commits the partial answer streamed so far) and bail out — the
139
+ # standardized `⎿ interrupted` marker is appended once by the Runner's
140
+ # rescue, right after this kept partial. Lifecycle will not persist a
141
+ # turn that never completed, but the user already saw the partial.
142
+ @ui.stream_end if streaming?
143
+ raise
144
+ end
145
+ @event_bus.emit(Interaction::Events::MODEL_CALL_FINISHED,
146
+ tokens: response.total_tokens,
147
+ has_tool_calls: response.has_tool_calls?)
148
+
149
+ token_total += response.total_tokens.to_i
150
+
151
+ if response.interrupted?
152
+ # The upstream stream was cut before a clean completion (no
153
+ # finish_reason / [DONE]); `response` carries only a buffered partial
154
+ # with no tool call. Returning it would end the run as "completed"
155
+ # with truncated/empty output — the silent-completion bug. Persist
156
+ # whatever streamed so the transcript keeps it, close the stream box,
157
+ # then raise: Lifecycle maps this to INTERACTION_FAILED → run.failed,
158
+ # the same path every other turn error already takes.
159
+ persist_assistant_message(response) unless response.content.to_s.empty?
160
+ finalize_stream(response)
161
+ emit_turn_summary(turn_started_at, token_total)
162
+ raise Rubino::StreamInterruptedError,
163
+ "stream ended before completion after " \
164
+ "#{response.content.to_s.bytesize} buffered byte(s) with no finish signal — " \
165
+ "the model did not finish (run marked failed, not completed). " \
166
+ "Often caused by a very large context pushing time-to-first-token past the " \
167
+ "provider's stream idle timeout."
168
+ end
169
+
170
+ if response.text_only?
171
+ persist_assistant_message(response)
172
+ finalize_stream(response)
173
+ emit_turn_summary(turn_started_at, token_total)
174
+ return response.content
175
+ end
176
+
177
+ if response.has_tool_calls?
178
+ persist_assistant_message(response)
179
+ close_intermediate_stream(response)
180
+
181
+ # Bedrock (and other providers) require the assistant turn with the
182
+ # toolUse block to appear in the conversation history before the
183
+ # toolResult turn. Append it now so the next LLM call sees the
184
+ # correct sequence: user → assistant(toolUse) → user(toolResult).
185
+ messages << build_assistant_tool_use_message(response)
186
+
187
+ # NOTE: counting and `tool` message persistence happen in the
188
+ # ToolExecutor's on_result sink (#handle_tool_result), which fires
189
+ # for BOTH this non-streaming path and the streaming path (where
190
+ # ruby_llm runs tools mid-stream and never returns here). We only
191
+ # build the conversation-history messages for the next iteration.
192
+ execute_tool_calls(response.tool_calls).each { |result| messages << result }
193
+ else
194
+ # Unreachable in practice: the ModelCallRunner either returns a
195
+ # response with text or tool calls, or raises EmptyModelResponseError.
196
+ # Kept as a defensive backstop so a future response shape can never
197
+ # silently complete an empty turn.
198
+ emit_turn_summary(turn_started_at, token_total)
199
+ raise Rubino::EmptyModelResponseError
200
+ end
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ # Mid-turn steering (Phase 2): drains anything the user typed while the
207
+ # agent was working and folds it into the live turn as a single USER
208
+ # message. Called at the top of each iteration (after the cancel check,
209
+ # before the model call) where appending a user message is always valid
210
+ # ordering — never between an assistant tool_use and its tool results.
211
+ #
212
+ # No-op when no queue is wired (API/server, subagents) or when nothing
213
+ # was typed. Multiple drained lines are coalesced (newline-joined) into
214
+ # ONE user message so a burst of keystrokes reads as one interjection.
215
+ # The drain is atomic, so the between-turns #next_input fallback in the
216
+ # CLI never double-consumes the same text.
217
+ def inject_steered_input(messages, iteration)
218
+ return unless @input_queue&.pending?
219
+
220
+ # Iteration 1's user input IS the turn: only parked background notices
221
+ # ([background-task] completion lines) fold in at turn start, so a
222
+ # notice never spends a standalone model turn restating itself (#13).
223
+ # Later iterations drain everything (typed steering + notices).
224
+ lines = iteration > 1 ? @input_queue.drain : @input_queue.drain_notices
225
+ return if lines.empty?
226
+
227
+ text = lines.join("\n")
228
+ # Turn-start fold-in: the notices are CONTEXT, the user's just-sent
229
+ # message is the INSTRUCTION. Appended after the user message, screens
230
+ # of completion reports drowned the prompt and the model answered the
231
+ # notices, ignoring the request (#148). Frame the notices and insert
232
+ # them BEFORE the user message so it stays last (most salient).
233
+ if iteration == 1
234
+ text = "#{NOTICES_PREAMBLE}\n#{text}"
235
+ persist_user_message(text)
236
+ insert_before_trailing_user(messages, text)
237
+ else
238
+ persist_user_message(text)
239
+ messages << { role: "user", content: text }
240
+ end
241
+
242
+ @event_bus.emit(Interaction::Events::INPUT_INJECTED,
243
+ text: text, iteration: iteration)
244
+ @ui.input_injected(text)
245
+ end
246
+
247
+ # Inserts the framed notice message just before the trailing user message
248
+ # (the turn's instruction, #148); appends defensively when the last
249
+ # message isn't a user one (should not happen at iteration 1).
250
+ def insert_before_trailing_user(messages, text)
251
+ notice = { role: "user", content: text }
252
+ if messages.last&.[](:role) == "user"
253
+ messages.insert(-2, notice)
254
+ else
255
+ messages << notice
256
+ end
257
+ end
258
+
259
+ # True when the model is configured to stream and the UI should display it
260
+ # AND this turn cannot block on a human. An interactive turn (one that may
261
+ # raise an approval/clarify gate that parks the run on a human answer) runs
262
+ # NON-STREAMING so the LLM request closes before the wait — otherwise the
263
+ # upstream socket sits open mid-response and the provider drops it.
264
+ def streaming?
265
+ return false if interactive_turn?
266
+
267
+ @config.streaming_enabled? && @config.display_streaming?
268
+ end
269
+
270
+ # A turn "may block on a human" when the UI bridges human input across
271
+ # threads (the HTTP/API path with a gate; CLI prompts inline and never
272
+ # parks) AND the toolset contains a tool that can trigger the gate:
273
+ # - `question` → @ui.ask (clarify) — always blocks when called.
274
+ # - any risky tool under manual approvals → @ui.confirm — blocks.
275
+ # - `shell` when require_confirmation_for_shell is on → confirm.
276
+ # Memoised per run; the toolset is fixed for the turn.
277
+ def interactive_turn?
278
+ return @interactive_turn unless @interactive_turn.nil?
279
+
280
+ @interactive_turn = gate_backed_ui? && toolset_can_block?
281
+ end
282
+
283
+ # The UI parks the run on a cross-thread gate (UI::API) rather than
284
+ # prompting inline (UI::CLI). Adapters opt in via #blocking_human_input?;
285
+ # anything that doesn't respond is treated as non-blocking (CLI/Null/test).
286
+ def gate_backed_ui?
287
+ @ui.respond_to?(:blocking_human_input?) && @ui.blocking_human_input?
288
+ end
289
+
290
+ def toolset_can_block?
291
+ names = @turn_tools.map { |t| tool_name_of(t) }
292
+ return true if names.include?("question")
293
+
294
+ manual = @config.approvals_mode == "manual"
295
+ # shell can park on the gate under EITHER confirm_policy: confirm_all
296
+ # always prompts; dangerous_only still prompts on a DangerousPattern.
297
+ # We don't have the concrete command here, so treat a present shell tool
298
+ # as potentially-blocking unless approvals are skipped entirely.
299
+ confirm_shell = @config.approvals_mode != "skip"
300
+ return true if confirm_shell && names.include?("shell")
301
+ return true if manual && @turn_tools.any? { |t| t.respond_to?(:risky?) && t.risky? }
302
+
303
+ false
304
+ end
305
+
306
+ def tool_name_of(tool)
307
+ tool.respond_to?(:name) ? tool.name.to_s : tool.to_s
308
+ end
309
+
310
+ # Budget exhausted: instead of ending the turn with nothing, issue ONE
311
+ # final model call with the tools stripped, nudging the model to summarise
312
+ # what it did and what remains. The summary still runs through the normal
313
+ # model-call path (validation + recovery via ModelCallRunner) and its text
314
+ # becomes the turn's final assistant content. Because tools are empty AND
315
+ # this is the loop's terminal action, the summary can never re-enter the
316
+ # tool loop. Ports conversation_loop.py:4296 / handle_max_iterations.
317
+ def summarize_on_budget_exhausted(messages, iteration, turn_started_at, token_total)
318
+ persist_user_message(MAX_ITERATIONS_SUMMARY_NUDGE)
319
+ messages << { role: "user", content: MAX_ITERATIONS_SUMMARY_NUDGE }
320
+
321
+ @event_bus.emit(Interaction::Events::MODEL_CALL_STARTED, iteration: iteration)
322
+ @ui.thinking_started if streaming?
323
+ response = call_model(messages, [], iteration)
324
+ @event_bus.emit(Interaction::Events::MODEL_CALL_FINISHED,
325
+ tokens: response.total_tokens,
326
+ has_tool_calls: response.has_tool_calls?)
327
+ token_total += response.total_tokens.to_i
328
+
329
+ persist_assistant_message(response)
330
+ finalize_stream(response)
331
+ emit_turn_summary(turn_started_at, token_total)
332
+ response.content
333
+ end
334
+
335
+ # Builds the per-call LLM::Request and runs it through the ModelCallRunner,
336
+ # which owns the inner retry loop (call → validate → classify → backoff).
337
+ # Returns a validated AdapterResponse or raises (EmptyModelResponseError on
338
+ # an exhausted empty turn; the classified error on an exhausted/permanent
339
+ # API failure). interrupted? / text / tool-call dispatch stays in #run.
340
+ def call_model(messages, tools, iteration)
341
+ # Pop the staged native-attachments slot — they only ride on the
342
+ # first model call of this turn (the one that sees the user's input).
343
+ image_paths = @pending_image_paths
344
+ @pending_image_paths = []
345
+
346
+ request = LLM::Request.new(
347
+ messages: messages,
348
+ tools: tools,
349
+ image_paths: image_paths,
350
+ stream: streaming?
351
+ )
352
+
353
+ # Single boundary entry (normalize_response seam).
354
+ # The adapter dispatches stream-vs-chat off request.stream internally;
355
+ # streaming yields chunks to the block, non-streaming returns in one shot.
356
+ # The runner forwards this block straight through on each attempt.
357
+ stream_chunk = lambda do |chunk|
358
+ @ui.stream(chunk)
359
+ @event_bus.emit(Interaction::Events::MODEL_STREAM, chunk: chunk)
360
+ end
361
+
362
+ response = @model_call_runner.call!(request, iteration: iteration, &stream_chunk)
363
+
364
+ # Truncation continuation (Slice 9 / conversation_loop.py:1560-1714,3382).
365
+ # When the model hit max_tokens (stop_reason==:length) we stitch the
366
+ # answer back together over ≤3 boosted re-issues. This is a no-op unless
367
+ # stop_reason==:length reaches us — which it does only on the NON-STREAMING
368
+ # path today (the adapter surfaces stop_reason from the raw body on #chat;
369
+ # the streaming path leaves it nil — see RubyLLMAdapter#extract_stop_reason
370
+ # see the boundary spike). On the streaming path #applicable? is
371
+ # therefore false and #continue returns the response untouched.
372
+ # TODO: once ruby_llm surfaces a stream finish_reason, this activates for
373
+ # streaming too with no change here.
374
+ truncation_continuation(iteration).continue(request, response, &stream_chunk)
375
+ end
376
+
377
+ # Each continuation re-issue still flows through the ModelCallRunner, so a
378
+ # boosted-budget retry gets the same validation/recovery/backoff as the
379
+ # first call. The boundary is a thin lambda matching #call(request, &block).
380
+ def truncation_continuation(iteration)
381
+ boundary = lambda do |req, &blk|
382
+ @model_call_runner.call!(req, iteration: iteration, &blk)
383
+ end
384
+ TruncationContinuation.new(
385
+ boundary: boundary,
386
+ base_tokens: @config.dig("model", "max_tokens"),
387
+ ui: @ui
388
+ )
389
+ end
390
+
391
+ def finalize_stream(response)
392
+ if streaming?
393
+ @ui.stream_end
394
+ else
395
+ # Non-streaming finalize: wrap the buffered content in the same chunk
396
+ # shape the streaming path yields so the UI never has to branch on
397
+ # String-vs-Hash. Single block ⇒ message_id 0.
398
+ @ui.stream({ type: :content, text: response.content.to_s, message_id: 0 })
399
+ @ui.stream_end
400
+ end
401
+ end
402
+
403
+ # Called when the model returned tool calls. If streaming was active,
404
+ # close the open stream so the UI can finalize the thinking/preamble text
405
+ # the model emitted before the tool call.
406
+ def close_intermediate_stream(response)
407
+ return unless streaming?
408
+ return if response.content.nil? || response.content.empty?
409
+
410
+ @ui.stream_end
411
+ end
412
+
413
+ # Build an assistant message that includes the tool use blocks.
414
+ # Providers like Bedrock require this message to appear in the conversation
415
+ # history between the user prompt and the tool result(s).
416
+ def build_assistant_tool_use_message(response)
417
+ {
418
+ role: "assistant",
419
+ content: response.content || "",
420
+ tool_calls: response.tool_calls
421
+ }
422
+ end
423
+
424
+ # Called once per executed tool by the ToolExecutor's on_result sink, on
425
+ # BOTH the streaming and non-streaming paths. Bumps the turn's tool count
426
+ # (B2 — the streaming path used to bypass the only counter) and persists
427
+ # the result as a `tool` message (B3 — streaming tool results never hit
428
+ # the message store, leaving `tool_calls`/role='tool' rows empty and
429
+ # breaking --resume + audit). Idempotency is structural: the executor
430
+ # calls #finish exactly once per tool call.
431
+ def handle_tool_result(name:, arguments:, call_id:, result:)
432
+ # A denied tool never ran, so it shouldn't inflate the "N tools" run
433
+ # count in the footer — track it separately and surface it as
434
+ # "0 run · 1 denied" so the deny outcome is unambiguous (#83).
435
+ if result.respond_to?(:denied?) && result.denied?
436
+ @denied_count += 1
437
+ else
438
+ @tool_count += 1
439
+ end
440
+ persist_tool_result(
441
+ role: "tool",
442
+ content: result.output,
443
+ tool_call_id: call_id,
444
+ name: name,
445
+ arguments: arguments
446
+ )
447
+ end
448
+
449
+ def execute_tool_calls(tool_calls)
450
+ tool_calls.map do |tc|
451
+ # TOOL_STARTED / TOOL_FINISHED + ui.tool_started/tool_finished are
452
+ # emitted from Agent::ToolExecutor#execute itself — the executor is
453
+ # the single source of truth so the streaming path (ruby_llm calls
454
+ # the tool mid-stream via ToolBridge → never lands here) and the
455
+ # non-streaming path (this branch) both emit exactly once.
456
+ result = @tool_executor.execute(
457
+ name: tc[:name],
458
+ arguments: tc[:arguments],
459
+ call_id: tc[:id]
460
+ )
461
+
462
+ {
463
+ role: "tool",
464
+ content: result.output,
465
+ tool_call_id: tc[:id],
466
+ name: tc[:name],
467
+ arguments: tc[:arguments]
468
+ }
469
+ end
470
+ end
471
+
472
+ # Persists a mid-turn injected user message the same way Lifecycle
473
+ # persists the initial user turn: one "user" row plus a session
474
+ # message-count bump, so session history and counts stay correct. Wrapped
475
+ # in the same DB-lock retry as the assistant/tool writes.
476
+ def persist_user_message(text)
477
+ with_db_retries do
478
+ @message_store.create(
479
+ session_id: @session[:id],
480
+ role: "user",
481
+ content: text
482
+ )
483
+ end
484
+ session_repo.increment_message_count!(@session[:id])
485
+ end
486
+
487
+ def session_repo
488
+ @session_repo ||= Session::Repository.new
489
+ end
490
+
491
+ def persist_assistant_message(response)
492
+ # Stash tool_calls under metadata so --resume can rebuild the
493
+ # assistant(toolUse) → tool(result) pair the provider expects. Without
494
+ # this, strict providers (Anthropic, Bedrock) 400 the next turn because
495
+ # they see tool result messages with no matching toolUse upstream.
496
+ metadata = response.has_tool_calls? ? { tool_calls: response.tool_calls } : {}
497
+
498
+ # Record the REAL context size the provider saw for this response:
499
+ # input_tokens covers the whole assembled prompt (system prompt +
500
+ # history + tools), which no local chars/4 estimate can reproduce
501
+ # without re-assembling. The status bar under the chat input prefers
502
+ # this over the estimate when present. Omitted when the provider
503
+ # reports no usage (same rule as the `↳ turn` footer, #86).
504
+ metadata[:input_tokens] = response.input_tokens if response.input_tokens.to_i.positive?
505
+
506
+ with_db_retries do
507
+ @message_store.create(
508
+ session_id: @session[:id],
509
+ role: "assistant",
510
+ content: response.content,
511
+ token_count: response.output_tokens,
512
+ metadata: metadata
513
+ )
514
+ end
515
+ end
516
+
517
+ def persist_tool_result(result)
518
+ # Persist arguments alongside the tool message so --resume replay can
519
+ # render the same "⏺ name · args" line the live session showed.
520
+ # Old rows that pre-date this field hydrate with empty metadata; the
521
+ # replay path falls back to printing just the name.
522
+ metadata = result[:arguments] ? { arguments: result[:arguments] } : {}
523
+
524
+ with_db_retries do
525
+ @message_store.create(
526
+ session_id: @session[:id],
527
+ role: "tool",
528
+ content: result[:content],
529
+ tool_name: result[:name],
530
+ tool_call_id: result[:tool_call_id],
531
+ metadata: metadata
532
+ )
533
+ end
534
+ end
535
+
536
+ # Closes the turn with a one-line dim summary: how long it took, how
537
+ # many tools the model called across all iterations, and the rough
538
+ # token spend. The cost stays visible without having to scroll back
539
+ # or run a stats command, and the user can spot a runaway turn
540
+ # (15 tools, 30s) at a glance.
541
+ def emit_turn_summary(started_at, token_total)
542
+ duration = monotonic_now - started_at
543
+ # Drop the token field entirely when usage is unknown/zero rather than
544
+ # printing a permanent "0 tok" that reads as broken (#86). Providers
545
+ # that don't report usage simply omit the segment.
546
+ # No "◆ " prefix: the static footer is all dim — red is the error
547
+ # color, and the only red ◆ left is the ANIMATED status row (P4).
548
+ parts = ["turn", format_duration(duration), tool_count_label]
549
+ parts << format_tokens(token_total) if token_total.to_i.positive?
550
+ summary = parts.join(" · ")
551
+ # The CLI renders the footer attached directly under the answer (no
552
+ # blank, P3) and folds pending subagent completions into its grammar
553
+ # (P4); other adapters keep the plain note path.
554
+ if @ui.respond_to?(:turn_footer)
555
+ @ui.turn_footer(summary)
556
+ else
557
+ @ui.note(summary)
558
+ end
559
+ end
560
+
561
+ # "1 tool" normally; "2 tools · 1 denied" when something was denied; and
562
+ # "0 run · 1 denied" when the only tool call(s) were denied — so a denied
563
+ # tool is never silently counted as if it ran (#83).
564
+ def tool_count_label
565
+ denied = @denied_count.to_i
566
+ return "#{@tool_count} tool#{"s" if @tool_count != 1}" if denied.zero?
567
+
568
+ ran = @tool_count.zero? ? "0 run" : "#{@tool_count} tool#{"s" if @tool_count != 1}"
569
+ "#{ran} · #{denied} denied"
570
+ end
571
+
572
+ def monotonic_now
573
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
574
+ end
575
+
576
+ def format_duration(seconds)
577
+ if seconds < 1
578
+ "#{(seconds * 1000).round}ms"
579
+ elsif seconds < 60
580
+ "#{seconds.round(1)}s"
581
+ else
582
+ mins, secs = seconds.divmod(60)
583
+ "#{mins.to_i}m#{secs.round}s"
584
+ end
585
+ end
586
+
587
+ # Only called for a positive count (see #emit_turn_summary); a zero total
588
+ # is omitted upstream rather than rendered as "0 tok".
589
+ def format_tokens(n)
590
+ n >= 1000 ? "#{(n / 1000.0).round(1)}k tok" : "#{n} tok"
591
+ end
592
+
593
+ # SQLite serialises writes; a backup tool, another session, or a
594
+ # mid-flight migration can hold the database busy for up to a second.
595
+ # Without retry the persist propagates a Sequel::DatabaseError up to
596
+ # Runner#run, which prints a generic error and discards the turn — we
597
+ # lose a completed assistant response over a transient lock. Three
598
+ # attempts with 100/200/400ms backoff cover the common case; if the
599
+ # lock outlives that, we re-raise and the turn does drop, but at
600
+ # least we tried instead of folding on the first hiccup.
601
+ def with_db_retries(max_attempts: 3)
602
+ attempt = 0
603
+ begin
604
+ yield
605
+ rescue Sequel::DatabaseError => e
606
+ raise unless e.message.to_s.match?(/locked|busy/i)
607
+
608
+ attempt += 1
609
+ raise if attempt >= max_attempts
610
+
611
+ sleep(0.1 * (2**(attempt - 1)))
612
+ retry
613
+ end
614
+ end
615
+ end
616
+ end
617
+ end