Class: Rubino::Tools::MultiEditTool

Inherits:
Base
  • Object
show all
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

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?

  expanded = expand_workspace_path(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 workspace_violation_message(file_path) unless within_workspace?(expanded)
  return "Error: File not found: #{file_path}" unless File.exist?(expanded)

  if (gate = read_gate_error(expanded, 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(expanded)
  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(expanded)
      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(expanded, 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(expanded, 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.message}"
end

#descriptionObject



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_schemaObject



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

#nameObject



13
14
15
# File 'lib/rubino/tools/multi_edit_tool.rb', line 13

def name
  "multi_edit"
end

#risk_levelObject



49
50
51
# File 'lib/rubino/tools/multi_edit_tool.rb', line 49

def risk_level
  :medium
end