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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +11 -2
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +172 -5
- data/CONTRIBUTING.md +10 -1
- data/README.md +14 -5
- data/Rakefile +31 -0
- data/docs/agents.md +42 -23
- data/docs/architecture.md +2 -2
- data/docs/commands.md +35 -3
- data/docs/configuration.md +20 -23
- data/docs/getting-started.md +5 -3
- data/docs/security.md +16 -5
- data/docs/skills.md +31 -0
- data/docs/troubleshooting.md +1 -1
- data/exe/rubino +16 -2
- data/install.sh +721 -59
- data/lib/rubino/active_agent.rb +73 -0
- data/lib/rubino/agent/action_claim_guard.rb +881 -0
- data/lib/rubino/agent/agent_registry.rb +5 -2
- data/lib/rubino/agent/definition.rb +1 -9
- data/lib/rubino/agent/fallback_chain.rb +0 -6
- data/lib/rubino/agent/iteration_budget.rb +109 -3
- data/lib/rubino/agent/loop.rb +476 -20
- data/lib/rubino/agent/model_call_runner.rb +81 -3
- data/lib/rubino/agent/prompts/build.txt +22 -5
- data/lib/rubino/agent/response_validator.rb +8 -0
- data/lib/rubino/agent/runner.rb +133 -8
- data/lib/rubino/agent/tool_executor.rb +166 -14
- data/lib/rubino/agent/truncation_continuation.rb +4 -1
- data/lib/rubino/api/server.rb +19 -0
- data/lib/rubino/attachments/classify.rb +35 -17
- data/lib/rubino/boot/config_guard.rb +71 -0
- data/lib/rubino/cli/chat/completion_builder.rb +42 -6
- data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
- data/lib/rubino/cli/chat/session_resolver.rb +87 -21
- data/lib/rubino/cli/chat_command.rb +1189 -50
- data/lib/rubino/cli/commands.rb +282 -2
- data/lib/rubino/cli/config_command.rb +68 -8
- data/lib/rubino/cli/doctor_command.rb +204 -12
- data/lib/rubino/cli/jobs_command.rb +12 -0
- data/lib/rubino/cli/memory_command.rb +53 -20
- data/lib/rubino/cli/onboarding_wizard.rb +79 -6
- data/lib/rubino/cli/session_command.rb +172 -18
- data/lib/rubino/cli/setup_command.rb +131 -8
- data/lib/rubino/cli/skills_command.rb +183 -9
- data/lib/rubino/cli/trust_gate.rb +16 -7
- data/lib/rubino/commands/built_ins.rb +2 -0
- data/lib/rubino/commands/command.rb +12 -2
- data/lib/rubino/commands/executor.rb +149 -12
- data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
- data/lib/rubino/commands/handlers/agents.rb +156 -41
- data/lib/rubino/commands/handlers/config.rb +4 -1
- data/lib/rubino/commands/handlers/help.rb +113 -14
- data/lib/rubino/commands/handlers/memory.rb +15 -5
- data/lib/rubino/commands/handlers/sessions.rb +26 -3
- data/lib/rubino/commands/handlers/status.rb +9 -4
- data/lib/rubino/commands/loader.rb +12 -0
- data/lib/rubino/config/configuration.rb +86 -24
- data/lib/rubino/config/defaults.rb +140 -33
- data/lib/rubino/config/loader.rb +62 -12
- data/lib/rubino/config/validator.rb +341 -0
- data/lib/rubino/config/writer.rb +123 -31
- data/lib/rubino/context/compressor.rb +184 -22
- data/lib/rubino/context/environment_inspector.rb +2 -2
- data/lib/rubino/context/file_discovery.rb +2 -2
- data/lib/rubino/context/message_boundary.rb +27 -1
- data/lib/rubino/context/project_languages.rb +90 -0
- data/lib/rubino/context/prompt_assembler.rb +105 -22
- data/lib/rubino/context/summary_builder.rb +45 -4
- data/lib/rubino/context/token_budget.rb +36 -11
- data/lib/rubino/context/token_estimate.rb +45 -0
- data/lib/rubino/context/tool_result_pruner.rb +81 -0
- data/lib/rubino/database/connection.rb +154 -3
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
- data/lib/rubino/database/migrator.rb +98 -5
- data/lib/rubino/documents/cap_exceeded.rb +13 -0
- data/lib/rubino/documents/converters/csv.rb +4 -3
- data/lib/rubino/documents/converters/docx.rb +29 -5
- data/lib/rubino/documents/converters/html.rb +5 -1
- data/lib/rubino/documents/converters/json.rb +2 -1
- data/lib/rubino/documents/converters/pdf.rb +11 -2
- data/lib/rubino/documents/converters/plain.rb +2 -1
- data/lib/rubino/documents/converters/pptx.rb +11 -2
- data/lib/rubino/documents/converters/xlsx.rb +35 -4
- data/lib/rubino/documents/converters/xml.rb +2 -1
- data/lib/rubino/documents/limits.rb +210 -0
- data/lib/rubino/documents.rb +10 -3
- data/lib/rubino/errors.rb +36 -5
- data/lib/rubino/interaction/cancel_token.rb +19 -3
- data/lib/rubino/interaction/events.rb +13 -0
- data/lib/rubino/interaction/lifecycle.rb +99 -13
- data/lib/rubino/interaction/polishing.rb +176 -0
- data/lib/rubino/jobs/cron_job_repository.rb +5 -8
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
- data/lib/rubino/jobs/queue.rb +63 -8
- data/lib/rubino/jobs/runner.rb +24 -6
- data/lib/rubino/jobs/worker.rb +0 -4
- data/lib/rubino/llm/adapter_response.rb +47 -4
- data/lib/rubino/llm/credential_check.rb +15 -16
- data/lib/rubino/llm/error_classifier.rb +89 -1
- data/lib/rubino/llm/inline_think_filter.rb +69 -12
- data/lib/rubino/llm/request.rb +30 -3
- data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
- data/lib/rubino/llm/tool_bridge.rb +113 -9
- data/lib/rubino/mcp/manager.rb +18 -1
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
- data/lib/rubino/memory/aux_retry.rb +107 -0
- data/lib/rubino/memory/backends/sqlite.rb +73 -44
- data/lib/rubino/memory/backends.rb +23 -7
- data/lib/rubino/memory/salience_gate.rb +103 -0
- data/lib/rubino/memory/sqlite_extraction.rb +70 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
- data/lib/rubino/memory/store.rb +33 -5
- data/lib/rubino/memory/threat_scanner.rb +52 -0
- data/lib/rubino/output/cost.rb +52 -0
- data/lib/rubino/output/headless_block_latch.rb +53 -0
- data/lib/rubino/output/result_serializer.rb +222 -0
- data/lib/rubino/output/turn_recorder.rb +77 -0
- data/lib/rubino/security/approval_policy.rb +227 -32
- data/lib/rubino/security/command_allowlist.rb +79 -4
- data/lib/rubino/security/doom_loop_detector.rb +21 -2
- data/lib/rubino/security/hardline_guard.rb +189 -16
- data/lib/rubino/security/pattern_matcher.rb +28 -5
- data/lib/rubino/security/prefix_deriver.rb +25 -6
- data/lib/rubino/security/readonly_commands.rb +145 -5
- data/lib/rubino/security/secret_path.rb +134 -0
- data/lib/rubino/security/url_safety.rb +255 -0
- data/lib/rubino/session/repository.rb +212 -11
- data/lib/rubino/session/store.rb +139 -14
- data/lib/rubino/skills/installer.rb +230 -0
- data/lib/rubino/skills/prompt_index.rb +2 -2
- data/lib/rubino/skills/registry.rb +52 -1
- data/lib/rubino/skills/skill.rb +64 -3
- data/lib/rubino/skills/skill_tool.rb +16 -5
- data/lib/rubino/tools/background_tasks.rb +157 -13
- data/lib/rubino/tools/base.rb +204 -3
- data/lib/rubino/tools/edit_tool.rb +73 -18
- data/lib/rubino/tools/glob_tool.rb +48 -9
- data/lib/rubino/tools/grep_tool.rb +103 -9
- data/lib/rubino/tools/multi_edit_tool.rb +64 -9
- data/lib/rubino/tools/patch_tool.rb +5 -0
- data/lib/rubino/tools/read_attachment_tool.rb +3 -1
- data/lib/rubino/tools/read_tool.rb +33 -15
- data/lib/rubino/tools/read_tracker.rb +153 -35
- data/lib/rubino/tools/registry.rb +113 -12
- data/lib/rubino/tools/result.rb +9 -1
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/shell_registry.rb +70 -0
- data/lib/rubino/tools/shell_tool.rb +40 -1
- data/lib/rubino/tools/summarize_file_tool.rb +6 -0
- data/lib/rubino/tools/task_stop_tool.rb +10 -16
- data/lib/rubino/tools/task_tool.rb +36 -8
- data/lib/rubino/tools/vision_tool.rb +5 -0
- data/lib/rubino/tools/webfetch_tool.rb +39 -7
- data/lib/rubino/tools/websearch_tool.rb +92 -30
- data/lib/rubino/tools/write_tool.rb +23 -4
- data/lib/rubino/ui/api.rb +10 -1
- data/lib/rubino/ui/base.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +382 -74
- data/lib/rubino/ui/cli.rb +515 -83
- data/lib/rubino/ui/completion_menu.rb +11 -7
- data/lib/rubino/ui/headless_trace.rb +63 -0
- data/lib/rubino/ui/live_region.rb +70 -7
- data/lib/rubino/ui/markdown_renderer.rb +142 -7
- data/lib/rubino/ui/notifier.rb +0 -2
- data/lib/rubino/ui/null.rb +52 -5
- data/lib/rubino/ui/paste_store.rb +16 -2
- data/lib/rubino/ui/queued_indicators.rb +6 -1
- data/lib/rubino/ui/status_bar.rb +61 -7
- data/lib/rubino/ui/streaming_markdown.rb +59 -6
- data/lib/rubino/ui/subagent_view.rb +29 -4
- data/lib/rubino/ui/tool_label.rb +52 -0
- data/lib/rubino/update_check.rb +39 -4
- data/lib/rubino/util/atomic_file.rb +117 -0
- data/lib/rubino/util/ignore_rules.rb +120 -0
- data/lib/rubino/util/output.rb +229 -12
- data/lib/rubino/util/secrets_mask.rb +70 -7
- data/lib/rubino/util/spill_store.rb +153 -0
- data/lib/rubino/version.rb +1 -1
- data/lib/rubino/workspace.rb +9 -1
- data/lib/rubino.rb +191 -7
- data/rubino-agent.gemspec +1 -0
- data/skills/ruby-expert/SKILL.md +1 -0
- metadata +42 -12
- data/lib/rubino/agent/router.rb +0 -65
- data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
- data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
|
@@ -38,8 +38,11 @@ module Rubino
|
|
|
38
38
|
# #record_tool_started / #record_tool_finished) under the registry mutex
|
|
39
39
|
# and read by the parent renderer (UI::SubagentCards) and
|
|
40
40
|
# the /agents drill-in. activity_log is a bounded ring of the last few
|
|
41
|
-
# `✓ verb · hint` lines for the live drill-in;
|
|
42
|
-
#
|
|
41
|
+
# `✓ verb · hint` lines for the live drill-in; output_tail is the bounded
|
|
42
|
+
# line buffer of the CURRENTLY RUNNING tool's streamed output (fed by
|
|
43
|
+
# #record_tool_output, wiped at #record_tool_finished) that the drill-in's
|
|
44
|
+
# output: block tails (#5). Nothing is persisted (it dies with the
|
|
45
|
+
# process, like the rest of the registry).
|
|
43
46
|
#
|
|
44
47
|
# approval_gate / approval_question / approval_command are the
|
|
45
48
|
# Option-2 approval-surfacing state: when a background child's tool needs
|
|
@@ -49,7 +52,7 @@ module Rubino
|
|
|
49
52
|
Entry = Struct.new(
|
|
50
53
|
:id, :subagent, :prompt, :status, :result, :error,
|
|
51
54
|
:thread, :runner, :started_at, :finished_at,
|
|
52
|
-
:last_activity, :tool_count, :activity_log,
|
|
55
|
+
:last_activity, :tool_count, :activity_log, :output_tail,
|
|
53
56
|
:approval_gate, :approval_id, :approval_question, :approval_command,
|
|
54
57
|
# Parent->child steer (the `/agents <id> steer "..."` note). Wired into
|
|
55
58
|
# the child Loop as its Interaction::InputQueue (the SAME turn-boundary
|
|
@@ -94,6 +97,26 @@ module Rubino
|
|
|
94
97
|
# How many recent activity lines the drill-in shows (the live `recent:` ring).
|
|
95
98
|
ACTIVITY_LOG_MAX = 6
|
|
96
99
|
|
|
100
|
+
# Bounds for the live output tail (#5): how many COMPLETE lines the
|
|
101
|
+
# drill-in's output: block shows (the buffer keeps one extra slot for the
|
|
102
|
+
# in-flight partial line), and the byte cap per buffered line so a
|
|
103
|
+
# newline-free stream can't grow a line unbounded.
|
|
104
|
+
OUTPUT_TAIL_MAX = 6
|
|
105
|
+
OUTPUT_TAIL_LINE_MAX = 200
|
|
106
|
+
|
|
107
|
+
# Prefix #deliver_answer stamps on the steer-queue COPY of an answer it has
|
|
108
|
+
# ALREADY delivered to the child via its ask gate (the dual-path delivery:
|
|
109
|
+
# gate for a blocking ask, steer-queue for a non-blocking one). When the
|
|
110
|
+
# child resumes via the gate and finishes WITHOUT another turn boundary, the
|
|
111
|
+
# still-queued copy is drained by #complete and would surface as an
|
|
112
|
+
# "undelivered steer note" — but the answer WAS delivered via the gate, so
|
|
113
|
+
# reporting it undelivered is a false alarm (the /reply happy-path
|
|
114
|
+
# regression from the H5 fix #457). The completion-notice paths filter notes
|
|
115
|
+
# carrying this prefix OUT of the undelivered report for exactly that
|
|
116
|
+
# reason; a genuine `/agents <id> steer "..."` note never carries it, so the
|
|
117
|
+
# deliver-or-report-undelivered invariant for real steer notes is intact.
|
|
118
|
+
ANSWER_NOTE_PREFIX = "[parent answer] "
|
|
119
|
+
|
|
97
120
|
class << self
|
|
98
121
|
def instance
|
|
99
122
|
@instance ||= new
|
|
@@ -190,6 +213,19 @@ module Rubino
|
|
|
190
213
|
# see a consistent snapshot. A failure landing on a :stopping entry is a
|
|
191
214
|
# USER-REQUESTED stop unwinding (Interrupted at the next checkpoint), so
|
|
192
215
|
# it is recorded as :stopped — distinct from a genuine :failed (#108/#13).
|
|
216
|
+
#
|
|
217
|
+
# H5 — closes the drain↔complete race. The final drain of the child's
|
|
218
|
+
# steer_queue happens HERE, under the SAME registry mutex that flips the
|
|
219
|
+
# status to terminal, and #steer refuses to push onto a terminal entry
|
|
220
|
+
# under that SAME mutex. So a steer/answer arriving concurrently is
|
|
221
|
+
# serialised against this finalize: it is EITHER pushed before the status
|
|
222
|
+
# flips (and drained right here into the returned `undelivered` notes) OR
|
|
223
|
+
# rejected by #steer (which then honestly reports not-delivered). The
|
|
224
|
+
# earlier shape — drain (InputQueue lock) then complete (registry lock),
|
|
225
|
+
# two locks with a gap — let an answer land on a now-dead queue: dropped,
|
|
226
|
+
# omitted from `undelivered`, yet reported delivered. Returns the notes
|
|
227
|
+
# that were still queued at finalize time (never delivered to the child),
|
|
228
|
+
# so the caller can surface them as undelivered.
|
|
193
229
|
def complete(entry, status:, result: nil, error: nil)
|
|
194
230
|
@mutex.synchronize do
|
|
195
231
|
status = :stopped if entry.status == :stopping && status == :failed
|
|
@@ -197,6 +233,10 @@ module Rubino
|
|
|
197
233
|
entry.result = result
|
|
198
234
|
entry.error = error
|
|
199
235
|
entry.finished_at = Time.now
|
|
236
|
+
# Drain UNDER the mutex: anything still here is undelivered (the child
|
|
237
|
+
# has no further turn to fold it in), and once status is terminal no
|
|
238
|
+
# new note can arrive — #steer rejects it.
|
|
239
|
+
entry.steer_queue&.drain || []
|
|
200
240
|
end
|
|
201
241
|
end
|
|
202
242
|
|
|
@@ -219,7 +259,8 @@ module Rubino
|
|
|
219
259
|
# Records a child tool FINISHING: appends a terse line to the bounded
|
|
220
260
|
# activity ring the live drill-in (#71) tails. Keeps the last
|
|
221
261
|
# ACTIVITY_LOG_MAX entries so the ring never grows unbounded for a
|
|
222
|
-
# read-heavy child.
|
|
262
|
+
# read-heavy child. Also wipes the live output tail — it belongs to the
|
|
263
|
+
# tool that just finished, so the drill-in's output: block clears (#5).
|
|
223
264
|
def record_tool_finished(id, line)
|
|
224
265
|
@mutex.synchronize do
|
|
225
266
|
entry = @entries[id]
|
|
@@ -228,6 +269,26 @@ module Rubino
|
|
|
228
269
|
log = (entry.activity_log ||= [])
|
|
229
270
|
log << line.to_s
|
|
230
271
|
log.shift while log.size > ACTIVITY_LOG_MAX
|
|
272
|
+
entry.output_tail = nil
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Records a streamed chunk of the CURRENTLY RUNNING tool's output (#5):
|
|
277
|
+
# splits on newlines into a bounded line buffer whose LAST slot carries
|
|
278
|
+
# the in-flight partial line, so the /agents drill-in can tail it live.
|
|
279
|
+
# Called from UI::SubagentView#tool_chunk on the CHILD thread, so it MUST
|
|
280
|
+
# take the mutex like the other record_* writers. No-op for an unknown id.
|
|
281
|
+
def record_tool_output(id, chunk)
|
|
282
|
+
@mutex.synchronize do
|
|
283
|
+
entry = @entries[id]
|
|
284
|
+
return unless entry
|
|
285
|
+
|
|
286
|
+
tail = (entry.output_tail ||= [""])
|
|
287
|
+
chunk.to_s.each_line do |line|
|
|
288
|
+
tail[-1] = "#{tail[-1]}#{line.chomp}"[0, OUTPUT_TAIL_LINE_MAX]
|
|
289
|
+
tail << "" if line.end_with?("\n")
|
|
290
|
+
end
|
|
291
|
+
tail.shift while tail.size > OUTPUT_TAIL_MAX + 1
|
|
231
292
|
end
|
|
232
293
|
end
|
|
233
294
|
|
|
@@ -267,17 +328,28 @@ module Rubino
|
|
|
267
328
|
# affordance). Pushes the text onto the child's steering queue, which the
|
|
268
329
|
# child Loop drains at its next iteration boundary (Loop#inject_steered_input)
|
|
269
330
|
# — between turns, never between a tool_use and its results. Best-effort:
|
|
270
|
-
# returns false (and pushes nothing) when the entry is gone
|
|
271
|
-
#
|
|
331
|
+
# returns false (and pushes nothing) when the entry is gone, has no queue,
|
|
332
|
+
# or has ALREADY reached a terminal state (the child finished — there is no
|
|
333
|
+
# more turn to fold the note into); true when the note was queued.
|
|
334
|
+
#
|
|
335
|
+
# H5 — the push happens UNDER the registry mutex, gated on a non-terminal
|
|
336
|
+
# status, so it is serialised against #complete (which flips the status to
|
|
337
|
+
# terminal AND drains the queue under that SAME mutex). Either this push
|
|
338
|
+
# wins the lock first (the note is queued and will be drained — by the
|
|
339
|
+
# child at its next turn, or by #complete into the undelivered report) or
|
|
340
|
+
# #complete wins first (status is terminal and this returns false). There
|
|
341
|
+
# is no window in which a note is pushed onto a queue nobody will drain yet
|
|
342
|
+
# reported delivered. Pushing inside the mutex is safe: InputQueue#push has
|
|
343
|
+
# its own lock and never calls back into the registry, so no lock cycle.
|
|
272
344
|
def steer(id, text)
|
|
273
|
-
|
|
345
|
+
@mutex.synchronize do
|
|
274
346
|
entry = @entries[id]
|
|
275
|
-
entry&.steer_queue
|
|
276
|
-
|
|
277
|
-
return false unless queue
|
|
347
|
+
return false unless entry&.steer_queue
|
|
348
|
+
return false if terminal_status?(entry.status)
|
|
278
349
|
|
|
279
|
-
|
|
280
|
-
|
|
350
|
+
entry.steer_queue.push(text)
|
|
351
|
+
true
|
|
352
|
+
end
|
|
281
353
|
end
|
|
282
354
|
|
|
283
355
|
# Records a BILLED live probe against a child (S3): bumps probe_count and
|
|
@@ -352,8 +424,22 @@ module Rubino
|
|
|
352
424
|
entry = find(id)
|
|
353
425
|
return false unless entry&.ask_gate
|
|
354
426
|
|
|
427
|
+
# H5 — #steer is the SINGLE race-free liveness oracle here: it pushes the
|
|
428
|
+
# answer onto the steer_queue under the registry mutex IFF the child is
|
|
429
|
+
# still non-terminal, returning false the instant the child has finished
|
|
430
|
+
# (atomic against #complete, which flips the status and drains the queue
|
|
431
|
+
# under that same mutex). So we steer FIRST and let its honest result
|
|
432
|
+
# decide everything:
|
|
433
|
+
# false ⇒ the child already finished; neither path can reach it. Do NOT
|
|
434
|
+
# decide the gate (a no-op for a child that will never await
|
|
435
|
+
# it) and do NOT clear the ask — report not-delivered.
|
|
436
|
+
# true ⇒ the child is live and the answer is queued; a BLOCKING ask
|
|
437
|
+
# additionally needs its gate decided so the parked child wakes
|
|
438
|
+
# with the answer as its tool result. Then clear the blocked
|
|
439
|
+
# state and report delivered.
|
|
440
|
+
return false unless steer(entry.id, "#{ANSWER_NOTE_PREFIX}#{answer}")
|
|
441
|
+
|
|
355
442
|
entry.ask_gate.decide(entry.ask_id, answer)
|
|
356
|
-
steer(entry.id, "[parent answer] #{answer}")
|
|
357
443
|
end_ask(entry.id)
|
|
358
444
|
true
|
|
359
445
|
end
|
|
@@ -451,6 +537,54 @@ module Rubino
|
|
|
451
537
|
descendants_of(id).each { |e| e.ask_gate&.cancel! }
|
|
452
538
|
end
|
|
453
539
|
|
|
540
|
+
# The ONE per-entry stop body, shared by every stop path (the human
|
|
541
|
+
# /agents <id> --stop, the model-callable task_stop, and the
|
|
542
|
+
# parent-teardown #cancel_all below). Marks the stop so the unwind records
|
|
543
|
+
# as :stopped (not ✗ failed) and the list shows ◌ stopping, then wakes the
|
|
544
|
+
# entry no matter HOW it is blocked: a child parked on its OWN approval or
|
|
545
|
+
# ask gate (cancel those → Interrupted → clean unwind), any descendant
|
|
546
|
+
# parked on a blocking ask (the stop-cascade), and the runner's CancelToken
|
|
547
|
+
# for a child between checkpoints. Idempotent and safe on an already-stopped
|
|
548
|
+
# or never-blocked entry (each cancel! is one-shot; request_stop no-ops on a
|
|
549
|
+
# non-live status), so #cancel_all can call it across the whole registry.
|
|
550
|
+
def stop_entry(entry)
|
|
551
|
+
return unless entry
|
|
552
|
+
|
|
553
|
+
request_stop(entry.id)
|
|
554
|
+
entry.approval_gate&.cancel!
|
|
555
|
+
entry.ask_gate&.cancel!
|
|
556
|
+
cancel_descendant_ask_gates(entry.id)
|
|
557
|
+
entry.runner&.cancel!
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Structured-concurrency teardown seam: cancel EVERY live subagent so the
|
|
561
|
+
# process never leaves a child parked. The required fix for the parent-death
|
|
562
|
+
# deadlock (#XXX) — when the PARENT dies/interrupts (REPL break, HUP/TERM,
|
|
563
|
+
# clean quit, an aborted turn) a child blocked on ask_parent(blocking:true)
|
|
564
|
+
# otherwise stays parked on its gate for the full ask_parent_timeout (~900s)
|
|
565
|
+
# because nothing cancels its gate; the per-id stop paths only fire on an
|
|
566
|
+
# explicit /agents --stop or task_stop. Calling this from each parent-death
|
|
567
|
+
# edge wakes every blocked child SYNCHRONOUSLY (cancel! pushes its sentinel;
|
|
568
|
+
# the gate's await observes it within one WAKE_TICK) so each unwinds via the
|
|
569
|
+
# existing `rescue Rubino::Interrupted` with the clean "parent question was
|
|
570
|
+
# cancelled" message instead of hanging to the bound. No-op when there are no
|
|
571
|
+
# live children, and idempotent (#stop_entry is), so it is safe to invoke
|
|
572
|
+
# from a teardown `ensure` and from a signal trap. Snapshots #running first
|
|
573
|
+
# (outside the per-entry work) so we don't hold the registry mutex across the
|
|
574
|
+
# gate/runner cancels.
|
|
575
|
+
def cancel_all
|
|
576
|
+
running.each { |entry| stop_entry(entry) }
|
|
577
|
+
# Logical cancel alone (above) only flips cancel tokens and trusts each
|
|
578
|
+
# child THREAD to observe the token and reap its own shell within a wake
|
|
579
|
+
# tick — but on parent-DEATH the process exits before the thread reaches
|
|
580
|
+
# that checkpoint, so any shell a child spawned (its own pgid) reparents
|
|
581
|
+
# to init as an orphan (MED-2). Reap the tracked shell process groups
|
|
582
|
+
# SYNCHRONOUSLY here so the same parent-death edges that call cancel_all
|
|
583
|
+
# (clean quit, HUP/TERM trap, REPL break) leave no surviving shell.
|
|
584
|
+
ShellRegistry.instance.kill_all_groups
|
|
585
|
+
end
|
|
586
|
+
alias shutdown! cancel_all
|
|
587
|
+
|
|
454
588
|
# True iff `child_id`'s direct owner is `parent_id` (the ownership predicate
|
|
455
589
|
# later slices' steer/probe/answer_child AUTHORIZATION checks will build on).
|
|
456
590
|
def owned_by?(parent_id, child_id)
|
|
@@ -508,6 +642,16 @@ module Rubino
|
|
|
508
642
|
%i[running needs_approval blocked_on_human blocked_on_parent stopping].include?(status)
|
|
509
643
|
end
|
|
510
644
|
|
|
645
|
+
# A child has reached a TERMINAL state once #complete has run: its worker
|
|
646
|
+
# thread is done, its steer_queue has been drained, and it has no further
|
|
647
|
+
# turn to fold a steer note into. #steer rejects pushes onto a terminal
|
|
648
|
+
# entry (H5) so an answer arriving after finalize is reported undelivered
|
|
649
|
+
# rather than dropped-but-reported-delivered. :cancelled is included for
|
|
650
|
+
# the API surface, which records cancellation via #complete too.
|
|
651
|
+
def terminal_status?(status)
|
|
652
|
+
%i[completed failed stopped cancelled].include?(status)
|
|
653
|
+
end
|
|
654
|
+
|
|
511
655
|
def running_count
|
|
512
656
|
@entries.values.count { |e| live_status?(e.status) }
|
|
513
657
|
end
|
data/lib/rubino/tools/base.rb
CHANGED
|
@@ -24,6 +24,14 @@ module Rubino
|
|
|
24
24
|
# tool with no streamable output (read, edit, glob) just ignores it.
|
|
25
25
|
attr_accessor :stream_chunk
|
|
26
26
|
|
|
27
|
+
# Optional render hint the ToolExecutor forwards to the UI alongside each
|
|
28
|
+
# streamed chunk (and the end-of-call body). :diff makes the CLI colorize
|
|
29
|
+
# +/-/@@ lines AND show the full hunks instead of collapsing to the 3-line
|
|
30
|
+
# preview — so "show me the diff" surfaces the real diff, not a snippet.
|
|
31
|
+
# Default nil ⇒ :plain. Set it from #call once the command/content kind is
|
|
32
|
+
# known; the streaming lambda reads it live.
|
|
33
|
+
attr_accessor :stream_kind
|
|
34
|
+
|
|
27
35
|
# Convenience guard so tools don't sprinkle nil-checks at every emit.
|
|
28
36
|
def emit_chunk(text)
|
|
29
37
|
return if text.nil? || text.to_s.empty?
|
|
@@ -90,6 +98,26 @@ module Rubino
|
|
|
90
98
|
|
|
91
99
|
protected
|
|
92
100
|
|
|
101
|
+
# Resolves a model-supplied path to an absolute one, anchoring a RELATIVE
|
|
102
|
+
# path at the workspace primary root (terminal.cwd || launch cwd) instead
|
|
103
|
+
# of the process cwd.
|
|
104
|
+
#
|
|
105
|
+
# `File.expand_path(rel)` anchors at Dir.pwd, but the agent's "current
|
|
106
|
+
# directory" — the dir the @-picker, shell/test and sandbox all agree on
|
|
107
|
+
# — is Workspace.primary_root, which is terminal.cwd when configured (e.g.
|
|
108
|
+
# bin/dev / the QA harness point it at a workspace subdir while the process
|
|
109
|
+
# launches from the parent). When the two diverge, a relative `shopkit/
|
|
110
|
+
# cart.py` resolved one directory too shallow and 404'd, forcing an
|
|
111
|
+
# ls→glob→re-read detour (r6 F3). Anchoring at primary_root fixes that
|
|
112
|
+
# while an ABSOLUTE path (or a ~ path) passes straight through unchanged,
|
|
113
|
+
# so the workspace guard downstream still sees the real target.
|
|
114
|
+
def expand_workspace_path(path)
|
|
115
|
+
str = path.to_s
|
|
116
|
+
return File.expand_path(str) if str.start_with?(File::SEPARATOR, "~")
|
|
117
|
+
|
|
118
|
+
File.expand_path(str, workspace_root)
|
|
119
|
+
end
|
|
120
|
+
|
|
93
121
|
# Filesystem sandbox for write/edit/delete operations.
|
|
94
122
|
#
|
|
95
123
|
# Defaults to Dir.pwd, overridable via terminal.cwd in config. Mutating
|
|
@@ -191,6 +219,152 @@ module Rubino
|
|
|
191
219
|
"Set tools.workspace_strict=false in config.yml to disable this check."
|
|
192
220
|
end
|
|
193
221
|
|
|
222
|
+
# Typed "outside workspace" error gate, retained for the AUX-LLM read
|
|
223
|
+
# tools (summarize_file, vision) ONLY. Those route the raw file bytes
|
|
224
|
+
# through a third-party auxiliary model, so an out-of-workspace read would
|
|
225
|
+
# EXFILTRATE a sibling-repo secret / ~/.ssh file — a stronger threat than
|
|
226
|
+
# the in-process read/grep/glob, which were relaxed to broad in #406. A
|
|
227
|
+
# `path` is outside iff within_workspace? is false (strict mode on) and it
|
|
228
|
+
# isn't under the agent home; strict mode off never fires.
|
|
229
|
+
def outside_workspace?(expanded)
|
|
230
|
+
return false unless workspace_strict?
|
|
231
|
+
return false if within_workspace?(expanded)
|
|
232
|
+
# The agent's OWN home dir (~/.rubino) holds pastes, attachments and
|
|
233
|
+
# session files the agent explicitly points the model at — legitimate
|
|
234
|
+
# reads even though they sit outside the project workspace.
|
|
235
|
+
return false if under_agent_home?(expanded)
|
|
236
|
+
|
|
237
|
+
true
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def outside_workspace_message(path)
|
|
241
|
+
roots = workspace_roots
|
|
242
|
+
roots_list = roots.length == 1 ? roots.first : roots.join(", ")
|
|
243
|
+
{ output: "Error: '#{path}' is outside your workspace roots (#{roots_list}) — " \
|
|
244
|
+
"it is NOT missing, you are not allowed to access it here. " \
|
|
245
|
+
"Run `/add-dir #{File.dirname(File.expand_path(path.to_s))}` to include its folder, " \
|
|
246
|
+
"or relaunch in that directory. Do not try to create or overwrite it.",
|
|
247
|
+
error_code: :outside_workspace }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# UNIFIED SECRET-PATH PREDICATE (#446). One "is this a secret/credential
|
|
251
|
+
# path?" question used by BOTH the read side (read/grep/glob) and the
|
|
252
|
+
# write side (write/edit/multi_edit/apply_patch). Previously the read
|
|
253
|
+
# denylist (#406) was a NARROW subset (.env*/.envrc + agent-home) and the
|
|
254
|
+
# write denylist (#413) the SUPERSET; the maintainer decision is that
|
|
255
|
+
# reading OR writing a secret both require EXPLICIT user approval, applied
|
|
256
|
+
# to the SAME set. So there is now ONE set — the (wider) write set — and
|
|
257
|
+
# ONE predicate: #secret_path_category. The approval gate lives in
|
|
258
|
+
# Security::ApprovalPolicy#decide (returns :ask for a secret target), which
|
|
259
|
+
# gives us the existing flow for free: interactive → approval dropdown
|
|
260
|
+
# auto-opens; approved → the tool proceeds; denied → refused; headless (no
|
|
261
|
+
# human) → fails CLOSED via ToolExecutor's :noninteractive floor. The tools
|
|
262
|
+
# therefore NO LONGER self-refuse a secret in #call — an approved read of
|
|
263
|
+
# your .env must actually return its bytes, and an approved write must
|
|
264
|
+
# actually write. The predicate is still consulted directly in ONE place:
|
|
265
|
+
# GrepTool post-filters its RESULTS through it so an include-glob
|
|
266
|
+
# (`include: "*.env"`) over a directory can't leak a secret the per-target
|
|
267
|
+
# gate never saw (F2).
|
|
268
|
+
#
|
|
269
|
+
# DELIBERATE DIVERGENCE FROM HERMES: Hermes' file_safety.get_read_block_error
|
|
270
|
+
# FLAT-DENIES reading project .env* (model-facing deny, no human in the
|
|
271
|
+
# loop, defense-in-depth only). rubino instead routes the read through an
|
|
272
|
+
# explicit user APPROVAL gate (ask, not deny) so the agent CAN read/update
|
|
273
|
+
# your .env when you say yes — stricter than Claude Code's default
|
|
274
|
+
# (ungated reads) and aider, more content-aware than Codex's OS-sandbox.
|
|
275
|
+
#
|
|
276
|
+
# Matches (by BASENAME, in any directory):
|
|
277
|
+
# - project credential files: .env, .env.* (.env.local/.production), .envrc
|
|
278
|
+
# - shell/credential dotfiles: .netrc, .pgpass, .npmrc, .pypirc,
|
|
279
|
+
# .git-credentials, .bashrc, .zshrc, .profile, .bash_profile, .zprofile
|
|
280
|
+
# Matches (by absolute PATH / PREFIX):
|
|
281
|
+
# - ~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.docker, ~/.azure,
|
|
282
|
+
# ~/.config/gh, ~/.config/gcloud (the whole tree)
|
|
283
|
+
# - /etc/sudoers, /etc/sudoers.d/*, /etc/passwd, /etc/shadow, /etc/systemd/*
|
|
284
|
+
# - anything UNDER the agent home (~/.rubino) that holds auth/secrets:
|
|
285
|
+
# the home .env, the sqlite DB, any *oauth* file, an mcp-tokens/ dir,
|
|
286
|
+
# and *.key / *.pem material.
|
|
287
|
+
# Returns the matched category string (truthy) or nil when the path is not
|
|
288
|
+
# a secret. (Non-predicate: the truthy return carries the category string
|
|
289
|
+
# the approval question / block message interpolates.)
|
|
290
|
+
#
|
|
291
|
+
# The UNIFIED predicate (delegates to the single source of truth,
|
|
292
|
+
# Security::SecretPath.category). Returns the matched-secret category
|
|
293
|
+
# string (truthy) for a secret/credential path, or nil for a normal file.
|
|
294
|
+
def secret_path_category(expanded)
|
|
295
|
+
Security::SecretPath.category(expanded)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Denial body for a secret hit that the GrepTool post-filter strips out of
|
|
299
|
+
# an include-glob result set (F2): the directory grep wasn't itself a
|
|
300
|
+
# secret target, so the per-call approval gate never saw it — we refuse the
|
|
301
|
+
# leaking RESULTS here instead. error_code stays :secret_denied for parity
|
|
302
|
+
# with the read side.
|
|
303
|
+
def secret_filtered_block_message(path, category)
|
|
304
|
+
{ output: "Error: refusing to return secret content from '#{path}' — it is a #{category}. " \
|
|
305
|
+
"The search matched a credential file via an include-glob; secrets are not " \
|
|
306
|
+
"returned without explicit user approval. Ask the user, or read the file " \
|
|
307
|
+
"directly (which prompts for approval).",
|
|
308
|
+
error_code: :secret_denied }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# True when +expanded+ resolves under the Rubino home directory. Symlinks
|
|
312
|
+
# are resolved on both sides so a link can't be used to claim home-ness.
|
|
313
|
+
def under_agent_home?(expanded)
|
|
314
|
+
home = Rubino.home_path
|
|
315
|
+
return false if home.nil? || home.to_s.empty?
|
|
316
|
+
|
|
317
|
+
home_real = (File.realpath(home) if File.exist?(home)) || File.expand_path(home)
|
|
318
|
+
target_real = canonical_path(expanded)
|
|
319
|
+
return false unless target_real
|
|
320
|
+
|
|
321
|
+
target_real == home_real || target_real.start_with?("#{home_real}#{File::SEPARATOR}")
|
|
322
|
+
rescue StandardError => e
|
|
323
|
+
# Fail closed (treat as NOT under home) on any resolution error — but log
|
|
324
|
+
# it: this predicate gates a security-relevant decision, so a swallowed
|
|
325
|
+
# error that mis-resolves home-ness must at least leave a trace.
|
|
326
|
+
Rubino.logger&.warn(event: "tools.under_agent_home_failed",
|
|
327
|
+
error: e.message, error_class: e.class.name)
|
|
328
|
+
false
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Reads a file and scrubs a stray non-UTF-8 byte (e.g. a Latin-1 `é` in a
|
|
332
|
+
# legacy/EU source) to the replacement char. Shared by EditTool and
|
|
333
|
+
# MultiEditTool so a single bad byte doesn't raise "invalid byte sequence
|
|
334
|
+
# in UTF-8" out of the include?/scan/sub that follow and leave the file
|
|
335
|
+
# uneditable. Lossy on the offending byte, graceful for everything else.
|
|
336
|
+
#
|
|
337
|
+
# IMPORTANT (#326): this is for MODEL CONTEXT only — NEVER feed the
|
|
338
|
+
# scrubbed buffer to a File.write, because `scrub` rewrites every
|
|
339
|
+
# non-UTF-8 byte on UNTOUCHED lines to U+FFFD, so a one-line ASCII edit
|
|
340
|
+
# would lossily corrupt the whole file. Use #read_for_edit for the
|
|
341
|
+
# read-modify-write path.
|
|
342
|
+
def read_scrubbed(path)
|
|
343
|
+
content = File.read(path)
|
|
344
|
+
content.valid_encoding? ? content : content.scrub
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Reads a file for the edit/multi_edit READ-MODIFY-WRITE path (#326).
|
|
348
|
+
#
|
|
349
|
+
# Returns the raw bytes as BINARY (ASCII-8BIT) so the literal
|
|
350
|
+
# include?/scan/sub/gsub run byte-wise and every byte OUTSIDE the matched
|
|
351
|
+
# span is preserved exactly — a Latin-1 `André` on an untouched line is
|
|
352
|
+
# written back byte-identical even when the file isn't valid UTF-8. The
|
|
353
|
+
# model-supplied old_string/new_string are likewise compared/spliced as
|
|
354
|
+
# bytes (see #to_match_bytes), so a UTF-8 needle still matches its UTF-8
|
|
355
|
+
# bytes in the file. Valid-UTF-8 files behave exactly as before.
|
|
356
|
+
def read_for_edit(path)
|
|
357
|
+
File.binread(path)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Forces a model-supplied string to the SAME binary encoding the on-disk
|
|
361
|
+
# content carries in #read_for_edit, so include?/scan/sub compare raw
|
|
362
|
+
# bytes (a UTF-8 `é` needle matches its two on-disk bytes). dup so we
|
|
363
|
+
# never mutate the caller's frozen literal.
|
|
364
|
+
def to_match_bytes(str)
|
|
365
|
+
str.to_s.dup.force_encoding(Encoding::BINARY)
|
|
366
|
+
end
|
|
367
|
+
|
|
194
368
|
# Read-before-edit gate shared by EditTool and MultiEditTool. Refuses the
|
|
195
369
|
# write when the model never read this file in the current session, or
|
|
196
370
|
# read it but the file changed on disk since. Returns nil (proceed) or an
|
|
@@ -208,15 +382,42 @@ module Rubino
|
|
|
208
382
|
error_code: :stale_read }
|
|
209
383
|
end
|
|
210
384
|
|
|
385
|
+
# Fresh? matches on EITHER unchanged mtime OR unchanged content hash, so
|
|
386
|
+
# the agent's own write (refreshed via note_write), a no-op touch, a
|
|
387
|
+
# CRLF normalisation, or a linter rewrite to identical bytes does NOT
|
|
388
|
+
# trip this guard (r5 B2). Only a genuine content change does.
|
|
389
|
+
return nil if @read_tracker.fresh?(expanded)
|
|
390
|
+
|
|
211
391
|
stashed = @read_tracker.mtime_at_read(expanded)
|
|
212
392
|
current = File.mtime(expanded)
|
|
213
|
-
return nil if stashed.nil? || current <= stashed
|
|
214
|
-
|
|
215
393
|
{ output: "Error: #{display_path} changed on disk since the last read " \
|
|
216
|
-
"(read at #{stashed
|
|
394
|
+
"(read at #{stashed&.utc&.iso8601}, now #{current.utc.iso8601}). " \
|
|
217
395
|
"Re-read the file before editing so the #{verb} reflect the current contents.",
|
|
218
396
|
error_code: :stale_read }
|
|
219
397
|
end
|
|
398
|
+
|
|
399
|
+
# Read-before-overwrite gate for WriteTool on an EXISTING file (r5 MF-2).
|
|
400
|
+
# Refuses a blind `write` that would clobber a file the model never read
|
|
401
|
+
# this session (or read but is now stale on disk). New files don't reach
|
|
402
|
+
# here. Returns nil (proceed) or an error Hash with error_code:
|
|
403
|
+
# :unread_overwrite. No tracker → no gate.
|
|
404
|
+
def overwrite_guard_error(expanded, display_path)
|
|
405
|
+
return nil unless @read_tracker
|
|
406
|
+
|
|
407
|
+
unless @read_tracker.seen?(expanded)
|
|
408
|
+
return { output: "Error: refusing to overwrite existing file #{display_path} — " \
|
|
409
|
+
"you have not read it this session, so a blind write would clobber its " \
|
|
410
|
+
"current contents. Read it first (then use `edit`/`multi_edit` for a " \
|
|
411
|
+
"targeted change, or `write` the full intended content).",
|
|
412
|
+
error_code: :unread_overwrite }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
return nil if @read_tracker.fresh?(expanded)
|
|
416
|
+
|
|
417
|
+
{ output: "Error: #{display_path} changed on disk since you last read it — " \
|
|
418
|
+
"re-read it before overwriting so you don't clobber newer content.",
|
|
419
|
+
error_code: :unread_overwrite }
|
|
420
|
+
end
|
|
220
421
|
end
|
|
221
422
|
end
|
|
222
423
|
end
|
|
@@ -46,12 +46,21 @@ module Rubino
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def call(arguments)
|
|
49
|
-
file_path
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
file_path, old_string, new_string, replace_all = parse_args(arguments)
|
|
50
|
+
|
|
51
|
+
# Input guards (#329a/b): reject an empty needle (a literal sub/gsub on
|
|
52
|
+
# "" matches at every char boundary and would corrupt the file under
|
|
53
|
+
# replace_all) and a no-op old==new (reporting "1 replacement" misleads
|
|
54
|
+
# the model — multi_edit already rejects it, so match that).
|
|
55
|
+
if (guard = guard_args(old_string, new_string))
|
|
56
|
+
return guard
|
|
57
|
+
end
|
|
53
58
|
|
|
54
|
-
expanded =
|
|
59
|
+
expanded = expand_workspace_path(file_path)
|
|
60
|
+
# SECRET/credential edits (#446) are no longer HARD-refused here — they
|
|
61
|
+
# are gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask): an
|
|
62
|
+
# APPROVED edit of your .env actually applies, a denied/headless one
|
|
63
|
+
# never reaches #call. The workspace sandbox below is unchanged.
|
|
55
64
|
return workspace_violation_message(file_path) unless within_workspace?(expanded)
|
|
56
65
|
|
|
57
66
|
return "Error: File not found: #{file_path}" unless File.exist?(expanded)
|
|
@@ -60,30 +69,40 @@ module Rubino
|
|
|
60
69
|
return gate
|
|
61
70
|
end
|
|
62
71
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
# Read the RAW bytes (binary) for the read-modify-write so non-UTF-8
|
|
73
|
+
# bytes on untouched lines are preserved verbatim on write (#326); the
|
|
74
|
+
# model-supplied needle/replacement are matched/spliced as bytes too.
|
|
75
|
+
content = read_for_edit(expanded)
|
|
76
|
+
old_bytes = to_match_bytes(old_string)
|
|
77
|
+
new_bytes = to_match_bytes(new_string)
|
|
78
|
+
|
|
79
|
+
unless content.include?(old_bytes)
|
|
80
|
+
# The model's mental model of the file was wrong (hallucinated text).
|
|
81
|
+
# Flag a recovery so its next read of this path bypasses dedup and
|
|
82
|
+
# returns FRESH bytes instead of a stale "[DUPLICATE READ]" nudge
|
|
83
|
+
# (r5 B3).
|
|
84
|
+
@read_tracker&.note_edit_failure(expanded)
|
|
66
85
|
return "Error: old_string not found in file content. " \
|
|
67
86
|
"Make sure the text matches exactly including whitespace."
|
|
68
87
|
end
|
|
69
88
|
|
|
70
89
|
# Count occurrences
|
|
71
|
-
count = content.scan(
|
|
90
|
+
count = content.scan(old_bytes).size
|
|
72
91
|
if count > 1 && !replace_all
|
|
73
92
|
return "Error: Found #{count} matches for old_string. " \
|
|
74
93
|
"Provide more surrounding context to make it unique, " \
|
|
75
94
|
"or set replace_all: true to replace all occurrences."
|
|
76
95
|
end
|
|
77
96
|
|
|
78
|
-
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
new_content = replace_literal(content, old_bytes, new_bytes, replace_all)
|
|
98
|
+
# Crash-safe write: temp-in-same-dir + fsync + atomic rename, so a
|
|
99
|
+
# SIGINT/crash mid-flush can't destroy the user's existing file content
|
|
100
|
+
# (this is a read-modify-write of an existing file — HIGH-1).
|
|
101
|
+
Util::AtomicFile.write_atomic(expanded, new_content)
|
|
102
|
+
# Refresh-on-own-write: the bytes we just wrote are now authoritative,
|
|
103
|
+
# so the very next edit to this file passes the read-gate instead of
|
|
104
|
+
# "changed on disk since last read" (r5 B2).
|
|
105
|
+
@read_tracker&.note_write(expanded, new_content)
|
|
87
106
|
|
|
88
107
|
replaced_count = replace_all ? count : 1
|
|
89
108
|
added = new_string.to_s.lines.size
|
|
@@ -93,10 +112,46 @@ module Rubino
|
|
|
93
112
|
"+#{added * replaced_count} −#{removed * replaced_count}",
|
|
94
113
|
body: build_diff_preview(old_string, new_string, replaced_count),
|
|
95
114
|
body_kind: :diff }
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
# Mirror WriteTool: a read-only/permission-denied target (Errno::EACCES)
|
|
117
|
+
# or any other filesystem error returns a clean, uniform message rather
|
|
118
|
+
# than leaking a raw exception/backtrace to the model.
|
|
119
|
+
"Error editing #{file_path}: #{e.message}"
|
|
96
120
|
end
|
|
97
121
|
|
|
98
122
|
private
|
|
99
123
|
|
|
124
|
+
# Returns an error string when old/new_string are unusable (#329a/b), or
|
|
125
|
+
# nil when they're fine. Kept out of #call so it stays under the length gate.
|
|
126
|
+
def guard_args(old_string, new_string)
|
|
127
|
+
if old_string.nil? || old_string.empty?
|
|
128
|
+
return "Error: old_string is empty. Provide the exact existing text to replace " \
|
|
129
|
+
"(use the write tool to create or fully replace a file)."
|
|
130
|
+
end
|
|
131
|
+
return unless old_string == new_string
|
|
132
|
+
|
|
133
|
+
"Error: old_string and new_string are identical — nothing to change."
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Pull the four inputs (string- or symbol-keyed) in one place so #call
|
|
137
|
+
# stays under the complexity gate.
|
|
138
|
+
def parse_args(arguments)
|
|
139
|
+
[arguments["file_path"] || arguments[:file_path],
|
|
140
|
+
arguments["old_string"] || arguments[:old_string],
|
|
141
|
+
arguments["new_string"] || arguments[:new_string],
|
|
142
|
+
arguments["replace_all"] || arguments[:replace_all] || false]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Block form so new_string is treated as a literal replacement, not a
|
|
146
|
+
# pattern — avoids \0, \1, \& interpolation bugs in the new text.
|
|
147
|
+
def replace_literal(content, old_string, new_string, replace_all)
|
|
148
|
+
if replace_all
|
|
149
|
+
content.gsub(old_string) { new_string }
|
|
150
|
+
else
|
|
151
|
+
content.sub(old_string) { new_string }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
100
155
|
# Inline diff shown between the `tool · edit` and `done · edit` headers.
|
|
101
156
|
# Not a real unified diff — just `- old` then `+ new` so the user can
|
|
102
157
|
# see at a glance what the model is changing without scrolling back to
|