Class: SwarmSDK::Tools::Read

Inherits:
Base
  • Object
show all
Includes:
PathResolver
Defined in:
lib/swarm_sdk/tools/read.rb

Overview

Read tool for reading file contents from the filesystem

Supports reading entire files or specific line ranges with line numbers. Provides system reminders to guide proper usage. Tracks reads per agent for enforcing read-before-write/edit rules.

Constant Summary collapse

CONVERTERS =

List of available document converters

[
  DocumentConverters::PdfConverter,
  DocumentConverters::DocxConverter,
  DocumentConverters::XlsxConverter,
].freeze

Instance Attribute Summary

Attributes included from PathResolver

#agent_name, #directory

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

removable, removable?, #removable?

Constructor Details

#initialize(agent_name:, directory:) ⇒ Read

Initialize the Read tool for a specific agent

Parameters:

  • agent_name (Symbol, String)

    The agent identifier

  • directory (String)

    Agent's working directory



74
75
76
77
# File 'lib/swarm_sdk/tools/read.rb', line 74

def initialize(agent_name:, directory:)
  super()
  initialize_agent_context(agent_name: agent_name, directory: directory)
end

Class Method Details

.creation_requirementsObject



32
33
34
# File 'lib/swarm_sdk/tools/read.rb', line 32

def creation_requirements
  [:agent_name, :directory]
end

Instance Method Details

#execute(file_path:, offset: nil, limit: nil) ⇒ Object



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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/swarm_sdk/tools/read.rb', line 84

def execute(file_path:, offset: nil, limit: nil)
  # Validate file path
  return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?

  # CRITICAL: Resolve path against agent directory
  resolved_path = resolve_path(file_path)

  unless File.exist?(resolved_path)
    return validation_error("File does not exist: #{file_path}")
  end

  # Check if it's a directory
  if File.directory?(resolved_path)
    return validation_error("Path is a directory, not a file. Use Bash with ls to read directories.")
  end

  # Check if it's a document and try to convert it
  converter = find_converter_for_file(resolved_path)
  if converter
    result = converter.new.convert(resolved_path)
    # For document files, register the converted text content
    # Extract text from result (may be wrapped in system-reminder tags)
    if result.is_a?(String)
      # Remove system-reminder wrapper if present to get clean text for digest
      text_content = result.gsub(%r{<system-reminder>.*?</system-reminder>}m, "").strip
      Stores::ReadTracker.register_read(@agent_name, resolved_path, text_content)
    end
    return result
  end

  # Try to read as text, handle binary files separately
  content = read_file_content(resolved_path)

  # If content is a Content object (binary file), track with binary digest and return
  if content.is_a?(RubyLLM::Content)
    # For binary files, read raw bytes for digest
    binary_content = File.binread(resolved_path)
    Stores::ReadTracker.register_read(@agent_name, resolved_path, binary_content)
    return content
  end

  # Return early if we got an error message or system reminder
  return content if content.is_a?(String) && (content.start_with?("Error:") || content.start_with?("<system-reminder>"))

  # At this point, we have valid text content - register the read with digest
  Stores::ReadTracker.register_read(@agent_name, resolved_path, content)

  # Check if file is empty
  if content.empty?
    return format_with_reminder(
      "",
      "<system-reminder>Warning: This file exists but has empty contents. This may be intentional or indicate an issue.</system-reminder>",
    )
  end

  # Split into lines and apply offset/limit
  lines = content.lines
  total_lines = lines.count

  # Apply offset if specified (1-indexed)
  start_line = offset ? offset - 1 : 0
  start_line = [start_line, 0].max # Ensure non-negative

  if start_line >= total_lines
    return validation_error("Offset #{offset} exceeds file length (#{total_lines} lines)")
  end

  lines = lines.drop(start_line)

  # Apply limit if specified, otherwise use default
  default_limit = SwarmSDK.config.read_line_limit
  effective_limit = limit || default_limit
  lines = lines.take(effective_limit)
  truncated = limit.nil? && total_lines > default_limit

  # Format with line numbers (cat -n style)
  max_line_length = SwarmSDK.config.line_character_limit
  output_lines = lines.each_with_index.map do |line, idx|
    line_number = start_line + idx + 1
    display_line = line.chomp

    # Truncate long lines
    if display_line.length > max_line_length
      display_line = display_line[0...max_line_length]
      display_line += "... (line truncated)"
    end

    # Add line indicator for better readability
    "#{line_number.to_s.rjust(6)}→#{display_line}"
  end

  output = output_lines.join("\n")

  # Add system reminder about usage
  reminder = build_system_reminder(file_path, truncated, total_lines)
  format_with_reminder(output, reminder)
rescue StandardError => e
  error("Unexpected error reading file: #{e.class.name} - #{e.message}")
end

#nameObject

Override name to return simple "Read" instead of full class path



80
81
82
# File 'lib/swarm_sdk/tools/read.rb', line 80

def name
  "Read"
end