rubino-agent 0.3.0 → 0.5.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +172 -5
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +35 -3
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/skills.md +31 -0
  15. data/docs/troubleshooting.md +1 -1
  16. data/exe/rubino +16 -2
  17. data/install.sh +721 -59
  18. data/lib/rubino/active_agent.rb +73 -0
  19. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  20. data/lib/rubino/agent/agent_registry.rb +5 -2
  21. data/lib/rubino/agent/definition.rb +1 -9
  22. data/lib/rubino/agent/fallback_chain.rb +0 -6
  23. data/lib/rubino/agent/iteration_budget.rb +109 -3
  24. data/lib/rubino/agent/loop.rb +476 -20
  25. data/lib/rubino/agent/model_call_runner.rb +81 -3
  26. data/lib/rubino/agent/prompts/build.txt +22 -5
  27. data/lib/rubino/agent/response_validator.rb +8 -0
  28. data/lib/rubino/agent/runner.rb +133 -8
  29. data/lib/rubino/agent/tool_executor.rb +166 -14
  30. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  31. data/lib/rubino/api/server.rb +19 -0
  32. data/lib/rubino/attachments/classify.rb +35 -17
  33. data/lib/rubino/boot/config_guard.rb +71 -0
  34. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  35. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  36. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  37. data/lib/rubino/cli/chat_command.rb +1189 -50
  38. data/lib/rubino/cli/commands.rb +282 -2
  39. data/lib/rubino/cli/config_command.rb +68 -8
  40. data/lib/rubino/cli/doctor_command.rb +204 -12
  41. data/lib/rubino/cli/jobs_command.rb +12 -0
  42. data/lib/rubino/cli/memory_command.rb +53 -20
  43. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  44. data/lib/rubino/cli/session_command.rb +172 -18
  45. data/lib/rubino/cli/setup_command.rb +131 -8
  46. data/lib/rubino/cli/skills_command.rb +183 -9
  47. data/lib/rubino/cli/trust_gate.rb +16 -7
  48. data/lib/rubino/commands/built_ins.rb +2 -0
  49. data/lib/rubino/commands/command.rb +12 -2
  50. data/lib/rubino/commands/executor.rb +149 -12
  51. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  52. data/lib/rubino/commands/handlers/agents.rb +156 -41
  53. data/lib/rubino/commands/handlers/config.rb +4 -1
  54. data/lib/rubino/commands/handlers/help.rb +113 -14
  55. data/lib/rubino/commands/handlers/memory.rb +15 -5
  56. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  57. data/lib/rubino/commands/handlers/status.rb +9 -4
  58. data/lib/rubino/commands/loader.rb +12 -0
  59. data/lib/rubino/config/configuration.rb +86 -24
  60. data/lib/rubino/config/defaults.rb +140 -33
  61. data/lib/rubino/config/loader.rb +62 -12
  62. data/lib/rubino/config/validator.rb +341 -0
  63. data/lib/rubino/config/writer.rb +123 -31
  64. data/lib/rubino/context/compressor.rb +184 -22
  65. data/lib/rubino/context/environment_inspector.rb +2 -2
  66. data/lib/rubino/context/file_discovery.rb +2 -2
  67. data/lib/rubino/context/message_boundary.rb +27 -1
  68. data/lib/rubino/context/project_languages.rb +90 -0
  69. data/lib/rubino/context/prompt_assembler.rb +105 -22
  70. data/lib/rubino/context/summary_builder.rb +45 -4
  71. data/lib/rubino/context/token_budget.rb +36 -11
  72. data/lib/rubino/context/token_estimate.rb +45 -0
  73. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  74. data/lib/rubino/database/connection.rb +154 -3
  75. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  76. data/lib/rubino/database/migrator.rb +98 -5
  77. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  78. data/lib/rubino/documents/converters/csv.rb +4 -3
  79. data/lib/rubino/documents/converters/docx.rb +29 -5
  80. data/lib/rubino/documents/converters/html.rb +5 -1
  81. data/lib/rubino/documents/converters/json.rb +2 -1
  82. data/lib/rubino/documents/converters/pdf.rb +11 -2
  83. data/lib/rubino/documents/converters/plain.rb +2 -1
  84. data/lib/rubino/documents/converters/pptx.rb +11 -2
  85. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  86. data/lib/rubino/documents/converters/xml.rb +2 -1
  87. data/lib/rubino/documents/limits.rb +210 -0
  88. data/lib/rubino/documents.rb +10 -3
  89. data/lib/rubino/errors.rb +36 -5
  90. data/lib/rubino/interaction/cancel_token.rb +19 -3
  91. data/lib/rubino/interaction/events.rb +13 -0
  92. data/lib/rubino/interaction/lifecycle.rb +99 -13
  93. data/lib/rubino/interaction/polishing.rb +176 -0
  94. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  95. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  96. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  97. data/lib/rubino/jobs/queue.rb +63 -8
  98. data/lib/rubino/jobs/runner.rb +24 -6
  99. data/lib/rubino/jobs/worker.rb +0 -4
  100. data/lib/rubino/llm/adapter_response.rb +47 -4
  101. data/lib/rubino/llm/credential_check.rb +15 -16
  102. data/lib/rubino/llm/error_classifier.rb +89 -1
  103. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  104. data/lib/rubino/llm/request.rb +30 -3
  105. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  106. data/lib/rubino/llm/tool_bridge.rb +113 -9
  107. data/lib/rubino/mcp/manager.rb +18 -1
  108. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  109. data/lib/rubino/memory/aux_retry.rb +107 -0
  110. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  111. data/lib/rubino/memory/backends.rb +23 -7
  112. data/lib/rubino/memory/salience_gate.rb +103 -0
  113. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  114. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  115. data/lib/rubino/memory/store.rb +33 -5
  116. data/lib/rubino/memory/threat_scanner.rb +52 -0
  117. data/lib/rubino/output/cost.rb +52 -0
  118. data/lib/rubino/output/headless_block_latch.rb +53 -0
  119. data/lib/rubino/output/result_serializer.rb +222 -0
  120. data/lib/rubino/output/turn_recorder.rb +77 -0
  121. data/lib/rubino/security/approval_policy.rb +227 -32
  122. data/lib/rubino/security/command_allowlist.rb +79 -4
  123. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  124. data/lib/rubino/security/hardline_guard.rb +189 -16
  125. data/lib/rubino/security/pattern_matcher.rb +28 -5
  126. data/lib/rubino/security/prefix_deriver.rb +25 -6
  127. data/lib/rubino/security/readonly_commands.rb +145 -5
  128. data/lib/rubino/security/secret_path.rb +134 -0
  129. data/lib/rubino/security/url_safety.rb +255 -0
  130. data/lib/rubino/session/repository.rb +212 -11
  131. data/lib/rubino/session/store.rb +139 -14
  132. data/lib/rubino/skills/installer.rb +230 -0
  133. data/lib/rubino/skills/prompt_index.rb +2 -2
  134. data/lib/rubino/skills/registry.rb +52 -1
  135. data/lib/rubino/skills/skill.rb +64 -3
  136. data/lib/rubino/skills/skill_tool.rb +16 -5
  137. data/lib/rubino/tools/background_tasks.rb +157 -13
  138. data/lib/rubino/tools/base.rb +204 -3
  139. data/lib/rubino/tools/edit_tool.rb +73 -18
  140. data/lib/rubino/tools/glob_tool.rb +48 -9
  141. data/lib/rubino/tools/grep_tool.rb +103 -9
  142. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  143. data/lib/rubino/tools/patch_tool.rb +5 -0
  144. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  145. data/lib/rubino/tools/read_tool.rb +33 -15
  146. data/lib/rubino/tools/read_tracker.rb +153 -35
  147. data/lib/rubino/tools/registry.rb +113 -12
  148. data/lib/rubino/tools/result.rb +9 -1
  149. data/lib/rubino/tools/ruby_tool.rb +0 -0
  150. data/lib/rubino/tools/shell_registry.rb +70 -0
  151. data/lib/rubino/tools/shell_tool.rb +40 -1
  152. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  153. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  154. data/lib/rubino/tools/task_tool.rb +36 -8
  155. data/lib/rubino/tools/vision_tool.rb +5 -0
  156. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  157. data/lib/rubino/tools/websearch_tool.rb +92 -30
  158. data/lib/rubino/tools/write_tool.rb +23 -4
  159. data/lib/rubino/ui/api.rb +10 -1
  160. data/lib/rubino/ui/base.rb +11 -0
  161. data/lib/rubino/ui/bottom_composer.rb +382 -74
  162. data/lib/rubino/ui/cli.rb +515 -83
  163. data/lib/rubino/ui/completion_menu.rb +11 -7
  164. data/lib/rubino/ui/headless_trace.rb +63 -0
  165. data/lib/rubino/ui/live_region.rb +70 -7
  166. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  167. data/lib/rubino/ui/notifier.rb +0 -2
  168. data/lib/rubino/ui/null.rb +52 -5
  169. data/lib/rubino/ui/paste_store.rb +16 -2
  170. data/lib/rubino/ui/queued_indicators.rb +6 -1
  171. data/lib/rubino/ui/status_bar.rb +61 -7
  172. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  173. data/lib/rubino/ui/subagent_view.rb +29 -4
  174. data/lib/rubino/ui/tool_label.rb +52 -0
  175. data/lib/rubino/update_check.rb +39 -4
  176. data/lib/rubino/util/atomic_file.rb +117 -0
  177. data/lib/rubino/util/ignore_rules.rb +120 -0
  178. data/lib/rubino/util/output.rb +229 -12
  179. data/lib/rubino/util/secrets_mask.rb +70 -7
  180. data/lib/rubino/util/spill_store.rb +153 -0
  181. data/lib/rubino/version.rb +1 -1
  182. data/lib/rubino/workspace.rb +9 -1
  183. data/lib/rubino.rb +191 -7
  184. data/rubino-agent.gemspec +1 -0
  185. data/skills/ruby-expert/SKILL.md +1 -0
  186. metadata +42 -12
  187. data/lib/rubino/agent/router.rb +0 -65
  188. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  189. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  190. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  191. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  192. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  193. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  194. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  195. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  196. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Rubino
