rubino-agent 0.5.0 → 0.5.1

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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -0
  3. data/.rubocop_todo.yml +1 -0
  4. data/CHANGELOG.md +317 -0
  5. data/README.md +56 -7
  6. data/Rakefile +17 -0
  7. data/docs/agents.md +40 -25
  8. data/docs/architecture.md +2 -9
  9. data/docs/commands.md +18 -6
  10. data/docs/configuration.md +154 -7
  11. data/docs/mcp.md +3 -3
  12. data/docs/memory.md +3 -3
  13. data/docs/security.md +1 -1
  14. data/docs/tools.md +45 -49
  15. data/ext/landlock/extconf.rb +78 -0
  16. data/ext/landlock/landlock.c +253 -0
  17. data/lib/rubino/agent/action_claim_guard.rb +61 -29
  18. data/lib/rubino/agent/definition.rb +3 -19
  19. data/lib/rubino/agent/iteration_budget.rb +1 -1
  20. data/lib/rubino/agent/loop.rb +188 -22
  21. data/lib/rubino/agent/prompts/build.txt +36 -5
  22. data/lib/rubino/agent/prompts/general.txt +8 -3
  23. data/lib/rubino/agent/runner.rb +179 -10
  24. data/lib/rubino/agent/tool_executor.rb +205 -20
  25. data/lib/rubino/agent/truncation_continuation.rb +7 -4
  26. data/lib/rubino/api/operations/approvals/decide_operation.rb +0 -4
  27. data/lib/rubino/api/operations/clarifications/decide_operation.rb +0 -4
  28. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +0 -4
  29. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +0 -4
  30. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +0 -4
  31. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +1 -5
  32. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +1 -5
  33. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +0 -4
  34. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +0 -4
  35. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +0 -4
  36. data/lib/rubino/api/operations/files/read_operation.rb +1 -5
  37. data/lib/rubino/api/operations/files/upload_operation.rb +0 -4
  38. data/lib/rubino/api/operations/health_operation.rb +1 -5
  39. data/lib/rubino/api/operations/memory/delete_operation.rb +0 -4
  40. data/lib/rubino/api/operations/memory/index_operation.rb +0 -4
  41. data/lib/rubino/api/operations/memory/stats_operation.rb +0 -4
  42. data/lib/rubino/api/operations/metrics_operation.rb +1 -1
  43. data/lib/rubino/api/operations/mode/show_operation.rb +0 -4
  44. data/lib/rubino/api/operations/mode/update_operation.rb +0 -4
  45. data/lib/rubino/api/operations/models/list_operation.rb +0 -4
  46. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +0 -4
  47. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +0 -4
  48. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +0 -4
  49. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +0 -4
  50. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +0 -4
  51. data/lib/rubino/api/operations/runs/create_operation.rb +0 -4
  52. data/lib/rubino/api/operations/runs/events_operation.rb +0 -4
  53. data/lib/rubino/api/operations/runs/stop_operation.rb +0 -4
  54. data/lib/rubino/api/operations/sessions/create_operation.rb +0 -4
  55. data/lib/rubino/api/operations/sessions/delete_operation.rb +0 -4
  56. data/lib/rubino/api/operations/sessions/index_operation.rb +0 -4
  57. data/lib/rubino/api/operations/sessions/retry_operation.rb +0 -4
  58. data/lib/rubino/api/operations/sessions/show_operation.rb +0 -4
  59. data/lib/rubino/api/operations/sessions/undo_operation.rb +0 -4
  60. data/lib/rubino/api/operations/skills/list_operation.rb +0 -4
  61. data/lib/rubino/api/operations/skills/toggle_operation.rb +0 -4
  62. data/lib/rubino/api/operations/tasks/index_operation.rb +0 -4
  63. data/lib/rubino/api/operations/tasks/show_operation.rb +0 -4
  64. data/lib/rubino/api/operations/tasks/stop_operation.rb +0 -4
  65. data/lib/rubino/api/router.rb +2 -2
  66. data/lib/rubino/attachments/policy.rb +8 -0
  67. data/lib/rubino/attachments/preamble.rb +16 -8
  68. data/lib/rubino/cli/chat/completion_builder.rb +2 -2
  69. data/lib/rubino/cli/chat/session_resolver.rb +100 -30
  70. data/lib/rubino/cli/chat_command.rb +607 -113
  71. data/lib/rubino/cli/commands.rb +93 -1
  72. data/lib/rubino/cli/config_command.rb +54 -7
  73. data/lib/rubino/cli/doctor_command.rb +73 -20
  74. data/lib/rubino/cli/jobs_command.rb +38 -11
  75. data/lib/rubino/cli/memory_command.rb +29 -9
  76. data/lib/rubino/cli/onboarding_wizard.rb +6 -1
  77. data/lib/rubino/cli/server_command.rb +43 -1
  78. data/lib/rubino/cli/session_command.rb +129 -29
  79. data/lib/rubino/cli/setup_command.rb +166 -4
  80. data/lib/rubino/cli/skills_command.rb +21 -0
  81. data/lib/rubino/commands/built_ins.rb +2 -2
  82. data/lib/rubino/commands/executor.rb +16 -11
  83. data/lib/rubino/commands/handlers/agents.rb +199 -30
  84. data/lib/rubino/commands/handlers/config.rb +4 -0
  85. data/lib/rubino/commands/handlers/display.rb +50 -0
  86. data/lib/rubino/commands/handlers/help.rb +2 -9
  87. data/lib/rubino/commands/handlers/mcp.rb +7 -32
  88. data/lib/rubino/commands/handlers/memory.rb +10 -35
  89. data/lib/rubino/commands/handlers/sessions.rb +64 -50
  90. data/lib/rubino/commands/handlers/skills.rb +47 -28
  91. data/lib/rubino/commands/handlers/status.rb +56 -6
  92. data/lib/rubino/compression/compression_result.rb +35 -0
  93. data/lib/rubino/compression/compressor.rb +109 -0
  94. data/lib/rubino/compression/content_router.rb +240 -0
  95. data/lib/rubino/compression/diff_compressor.rb +252 -0
  96. data/lib/rubino/compression/javascript_code_skeleton.rb +15 -0
  97. data/lib/rubino/compression/json_compressor.rb +274 -0
  98. data/lib/rubino/compression/line_skeleton.rb +92 -0
  99. data/lib/rubino/compression/log_compressor.rb +299 -0
  100. data/lib/rubino/compression/python_code_skeleton.rb +122 -0
  101. data/lib/rubino/compression/ruby_code_skeleton.rb +80 -0
  102. data/lib/rubino/compression/tree_sitter_code_skeleton.rb +118 -0
  103. data/lib/rubino/compression/tsx_code_skeleton.rb +15 -0
  104. data/lib/rubino/compression/typescript_code_skeleton.rb +15 -0
  105. data/lib/rubino/config/configuration.rb +70 -86
  106. data/lib/rubino/config/defaults.rb +229 -8
  107. data/lib/rubino/config/loader.rb +9 -1
  108. data/lib/rubino/config/reasoning_prefs.rb +23 -0
  109. data/lib/rubino/config/validator.rb +50 -7
  110. data/lib/rubino/context/compressor.rb +1 -1
  111. data/lib/rubino/context/file_discovery.rb +0 -8
  112. data/lib/rubino/context/message_boundary.rb +2 -7
  113. data/lib/rubino/context/project_languages.rb +0 -7
  114. data/lib/rubino/context/prompt_assembler.rb +7 -2
  115. data/lib/rubino/context/summary_builder.rb +34 -25
  116. data/lib/rubino/context/token_budget.rb +3 -3
  117. data/lib/rubino/database/migrations/001_create_initial_schema.rb +1 -1
  118. data/lib/rubino/database/migrator.rb +0 -26
  119. data/lib/rubino/files/workspace.rb +2 -2
  120. data/lib/rubino/interaction/events.rb +0 -3
  121. data/lib/rubino/interaction/input_queue.rb +11 -0
  122. data/lib/rubino/interaction/lifecycle.rb +144 -25
  123. data/lib/rubino/interaction/polishing.rb +8 -0
  124. data/lib/rubino/interaction/probe.rb +1 -1
  125. data/lib/rubino/jobs/cron_job_repository.rb +0 -4
  126. data/lib/rubino/jobs/handlers/distill_skill_job.rb +3 -13
  127. data/lib/rubino/jobs/queue.rb +70 -5
  128. data/lib/rubino/jobs/worker.rb +1 -1
  129. data/lib/rubino/llm/adapter_factory.rb +1 -1
  130. data/lib/rubino/llm/auxiliary_client.rb +63 -3
  131. data/lib/rubino/llm/cache_breakpoint_middleware.rb +194 -0
  132. data/lib/rubino/llm/credential_check.rb +61 -4
  133. data/lib/rubino/llm/error_classifier.rb +142 -121
  134. data/lib/rubino/llm/fake_provider.rb +3 -3
  135. data/lib/rubino/llm/inline_think_filter.rb +34 -3
  136. data/lib/rubino/llm/reasoning_manager.rb +3 -26
  137. data/lib/rubino/llm/request.rb +0 -16
  138. data/lib/rubino/llm/ruby_llm_adapter.rb +233 -25
  139. data/lib/rubino/llm/scenario_loader.rb +10 -17
  140. data/lib/rubino/llm/scenarios/glued-table-prose.yml +36 -0
  141. data/lib/rubino/llm/scenarios/growing-table.yml +49 -0
  142. data/lib/rubino/llm/scenarios/narrow-terminal-table.yml +47 -0
  143. data/lib/rubino/llm/scenarios/streamed-table.yml +55 -0
  144. data/lib/rubino/llm/scenarios/table-then-prose.yml +34 -0
  145. data/lib/rubino/llm/scenarios/too-wide-table.yml +47 -0
  146. data/lib/rubino/llm/scenarios/wide-table.yml +1 -1
  147. data/lib/rubino/llm/thinking_support.rb +17 -12
  148. data/lib/rubino/llm/tool_bridge.rb +101 -37
  149. data/lib/rubino/mcp/manager.rb +53 -9
  150. data/lib/rubino/mcp/mcp_tool_wrapper.rb +24 -0
  151. data/lib/rubino/memory/backends/sqlite.rb +43 -35
  152. data/lib/rubino/memory/backends.rb +3 -3
  153. data/lib/rubino/memory/deduplicator.rb +22 -0
  154. data/lib/rubino/memory/flusher.rb +35 -1
  155. data/lib/rubino/memory/salience_gate.rb +26 -0
  156. data/lib/rubino/memory/sqlite_extraction_prompt.rb +5 -1
  157. data/lib/rubino/memory/store.rb +29 -29
  158. data/lib/rubino/memory/threat_scanner.rb +8 -0
  159. data/lib/rubino/memory.rb +47 -0
  160. data/lib/rubino/oauth/provider.rb +0 -5
  161. data/lib/rubino/run/event_store.rb +1 -6
  162. data/lib/rubino/run/repository.rb +0 -14
  163. data/lib/rubino/security/approval_policy.rb +116 -30
  164. data/lib/rubino/security/command_normalizer.rb +36 -0
  165. data/lib/rubino/security/dangerous_patterns.rb +17 -4
  166. data/lib/rubino/security/hardline_guard.rb +4 -3
  167. data/lib/rubino/security/readonly_commands.rb +299 -15
  168. data/lib/rubino/security/redactor.rb +272 -0
  169. data/lib/rubino/security/sandbox.rb +460 -0
  170. data/lib/rubino/security/secret_detector.rb +110 -0
  171. data/lib/rubino/security/secret_path.rb +136 -7
  172. data/lib/rubino/session/lock.rb +91 -0
  173. data/lib/rubino/session/message.rb +38 -3
  174. data/lib/rubino/session/picker.rb +95 -0
  175. data/lib/rubino/session/repository.rb +57 -40
  176. data/lib/rubino/session/store.rb +0 -11
  177. data/lib/rubino/skills/registry.rb +14 -5
  178. data/lib/rubino/skills/skill.rb +31 -10
  179. data/lib/rubino/skills/skill_tool.rb +3 -18
  180. data/lib/rubino/skills/state_repository.rb +0 -4
  181. data/lib/rubino/tools/background_tasks.rb +179 -40
  182. data/lib/rubino/tools/base.rb +87 -73
  183. data/lib/rubino/tools/edit_tool.rb +50 -20
  184. data/lib/rubino/tools/fuzzy_match.rb +212 -0
  185. data/lib/rubino/tools/glob_tool.rb +5 -1
  186. data/lib/rubino/tools/grep_tool.rb +17 -51
  187. data/lib/rubino/tools/multi_edit_tool.rb +32 -19
  188. data/lib/rubino/tools/patch_tool.rb +51 -10
  189. data/lib/rubino/tools/probe_tool.rb +0 -20
  190. data/lib/rubino/tools/question_tool.rb +54 -2
  191. data/lib/rubino/tools/read_attachment_tool.rb +21 -11
  192. data/lib/rubino/tools/read_tool.rb +131 -25
  193. data/lib/rubino/tools/read_tracker.rb +36 -0
  194. data/lib/rubino/tools/registry.rb +63 -44
  195. data/lib/rubino/tools/result.rb +43 -12
  196. data/lib/rubino/tools/retrieve_output_tool.rb +70 -0
  197. data/lib/rubino/tools/ruby_tool.rb +0 -0
  198. data/lib/rubino/tools/shell_kill_tool.rb +6 -2
  199. data/lib/rubino/tools/shell_output_tool.rb +7 -1
  200. data/lib/rubino/tools/shell_registry.rb +169 -15
  201. data/lib/rubino/tools/shell_tail_tool.rb +6 -1
  202. data/lib/rubino/tools/shell_tool.rb +483 -53
  203. data/lib/rubino/tools/steer_tool.rb +2 -21
  204. data/lib/rubino/tools/subagent_probe.rb +1 -1
  205. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  206. data/lib/rubino/tools/task_result_tool.rb +8 -2
  207. data/lib/rubino/tools/task_stop_tool.rb +5 -6
  208. data/lib/rubino/tools/task_tool.rb +200 -103
  209. data/lib/rubino/tools/vision_tool.rb +32 -4
  210. data/lib/rubino/tools/webfetch_tool.rb +145 -0
  211. data/lib/rubino/tools/write_tool.rb +1 -1
  212. data/lib/rubino/ui/agent_menu.rb +179 -0
  213. data/lib/rubino/ui/api.rb +2 -2
  214. data/lib/rubino/ui/base.rb +2 -2
  215. data/lib/rubino/ui/bottom_composer.rb +1112 -140
  216. data/lib/rubino/ui/cli.rb +898 -262
  217. data/lib/rubino/ui/completion_menu.rb +24 -43
  218. data/lib/rubino/ui/composer/input_line.rb +131 -0
  219. data/lib/rubino/ui/composer/subagent_panel.rb +35 -0
  220. data/lib/rubino/ui/headless_trace.rb +1 -1
  221. data/lib/rubino/ui/input_history.rb +90 -5
  222. data/lib/rubino/ui/live_region.rb +12 -0
  223. data/lib/rubino/ui/markdown_renderer.rb +103 -41
  224. data/lib/rubino/ui/menu_view.rb +117 -0
  225. data/lib/rubino/ui/null.rb +1 -1
  226. data/lib/rubino/ui/paste_store.rb +33 -1
  227. data/lib/rubino/ui/printer_base.rb +135 -8
  228. data/lib/rubino/ui/streaming_markdown.rb +89 -0
  229. data/lib/rubino/ui/subagent_cards.rb +126 -25
  230. data/lib/rubino/util/atomic_file.rb +12 -0
  231. data/lib/rubino/util/duration.rb +8 -5
  232. data/lib/rubino/util/output.rb +55 -10
  233. data/lib/rubino/version.rb +7 -1
  234. data/lib/rubino/workspace.rb +65 -2
  235. data/lib/rubino.rb +29 -22
  236. data/rubino-agent.gemspec +27 -1
  237. metadata +78 -20
  238. data/docs/plugins.md +0 -195
  239. data/lib/rubino/interaction/state.rb +0 -56
  240. data/lib/rubino/memory/backends/default.rb +0 -101
  241. data/lib/rubino/memory/extractor.rb +0 -85
  242. data/lib/rubino/memory/retriever.rb +0 -50
  243. data/lib/rubino/plugins/registry.rb +0 -75
  244. data/lib/rubino/plugins.rb +0 -86
  245. data/lib/rubino/tools/answer_child_tool.rb +0 -83
  246. data/lib/rubino/tools/ask_parent_tool.rb +0 -232
  247. data/lib/rubino/tools/git_tool.rb +0 -71
  248. data/lib/rubino/tools/github_tool.rb +0 -233
  249. data/lib/rubino/tools/test_tool.rb +0 -454
  250. data/lib/rubino/ui/subagent_view.rb +0 -280
