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,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Context
5
+ # Assembles the complete prompt from all context sources.
6
+ # Returns the message array (system + summary + history) for LLM submission.
7
+ class PromptAssembler
8
+ # Process-wide cache of the memory snapshot keyed by session id.
9
+ # Captured the first time build_system_prompt runs for a session and
10
+ # reused on every subsequent assembly in that session — even if the
11
+ # agent calls Tools::MemoryTool mid-session. Rationale: without
12
+ # freezing, an injected memory written this turn would land in the
13
+ # *next* prompt and effectively self-elevate. The agent must wait
14
+ # for the next session (or call reset_snapshot!) for new writes to
15
+ # appear in the system prompt.
16
+ @snapshots = {}
17
+ @snapshots_mutex = Mutex.new
18
+
19
+ class << self
20
+ # Returns the cached snapshot for a session, computing it via the
21
+ # supplied block on first access. The block receives no args and
22
+ # must return the memory-context hash to freeze.
23
+ def snapshot_for(session_id)
24
+ @snapshots_mutex.synchronize do
25
+ @snapshots[session_id] ||= yield
26
+ end
27
+ end
28
+
29
+ # Drops the cached snapshot for a session so the next assembly
30
+ # captures fresh memory state. Use this when a tool call must
31
+ # influence the very next turn (trade-off: the freeze stops
32
+ # protecting against same-turn poisoning).
33
+ def reset_snapshot!(session_id)
34
+ @snapshots_mutex.synchronize { @snapshots.delete(session_id) }
35
+ end
36
+
37
+ # Test/teardown hook. Not part of the public API.
38
+ def reset_all_snapshots!
39
+ @snapshots_mutex.synchronize { @snapshots.clear }
40
+ end
41
+ end
42
+
43
+ def initialize(session:, memory_context:, config:, agent_definition: nil,
44
+ ignore_rules: false)
45
+ @session = session
46
+ @memory_context = memory_context
47
+ @config = config
48
+ @agent_definition = agent_definition
49
+ # --ignore-rules suppresses project-context discovery
50
+ # (AGENTS.md/CLAUDE.md/.rubino.md/.cursorrules). The flag is threaded
51
+ # from Lifecycle so the CLI option genuinely skips discovery (#47), not
52
+ # just the trust gate.
53
+ @ignore_rules = ignore_rules
54
+ @message_store = Session::Store.new
55
+ end
56
+
57
+ # Builds and returns the full message array for LLM submission
58
+ def build
59
+ messages = []
60
+
61
+ # System prompt (always first)
62
+ messages << { role: "system", content: build_system_prompt }
63
+
64
+ # Session summary (if compacted)
65
+ summary = load_summary
66
+ messages << { role: "system", content: "[Session Summary]\n#{summary}" } if summary
67
+
68
+ # Conversation history. Repair tool pairing across the FULL list before
69
+ # mapping to wire format — this is the defensive "net" that recovers
70
+ # sessions already corrupted by the historical metadata-dropping bug in
71
+ # compaction/fork (those rows exist in prod). Mirrors Claude Code's
72
+ # pre-call sanitization: never emit an orphan tool block that 400s a
73
+ # strict provider. Conservative by design — when in doubt, keep.
74
+ history = repair_tool_pairs(@message_store.for_session(@session[:id]))
75
+ history.each do |msg|
76
+ messages << msg.to_context
77
+ end
78
+
79
+ messages
80
+ end
81
+
82
+ private
83
+
84
+ # Final pairing repair over the full history (a list of Message objects).
85
+ # Two orphan shapes 400 strict providers; we fix both, conservatively:
86
+ #
87
+ # 1. tool RESULT with no matching assistant tool_call upstream → drop it.
88
+ # 2. assistant tool_call whose results are ENTIRELY absent downstream →
89
+ # strip its tool_calls (keep the message if it still has content,
90
+ # otherwise drop it). Partially-answered calls are LEFT ALONE: pruning
91
+ # a still-referenced id would itself create an orphan.
92
+ #
93
+ # Reuses ToolPairSanitizer's id predicates so the matching logic lives in
94
+ # one place. Returns a list of Message objects safe to map via to_context.
95
+ def repair_tool_pairs(history)
96
+ sanitizer = ToolPairSanitizer.new
97
+
98
+ # All tool_call ids declared by assistant messages anywhere in history.
99
+ declared_ids = history
100
+ .select { |m| sanitizer.assistant_tool_call?(m) }
101
+ .flat_map { |m| sanitizer.tool_call_ids(m) }
102
+ .to_set
103
+
104
+ # All ids actually answered by a tool result anywhere in history.
105
+ answered_ids = history
106
+ .select { |m| m.role == "tool" && m.tool_call_id }
107
+ .map(&:tool_call_id)
108
+ .to_set
109
+
110
+ repaired = []
111
+ history.each do |msg|
112
+ if msg.role == "tool" && msg.tool_call_id
113
+ # Drop a result whose triggering assistant call is gone.
114
+ next unless declared_ids.include?(msg.tool_call_id)
115
+
116
+ repaired << msg
117
+ elsif sanitizer.assistant_tool_call?(msg)
118
+ ids = sanitizer.tool_call_ids(msg)
119
+ if ids.any? { |id| answered_ids.include?(id) }
120
+ # At least one result present → keep the call intact. Partial
121
+ # answers stay as-is (pruning would re-orphan the kept result).
122
+ repaired << msg
123
+ else
124
+ # No results at all → strip tool_calls so we don't emit a toolUse
125
+ # with no following toolResult. Keep the surrounding prose if any.
126
+ stripped = strip_tool_calls(msg)
127
+ repaired << stripped if stripped
128
+ end
129
+ else
130
+ repaired << msg
131
+ end
132
+ end
133
+
134
+ repaired
135
+ end
136
+
137
+ # Returns a copy of an assistant message with tool_calls removed, or nil
138
+ # when the message would be empty afterwards (nothing left to send).
139
+ def strip_tool_calls(msg)
140
+ return nil if msg.content.nil? || msg.content.to_s.strip.empty?
141
+
142
+ metadata = msg.metadata.is_a?(Hash) ? msg.metadata.dup : {}
143
+ metadata.delete(:tool_calls)
144
+ metadata.delete("tool_calls")
145
+
146
+ Session::Message.new(
147
+ id: msg.id,
148
+ session_id: msg.session_id,
149
+ role: msg.role,
150
+ content: msg.content,
151
+ tool_name: msg.tool_name,
152
+ tool_call_id: msg.tool_call_id,
153
+ token_count: msg.token_count,
154
+ metadata: metadata,
155
+ created_at: msg.created_at
156
+ )
157
+ end
158
+
159
+ # Assembles the system prompt as a stack of labelled blocks:
160
+ # 1. Identity — role-specific built-in prompt (or override)
161
+ # 2. Product preamble— config.prompts.preamble, customer-side
162
+ # 3. Environment — date/OS/cwd/git/runtimes/PATH utilities
163
+ # 4. User profile — from memory
164
+ # 5. Relevant memories
165
+ # 6. Skills index — "## Skills (mandatory)" catalogue (auto-trigger)
166
+ # 7. Project context — AGENTS.md / CLAUDE.md walk
167
+ # Each block is independent: if a section is empty/disabled it just
168
+ # drops out without leaving a stray header.
169
+ def build_system_prompt
170
+ # Memory snapshot is frozen for the lifetime of the session — see
171
+ # the class-level @snapshots cache for why. The first assembly in
172
+ # a session captures @memory_context; later assemblies reuse it
173
+ # even if Memory::Store has been mutated in the meantime.
174
+ snapshot = self.class.snapshot_for(@session[:id]) { @memory_context }
175
+
176
+ parts = []
177
+ parts << agent_identity
178
+ product = product_preamble
179
+ parts << "[Product]\n#{product}" if product
180
+ env = environment_block
181
+ parts << env if env
182
+
183
+ if snapshot[:user_profile] && !snapshot[:user_profile].empty?
184
+ parts << "[User Profile]\n#{snapshot[:user_profile]}"
185
+ end
186
+
187
+ if snapshot[:relevant_memories]&.any?
188
+ memories_text = snapshot[:relevant_memories].map { |m| "- #{m[:content]}" }.join("\n")
189
+ parts << "[Relevant Memories]\n#{memories_text}"
190
+ end
191
+
192
+ skills_index = skills_index_block
193
+ parts << skills_index if skills_index
194
+
195
+ # The user-PINNED active skill (the `/skills <name>` picker): force-load
196
+ # its full SKILL.md into the prompt EACH turn so the model actually uses
197
+ # it, not just shows a chip. Sits after the skills index so the pinned
198
+ # skill is the most concrete, last-read instruction in the skills region.
199
+ active_skill = active_skill_block
200
+ parts << active_skill if active_skill
201
+
202
+ project_ctx = load_project_context
203
+ parts << "[Project Context]\n#{project_ctx}" if project_ctx
204
+
205
+ parts.join("\n\n")
206
+ end
207
+
208
+ # The "## Skills (mandatory)" catalogue. This is the load-bearing trigger
209
+ # for skill auto-activation — surfacing skills in the system prompt (not
210
+ # just the `skill` tool description) is what makes the model proactively
211
+ # scan and load a relevant skill before replying.
212
+ #
213
+ # Gated, mirroring the reference (which gates on the skills
214
+ # toolset being present), on both holding:
215
+ # - the skills feature is enabled (config skills.enabled), and
216
+ # - the `skill` tool is actually available this turn.
217
+ # When either fails we inject nothing. When both hold we always inject the
218
+ # block, even with zero skills discovered: the catalogue half drops out but
219
+ # the proactive-creation nudge remains, so a fresh install still gets told
220
+ # to distill repeatable work into a skill (PromptIndex#render handles the
221
+ # empty-catalogue case and never returns nil).
222
+ def skills_index_block
223
+ return nil unless skills_feature_enabled?
224
+ return nil unless skill_tool_available?
225
+
226
+ Skills::PromptIndex.new(
227
+ registry: Skills::Registry.new(
228
+ config: @config,
229
+ include_project_local: project_local_trusted?
230
+ )
231
+ ).render
232
+ rescue StandardError
233
+ nil
234
+ end
235
+
236
+ # The user-PINNED active skill block. When the user has activated a skill
237
+ # via the `/skills <name>` picker (Rubino::ActiveSkill), we force-load its
238
+ # FULL SKILL.md content into the system prompt every turn and prepend a
239
+ # strong directive naming it — so the model treats it as active and follows
240
+ # it without having to discover/load it via the `skill` tool. This is the
241
+ # load-bearing half of the picker: the chip is cosmetic, THIS is what makes
242
+ # the skill actually take effect.
243
+ #
244
+ # Gated on the skills feature being enabled (same gate as the index). A
245
+ # pinned-but-now-missing/disabled skill (deleted on disk, or toggled off)
246
+ # silently drops out rather than injecting an empty block. Never raises —
247
+ # a load failure must not take down prompt assembly — but it LOGS, so a
248
+ # logic error here (e.g. a signature drift) is visible instead of the
249
+ # pinned skill silently vanishing from the prompt (#62).
250
+ def active_skill_block
251
+ return nil unless skills_feature_enabled?
252
+
253
+ name = Rubino::ActiveSkill.current
254
+ return nil unless name
255
+
256
+ registry = Skills::Registry.new(
257
+ config: @config,
258
+ include_project_local: project_local_trusted?
259
+ )
260
+ return nil unless registry.enabled?(name)
261
+
262
+ content = registry.load_skill(name)
263
+ return nil if content.nil? || content.to_s.strip.empty?
264
+
265
+ <<~PROMPT.strip
266
+ ## Active skill (pinned): #{name}
267
+ The user has PINNED the "#{name}" skill active for this session. You MUST follow its instructions for this and every subsequent turn until it is changed. Its full content is loaded below — treat it as authoritative; you do not need to load it again with the `skill` tool.
268
+
269
+ <active_skill name="#{name}">
270
+ #{content.to_s.strip}
271
+ </active_skill>
272
+ PROMPT
273
+ rescue StandardError => e
274
+ Rubino.logger.debug(event: "prompt.active_skill_block_failed",
275
+ error: "#{e.class}: #{e.message}")
276
+ nil
277
+ end
278
+
279
+ def skills_feature_enabled?
280
+ value = @config.dig("skills", "enabled")
281
+ value.nil? || value == true
282
+ end
283
+
284
+ # True when the `skill` tool is exposed to the model this turn. Honors the
285
+ # agent definition's tool restrictions when present, else falls back to the
286
+ # globally enabled tools — the same source the loop uses to pick tools.
287
+ def skill_tool_available?
288
+ tools =
289
+ if @agent_definition
290
+ @agent_definition.resolved_tools
291
+ else
292
+ Tools::Registry.instance.enabled_tools
293
+ end
294
+ tools.any? { |t| t.respond_to?(:name) && t.name == "skill" }
295
+ rescue StandardError
296
+ false
297
+ end
298
+
299
+ def agent_identity
300
+ return @agent_definition.system_prompt if @agent_definition&.system_prompt
301
+
302
+ load_builtin_prompt("build") || <<~FALLBACK.strip
303
+ You are a helpful AI assistant powered by rubino.
304
+ You can use tools to help accomplish tasks.
305
+ Be concise and accurate in your responses.
306
+ FALLBACK
307
+ end
308
+
309
+ def product_preamble
310
+ return nil unless @config.respond_to?(:prompts_preamble)
311
+
312
+ @config.prompts_preamble
313
+ end
314
+
315
+ def environment_block
316
+ return nil unless environment_enabled?
317
+
318
+ EnvironmentInspector.new(
319
+ extra_utilities: environment_extra_utilities
320
+ ).render
321
+ rescue StandardError
322
+ # The env block is a convenience; never let a probe failure
323
+ # (read-only filesystem, missing `git`, weird PATH) take down the
324
+ # whole interaction.
325
+ nil
326
+ end
327
+
328
+ def environment_enabled?
329
+ return true unless @config.respond_to?(:prompts_environment_enabled?)
330
+
331
+ @config.prompts_environment_enabled?
332
+ end
333
+
334
+ def environment_extra_utilities
335
+ return [] unless @config.respond_to?(:prompts_environment_extra_utilities)
336
+
337
+ @config.prompts_environment_extra_utilities
338
+ end
339
+
340
+ def load_builtin_prompt(name)
341
+ path = File.expand_path("../agent/prompts/#{name}.txt", __dir__)
342
+ File.exist?(path) ? File.read(path).strip : nil
343
+ rescue StandardError
344
+ nil
345
+ end
346
+
347
+ def load_summary
348
+ Session::SummaryStore.new.latest_content(@session[:id])
349
+ rescue StandardError
350
+ nil
351
+ end
352
+
353
+ def load_project_context
354
+ return nil if @ignore_rules
355
+ return nil unless @config.dig("memory", "project_context_enabled")
356
+ return nil unless project_local_trusted?
357
+
358
+ # Discover from the PRIMARY workspace root (not just Dir.pwd) so project
359
+ # context tracks terminal.cwd and the dir the trust gate vouched for.
360
+ discovery = Context::FileDiscovery.new(base_path: Rubino::Workspace.primary_root)
361
+ discovery.load_project_context
362
+ rescue StandardError
363
+ nil
364
+ end
365
+
366
+ # Folder-trust gate (proportionate; see Rubino::Trust). The cwd's
367
+ # AGENTS.md/etc. and its .rubino/skills are auto-injected into the system
368
+ # prompt, so a hostile repo could STEER the agent the moment you start
369
+ # there. We withhold that project-local context until the primary root is
370
+ # trusted — the CLI prompts once at boot / on /add-dir and remembers the
371
+ # answer. An already-trusted dir (or one the user never gated, e.g. a bare
372
+ # scratch dir with no context files) loads normally.
373
+ def project_local_trusted?
374
+ Rubino::Trust.trusted?(Rubino::Workspace.primary_root)
375
+ rescue StandardError
376
+ # Never let the trust check itself drop context on a real error; the
377
+ # boot-time prompt is the authoritative gate, this is defence-in-depth.
378
+ true
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Context
5
+ # Builds structured summaries from compressible message segments.
6
+ # Uses the LLM to generate a comprehensive summary following the template.
7
+ class SummaryBuilder
8
+ SUMMARY_TEMPLATE = <<~TEMPLATE
9
+ ## Goal
10
+ Current user objective.
11
+
12
+ ## Constraints & Preferences
13
+ Technical constraints, preferences, conventions.
14
+
15
+ ## Progress
16
+
17
+ ### Done
18
+ Completed items.
19
+
20
+ ### In Progress
21
+ Work in progress.
22
+
23
+ ### Blocked
24
+ Open blockers or errors.
25
+
26
+ ## Key Decisions
27
+ Technical decisions made and their rationale.
28
+
29
+ ## Relevant Files
30
+ Files read, modified, or created.
31
+
32
+ ## Tool Results
33
+ Important tool execution results.
34
+
35
+ ## Current State
36
+ Current session state.
37
+
38
+ ## Next Steps
39
+ Planned next actions.
40
+
41
+ ## Critical Context
42
+ Information that must not be lost.
43
+ TEMPLATE
44
+
45
+ def initialize(session_id:, config: nil)
46
+ @session_id = session_id
47
+ @config = config || Rubino.configuration
48
+ end
49
+
50
+ # Builds a summary from messages, optionally incorporating a previous summary
51
+ def build(messages:, previous_summary: nil)
52
+ content = format_messages_for_summary(messages)
53
+
54
+ prompt = build_summary_prompt(content, previous_summary)
55
+ @config.compression_max_summary_tokens
56
+
57
+ # Use the auxiliary compression model if configured
58
+ model = compression_model
59
+ adapter = LLM::RubyLLMAdapter.new(model_id: model)
60
+
61
+ response = adapter.chat(messages: [
62
+ { role: "system", content: summary_system_prompt },
63
+ { role: "user", content: prompt }
64
+ ])
65
+
66
+ response&.content || fallback_summary(messages, previous_summary)
67
+ rescue StandardError
68
+ # If LLM fails, produce a basic extractive summary
69
+ fallback_summary(messages, previous_summary)
70
+ end
71
+
72
+ # Builds and saves the summary to the database
73
+ def build_and_save!
74
+ message_store = Session::Store.new
75
+ messages = message_store.for_session(@session_id)
76
+ return if messages.size < 10
77
+
78
+ summary = build(messages: messages, previous_summary: load_previous_summary)
79
+ save!(summary)
80
+ end
81
+
82
+ private
83
+
84
+ def summary_system_prompt
85
+ <<~PROMPT
86
+ You are a context summarizer. Your job is to create a structured summary
87
+ of a conversation segment that preserves all important information.
88
+
89
+ Follow this template structure:
90
+ #{SUMMARY_TEMPLATE}
91
+
92
+ Be concise but comprehensive. Do not lose critical technical details,
93
+ file paths, decisions, or error states.
94
+ PROMPT
95
+ end
96
+
97
+ def build_summary_prompt(content, previous_summary)
98
+ parts = []
99
+
100
+ parts << "Previous summary to incorporate:\n#{previous_summary}\n\n---\n" if previous_summary
101
+
102
+ parts << "New conversation segment to summarize:\n#{content}"
103
+ parts.join("\n")
104
+ end
105
+
106
+ def format_messages_for_summary(messages)
107
+ messages.map do |msg|
108
+ role = msg.respond_to?(:role) ? msg.role : msg[:role]
109
+ content = msg.respond_to?(:content) ? msg.content : msg[:content]
110
+ "[#{role}] #{content}"
111
+ end.join("\n\n")
112
+ end
113
+
114
+ def compression_model
115
+ aux_config = @config.auxiliary_compression_config
116
+ model = aux_config["model"]
117
+
118
+ if model && !model.empty?
119
+ model
120
+ else
121
+ @config.model_default
122
+ end
123
+ end
124
+
125
+ def summary_store
126
+ @summary_store ||= Session::SummaryStore.new
127
+ end
128
+
129
+ def load_previous_summary
130
+ summary_store.latest_content(@session_id)
131
+ end
132
+
133
+ def save!(content)
134
+ summary_store.insert(session_id: @session_id, content: content)
135
+ end
136
+
137
+ def fallback_summary(messages, previous_summary)
138
+ parts = []
139
+ parts << "## Previous Context\n#{previous_summary}" if previous_summary
140
+
141
+ # Extract key information heuristically
142
+ parts << "## Conversation Summary"
143
+ parts << "Messages in segment: #{messages.size}"
144
+
145
+ # Get user messages as goal indicators
146
+ user_msgs = messages.select { |m| (m.respond_to?(:role) ? m.role : m[:role]) == "user" }
147
+ unless user_msgs.empty?
148
+ parts << "\n### User Requests"
149
+ user_msgs.last(3).each do |m|
150
+ content = m.respond_to?(:content) ? m.content : m[:content]
151
+ parts << "- #{content&.slice(0, 200)}"
152
+ end
153
+ end
154
+
155
+ parts.join("\n")
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Context
5
+ # Manages token budget calculations and determines when compaction is needed.
6
+ class TokenBudget
7
+ CHARS_PER_TOKEN = 4 # Rough approximation
8
+ # Fallback when the user hasn't pinned `model.context_length` in config.
9
+ # Generous-but-safe; truncation kicks in via `needs_compaction?` long
10
+ # before the real provider limit would be hit.
11
+ DEFAULT_CONTEXT_WINDOW = 128_000
12
+
13
+ def initialize(model_id:, config:)
14
+ @model_id = model_id
15
+ @config = config
16
+ @context_window = determine_context_window
17
+ end
18
+
19
+ attr_reader :context_window
20
+
21
+ # Returns the max tokens available for conversation
22
+ def available_tokens
23
+ override = @config.dig("context", "max_tokens")
24
+ override || @context_window
25
+ end
26
+
27
+ # Estimates token count for a set of messages
28
+ def estimate_tokens(messages)
29
+ total_chars = messages.sum { |m| (m[:content] || "").length }
30
+ (total_chars.to_f / CHARS_PER_TOKEN).ceil
31
+ end
32
+
33
+ # Returns true if the messages exceed the compaction threshold
34
+ def needs_compaction?(messages)
35
+ return false unless @config.compression_enabled?
36
+
37
+ estimated = estimate_tokens(messages)
38
+ threshold = (available_tokens * @config.compression_threshold).to_i
39
+ estimated > threshold
40
+ end
41
+
42
+ # Returns true if critically close to context limit
43
+ def critical?(messages)
44
+ return false unless @config.compression_enabled?
45
+
46
+ estimated = estimate_tokens(messages)
47
+ gateway = (available_tokens * @config.compression_gateway_threshold).to_i
48
+ estimated > gateway
49
+ end
50
+
51
+ # Returns the target token count after compaction
52
+ def compaction_target
53
+ (available_tokens * @config.compression_target_ratio).to_i
54
+ end
55
+
56
+ private
57
+
58
+ # Single source of truth: the user's `model.context_length` config
59
+ # value if set, else the default. We deliberately do NOT maintain a
60
+ # per-model lookup table — `assume_model_exists: true` already lets
61
+ # any provider-compatible model id work; if its real window differs
62
+ # from the default, the user pins it in config.
63
+ def determine_context_window
64
+ @config.model_context_length || DEFAULT_CONTEXT_WINDOW
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Context
5
+ # Ensures tool_call and tool_result pairs are not split during compaction.
6
+ # If a tool_call is in the compressible section, its result must be too (and
7
+ # vice versa).
8
+ #
9
+ # WIRE FORMAT: an assistant tool call lives in metadata[:tool_calls] (a list
10
+ # of { id:, name:, arguments: }), NOT in tool_call_id — tool_call_id is only
11
+ # set on role:"tool" RESULT rows. The original predicate keyed off
12
+ # `tool_call_id && role=="assistant"`, a contradiction that never matched, so
13
+ # the trailing-orphan trim was inert. The methods below read metadata, and
14
+ # are also reused by PromptAssembler's pre-send repair pass.
15
+ class ToolPairSanitizer
16
+ # Adjusts a slice to ensure tool pairs remain intact at its boundaries.
17
+ def sanitize(middle_messages)
18
+ adjusted = middle_messages.dup
19
+
20
+ # Leading orphan: a tool RESULT whose call lives in the head section.
21
+ adjusted.shift while adjusted.first&.role == "tool"
22
+
23
+ # Trailing orphan: an assistant tool call whose results are NOT all
24
+ # present after it in this slice (e.g. interrupted turn, or results
25
+ # landed in the tail section). A fully-PAIRED trailing call is kept.
26
+ adjusted.pop while adjusted.last && trailing_unanswered_tool_call?(adjusted)
27
+
28
+ adjusted
29
+ end
30
+
31
+ # True when the message is an assistant turn carrying tool calls.
32
+ def assistant_tool_call?(message)
33
+ message.role == "assistant" &&
34
+ message.respond_to?(:metadata) && message.metadata.is_a?(Hash) &&
35
+ !Array(message.metadata[:tool_calls]).empty?
36
+ end
37
+
38
+ # The tool_call ids declared by an assistant message. Handles both
39
+ # symbol and string keys — metadata is hydrated with symbolize_names but
40
+ # in-memory messages (pre-persist) may carry string keys.
41
+ def tool_call_ids(message)
42
+ Array(message.metadata[:tool_calls]).map { |tc| tc[:id] || tc["id"] }.compact
43
+ end
44
+
45
+ private
46
+
47
+ # The last message is an unanswered assistant tool call: it declares ids
48
+ # that are not all satisfied by role:"tool" results appearing after it.
49
+ def trailing_unanswered_tool_call?(messages)
50
+ last = messages.last
51
+ return false unless assistant_tool_call?(last)
52
+
53
+ ids = tool_call_ids(last)
54
+ return true if ids.empty? # malformed call with no id → cannot be paired
55
+
56
+ answered = answered_ids(messages, after_index: messages.length - 1)
57
+ !ids.all? { |id| answered.include?(id) }
58
+ end
59
+
60
+ # Set of tool_call_ids answered by role:"tool" messages positioned after
61
+ # the given index in the slice.
62
+ def answered_ids(messages, after_index:)
63
+ messages[(after_index + 1)..]
64
+ &.select { |m| m.role == "tool" && m.tool_call_id }
65
+ &.map(&:tool_call_id)
66
+ &.to_set || Set.new
67
+ end
68
+ end
69
+ end
70
+ end