swarm_sdk 2.0.0.pre.2
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 +7 -0
- data/lib/swarm_sdk/agent/builder.rb +333 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
- data/lib/swarm_sdk/agent/chat.rb +779 -0
- data/lib/swarm_sdk/agent/context.rb +108 -0
- data/lib/swarm_sdk/agent/definition.rb +335 -0
- data/lib/swarm_sdk/configuration.rb +251 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +163 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +143 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +83 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +46 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
- data/lib/swarm_sdk/swarm/builder.rb +240 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
- data/lib/swarm_sdk/swarm.rb +837 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/delegate.rb +152 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +231 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +73 -0
- data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
- data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +69 -0
- metadata +169 -0
@@ -0,0 +1,232 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# MultiEdit tool for performing multiple exact string replacements in a file
|
6
|
+
#
|
7
|
+
# Applies multiple edit operations sequentially to a single file.
|
8
|
+
# Each edit sees the result of all previous edits, allowing for
|
9
|
+
# coordinated multi-step transformations.
|
10
|
+
# Enforces read-before-edit rule.
|
11
|
+
class MultiEdit < RubyLLM::Tool
|
12
|
+
include PathResolver
|
13
|
+
|
14
|
+
description <<~DESC
|
15
|
+
Performs multiple exact string replacements in a single file.
|
16
|
+
Edits are applied sequentially, so later edits see the results of earlier ones.
|
17
|
+
You must use your Read tool at least once in the conversation before editing.
|
18
|
+
This tool will error if you attempt an edit without reading the file.
|
19
|
+
When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix.
|
20
|
+
The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match.
|
21
|
+
Never include any part of the line number prefix in the old_string or new_string.
|
22
|
+
ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
23
|
+
Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
24
|
+
Each edit will FAIL if old_string is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use replace_all to change every instance of old_string.
|
25
|
+
Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
26
|
+
|
27
|
+
IMPORTANT - Path Handling:
|
28
|
+
- Relative paths (e.g., "tmp/file.txt", "src/main.rb") are resolved relative to your agent's working directory
|
29
|
+
- Absolute paths (e.g., "/tmp/file.txt", "/etc/passwd") are treated as system absolute paths
|
30
|
+
- When the user says "tmp/file.txt" they mean the tmp directory in your working directory, NOT /tmp
|
31
|
+
- Only use absolute paths (starting with /) when explicitly referring to system-level paths
|
32
|
+
DESC
|
33
|
+
|
34
|
+
param :file_path,
|
35
|
+
type: "string",
|
36
|
+
desc: "Path to the file. Use relative paths (e.g., 'tmp/file.txt') for files in your working directory, or absolute paths (e.g., '/etc/passwd') for system files.",
|
37
|
+
required: true
|
38
|
+
|
39
|
+
param :edits_json,
|
40
|
+
type: "string",
|
41
|
+
desc: <<~DESC.chomp,
|
42
|
+
JSON array of edit operations. Each edit must have:
|
43
|
+
old_string (exact text to replace),
|
44
|
+
new_string (replacement text),
|
45
|
+
and optionally replace_all (boolean, default false).
|
46
|
+
Example: [{"old_string":"foo","new_string":"bar","replace_all":false}]
|
47
|
+
DESC
|
48
|
+
required: true
|
49
|
+
|
50
|
+
# Initialize the MultiEdit tool for a specific agent
|
51
|
+
#
|
52
|
+
# @param agent_name [Symbol, String] The agent identifier
|
53
|
+
# @param directory [String] Agent's working directory
|
54
|
+
def initialize(agent_name:, directory:)
|
55
|
+
super()
|
56
|
+
@agent_name = agent_name.to_sym
|
57
|
+
@directory = File.expand_path(directory)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Override name to return simple "MultiEdit" instead of full class path
|
61
|
+
def name
|
62
|
+
"MultiEdit"
|
63
|
+
end
|
64
|
+
|
65
|
+
def execute(file_path:, edits_json:)
|
66
|
+
# Validate inputs
|
67
|
+
return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
|
68
|
+
|
69
|
+
# CRITICAL: Resolve path against agent directory
|
70
|
+
resolved_path = resolve_path(file_path)
|
71
|
+
|
72
|
+
# Parse JSON
|
73
|
+
edits = begin
|
74
|
+
JSON.parse(edits_json)
|
75
|
+
rescue JSON::ParserError
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
|
79
|
+
return validation_error("Invalid JSON format. Please provide a valid JSON array of edit operations.") if edits.nil?
|
80
|
+
|
81
|
+
return validation_error("edits must be an array") unless edits.is_a?(Array)
|
82
|
+
return validation_error("edits array cannot be empty") if edits.empty?
|
83
|
+
|
84
|
+
# File must exist (use resolved path)
|
85
|
+
unless File.exist?(resolved_path)
|
86
|
+
return validation_error("File does not exist: #{file_path}")
|
87
|
+
end
|
88
|
+
|
89
|
+
# Enforce read-before-edit (use resolved path)
|
90
|
+
unless Stores::ReadTracker.file_read?(@agent_name, resolved_path)
|
91
|
+
return validation_error(
|
92
|
+
"Cannot edit file without reading it first. " \
|
93
|
+
"You must use the Read tool on '#{file_path}' before editing it. " \
|
94
|
+
"This ensures you have the current file contents to match against.",
|
95
|
+
)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Read current content (use resolved path)
|
99
|
+
content = File.read(resolved_path, encoding: "UTF-8")
|
100
|
+
|
101
|
+
# Validate edit operations
|
102
|
+
validated_edits = []
|
103
|
+
edits.each_with_index do |edit, index|
|
104
|
+
unless edit.is_a?(Hash)
|
105
|
+
return validation_error("Edit at index #{index} must be a hash/object with old_string and new_string")
|
106
|
+
end
|
107
|
+
|
108
|
+
# Convert string keys to symbols for consistency
|
109
|
+
edit = edit.transform_keys(&:to_sym)
|
110
|
+
|
111
|
+
unless edit[:old_string]
|
112
|
+
return validation_error("Edit at index #{index} missing required field 'old_string'")
|
113
|
+
end
|
114
|
+
|
115
|
+
unless edit[:new_string]
|
116
|
+
return validation_error("Edit at index #{index} missing required field 'new_string'")
|
117
|
+
end
|
118
|
+
|
119
|
+
# old_string and new_string must be different
|
120
|
+
if edit[:old_string] == edit[:new_string]
|
121
|
+
return validation_error("Edit at index #{index}: old_string and new_string must be different")
|
122
|
+
end
|
123
|
+
|
124
|
+
validated_edits << {
|
125
|
+
old_string: edit[:old_string].to_s,
|
126
|
+
new_string: edit[:new_string].to_s,
|
127
|
+
replace_all: edit[:replace_all] == true,
|
128
|
+
index: index,
|
129
|
+
}
|
130
|
+
end
|
131
|
+
|
132
|
+
# Apply edits sequentially
|
133
|
+
results = []
|
134
|
+
current_content = content
|
135
|
+
|
136
|
+
validated_edits.each do |edit|
|
137
|
+
# Check if old_string exists in current content
|
138
|
+
unless current_content.include?(edit[:old_string])
|
139
|
+
return error_with_results(
|
140
|
+
<<~ERROR.chomp,
|
141
|
+
Edit #{edit[:index]}: old_string not found in file.
|
142
|
+
Make sure it matches exactly, including all whitespace and indentation.
|
143
|
+
Do not include line number prefixes from Read tool output.
|
144
|
+
Note: This edit follows #{edit[:index]} previous edit(s) which may have changed the file content.
|
145
|
+
ERROR
|
146
|
+
results,
|
147
|
+
)
|
148
|
+
end
|
149
|
+
|
150
|
+
# Count occurrences
|
151
|
+
occurrences = current_content.scan(edit[:old_string]).count
|
152
|
+
|
153
|
+
# If not replace_all and multiple occurrences, error
|
154
|
+
if !edit[:replace_all] && occurrences > 1
|
155
|
+
return error_with_results(
|
156
|
+
<<~ERROR.chomp,
|
157
|
+
Edit #{edit[:index]}: Found #{occurrences} occurrences of old_string.
|
158
|
+
Either provide more surrounding context to make the match unique, or set replace_all: true to replace all occurrences.
|
159
|
+
ERROR
|
160
|
+
results,
|
161
|
+
)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Perform replacement
|
165
|
+
new_content = if edit[:replace_all]
|
166
|
+
current_content.gsub(edit[:old_string], edit[:new_string])
|
167
|
+
else
|
168
|
+
current_content.sub(edit[:old_string], edit[:new_string])
|
169
|
+
end
|
170
|
+
|
171
|
+
# Record result
|
172
|
+
replaced_count = edit[:replace_all] ? occurrences : 1
|
173
|
+
results << {
|
174
|
+
index: edit[:index],
|
175
|
+
status: "success",
|
176
|
+
occurrences: replaced_count,
|
177
|
+
message: "Replaced #{replaced_count} occurrence(s)",
|
178
|
+
}
|
179
|
+
|
180
|
+
# Update content for next edit
|
181
|
+
current_content = new_content
|
182
|
+
end
|
183
|
+
|
184
|
+
# Write back to file (use resolved path)
|
185
|
+
File.write(resolved_path, current_content, encoding: "UTF-8")
|
186
|
+
|
187
|
+
# Build success message
|
188
|
+
total_replacements = results.sum { |r| r[:occurrences] }
|
189
|
+
message = "Successfully applied #{validated_edits.size} edit(s) to #{file_path}\n"
|
190
|
+
message += "Total replacements: #{total_replacements}\n\n"
|
191
|
+
message += "Details:\n"
|
192
|
+
results.each do |result|
|
193
|
+
message += " Edit #{result[:index]}: #{result[:message]}\n"
|
194
|
+
end
|
195
|
+
|
196
|
+
message
|
197
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
198
|
+
error("File contains invalid UTF-8. Cannot edit binary or improperly encoded files.")
|
199
|
+
rescue Errno::EACCES
|
200
|
+
error("Permission denied: Cannot read or write file '#{file_path}'")
|
201
|
+
rescue StandardError => e
|
202
|
+
error("Unexpected error editing file: #{e.class.name} - #{e.message}")
|
203
|
+
end
|
204
|
+
|
205
|
+
private
|
206
|
+
|
207
|
+
# Helper methods
|
208
|
+
def validation_error(message)
|
209
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
210
|
+
end
|
211
|
+
|
212
|
+
def error(message)
|
213
|
+
"Error: #{message}"
|
214
|
+
end
|
215
|
+
|
216
|
+
def error_with_results(message, results)
|
217
|
+
output = "<tool_use_error>InputValidationError: #{message}\n\n"
|
218
|
+
|
219
|
+
if results.any?
|
220
|
+
output += "Previous successful edits before error:\n"
|
221
|
+
results.each do |result|
|
222
|
+
output += " Edit #{result[:index]}: #{result[:message]}\n"
|
223
|
+
end
|
224
|
+
output += "\n"
|
225
|
+
end
|
226
|
+
|
227
|
+
output += "Note: The file has NOT been modified. All or nothing approach - if any edit fails, no changes are saved.</tool_use_error>"
|
228
|
+
output
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Shared path resolution logic for all file tools
|
6
|
+
#
|
7
|
+
# Tools resolve relative paths against the agent's directory.
|
8
|
+
# Absolute paths are used as-is.
|
9
|
+
#
|
10
|
+
# @example
|
11
|
+
# class Read < RubyLLM::Tool
|
12
|
+
# include PathResolver
|
13
|
+
#
|
14
|
+
# def initialize(agent_name:, directory:)
|
15
|
+
# @directory = File.expand_path(directory)
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# def execute(file_path:)
|
19
|
+
# resolved_path = resolve_path(file_path)
|
20
|
+
# File.read(resolved_path)
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
module PathResolver
|
24
|
+
private
|
25
|
+
|
26
|
+
# Resolve a path relative to the agent's directory
|
27
|
+
#
|
28
|
+
# - Absolute paths (starting with /) are returned as-is
|
29
|
+
# - Relative paths are resolved against @directory
|
30
|
+
#
|
31
|
+
# @param path [String] Path to resolve (relative or absolute)
|
32
|
+
# @return [String] Absolute path
|
33
|
+
# @raise [RuntimeError] If @directory not set (developer error)
|
34
|
+
def resolve_path(path)
|
35
|
+
raise "PathResolver requires @directory to be set" unless @directory
|
36
|
+
|
37
|
+
return path if path.to_s.start_with?("/")
|
38
|
+
|
39
|
+
File.expand_path(path, @directory)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,251 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Read tool for reading file contents from the filesystem
|
6
|
+
#
|
7
|
+
# Supports reading entire files or specific line ranges with line numbers.
|
8
|
+
# Provides system reminders to guide proper usage.
|
9
|
+
# Tracks reads per agent for enforcing read-before-write/edit rules.
|
10
|
+
class Read < RubyLLM::Tool
|
11
|
+
include PathResolver
|
12
|
+
|
13
|
+
MAX_LINE_LENGTH = 2000
|
14
|
+
DEFAULT_LIMIT = 2000
|
15
|
+
|
16
|
+
# List of available document converters
|
17
|
+
CONVERTERS = [
|
18
|
+
DocumentConverters::PdfConverter,
|
19
|
+
DocumentConverters::DocxConverter,
|
20
|
+
DocumentConverters::XlsxConverter,
|
21
|
+
].freeze
|
22
|
+
|
23
|
+
# Build dynamic description based on available gems
|
24
|
+
available_formats = CONVERTERS.select(&:available?).map(&:format_name)
|
25
|
+
doc_support_text = if available_formats.any?
|
26
|
+
"- Document files: #{available_formats.join(", ")} are converted to text"
|
27
|
+
else
|
28
|
+
""
|
29
|
+
end
|
30
|
+
|
31
|
+
description <<~DESC
|
32
|
+
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
33
|
+
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid.
|
34
|
+
It is okay to read a file that does not exist; an error will be returned.
|
35
|
+
|
36
|
+
Supports text, binary, and document files:
|
37
|
+
- Text files are returned with line numbers
|
38
|
+
- Binary files (images) are returned as visual content for analysis
|
39
|
+
- Supported image formats: PNG, JPG, GIF, WEBP, BMP, TIFF, SVG, ICO
|
40
|
+
#{doc_support_text}
|
41
|
+
|
42
|
+
IMPORTANT - Path Handling:
|
43
|
+
- Relative paths (e.g., "tmp/file.txt", "src/main.rb") are resolved relative to your agent's working directory
|
44
|
+
- Absolute paths (e.g., "/tmp/file.txt", "/etc/passwd") are treated as system absolute paths
|
45
|
+
- When the user says "tmp/file.txt" they mean the tmp directory in your working directory, NOT /tmp
|
46
|
+
- Only use absolute paths (starting with /) when explicitly referring to system-level paths
|
47
|
+
DESC
|
48
|
+
|
49
|
+
param :file_path,
|
50
|
+
type: "string",
|
51
|
+
desc: "Path to the file. Use relative paths (e.g., 'tmp/file.txt') for files in your working directory, or absolute paths (e.g., '/etc/passwd') for system files.",
|
52
|
+
required: true
|
53
|
+
|
54
|
+
param :offset,
|
55
|
+
type: "integer",
|
56
|
+
desc: "The line number to start reading from (1-indexed). Only provide if the file is too large to read at once.",
|
57
|
+
required: false
|
58
|
+
|
59
|
+
param :limit,
|
60
|
+
type: "integer",
|
61
|
+
desc: "The number of lines to read. Only provide if the file is too large to read at once.",
|
62
|
+
required: false
|
63
|
+
|
64
|
+
# Initialize the Read tool for a specific agent
|
65
|
+
#
|
66
|
+
# @param agent_name [Symbol, String] The agent identifier
|
67
|
+
# @param directory [String] Agent's working directory
|
68
|
+
def initialize(agent_name:, directory:)
|
69
|
+
super()
|
70
|
+
@agent_name = agent_name.to_sym
|
71
|
+
@directory = File.expand_path(directory)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Override name to return simple "Read" instead of full class path
|
75
|
+
def name
|
76
|
+
"Read"
|
77
|
+
end
|
78
|
+
|
79
|
+
def execute(file_path:, offset: nil, limit: nil)
|
80
|
+
# Validate file path
|
81
|
+
return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
|
82
|
+
|
83
|
+
# CRITICAL: Resolve path against agent directory
|
84
|
+
resolved_path = resolve_path(file_path)
|
85
|
+
|
86
|
+
unless File.exist?(resolved_path)
|
87
|
+
return validation_error("File does not exist: #{file_path}")
|
88
|
+
end
|
89
|
+
|
90
|
+
# Check if it's a directory
|
91
|
+
if File.directory?(resolved_path)
|
92
|
+
return validation_error("Path is a directory, not a file. Use Bash with ls to read directories.")
|
93
|
+
end
|
94
|
+
|
95
|
+
# Register this read in the tracker (use resolved path)
|
96
|
+
Stores::ReadTracker.register_read(@agent_name, resolved_path)
|
97
|
+
|
98
|
+
# Check if it's a document and try to convert it
|
99
|
+
converter = find_converter_for_file(resolved_path)
|
100
|
+
if converter
|
101
|
+
result = converter.new.convert(resolved_path)
|
102
|
+
return result
|
103
|
+
end
|
104
|
+
|
105
|
+
# Try to read as text, handle binary files separately
|
106
|
+
content = read_file_content(resolved_path)
|
107
|
+
|
108
|
+
# If content is a Content object (binary file), return it directly
|
109
|
+
return content if content.is_a?(RubyLLM::Content)
|
110
|
+
|
111
|
+
# Return early if we got an error message or system reminder
|
112
|
+
return content if content.is_a?(String) && (content.start_with?("Error:") || content.start_with?("<system-reminder>"))
|
113
|
+
|
114
|
+
# Check if file is empty
|
115
|
+
if content.empty?
|
116
|
+
return format_with_reminder(
|
117
|
+
"",
|
118
|
+
"<system-reminder>Warning: This file exists but has empty contents. This may be intentional or indicate an issue.</system-reminder>",
|
119
|
+
)
|
120
|
+
end
|
121
|
+
|
122
|
+
# Split into lines and apply offset/limit
|
123
|
+
lines = content.lines
|
124
|
+
total_lines = lines.count
|
125
|
+
|
126
|
+
# Apply offset if specified (1-indexed)
|
127
|
+
start_line = offset ? offset - 1 : 0
|
128
|
+
start_line = [start_line, 0].max # Ensure non-negative
|
129
|
+
|
130
|
+
if start_line >= total_lines
|
131
|
+
return validation_error("Offset #{offset} exceeds file length (#{total_lines} lines)")
|
132
|
+
end
|
133
|
+
|
134
|
+
lines = lines.drop(start_line)
|
135
|
+
|
136
|
+
# Apply limit if specified, otherwise use default
|
137
|
+
effective_limit = limit || DEFAULT_LIMIT
|
138
|
+
lines = lines.take(effective_limit)
|
139
|
+
truncated = limit.nil? && total_lines > DEFAULT_LIMIT
|
140
|
+
|
141
|
+
# Format with line numbers (cat -n style)
|
142
|
+
output_lines = lines.each_with_index.map do |line, idx|
|
143
|
+
line_number = start_line + idx + 1
|
144
|
+
display_line = line.chomp
|
145
|
+
|
146
|
+
# Truncate long lines
|
147
|
+
if display_line.length > MAX_LINE_LENGTH
|
148
|
+
display_line = display_line[0...MAX_LINE_LENGTH]
|
149
|
+
display_line += "... (line truncated)"
|
150
|
+
end
|
151
|
+
|
152
|
+
# Add line indicator for better readability
|
153
|
+
"#{line_number.to_s.rjust(6)}→#{display_line}"
|
154
|
+
end
|
155
|
+
|
156
|
+
output = output_lines.join("\n")
|
157
|
+
|
158
|
+
# Add system reminder about usage
|
159
|
+
reminder = build_system_reminder(file_path, truncated, total_lines)
|
160
|
+
format_with_reminder(output, reminder)
|
161
|
+
rescue StandardError => e
|
162
|
+
error("Unexpected error reading file: #{e.class.name} - #{e.message}")
|
163
|
+
end
|
164
|
+
|
165
|
+
private
|
166
|
+
|
167
|
+
# Find the appropriate converter for a file based on extension
|
168
|
+
def find_converter_for_file(file_path)
|
169
|
+
ext = File.extname(file_path).downcase
|
170
|
+
CONVERTERS.find { |converter| converter.extensions.include?(ext) }
|
171
|
+
end
|
172
|
+
|
173
|
+
# Helper methods
|
174
|
+
def validation_error(message)
|
175
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
176
|
+
end
|
177
|
+
|
178
|
+
def error(message)
|
179
|
+
"Error: #{message}"
|
180
|
+
end
|
181
|
+
|
182
|
+
def format_with_reminder(content, reminder)
|
183
|
+
return content if reminder.nil? || reminder.empty?
|
184
|
+
|
185
|
+
[content, "", reminder].join("\n")
|
186
|
+
end
|
187
|
+
|
188
|
+
def build_system_reminder(_file_path, truncated, total_lines)
|
189
|
+
reminders = []
|
190
|
+
|
191
|
+
reminders << "<system-reminder>"
|
192
|
+
reminders << "Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior."
|
193
|
+
|
194
|
+
if truncated
|
195
|
+
reminders << ""
|
196
|
+
reminders << "Note: This file has #{total_lines} lines but only the first #{DEFAULT_LIMIT} lines are shown. Use the offset and limit parameters to read additional sections if needed."
|
197
|
+
end
|
198
|
+
|
199
|
+
reminders << "</system-reminder>"
|
200
|
+
|
201
|
+
reminders.join("\n")
|
202
|
+
end
|
203
|
+
|
204
|
+
def read_file_content(file_path)
|
205
|
+
content = File.read(file_path, encoding: "UTF-8")
|
206
|
+
|
207
|
+
# Check if the content is valid UTF-8
|
208
|
+
unless content.valid_encoding?
|
209
|
+
# Binary file detected
|
210
|
+
if supported_binary_file?(file_path)
|
211
|
+
return RubyLLM::Content.new("File: #{File.basename(file_path)}", file_path)
|
212
|
+
else
|
213
|
+
return "Error: File contains binary data and cannot be displayed as text. This may be an executable or other unsupported binary file."
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
content
|
218
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
219
|
+
# Binary file detected
|
220
|
+
if supported_binary_file?(file_path)
|
221
|
+
RubyLLM::Content.new("File: #{File.basename(file_path)}", file_path)
|
222
|
+
else
|
223
|
+
"Error: File contains binary data and cannot be displayed as text. This may be an executable or other unsupported binary file."
|
224
|
+
end
|
225
|
+
rescue Errno::EACCES
|
226
|
+
error("Permission denied: Cannot read file '#{file_path}'")
|
227
|
+
rescue StandardError => e
|
228
|
+
error("Failed to read file: #{e.message}")
|
229
|
+
end
|
230
|
+
|
231
|
+
def supported_binary_file?(file_path)
|
232
|
+
ext = File.extname(file_path).downcase
|
233
|
+
# Supported binary file types that can be sent to the model
|
234
|
+
# Images only - documents are converted to text
|
235
|
+
supported_formats = [
|
236
|
+
".png",
|
237
|
+
".jpg",
|
238
|
+
".jpeg",
|
239
|
+
".gif",
|
240
|
+
".webp",
|
241
|
+
".bmp",
|
242
|
+
".tiff",
|
243
|
+
".tif",
|
244
|
+
".svg",
|
245
|
+
".ico",
|
246
|
+
]
|
247
|
+
supported_formats.include?(ext)
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Registry for built-in SwarmSDK tools
|
6
|
+
#
|
7
|
+
# Maps tool names (symbols) to their RubyLLM::Tool classes.
|
8
|
+
# Provides validation and lookup functionality for tool registration.
|
9
|
+
class Registry
|
10
|
+
# All available built-in tools
|
11
|
+
BUILTIN_TOOLS = {
|
12
|
+
Read: :special, # Requires agent context for read tracking
|
13
|
+
Write: :special, # Requires agent context for read-before-write enforcement
|
14
|
+
Edit: :special, # Requires agent context for read-before-edit enforcement
|
15
|
+
Bash: SwarmSDK::Tools::Bash,
|
16
|
+
Grep: SwarmSDK::Tools::Grep,
|
17
|
+
Glob: SwarmSDK::Tools::Glob,
|
18
|
+
MultiEdit: :special, # Requires agent context for read-before-edit enforcement
|
19
|
+
TodoWrite: :special, # Requires agent context for todo tracking
|
20
|
+
ScratchpadWrite: :special, # Requires scratchpad instance
|
21
|
+
ScratchpadRead: :special, # Requires scratchpad instance
|
22
|
+
ScratchpadList: :special, # Requires scratchpad instance
|
23
|
+
}.freeze
|
24
|
+
|
25
|
+
class << self
|
26
|
+
# Get tool class by name
|
27
|
+
#
|
28
|
+
# @param name [Symbol, String] Tool name
|
29
|
+
# @return [Class, nil] Tool class or nil if not found
|
30
|
+
def get(name)
|
31
|
+
BUILTIN_TOOLS[name.to_sym]
|
32
|
+
end
|
33
|
+
|
34
|
+
# Get multiple tool classes by names
|
35
|
+
#
|
36
|
+
# @param names [Array<Symbol, String>] Tool names
|
37
|
+
# @return [Array<Class>] Array of tool classes
|
38
|
+
# @raise [ConfigurationError] If any tool name is invalid
|
39
|
+
def get_many(names)
|
40
|
+
names.map do |name|
|
41
|
+
tool_class = get(name)
|
42
|
+
raise ConfigurationError, "Unknown tool: #{name}. Available tools: #{available_names.join(", ")}" unless tool_class
|
43
|
+
|
44
|
+
tool_class
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Check if a tool exists
|
49
|
+
#
|
50
|
+
# @param name [Symbol, String] Tool name
|
51
|
+
# @return [Boolean]
|
52
|
+
def exists?(name)
|
53
|
+
BUILTIN_TOOLS.key?(name.to_sym)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Get all available tool names
|
57
|
+
#
|
58
|
+
# @return [Array<Symbol>]
|
59
|
+
def available_names
|
60
|
+
BUILTIN_TOOLS.keys
|
61
|
+
end
|
62
|
+
|
63
|
+
# Validate tool names
|
64
|
+
#
|
65
|
+
# @param names [Array<Symbol, String>] Tool names to validate
|
66
|
+
# @return [Array<Symbol>] Invalid tool names
|
67
|
+
def validate(names)
|
68
|
+
names.reject { |name| exists?(name) }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Tool for listing scratchpad entries with metadata
|
6
|
+
#
|
7
|
+
# Lists available scratchpad entries with titles and sizes.
|
8
|
+
# Supports filtering by path prefix.
|
9
|
+
class ScratchpadList < RubyLLM::Tool
|
10
|
+
define_method(:name) { "ScratchpadList" }
|
11
|
+
|
12
|
+
description <<~DESC
|
13
|
+
List available scratchpad entries with titles and metadata.
|
14
|
+
Use this to discover what content is available in scratchpad memory.
|
15
|
+
Optionally filter by path prefix.
|
16
|
+
DESC
|
17
|
+
|
18
|
+
param :prefix,
|
19
|
+
desc: "Filter by path prefix (e.g., 'parallel/', 'analysis/'). Leave empty to list all entries.",
|
20
|
+
required: false
|
21
|
+
|
22
|
+
class << self
|
23
|
+
# Create a ScratchpadList tool for a specific scratchpad instance
|
24
|
+
#
|
25
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
26
|
+
# @return [ScratchpadList] Tool instance
|
27
|
+
def create_for_scratchpad(scratchpad)
|
28
|
+
new(scratchpad)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Initialize with scratchpad instance
|
33
|
+
#
|
34
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
35
|
+
def initialize(scratchpad)
|
36
|
+
super() # Call RubyLLM::Tool's initialize
|
37
|
+
@scratchpad = scratchpad
|
38
|
+
end
|
39
|
+
|
40
|
+
# Execute the tool
|
41
|
+
#
|
42
|
+
# @param prefix [String, nil] Optional path prefix filter
|
43
|
+
# @return [String] Formatted list of entries
|
44
|
+
def execute(prefix: nil)
|
45
|
+
entries = scratchpad.list(prefix: prefix)
|
46
|
+
|
47
|
+
if entries.empty?
|
48
|
+
return "Scratchpad is empty" if prefix.nil? || prefix.empty?
|
49
|
+
|
50
|
+
return "No entries found with prefix '#{prefix}'"
|
51
|
+
end
|
52
|
+
|
53
|
+
result = []
|
54
|
+
result << "Scratchpad contents (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
|
55
|
+
|
56
|
+
entries.each do |entry|
|
57
|
+
result << " scratchpad://#{entry[:path]} - \"#{entry[:title]}\" (#{format_bytes(entry[:size])})"
|
58
|
+
end
|
59
|
+
|
60
|
+
result.join("\n")
|
61
|
+
rescue ArgumentError => e
|
62
|
+
validation_error(e.message)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
attr_reader :scratchpad
|
68
|
+
|
69
|
+
def validation_error(message)
|
70
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
71
|
+
end
|
72
|
+
|
73
|
+
# Format bytes to human-readable size
|
74
|
+
#
|
75
|
+
# @param bytes [Integer] Number of bytes
|
76
|
+
# @return [String] Formatted size
|
77
|
+
def format_bytes(bytes)
|
78
|
+
if bytes >= 1_000_000
|
79
|
+
"#{(bytes.to_f / 1_000_000).round(1)}MB"
|
80
|
+
elsif bytes >= 1_000
|
81
|
+
"#{(bytes.to_f / 1_000).round(1)}KB"
|
82
|
+
else
|
83
|
+
"#{bytes}B"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|