data/docs/commands.md CHANGED
@@ -85,6 +85,7 @@ Pasting **text** into the chat input goes through the file-backed paste pipeline
85
85
  - `--new` forces a fresh session; `--continue`/`-c` resumes the latest; `--resume`/`-r <id|title>` resumes a specific one.
86
86
  - `--resume` matches an ID prefix first, then a case-insensitive substring of the session title **or its full first prompt** — so a memorable phrase from the tail of a long first message works even though the stored title is truncated. More than one match is an error listing the candidates; no match exits non-zero with a pointer to `rubino sessions list`.
87
87
  - One-shot mode (`-q` / `prompt`) does **not** auto-resume — automation isn't silently hijacked onto a past session; pass `--resume`/`--continue` explicitly if you want it.
88
+ - A bare **`rubino sessions`** on a real terminal opens an arrow-key resume picker over this directory's sessions (`--all` for every dir); ↑↓ select, Enter loads the chosen session into the chat REPL (the same as `rubino chat --session <id>`), Esc cancels. Off a TTY (piped/redirected) it prints the static `list` table so scripts stay deterministic; `rubino sessions list` is always the table.
88
89
  - One-shot output: when stdout is a **terminal** the answer renders through the same markdown pipeline as interactive chat (styled text, fitted tables, wrapping); when stdout is **piped/redirected** the answer stays plain raw text and diagnostics go to stderr, so `$(rubino prompt …)` stays clean.
