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.

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk

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
# 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 = File.expand_path(file_path)
  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

  content       = File.read(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?
    return "Error: edit ##{idx + 1}: old_string and new_string are identical" if old_s == new_s
    unless working.include?(old_s)
      return "Error: edit ##{idx + 1}: old_string not found (check whitespace; " \
             "remember edits see the result of prior edits)"
    end

    count = working.scan(old_s).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_s) { new_s }
              else
                working.sub(old_s) { new_s }
              end
    applied_count += replace_all ? count : 1
  end

  File.write(expanded, working)
  "Applied #{edits.size} edit(s), #{applied_count} replacement(s) in #{file_path}"
rescue StandardError => e
  "Error: #{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