4
6
  module Agent
5
7
  # Executes tool calls with approval checks and result formatting.
@@ -9,16 +11,29 @@ module Rubino
9
11
  # Loop#handle_tool_result.
10
12
  attr_writer :on_result
11
13
 
14
+ # True once any tool was BLOCKED for approval in a non-interactive session
15
+ # (#260): a write/edit/shell that needed a prompt no one could answer. The
16
+ # one-shot CLI reads this after the run to exit NON-ZERO so CI/automation
17
+ # fails loudly instead of treating a silently-skipped action as success.
18
+ def blocked_for_approval?
19
+ @blocked_for_approval == true
20
+ end
21
+
12
22
  def initialize(registry:, approval_policy:, ui:, config:,
13
23
  tool_call_repository: Tools::ToolCallRepository.new,
14
24
  cancel_token: nil, read_tracker: nil, event_bus: nil,
15
- on_result: nil)
25
+ on_result: nil, session_id: nil)
16
26
  @registry = registry
17
27
  @approval_policy = approval_policy
18
28
  @ui = ui
19
29
  @config = config
20
30
  @tool_call_repository = tool_call_repository
21
31
  @cancel_token = cancel_token
32
+ # Session the audit row is attributed to. The tool_calls table requires
33
+ # a non-null session_id FK, so without this every audit insert violated
34
+ # the constraint and was swallowed by the repository's rescue — leaving
35
+ # the table empty on every execution, streaming or not (#262).
36
+ @session_id = session_id
22
37
  # Optional sink the Loop registers so a tool that runs on the STREAMING