89
90
  - Sessions are marked ended on clean exit, terminal close (SIGHUP), or kill (SIGTERM), so a closed window doesn't leave a session looking active.
90
91
 
@@ -134,6 +135,7 @@ rubino memory show ID
134
135
  rubino memory delete ID
135
136
  rubino memory backend [NAME] # show the active memory backend, or switch to NAME
136
137
 
138
+ rubino sessions # on a TTY: arrow-key resume picker (Enter loads, Esc cancels); piped: lists
137
139
  rubino sessions list
138
140
  rubino sessions show ID
139
141
  rubino sessions compact ID
@@ -175,9 +177,9 @@ Type these inside `rubino chat`. Generated from `BuiltIns::DESCRIPTIONS` (drift-
175
177
  | `/export` | Write the session transcript as markdown (/export [path]) |
176
178
  | `/memory` | Inspect/search/forget what the agent remembers (show ID, backend, --all) |
177
179
  | `/agent` | Switch the primary agent (/agent <name>; a bare /<name> or Tab cycles) |
178
- | `/agents` | List background subagents; steer/probe a running one, or view output |
180
+ | `/agents` | List background subagents; ↓+Enter to attach & steer one live, or steer/probe/view by id |
179
181
  | `/tasks` | Alias for /agents |
180
- | `/reply` | Answer a subagent that is blocked waiting on you (ask_parent) |
182
+ | `/reply` | Answer a subagent that is blocked waiting on you (e.g. an approval) |
181
183
  | `/stop` | Stop a running subagent (/stop <id>; alias for /agents <id> --stop) |
182
184
  | `/jobs` | List the background job queue (status counts); /jobs <id> for detail |
183
185
  | `/skills` | List skills; activate one ('none' clears), or enable/disable NAME |
@@ -202,9 +204,10 @@ Type these inside `rubino chat`. Generated from `BuiltIns::DESCRIPTIONS` (drift-
202
204
 
203
205
  You can keep typing while a turn is running — the pinned input stays live:
204
206
 
205
- - **Enter** interrupts the current turn and runs your line as the **next** turn (the partial answer is kept and marked `⎿ interrupted`).
206
- - **Alt+Enter** queues the line **without** interrupting: it runs after the current turn finishes, with a live `⏳ queued:` indicator above the input until it does. At idle (no turn running) there is nothing to queue behind, so Alt+Enter submits the line immediately, same as Enter.
207
- - **`/queued <message>`** is the terminal-independent fallback for Alt+Enter (some terminals don't deliver the chord) — it queues the message the same way.
207
+ - **Enter** queues the line **without** interrupting (the queue-by-default / type-ahead model, #421): the current turn keeps running, the line waits behind any earlier-queued items (FIFO) with a live `⏳ queued:` indicator above the input, and it is committed as a normal message when its turn runs. At idle (no turn running) Enter submits immediately.
208
+ - **Esc** interrupts the current turn (the partial answer is kept and marked `⎿ interrupted`); any queued lines then run as the next turns.
209
+ - **`/queued <message>`** queues a message explicitly — the terminal-independent way to enqueue without typing it into the live input.
210
+ - **Read-only meta-commands run immediately, mid-turn.** A small set of non-mutating slash commands — `/agents` (and `/tasks`), `/stop`, `/status`, `/jobs`, `/help`, `/commands`, `/dirs` — execute **right away** while a turn is running, so you can drill into a sub-agent, stop the run, or check status without interrupting. State-mutating commands (`/model`, `/clear`, `/new`, `/config`, `/mode`, `/reasoning`, `/think`, …) are not available mid-turn: they show a transient `⚠ <cmd> is not available during an active turn — press Esc to interrupt first` notice instead of running.
208
211
 
209
212
  ### Keys: `Esc Esc` — rewind to an earlier message
210
213
 
@@ -324,12 +327,21 @@ The agent spawns background subagents with its `task` tool; these commands are t
324
327
  /agents <id> --stop # cancel a running subagent (blocked descendants unwind too)
325
328
  /agents <id> steer "note" # park a note folded into the child's context at its next turn
326
329
  /agents <id> probe "question" # ephemeral read-only peek — nothing is saved to the child
327
- /reply <id> <answer> # answer a subagent blocked on an ask_parent question
330
+ /reply <id> <answer> # answer a subagent blocked on you (e.g. an approval)
328
331
  /reply # bare: list the subagents currently blocked on you
329
332
  ```
330
333
 
331
334
  `/tasks` is an alias for `/agents`.
332
335
 
336
+ **Attach to a subagent (agent-view).** Instead of typing ids, press `↓` at the
337
+ idle prompt to open the subagent picker, arrow to one, and `Enter` to **attach**:
338
+ the screen switches to that agent's own full timeline (its tool calls and what it
339
+ said, replayed) and the prompt becomes scoped — `sa_xxxx ❯`. While attached, just
340
+ type to steer the running child (or answer it if it's blocked on you); `←` on the
341
+ empty prompt (or `/detach`) returns to the main timeline. The scoped prompt makes
342
+ the global `/agents <id> steer/probe` and `/reply <id>` forms redundant — they're
343
+ the same operations, by id, from anywhere.
344
+
333
345
  ### Workspace roots: `/add-dir` and `/dirs`
334
346
 
335
347
  The workspace sandbox confines write/edit/delete tools to the workspace roots. `/add-dir <path>` adds an extra allowed root mid-session (and runs the one-time folder-trust gate, so the new root's `AGENTS.md`/skills are only honored once vouched for); `/dirs` lists the current roots and their trust state. Typing `/add-dir ` opens a directory-path dropdown (relative, absolute, and `~` paths complete as you type).
@@ -65,10 +65,27 @@ providers:
65
65
  assume_model_exists: true
66
66
  base_url: null
67
67
  request_timeout_seconds: 600
68
+ extra_body: {} # free-form body merged into /v1/chat/completions
68
69
  ```
69
70
 
70
71
  Per-provider you may also set `api_key`, and for custom gateways `anthropic_compatible: true` (MiniMax) or `openai_compatible: true`. See [models-and-keys.md](models-and-keys.md).
71
72
 
73
+ #### `extra_body` — OpenAI-compatible request passthrough
74
+
75
+ `providers.<name>.extra_body` is a free-form hash deep-merged verbatim into the OpenAI-style `/v1/chat/completions` request body. It is honored **only on the OpenAI-compatible request path** (`openai_compatible: true`, or the native `openai` provider) and is never applied on the anthropic-family path, nor does it touch the thinking-budget logic. Adapter-routed keys (`max_tokens`, `thinking`) win on conflict. Left unset (the default `{}`) the request is byte-identical to before.
76
+
77
+ Use it to pass provider-specific knobs the adapter does not model natively. The canonical case is suppressing chain-of-thought leakage on oMLX / Qwen-style backends that emit `<think>` text instead of native `tool_calls` unless the request carries `chat_template_kwargs: { enable_thinking: false }`:
78
+
79
+ ```yaml
80
+ providers:
81
+ gateway:
82
+ openai_compatible: true
83
+ base_url: "http://localhost:8000/v1"
84
+ extra_body:
85
+ chat_template_kwargs:
86
+ enable_thinking: false
87
+ ```
88
+
72
89
  Per-provider `supports_thinking: true | false` declares whether the backend handles an Anthropic-style thinking budget correctly; `false` means no budget is ever sent to it, regardless of `thinking.effort`. Unset, MiniMax-family model ids default to `false`, everything else to `true` — see [reasoning & thinking](#reasoning--thinking).
73
90
 
74
91
  ### auxiliary
@@ -97,6 +114,14 @@ auxiliary:
97
114
  timeout: 300
98
115
  ```
99
116
 
117
+ Each block routes through `LLM::AuxiliaryClient`, so `provider`/`model`/`base_url`
118
+ are all honored: `provider: "main"` (or empty) reuses the primary provider, an empty
119
+ `model` falls back to `model.default`, and a `base_url` points that task at a
120
+ different endpoint. `auxiliary.compression` is the **context-compaction summary**
121
+ model — at the defaults it is the primary model (e.g. MiniMax-M3), unchanged; set
122
+ `provider`/`model`/`base_url` to run compaction summaries on a different
123
+ (OpenAI-compatible) endpoint.
124
+
100
125
  ### agent
101
126
 
102
127
  ```yaml
@@ -154,7 +179,7 @@ ui:
154
179
 
155
180
  ### notifications
156
181
 
157
- Attention signals for the moments the agent needs human eyes: a long turn finishing, an approval prompt parking the run on a decision, or a background subagent escalating an `ask_parent` to you (the ⛔ banner).
182
+ Attention signals for the moments the agent needs human eyes: a long turn finishing, or an approval prompt parking the run on a decision (the main agent's card or a background subagent flipping to `needs_approval`).
158
183
 
159
184
  ```yaml
160
185
  notifications:
@@ -164,7 +189,7 @@ notifications:
164
189
  min_turn_seconds: 10 # a turn must run at least this long before its completion notifies; quick turns stay silent
165
190
  ```
166
191
 
167
- - **Events**: `turn_finished` (only when the turn ran ≥ `min_turn_seconds`), `needs_approval` (the main agent's approval card or a background child flipping to `needs approval`), `blocked` (a background child escalated `ask_parent` to the human).
192
+ - **Events**: `turn_finished` (only when the turn ran ≥ `min_turn_seconds`), `needs_approval` (the main agent's approval card or a background child flipping to `needs approval`). A third event, `blocked`, exists in the enum for a child parked on the human; with subagents now non-blocking it is not raised in normal operation.
168
193
  - **Bell hygiene**: the BEL byte is only ever written to a real terminal — never into a pipe — and is routed to the real terminal IO even while the bottom composer owns the screen (BEL doesn't move the cursor).
169
194
  - **`command` hook**: runs detached and best-effort (stdio nulled, errors swallowed to the log) with `RUBINO_EVENT` (`turn_finished` | `needs_approval` | `blocked`) and `RUBINO_MESSAGE` in its environment — the seam for `osascript` (macOS), `notify-send` (Linux), or any custom notifier.
170
195
  - **Spam control**: events within ~1s of the last emitted one coalesce into a single signal.
@@ -212,7 +237,6 @@ streaming:
212
237
  transport: "off"
213
238
  edit_interval: 0.3
214
239
  buffer_threshold: 40
215
- cursor: " ▉"
216
240
 
217
241
  context:
218
242
  engine: "compressor"
@@ -244,7 +268,7 @@ compression:
244
268
  ```yaml
245
269
  memory:
246
270
  enabled: true
247
- backend: "sqlite" # tiny-Zep FTS5/BM25 + graph-lite recall (default). "default" = legacy non-ranked store
271
+ backend: "sqlite" # SQLite FTS5/BM25 + graph-lite recall (default). "default" = legacy non-ranked store
248
272
  auto_extract: true
249
273
  auto_save: true
250
274
  user_profile_enabled: true
@@ -279,7 +303,7 @@ tasks:
279
303
  max_children_per_node: 3 # max LIVE direct children per node
280
304
  max_concurrent_total: 8 # hard ceiling on total LIVE subagents across the tree
281
305
  max_live_probes_per_child: 5 # per-child budget for billed live probes (probe(live: true))
282
- ask_parent_timeout: 900 # seconds a blocking ask_parent waits before the child self-heals
306
+ ask_parent_timeout: 900 # vestigial: governed the removed child→parent ask channel; no effect now
283
307
  ```
284
308
 
285
309
  ### tools
@@ -291,7 +315,7 @@ tools:
291
315
  shell: true # ON by default (the agent ships to run inside an isolated VM);
292
316
  # dangerous commands are still gated by security.confirm_policy
293
317
  ruby: true
294
- web: false # Gates BOTH the webfetch and websearch tools
318
+ web: true # ON by default (keyless DuckDuckGo backend); gates BOTH the webfetch and websearch tools
295
319
  memory: true
296
320
  ```
297
321
 
@@ -315,6 +339,107 @@ file_read:
315
339
  max_chars: 100000
316
340
  ```
317
341
 
342
+ ### tool_output_compression
343
+
344
+ Deterministic (no-LLM) compression of a tool's output **before it reaches the
345
+ model**, to spend fewer context tokens on high-volume, low-signal output. This is
346
+ distinct from [`compression`](#compression) (which summarises the *conversation
347
+ history* when the window fills) and from `display.tool_output_preview_lines`
348
+ (scrollback-only). It runs at a single seam — every tool's output passes through
349
+ `Agent::ToolExecutor` — so a content **router** picks the strategy by what the
350
+ output *is*, not by which tool produced it:
351
+
352
+ | Output detected as | Strategy | Effect |
353
+ | --- | --- | --- |
354
+ | test / build / lint / shell logs (rspec, pytest, jest, cargo, npm, make, generic) | `LogCompressor` | keep every error/failure + the summary tally + context, drop passing/info noise (≈97% fewer tokens on a failing suite) |
355
+ | a **whole-file** source read (Ruby) | code `skeleton` | keep signatures, elide large method bodies behind a `read offset:/limit:` pointer |
356
+ | a unified diff (`git diff`, `diff`) | `DiffCompressor` | keep every `+`/`-` line and every file/hunk header; trim far unchanged context to ±N lines; collapse a generated/lock file to a one-line summary. A small/tight diff (the "show me the diff" case) passes through **byte-identical** via the saving guard. The human view is the tool's separate scrollback diff (`body`), which is **never** compressed |
357
+ | a **whole-output** JSON dump (`curl \| jq`, `kubectl get -o json`, `gh api`, `docker inspect`, `aws --output json`, MCP/custom-tool JSON) | `JsonCompressor` | an array of **uniform** objects folds **losslessly** to a schema header + one compact row per item (repeated key names emitted once); a large array whose fold is too thin falls back to lossy row selection where **error-bearing rows and statistical outliers always survive** and dropped rows collapse to an `{"_elided": N}` sentinel; a single large object elides only **big string values** (never drops a key). Detected **before** the log channel, so a JSON shell dump folds as a table and is never log-compressed. Small JSON passes through **byte-identical** via the saving guard |
358
+ | grep / search results (`path:line:`) | passthrough | **byte-identical** |
359
+ | short output | passthrough | unchanged |
360
+
361
+ ```yaml
362
+ tool_output_compression:
363
+ enabled: false # MASTER switch — off ships by default; the whole
364
+ # router is bypassed when false. `rubino setup`
365
+ # offers to turn this (and logs.enabled) on.
366
+ code: # whole-file source reads → skeleton
367
+ strategy: skeleton # only "skeleton" is implemented; any other value = passthrough
368
+ min_lines: 150 # files shorter than this are never skeletonised
369
+ keep_method_body_max_lines: 8 # bodies up to N lines are kept inline; larger ones are elided
370
+ languages: [ruby] # source languages to skeletonise (see note below); `rubino setup` lets you pick
371
+ logs:
372
+ enabled: false # sub-gate: log compression only runs when BOTH this and the master are on
373
+ min_lines: 40 # outputs shorter than this pass through unchanged
374
+ max_total_lines: 100 # cap on kept lines
375
+ max_errors: 10 # keep up to N errors/failures (first and last always kept)
376
+ max_warnings: 5
377
+ max_stack_traces: 3
378
+ context_lines: 4 # lines of surrounding context kept around each failure
379
+ diff: # unified diffs (git diff / diff) — model copy only
380
+ context_lines: 3 # unchanged context kept on each side of a change; far context → `… N unchanged lines`
381
+ min_lines: 40 # diffs shorter than this pass through unchanged ("show me the diff")
382
+ min_saving: 0.25 # only apply when ≥25% smaller; else byte-identical passthrough
383
+ generated_patterns: # changed files matching these collapse to a one-line summary
384
+ - "*.lock"
385
+ - Gemfile.lock
386
+ - package-lock.json
387
+ - yarn.lock
388
+ - pnpm-lock.yaml
389
+ - composer.lock
390
+ - "*.min.js"
391
+ - "*.min.css"
392
+ - dist/
393
+ - build/
394
+ - "*.snap"
395
+ - vendor/
396
+ json: # whole-output JSON dumps (kubectl/gh/docker/aws/jq)
397
+ min_items: 8 # arrays with fewer items (and < min_lines) pass through unchanged
398
+ min_lines: 40 # objects / text shorter than this pass through unchanged
399
+ min_saving: 0.25 # only apply when ≥25% smaller; else byte-identical passthrough
400
+ outlier_sigma: 3.0 # a numeric field > N σ from its column mean = a kept outlier row (lossy)
401
+ max_string_chars: 400 # in a single object, string values longer than this collapse to `<elided N chars>` (key kept)
402
+ ```
403
+
404
+ > **`code.languages`** (default `["ruby"]`) selects which source languages get
405
+ > whole-file skeletonisation; a read whose language isn't listed passes through
406
+ > verbatim, so removing a language disables compression for it. Values: `ruby`
407
+ > (built-in Prism parser, always available), `python` (stdlib `ast` via your
408
+ > `python3` — a no-op if `python3` isn't on PATH), and `javascript` /
409
+ > `typescript` / `tsx` (need the optional `tree_sitter_language_pack` gem — a
410
+ > no-op until it's installed). `rubino setup` offers a language picker and, if you
411
+ > choose JS/TS, asks before installing the parser gem.
412
+
413
+ > `diff` and `json` have **no** own `enabled` sub-gate (like `code`): they are
414
+ > active whenever the master flag is on, and the saving guard (`min_lines`/
415
+ > `min_items` + `min_saving`) is the real gate — small/tight diffs and small JSON
416
+ > the user wants to see stay verbatim automatically.
417
+
418
+ **Reversibility.** When the router compresses, the executor spills the *full
419
+ original* to `<home>/tool-results/<call_id>.txt` and the compressed output ends
420
+ with a passive pointer carrying the call's **id** (`… N line(s) hidden …
421
+ retrieve_output id=<id> only if a hidden line is specifically needed`). The model
422
+ recovers the original by calling the **`retrieve_output`** tool with that id —
423
+ registered **only** while compression is enabled (so the default registry/tool
424
+ count is unchanged). The pointer deliberately prints **no cat-able filesystem
425
+ path**: recovery is an id behind a dedicated tool (headroom-style), so a small
426
+ model can't `sed`/`grep`/`cat` a printed spill path and re-inflate the output the
427
+ compressor just shrank. If the spill failed the pointer says *full output
428
+ unavailable (spill failed)* with no id. **Fidelity:** a failure or summary line
429
+ is never dropped; only passing/info noise is.
430
+
431
+ **Per-call opt-out.** When the feature is on, `read` and `shell` advertise a
432
+ `compress` boolean parameter (default `true`); the model can pass `compress:false`
433
+ to receive the verbatim output for that one call (returned byte-identical).
434
+
435
+ **Telemetry.** Compression events are logged as `compression.applied` /
436
+ `compression.drill_in` / `compression.failed`. `compression.drill_in` is emitted
437
+ on every `retrieve_output` call (a deliberate recovery, carrying the `id`) and on
438
+ a `read`'s targeted offset-read into an elided `:code` skeleton body — so the
439
+ counter measures real recoveries and is **not** bypassable by a shell `sed`/
440
+ `grep`/`cat` (there is no path to cat). A strategy error always falls back to the
441
+ uncompressed text, so compression can never break a tool call.
442
+
318
443
  ### terminal
319
444
 
320
445
  ```yaml
@@ -366,10 +491,18 @@ attachments:
366
491
  inline_text_budget_bytes: 100000
367
492
  allow_kinds: [image, text, document, archive, binary]
368
493
  auto_extract_documents: false
369
- aux_vision_egress: true
494
+ aux_vision_egress: true # allow the `vision` tool to send an image to an EXTERNAL aux model (data egress; see below)
370
495
  archive: { max_entries: 2000, max_uncompressed_bytes: 268435456, max_entry_ratio: 100, max_total_ratio: 50, max_nesting_depth: 1 }
371
496
  ```
372
497
 
498
+ `aux_vision_egress` (default `true`) gates the **`vision` tool**: routing an
499
+ image to an external auxiliary vision model is data egress, so set it to `false`
500
+ to refuse — the tool then returns a clean error instead of sending the bytes
501
+ (#578). Independently, before any egress the tool **content-sniffs** the file
502
+ (magic bytes win over the extension, fail-closed): a path that isn't actually an
503
+ image is rejected, so a mislabelled or non-image file can't be smuggled to the
504
+ external host (#579).
505
+
373
506
  ### security
374
507
 
375
508
  ```yaml
@@ -537,8 +670,22 @@ api:
537
670
  rate_limit_enabled: true
538
671
  rate_limit_unauth_per_minute: 60
539
672
  rate_limit_auth_per_minute: 600
673
+ allow_public_bind: false # gate for a non-loopback bind (see below)
540
674
  ```
541
675
 
676
+ `allow_public_bind` is **false by default (safe)**. The API can execute shell
677
+ tools, so binding it to a non-loopback address (`--host 0.0.0.0`,
678
+ `RUBINO_API_HOST` set to anything other than `127.0.0.1` / `::1` / `localhost`)
679
+ publishes a remote-code-execution surface to the network — and with TLS off the
680
+ bearer token and all traffic travel in cleartext. While this is `false`, the
681
+ server **refuses to boot** on a non-loopback host with an actionable error.
682
+ Loopback binds (the default) are unaffected and need no opt-in.
683
+
684
+ To deliberately expose the listener, set `allow_public_bind: true`. The server
685
+ then boots on the routable host but prints a one-time exposure **WARNING** at
686
+ startup. When you opt in, enable TLS (`RUBINO_TLS=1`) and a strong
687
+ `RUBINO_API_KEY`, and prefer a reverse proxy over a direct bind.
688
+
542
689
  ---
543
690
 
544
691
  ## Environment Variables
data/docs/mcp.md CHANGED
@@ -44,10 +44,10 @@ mcp:
44
44
 
45
45
  ## How It Works
46
46
 
47
- 1. At chat boot (and in `rubino tools`), `MCP::Manager` connects to all configured servers — best-effort: a server that fails to start prints a warning and is skipped, it never blocks the session
47
+ 1. At chat boot (and in `rubino tools`), `MCP::Manager` connects to all configured servers **in parallel** (#576), so one hanging server no longer serializes startup — best-effort: a server that fails to start prints a warning and is skipped, it never blocks the session
48
48
  2. Each server's tools are wrapped in `MCPToolWrapper` (adapts to `Tools::Base` interface), forwarding the server-declared input schema so the model calls them with the right argument names
49
49
  3. Wrapped tools are registered in `Tools::Registry` with a prefix (`servername_toolname`)
50
- 4. The agent can use MCP tools like any built-in tool; a failed MCP call (including a server-side argument rejection) surfaces as an `Error: …` tool result and renders ✗ like any failed built-in tool
50
+ 4. The agent can use MCP tools like any built-in tool; a failed MCP call (including a server-side argument rejection) surfaces as an `Error: …` tool result and renders ✗ like any failed built-in tool. To keep external code visible, an MCP tool's **display label** is suffixed with its source — the live tool card and the approval card both show `<bare> (mcp:<server>)` (e.g. `echo (mcp:chaos)`), so you can tell at a glance that an out-of-process server is running (#582). The model-facing tool name is unchanged.
51
51
  5. `ruby_llm-mcp`'s own log lines (including everything a stdio server prints on its stderr) go to `<home>/logs/mcp.log`, never to stdout — one-shot `rubino prompt` output stays machine-readable
52
52
 
53
53
  MCP tools are dynamic — they come from whatever servers you configure — so they are not part of the drift-checked built-in tool list in [tools.md](tools.md) and have no `tools.<key>` config gate; disable a server (`/mcp <server> off` for the session, or set `mcp.enabled: false`) to remove its tools.
@@ -95,7 +95,7 @@ An `oauth` hash on a `streamable` server is forwarded verbatim to `ruby_llm-mcp`
95
95
  /mcp reload # re-read config.yml and reconnect every server (no chat restart needed)
96
96
  ```
97
97
 
98
- `off`/`on` are session-scoped — config is untouched. `/mcp reload` is how a server added to `config.yml` mid-session becomes usable. When servers are configured, `/status` includes an `mcp` line (`2 servers · 1 reachable · 14 tools`).
98
+ `/mcp`'s server list glyphs each server by health: green `●` **reachable**, yellow `⚠` **degraded** (#575 — the process is alive but a protocol call such as `tools/list` failed, so it's up but not fully serving), and a stopped/failed server shows its last start error in the drill-in. `off`/`on` are session-scoped — config is untouched. `/mcp reload` is how a server added to `config.yml` mid-session becomes usable. When servers are configured, `/status` includes an `mcp` line (`2 servers · 1 reachable · 14 tools`).
99
99
 
100
100
  ## Manual Management
101
101
 
data/docs/memory.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Memory
2
2
 
3
- rubino remembers facts about you and the project across sessions. The default backend is a small SQLite "tiny-Zep" store — Zep/Graphiti-inspired, minus the graph database, the server, and the multi-call pipeline.
3
+ rubino remembers facts about you and the project across sessions. The default backend is a small SQLite fact store — an LLM-extracted, bi-temporal fact store with hybrid recall, minus the graph database, the server, and the multi-call pipeline.
4
4
 
5
5
  ## Backends
6
6
 
@@ -8,7 +8,7 @@ Memory backends are pluggable (registered like tools). Two ship:
8
8
 
9
9
  | `memory.backend` | What it is |
10
10
  |---|---|
11
- | `sqlite` (**default**) | tiny-Zep: LLM-extracted atomic facts, bi-temporal supersession, hybrid FTS5/BM25 (+ optional vector) ranked recall, graph-lite 1-hop blend |
11
+ | `sqlite` (**default**) | LLM-extracted atomic facts, bi-temporal supersession, hybrid FTS5/BM25 (+ optional vector) ranked recall, graph-lite 1-hop blend |
12
12
  | `default` | the legacy non-ranked store (kept for back-compat) |
13
13
 
14
14
  Switch backends:
@@ -20,7 +20,7 @@ rubino memory backend sqlite # switch (writes memory.backend to config.yml)
20
20
 
21
21
  The agent loop, the in-chat `/memory` view, the `/status` panel, the `rubino memory` CLI, and the HTTP `/v1/memory` operations all use the **active** backend (fixed in #94/#106/#83 — these surfaces previously read a hardwired legacy table and never saw the facts the agent actually persists).
22
22
 
23
- ## The sqlite tiny-Zep backend
23
+ ## The sqlite memory backend
24
24
 
25
25
  ### What's stored
26
26
 
data/docs/security.md CHANGED
@@ -145,7 +145,7 @@ The fake LLM provider can short-circuit tool decisions, so `chat` and `server` r
145
145
 
146
146
  ## TLS for the HTTP API
147
147
 
148
- The API binds `127.0.0.1` by default; only expose it (`--host 0.0.0.0` / `RUBINO_API_HOST`) behind TLS or a trusted segment. For a remote HTTP client, set `RUBINO_TLS=1` (or leave a cert in place) and the API serves over a self-signed cert that the client **pins** (no DNS / Let's Encrypt needed). On first boot it generates `cert.pem` + `key.pem` under `$RUBINO_HOME/tls` (CN/SAN = host/IP, ~10y) and reuses them. Hand the public cert to a pinning client with:
148
+ The API binds `127.0.0.1` by default; only expose it (`--host 0.0.0.0` / `RUBINO_API_HOST`) behind TLS or a trusted segment. Because the API can execute shell tools, a non-loopback bind is **config-gated and refused by default** (#577): the server will not boot on a routable host unless `api.allow_public_bind: true` is set in `config.yml`, and when it is, it prints a one-time exposure warning at startup. Loopback binds (`127.0.0.1` / `::1` / `localhost`) are unaffected. For a remote HTTP client, set `RUBINO_TLS=1` (or leave a cert in place) and the API serves over a self-signed cert that the client **pins** (no DNS / Let's Encrypt needed). On first boot it generates `cert.pem` + `key.pem` under `$RUBINO_HOME/tls` (CN/SAN = host/IP, ~10y) and reuses them. Hand the public cert to a pinning client with:
149
149
 
150
150
  ```bash
151
151
  rubino tls-cert # prints $RUBINO_HOME/tls/cert.pem (generating it if absent)
data/docs/tools.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Tools Reference
2
2
 
3
- rubino ships **34 built-in tools** plus dynamic MCP tools (started at boot when `mcp.servers` is configured — see [mcp.md](mcp.md); being server-dependent they are excluded from the drift-checked list below) and custom user-defined tools. Each tool is gated by a `tools.<key>` config flag (opt-out: absent key = enabled, only an explicit `false` disables) and the approval model. The count and list below are drift-checked against the live registry by `spec/docs/tools_doc_drift_spec.rb`.
3
+ rubino ships **29 built-in tools** plus dynamic MCP tools (started at boot when `mcp.servers` is configured — see [mcp.md](mcp.md); being server-dependent they are excluded from the drift-checked list below) and custom user-defined tools. Each tool is gated by a `tools.<key>` config flag (opt-out: absent key = enabled, only an explicit `false` disables) and the approval model. The count and list below are drift-checked against the live registry by `spec/docs/tools_doc_drift_spec.rb`.
4
4
 
5
- The full list (registration order): `read`, `summarize_file`, `write`, `edit`, `multi_edit`, `grep`, `glob`, `git`, `github`, `shell`, `shell_output`, `shell_tail`, `shell_input`, `shell_kill`, `ruby`, `run_tests`, `apply_patch`, `webfetch`, `websearch`, `question`, `todowrite`, `memory`, `session_search`, `attach_file`, `read_attachment`, `vision`, `skill`, `task`, `task_result`, `task_stop`, `ask_parent`, `steer`, `probe`, `answer_child`.
5
+ The full list (registration order): `read`, `summarize_file`, `write`, `edit`, `multi_edit`, `grep`, `glob`, `shell`, `shell_output`, `shell_tail`, `shell_input`, `shell_kill`, `ruby`, `apply_patch`, `webfetch`, `websearch`, `question`, `todowrite`, `memory`, `session_search`, `attach_file`, `read_attachment`, `vision`, `skill`, `task`, `task_result`, `task_stop`, `steer`, `probe`.
6
6
 
7
- Several tools share one config gate, so `rubino tools` shows **27 rows** (config groups), not 34: `webfetch` + `websearch` share `tools.web`, and the whole delegation family (`task`, `task_result`, `task_stop`, `ask_parent`, `steer`, `probe`, `answer_child`) rides on `tools.task` — disabling delegation disables them all.
7
+ Several tools share one config gate, so `rubino tools` shows **24 rows** (config groups), not 29: `webfetch` + `websearch` share `tools.web`, and the whole delegation family (`task`, `task_result`, `task_stop`, `steer`, `probe`) rides on `tools.task` — disabling delegation disables them all.
8
8
 
9
9
  ## How tools are gated
10
10
 
@@ -13,6 +13,27 @@ Several tools share one config gate, so `rubino tools` shows **27 rows** (config
13
13
  - **Approval** — see [security.md](security.md). Shell commands are confirmation-gated by default; a non-bypassable hardline floor blocks catastrophic commands regardless of mode.
14
14
  - **Workspace sandbox** — with `tools.workspace_strict: true` (default), write/edit/delete tools are confined to the workspace root (`terminal.cwd` or `Dir.pwd`).
15
15
 
16
+ ## Output compression
17
+
18
+ When `tool_output_compression.enabled` is on (off by default; `rubino setup`
19
+ offers it), every tool's output passes through a deterministic content router
20
+ before it reaches the model: test/build/lint **logs** are reduced to their
21
+ failures + summary, a **whole-file source read** can come back as a skeleton, and
22
+ **diffs / grep results / JSON / short output pass through byte-identical**. The
23
+ full original is always recoverable: the compressed view ends with a passive
24
+ pointer carrying an `id` (`retrieve_output id=…`), and the model recovers the
25
+ verbatim original by calling the `retrieve_output` tool with that id — there is
26
+ **no cat-able filesystem path** in the pointer, so a small model can't `sed`/
27
+ `grep`/`cat` a spill path and re-inflate the very output compression just shrank.
28
+ While enabled, `read` and `shell` advertise an extra `compress` boolean parameter
29
+ (default `true`) so the model can pass `compress:false` to get one call's output
30
+ verbatim, and the registry adds the `retrieve_output` recovery tool (present
31
+ **only** while compression is enabled — it is absent from the default registry,
32
+ so the count below is unchanged). See
33
+ [configuration.md](configuration.md#tool_output_compression) for the full key
34
+ reference. Compression is OFF in the default registry, so the parameter lists
35
+ below describe the shipped (uncompressed) behaviour.
36
+
16
37
  ## Built-in Tools
17
38
 
18
39
  ### read
@@ -78,25 +99,6 @@ Risk: low
78
99
  Parameters: pattern, path, max_results
79
100
  ```
80
101
 
81
- ### git
82
-
83
- Git operations: status, diff, log, branch, show.
84
-
85
- ```
86
- Risk: low (read-only operations)
87
- Parameters: command, args
88
- ```
89
-
90
- ### github
91
-
92
- GitHub integration: PRs, issues, reviews. Uses gh CLI or REST API.
93
-
94
- ```
95
- Risk: medium
96
- Parameters: action, title, body, number, repo, base, labels
97
- Actions: pr_create, pr_list, pr_view, pr_checks, pr_diff, issue_create, issue_list, issue_view, repo_view, release_list
98
- ```
99
-
100
102
  ### shell
101
103
 
102
104
  Execute a shell command. Foreground blocks until exit or `timeout`; pass `run_in_background: true` to fire-and-forget and get a `run_id`.
@@ -155,15 +157,6 @@ Risk: medium
155
157
  Parameters: code
156
158
  ```
157
159
 
158
- ### run_tests
159
-
160
- Run the workspace project's test suite and return a **structured** result instead of the raw toolchain firehose. Auto-detects RSpec / Minitest / a Rakefile default task, prefers `bundle exec` when a Gemfile is present (falls back to the bare runner if the bundle is broken), and returns pass/fail counts plus the failing examples (name + file:line + short message) and a short raw tail. Distinguishes "the suite couldn't start" (toolchain error) from "the suite ran and N failed". Use this instead of driving `shell` by hand to run tests. (issue #101)
161
-
162
- ```
163
- Risk: low
164
- Parameters: path (optional file/pattern), framework (optional: rspec|minitest|rake)
165
- ```
166
-
167
160
  ### apply_patch
168
161
 
169
162
  Apply unified diff patches to files.
@@ -182,6 +175,27 @@ Risk: low
182
175
  Parameters: url, format (text|html)
183
176
  ```
184
177
 
178
+ `format: "text"` (default) runs a readability-style **main-content extraction**
179
+ (nokogiri): page chrome — `script`, `style`, `noscript`, `nav`, `header`,
180
+ `footer`, `aside`, `form`, `svg`, `iframe`, `button`, plus ARIA landmark roles
181
+ (`navigation`, `banner`, `contentinfo`, `search`, `complementary`) — is dropped,
182
+ the main container is preferred (`<main>` → `[role=main]` → `<article>` →
183
+ `<body>`), and the kept subtree is serialized to markdown-ish text (`## `
184
+ headings, `- ` list items, blank-line-separated paragraphs, entities decoded).
185
+ This strips nav menus/footers/cookie banners and typically cuts tokens
186
+ substantially on article and docs pages.
187
+
188
+ Two guarantees so capability is never lost:
189
+
190
+ - **Safety fallback** — if the extracted text is under ~30% of the full page
191
+ text (or below a small char floor), the tool returns the full-page strip
192
+ instead, so a page whose content isn't in a clean `<main>`/`<article>` is never
193
+ over-trimmed. Malformed HTML that nokogiri can't parse also falls back (a fetch
194
+ never crashes). When extraction trims a lot, a one-line note points back at the
195
+ raw escape hatch.
196
+ - **Raw escape hatch** — `format: "html"` returns the full raw HTML **verbatim**,
197
+ completely unprocessed, for when the model wants the original page.
198
+
185
199
  ### websearch
186
200
 
187
201
  Search the web. Supports Tavily (best), SearXNG, or DuckDuckGo fallback.
@@ -302,15 +316,6 @@ Risk: medium
302
316
  Parameters: task_id
303
317
  ```
304
318
 
305
- ### ask_parent
306
-
307
- Child→parent escalation: a subagent asks its parent a question it cannot resolve from its sealed prompt. `blocking: true` pauses the child until the answer arrives; `blocking: false` (default) lets it keep working and folds the answer in later as a note. The parent (agent or human) answers via `answer_child` / `/reply`. Only available to subagents — a top-level agent has no parent to ask. Gated by `tools.task`.
308
-
309
- ```
310
- Risk: low
311
- Parameters: question, blocking
312
- ```
313
-
314
319
  ### steer
315
320
 
316
321
  Parent→child steering note: park a short note on one of YOUR OWN running subagents; it is folded into the child's context at its next turn boundary and persists (it changes the child's trajectory). Ownership-scoped at call time — only your direct children. The model counterpart of the human `/agents <id> steer "…"`. Gated by `tools.task`.
@@ -329,15 +334,6 @@ Risk: low
329
334
  Parameters: task_id, question, live
330
335
  ```
331
336
 
332
- ### answer_child
333
-
334
- Parent→child answer to an `ask_parent` question: delivers the answer into the asking child's context (unblocks a blocking ask; folds in for a non-blocking one). Ownership-scoped — only a direct child that is actually waiting. The model counterpart of the human `/reply <id> <answer>`. Gated by `tools.task`.
335
-
336
- ```
337
- Risk: low
338
- Parameters: task_id, answer
339
- ```
340
-
341
337
  ---
342
338
 
343
339
  ## MCP Tools
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Build step for the standalone `rubino-landlock` helper.
4
+ #
5
+ # This is NOT a Ruby C extension in the usual sense — the gem has no native
6
+ # Ruby bindings. We (ab)use the extension mechanism purely so RubyGems compiles
7
+ # a tiny standalone executable at install time: a Landlock-applying exec-wrapper
8
+ # the Linux sandbox path (Security::Sandbox) launches in front of `bash`.
9
+ #
10
+ # CRITICAL: this must DEGRADE GRACEFULLY. The gem must install on macOS, on
11
+ # Windows, and on a Linux kernel/toolchain without Landlock headers. So we never
12
+ # fail the build: on any non-Linux host, a missing compiler, or missing Landlock
13
+ # headers we write a no-op Makefile and exit 0. The sandbox then reports the
14
+ # Linux mechanism as unavailable and fails OPEN with a loud banner (by design).
15
+ require "mkmf"
16
+
17
+ # A Makefile that satisfies `make` / `make install` with no work, so
18
+ # `gem install` always succeeds even when we can't build the helper.
19
+ def write_noop_makefile(reason)
20
+ warn "rubino-landlock: skipping native helper build (#{reason}); " \
21
+ "the Linux OS write-sandbox will be unavailable (fail-open)."
22
+ File.write("Makefile", <<~MAKE)
23
+ all:
24
+ \t@true
25
+ install:
26
+ \t@true
27
+ clean:
28
+ \t@true
29
+ MAKE
30
+ end
31
+
32
+ # Only Linux has Landlock. Everywhere else the helper is irrelevant.
33
+ unless RUBY_PLATFORM.include?("linux")
34
+ write_noop_makefile("not Linux")
35
+ exit 0
36
+ end
37
+
38
+ # The helper needs the Landlock UAPI header to know the struct/flag layout.
39
+ # (The syscall itself is invoked by number, so no libc wrapper is required.)
40
+ unless have_header("linux/landlock.h")
41
+ write_noop_makefile("linux/landlock.h not found")
42
+ exit 0
43
+ end
44
+
45
+ # The compiled helper lands in the gem's exe/ dir as `rubino-landlock`, on PATH
46
+ # of the installed gem. extconf builds into the extension dir; we install it to
47
+ # the gem's bin via a custom rule appended after create_makefile.
48
+ target = "rubino-landlock"
49
+
50
+ # create_makefile expects a Ruby-loadable .so; we instead emit our own Makefile
51
+ # that compiles a freestanding executable. Bypass the .so machinery entirely.
52
+ cc = RbConfig::CONFIG["CC"] || "cc"
53
+ srcdir = __dir__
54
+
55
+ File.write("Makefile", <<~MAKE)
56
+ CC = #{cc}
57
+ TARGET = #{target}
58
+ SRC = #{File.join(srcdir, "landlock.c")}
59
+
60
+ all: $(TARGET)
61
+
62
+ $(TARGET): $(SRC)
63
+ \t$(CC) -O2 -Wall -o $(TARGET) $(SRC)
64
+
65
+ # `gem install` runs `make` then `make install`; RubyGems copies any built
66
+ # artifact reported in the extension dir. We additionally drop the binary into
67
+ # the gem's exe/ so Security::Sandbox can resolve it next to the `rubino` exe.
68
+ install: all
69
+ \t@true
70
+
71
+ clean:
72
+ \t-rm -f $(TARGET)
73
+ MAKE
74
+
75
+ # If the compile would fail (no working cc), we still don't want to break the
76
+ # install — but mkmf already verified a compiler via have_header above. Leave
77
+ # the real compile to `make`; a failure there is rare and caught by Sandbox's
78
+ # runtime probe (binary absent ⇒ fail-open).