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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/lib/swarm_sdk/agent/builder.rb +333 -0
  3. data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
  4. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  5. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
  6. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
  7. data/lib/swarm_sdk/agent/chat.rb +779 -0
  8. data/lib/swarm_sdk/agent/context.rb +108 -0
  9. data/lib/swarm_sdk/agent/definition.rb +335 -0
  10. data/lib/swarm_sdk/configuration.rb +251 -0
  11. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  12. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  13. data/lib/swarm_sdk/context_compactor.rb +340 -0
  14. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  15. data/lib/swarm_sdk/hooks/context.rb +163 -0
  16. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  17. data/lib/swarm_sdk/hooks/error.rb +29 -0
  18. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  19. data/lib/swarm_sdk/hooks/registry.rb +143 -0
  20. data/lib/swarm_sdk/hooks/result.rb +150 -0
  21. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  22. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  23. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  24. data/lib/swarm_sdk/log_collector.rb +83 -0
  25. data/lib/swarm_sdk/log_stream.rb +69 -0
  26. data/lib/swarm_sdk/markdown_parser.rb +46 -0
  27. data/lib/swarm_sdk/permissions/config.rb +239 -0
  28. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  29. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  30. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  31. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  32. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
  33. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  34. data/lib/swarm_sdk/result.rb +97 -0
  35. data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
  36. data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
  37. data/lib/swarm_sdk/swarm/builder.rb +240 -0
  38. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  39. data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
  40. data/lib/swarm_sdk/swarm.rb +837 -0
  41. data/lib/swarm_sdk/tools/bash.rb +274 -0
  42. data/lib/swarm_sdk/tools/delegate.rb +152 -0
  43. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  44. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  45. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  46. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  47. data/lib/swarm_sdk/tools/edit.rb +150 -0
  48. data/lib/swarm_sdk/tools/glob.rb +158 -0
  49. data/lib/swarm_sdk/tools/grep.rb +231 -0
  50. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  51. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  52. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  53. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  54. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  55. data/lib/swarm_sdk/tools/read.rb +251 -0
  56. data/lib/swarm_sdk/tools/registry.rb +73 -0
  57. data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
  58. data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
  59. data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
  60. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  61. data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
  62. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  63. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  64. data/lib/swarm_sdk/tools/write.rb +117 -0
  65. data/lib/swarm_sdk/utils.rb +50 -0
  66. data/lib/swarm_sdk/version.rb +5 -0
  67. data/lib/swarm_sdk.rb +69 -0
  68. 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