23
38
  # path (ruby_llm dispatches it mid-stream via ToolBridge → straight into
24
39
  # #execute, never returning through Loop#execute_tool_calls) is still
@@ -44,6 +59,17 @@ module Rubino
44
59
 
45
60
  # Executes a single tool call, returns a Tools::Result.
46
61
  def execute(name:, arguments:, call_id:)
62
+ # Cancellation checkpoint BEFORE the tool runs (#335b). On the streaming
63
+ # path ruby_llm dispatches tool calls mid-stream through ToolBridge into
64
+ # here, and the loop's per-iteration #check! is far above us — so without
65
+ # this a cancel that arrived while a PREVIOUS tool was running (or during
66
+ # the thinking phase) wouldn't be observed until the model resumed
67
+ # streaming, letting the next tool fire after the user already hit
68
+ # interrupt. Raising here halts the in-flight turn at the next tool
69
+ # boundary, the soonest safe checkpoint, so "esc to interrupt" actually
70
+ # stops the agent instead of letting it run one more tool.
71
+ @cancel_token&.check!
72
+
47
73
  tool = @registry.find(name)
48
74
  raise ToolError, "Unknown tool: #{name}" unless tool
49
75
 
@@ -58,6 +84,29 @@ module Rubino
58
84
  result: denied, reason: "policy-denied")
59
85
  return finish(name, arguments, call_id, denied)
60
86
  when :ask
87
+ # Headless FAIL-CLOSED floor (#260). A tool the policy wants to ASK
88
+ # about — a write/edit, or a shell command not covered by the
89
+ # permissions allowlist / read-only auto-allow — cannot be approved
90
+ # when there is no interactive session (one-shot `rubino prompt`/`-q`,
91
+ # a pipe, a gate-less embed). Auto-running it (the old UI::Null#confirm
92
+ # → true bug) is the prompt-injection→RCE foot-gun; hanging on a prompt
93
+ # no one can answer is the opencode bug. So DENY with a clear,
94
+ # single-line block message and record the block so the run can exit
95
+ # non-zero. Anything the user already allowlisted resolved to :allow
96
+ # before reaching here, so this never regresses a configured command.
97
+ unless @ui.interactive?
98
+ @blocked_for_approval = true
99
+ message = approval_block_message(tool, arguments)
100
+ @ui.warning(message) if @ui.respond_to?(:warning)
101
+ # Let the headless adapter latch the block so the one-shot CLI can
102
+ # exit non-zero (#260) without threading a flag up through the loop.
103
+ @ui.tool_blocked(message) if @ui.respond_to?(:tool_blocked)
104
+ blocked = Tools::Result.denied(name: name, call_id: call_id, reason: :noninteractive)
105
+ record_denied(name: name, call_id: call_id, arguments: arguments,
106
+ result: blocked, reason: "noninteractive-blocked")
107
+ return finish(name, arguments, call_id, blocked)
108
+ end
109
+
61
110
  unless request_approval(tool, arguments)
62
111
  denied = Tools::Result.denied(name: name, call_id: call_id, reason: :user)
