Class: Rubino::Tools::MultiEditTool
- Defined in:
- lib/rubino/tools/multi_edit_tool.rb
Overview
Applies an ordered list of exact string replacements to a single file in one transactional shot. If any edit fails (string not found, or non-unique without replace_all) the file is left untouched — the LLM gets a single error pointing at the offending edit index.
Each subsequent edit sees the result of prior edits in the same call, so you can rename A→B and then change a line that contains B.
Constant Summary collapse
- MAX_DIFF_LINES =
Inline diff for the applied result, mirroring EditTool: per edit, the old lines as ‘-` then the new lines as `+`, edits separated by a blank line. Trimmed to the first MAX_DIFF_LINES so a big batch stays a preview (the edits all still apply).
16
Instance Attribute Summary
Attributes inherited from Base
#cancel_token, #read_tracker, #stream_chunk, #stream_kind
Instance Method Summary collapse
- #call(arguments) ⇒ Object
- #description ⇒ Object
- #input_schema ⇒ Object
- #name ⇒ Object
- #risk_level ⇒ Object
Methods inherited from Base
#cancellation_requested?, #config_key, #emit_chunk, #risky?, #to_tool_definition, workspace_root, workspace_roots
Instance Method Details
#call(arguments) ⇒ Object
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/rubino/tools/multi_edit_tool.rb', line 53 def call(arguments) file_path = arguments["file_path"] || arguments[:file_path] edits = arguments["edits"] || arguments[:edits] || [] return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty? return "Error: edits must be a non-empty array" if !edits.is_a?(Array) || edits.empty? = (file_path) # SECRET/credential edits (#446) are no longer HARD-refused here — they # are gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask): an # APPROVED multi_edit of your .env actually applies, a denied/headless # one never reaches #call. The workspace sandbox below is unchanged. return (file_path) unless within_workspace?() return "Error: File not found: #{file_path}" unless File.exist?() if (gate = read_gate_error(, file_path, verb: "edits")) return gate end # Read RAW bytes (binary) so the read-modify-write preserves every byte # outside the matched spans — a non-UTF-8 byte on an untouched line is # written back verbatim (#326). The model-supplied needles/replacements # are matched and spliced as bytes too (see Base#to_match_bytes). content = read_for_edit() working = content.dup applied_count = 0 edits.each_with_index do |edit, idx| if cancellation_requested? return "Cancelled before edit ##{idx + 1} — no changes written " \ "(multi_edit is atomic: stages in memory, writes once)" end old_s = edit["old_string"] || edit[:old_string] new_s = edit["new_string"] || edit[:new_string] replace_all = edit["replace_all"] || edit[:replace_all] || false return "Error: edit ##{idx + 1} is missing old_string or new_string" if old_s.nil? || new_s.nil? # Empty needle would match at every char boundary and corrupt the # file under replace_all (#329a) — reject it like a missing string. return "Error: edit ##{idx + 1}: old_string is empty" if old_s.empty? return "Error: edit ##{idx + 1}: old_string and new_string are identical" if old_s == new_s old_b = to_match_bytes(old_s) new_b = to_match_bytes(new_s) unless working.include?(old_b) # Mental model was wrong — let the model's next read of this path # bypass dedup and fetch fresh bytes for recovery (r5 B3). @read_tracker&.note_edit_failure() return "Error: edit ##{idx + 1}: old_string not found (check whitespace; " \ "remember edits see the result of prior edits)" end count = working.scan(old_b).size if count > 1 && !replace_all return "Error: edit ##{idx + 1}: #{count} matches for old_string. " \ "Add surrounding context to disambiguate, or set replace_all: true." end working = if replace_all working.gsub(old_b) { new_b } else working.sub(old_b) { new_b } end applied_count += replace_all ? count : 1 end # Crash-safe write: temp-in-same-dir + fsync + atomic rename. The tool's # description advertises "atomically" — make it true on the disk seam too, # so a SIGINT/crash mid-flush leaves the ORIGINAL file intact (HIGH-1). Util::AtomicFile.write_atomic(, working) # Refresh-on-own-write so a follow-up edit to this file isn't refused # as "changed on disk since last read" (r5 B2). @read_tracker&.note_write(, working) { output: "Applied #{edits.size} edit(s), #{applied_count} replacement(s) in #{file_path}", metrics: "#{edits.size} edit#{"s" if edits.size != 1} · " \ "#{applied_count} replacement#{"s" if applied_count != 1}", body: build_diff_preview(edits), body_kind: :diff } rescue StandardError => e # Uniform with WriteTool/EditTool: a read-only target (Errno::EACCES) # or any other filesystem error returns a clean message. "Error editing #{file_path}: #{e.}" end |
#description ⇒ Object
17 18 19 20 21 |
# File 'lib/rubino/tools/multi_edit_tool.rb', line 17 def description "Apply multiple exact string replacements to a single file atomically. " \ "Edits are applied sequentially in the given order; later edits see " \ "the result of earlier ones. If any edit fails, NO changes are written." end |
#input_schema ⇒ Object
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/rubino/tools/multi_edit_tool.rb', line 23 def input_schema { type: "object", properties: { file_path: { type: "string", description: "Path to the file to edit" }, edits: { type: "array", description: "Ordered list of edits to apply", items: { type: "object", properties: { old_string: { type: "string", description: "Exact text to find" }, new_string: { type: "string", description: "Replacement text" }, replace_all: { type: "boolean", description: "Replace all occurrences (default false)" } }, required: %w[old_string new_string] } } }, required: %w[file_path edits] } end |
#name ⇒ Object
13 14 15 |
# File 'lib/rubino/tools/multi_edit_tool.rb', line 13 def name "multi_edit" end |
#risk_level ⇒ Object
49 50 51 |
# File 'lib/rubino/tools/multi_edit_tool.rb', line 49 def risk_level :medium end |