63
112
  record_denied(name: name, call_id: call_id, arguments: arguments,
@@ -66,6 +115,18 @@ module Rubino
66
115
  end
67
116
  end
68
117
 
118
+ # Warn-not-block doom-loop guard (#414): when the detector tripped but
119
+ # hard_stop is off (the default), the call is ALLOWED — surface a
120
+ # one-time warning so a stuck autopilot is visible without hard-denying a
121
+ # legitimate repeated/idempotent call.
122
+ if @approval_policy.respond_to?(:doom_loop_warning) &&
123
+ @approval_policy.doom_loop_warning && @ui.respond_to?(:warning)
124
+ @ui.warning(
125
+ "doom-loop guard: '#{name}' called with identical arguments repeatedly — " \
126
+ "proceeding (set doom_loop.hard_stop:true to block)"
127
+ )
128
+ end
129
+
69
130
  notify_yolo_if_applicable(tool, arguments)
70
131
  emit_started(name, arguments)
71
132
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -105,7 +166,11 @@ module Rubino
105
166
  if tool.respond_to?(:stream_chunk=) && (@ui.respond_to?(:tool_chunk) || @event_bus)
106
167
  tool.stream_chunk = lambda do |chunk|
107
168
  streamed = true
108
- @ui.tool_chunk(name, chunk) if @ui.respond_to?(:tool_chunk)
169
+ # Read stream_kind LAZILY: the tool only knows its output kind
170
+ # (e.g. :diff for `git diff`) once #call has inspected the command,
171
+ # which happens AFTER this lambda is installed.
172
+ kind = tool.respond_to?(:stream_kind) ? (tool.stream_kind || :plain) : :plain
173
+ @ui.tool_chunk(name, chunk, kind: kind) if @ui.respond_to?(:tool_chunk)
109
174
  # Mirror the chunk onto the bus so the API/SSE stream isn't silent
110
175
  # during a long tool call: the Recorder maps TOOL_PROGRESS to a
111
176
  # `tool.progress` event, which resets the idle watchdog. Without
@@ -155,13 +220,13 @@ module Rubino
155
220
  error_code: error_code&.to_sym,
156
221
  artifact: artifact
157
222
  )
158
- @tool_call_repository.record(name: name, call_id: call_id, arguments: arguments,
159
- result: result, status: "completed")
223
+ record_audit(name: name, call_id: call_id, arguments: arguments,
224
+ result: result, status: "completed")
160
225
  result
161
226
  rescue StandardError => e
162
227
  result = Tools::Result.error(name: name, call_id: call_id, error: e.message)
163
- @tool_call_repository.record(name: name, call_id: call_id, arguments: arguments,
164
- result: result, status: "failed", error: e.message)
228
+ record_audit(name: name, call_id: call_id, arguments: arguments,
229
+ result: result, status: "failed", error: e.message)
165
230
  result
166
231
  ensure
167
232
  tool.cancel_token = nil if tool.respond_to?(:cancel_token=)
@@ -243,9 +308,12 @@ module Rubino
243
308
  masked = Util::SecretsMask.mask_value(value, key: key)
244
309
  memo[key.to_s] = truncate_for_event(masked.to_s)
245
310
  end
246
- rescue StandardError
311
+ rescue StandardError => e
247
312
  # Never block the run because of a serialisation hiccup — drop the
248
- # arguments rather than crash the tool emission path.
313
+ # arguments rather than crash the tool emission path. Log it so a coding
314
+ # bug here doesn't silently blank every tool event's arguments.
315
+ Rubino.logger&.warn(event: "tool_executor.sanitize_arguments_failed",
316
+ error: e.message, error_class: e.class.name)
249
317
  nil
250
318
  end
251
319
 
@@ -265,7 +333,7 @@ module Rubino
265
333
  end
266
334
 
267
335
  def record_denied(name:, call_id:, arguments:, result:, reason:)
268
- @tool_call_repository.record(
336
+ record_audit(
269
337
  name: name,
270
338
  call_id: call_id,
271
339
  arguments: arguments,
@@ -273,8 +341,21 @@ module Rubino
273
341
  status: "denied",
274
342
  error: reason
275
343
  )
276
- rescue StandardError
277
- # Don't fail the user's request just because the audit write failed.
344
+ rescue StandardError => e
345
+ # Don't fail the user's request just because the audit write failed
346
+ # but log it, so a silently dropped denial-audit row is traceable.
347
+ Rubino.logger&.warn(event: "tool_executor.record_denied_failed",
348
+ error: e.message, error_class: e.class.name)
349
+ end
350
+
351
+ # Stamps the executor's session id onto the Result (built deep in the tool
352
+ # pipeline with no session context) before the audit write, so the
353
+ # NOT-NULL session_id FK on tool_calls is satisfied (#262). Single
354
+ # chokepoint for every record call — success, failure, and denial.
355
+ def record_audit(name:, call_id:, arguments:, result:, status:, error: nil)
356
+ result.session_id = @session_id if result.respond_to?(:session_id=)
357
+ @tool_call_repository.record(name: name, call_id: call_id, arguments: arguments,
358
+ result: result, status: status, error: error)
278
359
  end
279
360
 
280
361
  # The reason behind the policy's :deny, when the policy exposes one
@@ -285,6 +366,19 @@ module Rubino
285
366
  @approval_policy.last_deny_reason || :policy
286
367
  end
287
368
 
369
+ # The single-line "blocked" notice surfaced to stderr (via @ui.warning)
370
+ # when a tool needs approval but there is no interactive session (#260).
371
+ # Names the tool and the actionable escape hatches so a scripted run shows
372
+ # WHY nothing happened instead of failing silently.
373
+ def approval_block_message(tool, arguments)
374
+ cmd = Security::ApprovalPolicy.command_string(tool, arguments).to_s
375
+ cmd = cmd.lines.first.to_s.rstrip
376
+ cmd = "#{cmd[0, 57]}…" if cmd.length > 60
377
+ suffix = cmd.empty? ? "" : " (#{cmd})"
378
+ "blocked: #{tool.name}#{suffix} needs approval but no interactive session " \
379
+ "(use --yolo to allow, or allowlist it)"
380
+ end
381
+
288
382
  def request_approval(tool, arguments)
289
383
  command = Security::ApprovalPolicy.command_string(tool, arguments)
290
384
  _hit, pattern_key, description = Security::DangerousPatterns.detect(command)
@@ -347,13 +441,21 @@ module Rubino
347
441
  # header followed by nothing reads as a truncated/broken card (#109).
348
442
  return "#{tool.name} wants to run" if pairs.empty?
349
443
 
444
+ # multi_edit carries an `edits` ARRAY whose generic .to_s render is an
445
+ # unreadable escaped Ruby hash (literal \n, truncated). Lay it out as
446
+ # clean per-edit `- old` / `+ new` blocks, matching how the single
447
+ # `edit` tool already previews — so the user can see what will change.
448
+ if (edits_preview = multi_edit_preview(tool, arguments))
449
+ return edits_preview
450
+ end
451
+
350
452
  # The common case — ONE short single-line argument (a shell command, a
351
- # file path) — inlines onto the header: `shell wants: touch hello.txt`
453
+ # file path) — inlines onto the header: `shell wants: touch hello.txt`
352
454
  # (P7). Multi-arg / multi-line calls keep the per-key layout below.
353
455
  if pairs.size == 1
354
456
  key, value = pairs.first
355
457
  text = Util::SecretsMask.mask_value(value, key: key).to_s
356
- return "#{tool.name} wants: #{text}" if !text.include?("\n") && text.length <= 120
458
+ return "#{tool.name} wants: #{text}" if !text.include?("\n") && text.length <= 120
357
459
  end
358
460
 
359
461
  lines = ["#{tool.name} wants:"]
@@ -378,6 +480,42 @@ module Rubino
378
480
  end
379
481
  end
380
482
 
483
+ # Clean per-edit preview for multi_edit: a header with the file path then,
484
+ # for each edit, its `- old` / `+ new` lines (edits blank-line separated),
485
+ # trimmed to a sane line budget. nil for any other tool / shape so the
486
+ # generic per-key formatter handles it. Mirrors EditTool's diff preview.
487
+ MULTI_EDIT_PREVIEW_LINES = 16
488
+ def multi_edit_preview(tool, arguments)
489
+ return nil unless tool.name == "multi_edit"
490
+
491
+ edits = arguments["edits"] || arguments[:edits]
492
+ return nil unless edits.is_a?(Array) && !edits.empty?
493
+
494
+ path = arguments["file_path"] || arguments[:file_path]
495
+ lines = ["multi_edit wants: #{path} (#{edits.size} edit#{"s" if edits.size != 1})"]
496
+ body = []
497
+ edits.each_with_index do |edit, idx|
498
+ old_s = edit["old_string"] || edit[:old_string]
499
+ new_s = edit["new_string"] || edit[:new_string]
500
+ body << "" unless idx.zero?
501
+ body.concat(Util::SecretsMask.mask_value(old_s, key: "old_string").to_s.lines.map { |l| " - #{l.chomp}" })
502
+ body.concat(Util::SecretsMask.mask_value(new_s, key: "new_string").to_s.lines.map { |l| " + #{l.chomp}" })
503
+ end
504
+ if body.size > MULTI_EDIT_PREVIEW_LINES
505
+ dropped = body.size - MULTI_EDIT_PREVIEW_LINES
506
+ body = body.first(MULTI_EDIT_PREVIEW_LINES)
507
+ body << " [… #{dropped} more line(s)]"
508
+ end
509
+ (lines + body).join("\n")
510
+ rescue StandardError => e
511
+ # A preview is cosmetic — fall back to the generic per-key formatter
512
+ # rather than crash the approval prompt. Log it so a malformed-shape
513
+ # coding bug here doesn't silently disable the multi_edit diff preview.
514
+ Rubino.logger&.warn(event: "tool_executor.multi_edit_preview_failed",
515
+ error: e.message, error_class: e.class.name)
516
+ nil
517
+ end
518
+
381
519
  # Persists the complete (pre-truncation) output to a per-call file under
382
520
  # the rubino home so the model can read back whatever the inline
383
521
  # head+tail elided (the spill seam Util::Output.truncate calls back into
@@ -391,7 +529,21 @@ module Rubino
391
529
  dir = File.join(Rubino.home_path, "tool-results")
392
530
  FileUtils.mkdir_p(dir)
393
531
  path = File.join(dir, "#{id}.txt")
394
- File.write(path, text)
532
+ # Write ATOMICALLY (temp + rename): a plain File.write can be cut MID-
533
+ # WRITE by an Interrupt (Ctrl+C) — which is NOT a StandardError, so the
534
+ # rescue below never catches it — leaving a TRUNCATED recovery file the
535
+ # marker still points the model at, so it reads back a silently partial
536
+ # output. rename(2) on the same filesystem is atomic, so a reader sees
537
+ # either the old file or the complete new one, never a torn one; the temp
538
+ # is cleaned up if the interrupt lands before the rename.
539
+ tmp = "#{path}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp"
540
+ begin
541
+ File.write(tmp, text)
542
+ File.rename(tmp, path)
543
+ rescue Exception # rubocop:disable Lint/RescueException
544
+ FileUtils.rm_f(tmp)
545
+ raise
546
+ end
395
547
  path
396
548
  rescue StandardError => e
397
549
  Rubino.logger&.warn(event: "tool_output.spill_failed", error: e.message)
@@ -103,7 +103,10 @@ module Rubino
103
103
  thinking: request.thinking,
104
104
  prefill: request.prefill,
105
105
  image_paths: request.image_paths,
106
- stream: request.stream?
106
+ stream: request.stream?,
107
+ on_intermediate_message: request.on_intermediate_message,
108
+ on_round_trip: request.on_round_trip,
109
+ budget_exhausted: request.budget_exhausted
107
110
  )
108
111
  end
109
112
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rack"
4
4
  require "uri"
5
+ require "json"
5
6
  require "puma"
6
7
  require "puma/configuration"
7
8
  require "puma/launcher"
@@ -62,10 +63,28 @@ module Rubino
62
63
  c.bind(bind_url)
63
64
  c.app(app)
64
65
  c.quiet
66
+ # Errors raised below the Rack stack (e.g. Puma's HTTP parser rejecting
67
+ # an oversized QUERY_STRING) bypass ErrorHandler and would otherwise
68
+ # render Puma's verbose default page — leaking the Puma version and
69
+ # gem file paths/line numbers (S5-1). Render the same clean envelope
70
+ # with no internals instead.
71
+ c.lowlevel_error_handler(Server.lowlevel_error_handler)
65
72
  end
66
73
  Puma::Launcher.new(config).run
67
74
  end
68
75
 
76
+ # A Puma lowlevel_error_handler that mirrors ErrorHandler's
77
+ # {error:{code,message}} JSON envelope and never exposes the exception
78
+ # class, message, backtrace, Puma version, or file paths.
79
+ #
80
+ # @return [Proc] callable Puma invokes as (error, env=nil, status=nil)
81
+ def self.lowlevel_error_handler
82
+ lambda do |_error, _env = nil, _status = nil|
83
+ body = JSON.generate(error: { code: "bad_request", message: "bad request" })
84
+ [400, { "content-type" => "application/json" }, [body]]
85
+ end
86
+ end
87
+
69
88
  # Composes the Rack middleware stack around the router. Order matters:
70
89
  # Observability is outermost (sees every status, including 500s from
71
90
  # ErrorHandler), then ErrorHandler, then RateLimit (so /v1/health and
@@ -35,19 +35,35 @@ module Rubino
35
35
  ].freeze
36
36
  IMAGE_EXTS = %w[.png .jpg .jpeg .gif .webp .bmp .tiff .tif].freeze
37
37
 
38
- # Leading magic bytes per recognised image MIME (WebP is special-cased:
39
- # RIFF container + WEBP tag). Marcel lets the file NAME break the tie
40
- # when the content sniff only yields a generic type (text/plain,
41
- # octet-stream), so a text file renamed fake.png came back image/png and
42
- # was shipped to the provider (#158). An image verdict must therefore be
43
- # backed by the actual signature.
44
- IMAGE_SIGNATURES = {
38
+ # Leading magic bytes per recognised image/document MIME (WebP is
39
+ # special-cased: RIFF container + WEBP tag). Marcel lets the file NAME
40
+ # break the tie when the content sniff only yields a generic type
41
+ # (text/plain, octet-stream), so a text file renamed fake.png came back
42
+ # image/png and was shipped to the provider (#158) and a text file
43
+ # renamed report.docx came back as :document and got a shell-hint
44
+ # instead of reading inline (#239). An image or document verdict must
45
+ # therefore be backed by the actual signature.
46
+ OLE2 = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b
47
+ SIGNATURES = {
45
48
  "image/png" => ["\x89PNG\r\n\x1a\n".b],
46
49
  "image/jpeg" => ["\xFF\xD8\xFF".b],
47
50
  "image/gif" => ["GIF87a".b, "GIF89a".b],
48
51
  "image/bmp" => ["BM".b],
49
52
  "image/x-ms-bmp" => ["BM".b],
50
- "image/tiff" => ["II*\x00".b, "MM\x00*".b]
53
+ "image/tiff" => ["II*\x00".b, "MM\x00*".b],
54
+ "application/pdf" => ["%PDF".b],
55
+ # OOXML and ODF are ZIP containers.
56
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ["PK".b],
57
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ["PK".b],
58
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation" => ["PK".b],
59
+ "application/vnd.oasis.opendocument.text" => ["PK".b],
60
+ "application/vnd.oasis.opendocument.spreadsheet" => ["PK".b],
61
+ # Legacy Office is an OLE2 compound file.
62
+ "application/msword" => [OLE2],
63
+ "application/vnd.ms-excel" => [OLE2],
64
+ "application/vnd.ms-powerpoint" => [OLE2],
65
+ "application/rtf" => ["{\\rtf".b],
66
+ "text/rtf" => ["{\\rtf".b]
51
67
  }.freeze
52
68
 
53
69
  module_function
@@ -97,12 +113,14 @@ module Rubino
97
113
  basename = File.basename(real)
98
114
  mime = Marcel::MimeType.for(Pathname(real), name: basename).to_s
99
115
 
100
- # Extension-spoof gate (#158): an image verdict that the magic bytes
101
- # don't back up came from the extension, not the content. Re-resolve
102
- # from content alone (no name:); when that is generic too, the text/
103
- # binary sniff names the honest type — so fake.png full of text is
104
- # rejected at the staging gate as text/plain, before any network call.
105
- if IMAGE_MIMES.include?(mime) && !image_signature?(real, mime)
116
+ # Extension-spoof gate (#158, #239): an image or document verdict that
117
+ # the magic bytes don't back up came from the extension, not the
118
+ # content. Re-resolve from content alone (no name:); when that is
119
+ # generic too, the text/binary sniff names the honest type — so
120
+ # fake.png full of text is rejected at the staging gate as text/plain
121
+ # before any network call, and report.docx full of text reads inline
122
+ # as text instead of bouncing off the document converter.
123
+ if (IMAGE_MIMES.include?(mime) || DOCUMENT_MIMES.include?(mime)) && !signature?(real, mime)
106
124
  mime = Marcel::MimeType.for(Pathname(real)).to_s
107
125
  if mime.empty? || mime == "application/octet-stream"
108
126
  return base_helper.send(:binary?, real) ? [:binary, "application/octet-stream"] : [:text, "text/plain"]
@@ -136,12 +154,12 @@ module Rubino
136
154
  end
137
155
 
138
156
  # True when the file's leading bytes carry the signature +mime+ claims.
139
- # Unknown image MIMEs fail closed (no signature -> not verified).
140
- def image_signature?(real, mime)
157
+ # MIMEs without a known signature fail closed (not verified).
158
+ def signature?(real, mime)
141
159
  head = File.binread(real, 16).to_s.b
142
160
  return head.start_with?("RIFF") && head[8, 4] == "WEBP" if mime == "image/webp"
143
161
 
144
- Array(IMAGE_SIGNATURES[mime]).any? { |sig| head.start_with?(sig) }
162
+ Array(SIGNATURES[mime]).any? { |sig| head.start_with?(sig) }
145
163
  end
146
164
 
147
165
  # JSON/XML/YAML/JS and friends arrive as application/* but are text.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Boot
5
+ # Loads configuration at process startup, turning a malformed/corrupt
6
+ # config.yml into a clean, actionable boot abort instead of a raw Ruby +
7
+ # Psych double backtrace (CFG-1).
8
+ #
9
+ # The entrypoint (`exe/rubino`) calls {Config::Loader#load} for EVERY
10
+ # command, before Thor dispatch. Any {Config::ConfigError} (or a
11
+ # {Psych::SyntaxError} that escapes the loader) used to propagate all the
12
+ # way out of `exe/rubino:16`, so a single typo in config.yml killed the
13
+ # process with a stack trace — even `rubino doctor`, whose graceful
14
+ # corruption handler (#259) was never reached because boot died first.
15
+ #
16
+ # {.load!} runs the load behind a rescue that writes a single-line
17
+ # diagnostic (what's wrong + the config path + how to fix it) to $stderr
18
+ # and exits non-zero — boot abort, not exception, mirroring
19
+ # {EncryptionKey.validate!}. doctor's own handling still works: doctor
20
+ # re-loads via the Loader and reports corruption itself, so a clean boot
21
+ # here does not mask it.
22
+ module ConfigGuard
23
+ # The Loader normalizes every malformed config shape into a
24
+ # {Config::ConfigError} at the source. The remaining classes here are a
25
+ # defensive backstop: should any raw Psych/IO failure ever slip past the
26
+ # loader (a new shape, a refactor), it still becomes a clean boot abort
27
+ # rather than a double backtrace on every command (CFG-R2).
28
+ def self.load!(loader: Config::Loader.new, stderr: $stderr, argv: [])
29
+ loader.load
30
+ # LOAD-time schema validation (F8): a HAND-EDITED config.yml with an
31
+ # unknown key or a wrong-typed value used to load SILENTLY (the validator
32
+ # only ran at `config set` time) and only blow up later. Surface those as
33
+ # a clear, NON-FATAL warning here — the boot chokepoint every command
34
+ # already passes through — so the user is told at startup instead of
35
+ # discovering it as a runtime crash / provider 4xx. Never fatal: a
36
+ # warning must not block a usable config, and a probe hiccup is ignored.
37
+ # Pure-meta commands (version/help) never need a configured model, so the
38
+ # config-issue warning is noise on `rubino --version`/`--help` — skip it.
39
+ warn_config_issues(loader, stderr) unless meta_command?(argv)
40
+ nil
41
+ rescue Config::ConfigError, Psych::Exception, SystemCallError, IOError => e
42
+ stderr.puts "rubino: config error — #{e.message}"
43
+ stderr.puts "rubino: fix #{loader.config_path}, restore a backup, or re-run 'rubino setup'."
44
+ exit 1
45
+ end
46
+
47
+ # `--version`/`-v`/`version` and `--help`/`-h`/`help` are pure-meta: they
48
+ # print static text and exit, so a config-issue warning on them is pure
49
+ # noise. True when the invocation is one of those (the meta flag/word is the
50
+ # FIRST token, matching how Commands.start dispatches them).
51
+ def self.meta_command?(argv)
52
+ first = Array(argv).first.to_s
53
+ %w[--version -v version --help -h help].include?(first)
54
+ end
55
+
56
+ # Emits a one-line-per-issue config WARNING to stderr (F8), or nothing when
57
+ # the config is clean. Best-effort — any failure here is swallowed so a
58
+ # validation hiccup can never break boot.
59
+ def self.warn_config_issues(loader, stderr)
60
+ issues = Config::Validator.warnings(loader.raw_config)
61
+ return if issues.empty?
62
+
63
+ stderr.puts "rubino: warning: #{loader.config_path} has #{issues.size} " \
64
+ "config issue#{"s" if issues.size != 1} (run `rubino doctor` for details):"
65
+ issues.first(5).each { |msg| stderr.puts "rubino: - #{msg}" }
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -52,7 +52,7 @@ module Rubino
52
52
  rescue StandardError
53
53
  []
54
54
  end
55
- names = (::Rubino::Commands::BuiltIns::NAMES + custom).uniq
55
+ names = (::Rubino::Commands::BuiltIns::NAMES + agent_command_names + custom).uniq
56
56
  files = -> { Rubino::Workspace.primary_root }
57
57
  # ARGUMENT sources: the dropdown completes the argument of these commands
58
58
  # the same way it completes `/command` and `@file`.
@@ -79,10 +79,22 @@ module Rubino
79
79
  # verbs + the known config keys flattened from the defaults tree.
80
80
  # * /skills — the `✗ none` clear entry + the enable/disable verbs +
81
81
  # the skill names (#188); after a toggle verb, the names again.
82
- arg_sources = {
82
+ Rubino::UI::CompletionSource.new(commands: names, files: files,
83
+ arg_sources: arg_sources,
84
+ descriptions: completion_descriptions)
85
+ end
86
+
87
+ private
88
+
89
+ # The per-command ARGUMENT completion sources (#39): the dropdown
90
+ # completes the argument of these commands the same way it completes
91
+ # `/command` and `@file`. See the per-entry notes inline.
92
+ def arg_sources
93
+ {
83
94
  "skills" => ->(args) { skills_arg_candidates(args) },
84
95
  "agents" => ->(args) { agents_arg_candidates(args) },
85
96
  "tasks" => ->(args) { agents_arg_candidates(args) },
97
+ "agent" => ->(args) { args.empty? ? primary_agent_names : [] },
86
98
  "reply" => ->(args) { args.empty? ? blocked_subagent_ids : [] },
87
99
  "mcp" => ->(args) { mcp_arg_candidates(args) },
88
100
  "mode" => ->(args) { args.empty? ? Rubino::Modes::ALL.map(&:to_s) : [] },
@@ -97,12 +109,35 @@ module Rubino
97
109
  "jobs" => ->(args) { args.empty? ? recent_job_ids : [] },
98
110
  "config" => ->(args) { config_arg_candidates(args) }
99
111
  }
100
- Rubino::UI::CompletionSource.new(commands: names, files: files,
101
- arg_sources: arg_sources,
102
- descriptions: completion_descriptions)
103
112
  end
104
113
 
105
- private
114
+ # Agent slash commands (#320): every visible agent is reachable as a
115
+ # `/<name>` (a bare `/<primary>` switches, `/<name> <msg>` routes one
116
+ # turn). Surfaced in the dropdown alongside the built-ins so they're
117
+ # discoverable; resolved lazily so a freshly registered agent appears.
118
+ def agent_command_names
119
+ ::Rubino.agent_registry.all.reject(&:hidden?).map { |a| "/#{a.name}" }
120
+ rescue StandardError
121
+ []
122
+ end
123
+
124
+ # The switchable primary-agent names, for the `/agent <name>` argument.
125
+ def primary_agent_names
126
+ ::Rubino.agent_registry.primary_agents.map(&:name)
127
+ rescue StandardError
128
+ []
129
+ end
130
+
131
+ # Describe each `/<name>` agent command so the dropdown explains what
132
+ # switching/routing to it does — primaries switch, subagents run one-shot.
133
+ def merge_agent_descriptions!(descriptions)
134
+ ::Rubino.agent_registry.all.reject(&:hidden?).each do |a|
135
+ verb = a.primary? ? "switch to" : "run one turn as"
136
+ descriptions["/#{a.name}"] = "#{verb} the #{a.name} agent — #{a.description}"
137
+ end
138
+ rescue StandardError
139
+ nil
140
+ end
106
141
 
107
142
  # Argument candidates per /agents position: ids → subcommands → nothing.
108
143
  def agents_arg_candidates(args)
@@ -247,6 +282,7 @@ module Rubino
247
282
  rescue StandardError
248
283
  nil
249
284
  end
285
+ merge_agent_descriptions!(descriptions)
250
286
  descriptions.merge(
251
287
  "steer" => "park a note the subagent folds in at its next turn",
252
288
  "probe" => "ask the subagent an ephemeral question (not saved)",
@@ -53,7 +53,13 @@ module Rubino
53
53
  paint
54
54
  break unless children_live?
55
55
  end
56
- rescue StandardError
56
+ rescue StandardError => e
57
+ # The ticker exits on any error so a hiccup never crashes the REPL,
58
+ # but a swallowed coding bug would silently kill the live-card refresh
59
+ # for the rest of the session with no trace. Log it once (this rescue
60
+ # only ever fires once per ticker — the loop is already dead here).
61
+ Rubino.logger.warn(event: "cli.idle_card_ticker.crashed",
62
+ error: e.message, error_class: e.class.name)
57
63
  nil
58
64
  end
59
65
  end