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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module DocumentConverters
6
+ # Converts XLSX/XLS spreadsheets to text with image extraction
7
+ class XlsxConverter < BaseConverter
8
+ class << self
9
+ def gem_name
10
+ "roo"
11
+ end
12
+
13
+ def format_name
14
+ "XLSX/XLS"
15
+ end
16
+
17
+ def extensions
18
+ [".xlsx", ".xls"]
19
+ end
20
+
21
+ # XLS files require an additional gem
22
+ def xls_gem_available?
23
+ gem_available?("roo-xls")
24
+ end
25
+ end
26
+
27
+ # Convert a spreadsheet to text/content
28
+ # @param file_path [String] Path to the spreadsheet file
29
+ # @return [String, RubyLLM::Content] Converted content or error message
30
+ def convert(file_path)
31
+ unless self.class.available?
32
+ return unsupported_format_reminder(self.class.format_name, self.class.gem_name)
33
+ end
34
+
35
+ # Check for legacy XLS files
36
+ extension = File.extname(file_path).downcase
37
+ if extension == ".xls" && !self.class.xls_gem_available?
38
+ return error("Legacy .xls files require the 'roo-xls' gem. Install with: gem install roo-xls")
39
+ end
40
+
41
+ spreadsheet = nil
42
+
43
+ begin
44
+ require "roo"
45
+ require "csv"
46
+ require "tmpdir"
47
+
48
+ spreadsheet = Roo::Spreadsheet.open(file_path)
49
+
50
+ # Extract images from all sheets
51
+ image_paths = extract_images(spreadsheet, file_path)
52
+
53
+ output = []
54
+ output << "Spreadsheet: #{File.basename(file_path)}"
55
+ output << "Sheets: #{spreadsheet.sheets.size}"
56
+ output << "=" * 60
57
+ output << ""
58
+
59
+ spreadsheet.sheets.each_with_index do |sheet_name, sheet_idx|
60
+ sheet = spreadsheet.sheet(sheet_name)
61
+
62
+ output << "Sheet #{sheet_idx + 1}: #{sheet_name}"
63
+ output << "-" * 60
64
+
65
+ # Add sheet dimensions
66
+ first_row = sheet.first_row
67
+ last_row = sheet.last_row
68
+ first_col = sheet.first_column
69
+ last_col = sheet.last_column
70
+
71
+ if first_row && last_row && first_col && last_col
72
+ row_count = last_row - first_row + 1
73
+ col_count = last_col - first_col + 1
74
+ output << "Dimensions: #{row_count} rows × #{col_count} columns"
75
+ output << ""
76
+ else
77
+ output << "(Empty sheet)"
78
+ output << ""
79
+ next
80
+ end
81
+
82
+ # Extract data rows
83
+ sheet.each_row_streaming(pad_cells: true) do |row|
84
+ row_values = row.map do |cell|
85
+ format_cell_value(cell)
86
+ end
87
+
88
+ # Skip completely empty rows
89
+ next if row_values.all? { |v| v.nil? || v.empty? }
90
+
91
+ # Format as CSV with proper escaping
92
+ output << CSV.generate_line(row_values).chomp
93
+ end
94
+
95
+ output << ""
96
+ end
97
+
98
+ text_content = output.join("\n")
99
+
100
+ # If there are images, return Content with attachments
101
+ if image_paths.any?
102
+ content = RubyLLM::Content.new(text_content)
103
+ image_paths.each do |image_path|
104
+ content.add_attachment(image_path)
105
+ end
106
+ content
107
+ else
108
+ # No images, return just text
109
+ text_content
110
+ end
111
+ rescue ArgumentError => e
112
+ error("Failed to open spreadsheet: #{e.message}")
113
+ rescue RangeError => e
114
+ error("Sheet access error: #{e.message}")
115
+ rescue Zip::Error => e
116
+ error("Corrupted or invalid XLSX file: #{e.message}")
117
+ rescue IOError => e
118
+ error("File reading error: #{e.message}")
119
+ rescue StandardError => e
120
+ error("Failed to parse spreadsheet: #{e.message}")
121
+ ensure
122
+ # Always clean up resources
123
+ spreadsheet.close if spreadsheet&.respond_to?(:close)
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ # Format cell value based on type
130
+ # @param cell [Roo::Cell] The cell to format
131
+ # @return [String] Formatted cell value
132
+ def format_cell_value(cell)
133
+ return "" if cell.nil? || cell.empty?
134
+
135
+ case cell.type
136
+ when :string
137
+ cell.value.to_s
138
+ when :float, :number
139
+ cell.value.to_s
140
+ when :date
141
+ cell.value.strftime("%Y-%m-%d")
142
+ when :datetime
143
+ cell.value.strftime("%Y-%m-%d %H:%M:%S")
144
+ when :time
145
+ hours = (cell.value / 3600).to_i
146
+ minutes = ((cell.value % 3600) / 60).to_i
147
+ seconds = (cell.value % 60).to_i
148
+ format("%02d:%02d:%02d", hours, minutes, seconds)
149
+ when :boolean
150
+ cell.value ? "TRUE" : "FALSE"
151
+ when :formula
152
+ # Returns calculated value, not the formula itself
153
+ cell.value.to_s
154
+ when :link
155
+ cell.value.to_s
156
+ when :percentage
157
+ (cell.value * 100).to_s + "%"
158
+ else
159
+ cell.value.to_s
160
+ end
161
+ rescue StandardError
162
+ "[ERROR]"
163
+ end
164
+
165
+ # Extract images from spreadsheet
166
+ # @param spreadsheet [Roo::Spreadsheet] The spreadsheet
167
+ # @param _xlsx_path [String] Path to the XLSX file (unused but kept for consistency)
168
+ # @return [Array<String>] Array of temporary file paths containing extracted images
169
+ def extract_images(spreadsheet, _xlsx_path)
170
+ image_paths = []
171
+
172
+ spreadsheet.sheets.each do |sheet_name|
173
+ # Check if the spreadsheet supports image extraction
174
+ next unless spreadsheet.respond_to?(:images)
175
+
176
+ images = spreadsheet.images(sheet_name)
177
+ next unless images && !images.empty?
178
+
179
+ images.each do |img_path|
180
+ if img_path && File.exist?(img_path)
181
+ image_paths << img_path
182
+ end
183
+ end
184
+ end
185
+
186
+ image_paths
187
+ rescue StandardError
188
+ # If image extraction fails, don't fail the entire spreadsheet read
189
+ []
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ # Edit tool for performing exact string replacements in files
6
+ #
7
+ # Uses exact string matching to find and replace content.
8
+ # Requires unique matches and proper Read tool usage beforehand.
9
+ # Enforces read-before-edit rule.
10
+ class Edit < RubyLLM::Tool
11
+ include PathResolver
12
+
13
+ description <<~DESC
14
+ Performs exact string replacements in files.
15
+ You must use your Read tool at least once in the conversation before editing.
16
+ This tool will error if you attempt an edit without reading the file.
17
+ When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix.
18
+ The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match.
19
+ Never include any part of the line number prefix in the old_string or new_string.
20
+ ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
21
+ Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
22
+ The 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.
23
+ Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
24
+
25
+ IMPORTANT - Path Handling:
26
+ - Relative paths (e.g., "tmp/file.txt", "src/main.rb") are resolved relative to your agent's working directory
27
+ - Absolute paths (e.g., "/tmp/file.txt", "/etc/passwd") are treated as system absolute paths
28
+ - When the user says "tmp/file.txt" they mean the tmp directory in your working directory, NOT /tmp
29
+ - Only use absolute paths (starting with /) when explicitly referring to system-level paths
30
+ DESC
31
+
32
+ param :file_path,
33
+ type: "string",
34
+ 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.",
35
+ required: true
36
+
37
+ param :old_string,
38
+ type: "string",
39
+ desc: "The exact text to replace (must match exactly including whitespace)",
40
+ required: true
41
+
42
+ param :new_string,
43
+ type: "string",
44
+ desc: "The text to replace it with (must be different from old_string)",
45
+ required: true
46
+
47
+ param :replace_all,
48
+ type: "boolean",
49
+ desc: "Replace all occurrences of old_string (default false)",
50
+ required: false
51
+
52
+ # Initialize the Edit tool for a specific agent
53
+ #
54
+ # @param agent_name [Symbol, String] The agent identifier
55
+ # @param directory [String] Agent's working directory
56
+ def initialize(agent_name:, directory:)
57
+ super()
58
+ @agent_name = agent_name.to_sym
59
+ @directory = File.expand_path(directory)
60
+ end
61
+
62
+ # Override name to return simple "Edit" instead of full class path
63
+ def name
64
+ "Edit"
65
+ end
66
+
67
+ def execute(file_path:, old_string:, new_string:, replace_all: false)
68
+ # Validate inputs
69
+ return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
70
+ return validation_error("old_string is required") if old_string.nil? || old_string.empty?
71
+ return validation_error("new_string is required") if new_string.nil?
72
+
73
+ # old_string and new_string must be different
74
+ if old_string == new_string
75
+ return validation_error("old_string and new_string must be different. They are currently identical.")
76
+ end
77
+
78
+ # CRITICAL: Resolve path against agent directory
79
+ resolved_path = resolve_path(file_path)
80
+
81
+ # File must exist (use resolved path)
82
+ unless File.exist?(resolved_path)
83
+ return validation_error("File does not exist: #{file_path}")
84
+ end
85
+
86
+ # Enforce read-before-edit (use resolved path)
87
+ unless Stores::ReadTracker.file_read?(@agent_name, resolved_path)
88
+ return validation_error(
89
+ "Cannot edit file without reading it first. " \
90
+ "You must use the Read tool on '#{file_path}' before editing it. " \
91
+ "This ensures you have the current file contents to match against.",
92
+ )
93
+ end
94
+
95
+ # Read current content (use resolved path)
96
+ content = File.read(resolved_path, encoding: "UTF-8")
97
+
98
+ # Check if old_string exists in file
99
+ unless content.include?(old_string)
100
+ return validation_error(<<~ERROR.chomp)
101
+ old_string not found in file. Make sure it matches exactly, including all whitespace and indentation.
102
+ Do not include line number prefixes from Read tool output.
103
+ ERROR
104
+ end
105
+
106
+ # Count occurrences
107
+ occurrences = content.scan(old_string).count
108
+
109
+ # If not replace_all and multiple occurrences, error
110
+ if !replace_all && occurrences > 1
111
+ return validation_error(<<~ERROR.chomp)
112
+ Found #{occurrences} occurrences of old_string.
113
+ Either provide more surrounding context to make the match unique, or use replace_all: true to replace all occurrences.
114
+ ERROR
115
+ end
116
+
117
+ # Perform replacement
118
+ new_content = if replace_all
119
+ content.gsub(old_string, new_string)
120
+ else
121
+ content.sub(old_string, new_string)
122
+ end
123
+
124
+ # Write back to file (use resolved path)
125
+ File.write(resolved_path, new_content, encoding: "UTF-8")
126
+
127
+ # Build success message
128
+ replaced_count = replace_all ? occurrences : 1
129
+ "Successfully replaced #{replaced_count} occurrence(s) in #{file_path}"
130
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
131
+ error("File contains invalid UTF-8. Cannot edit binary or improperly encoded files.")
132
+ rescue Errno::EACCES
133
+ error("Permission denied: Cannot read or write file '#{file_path}'")
134
+ rescue StandardError => e
135
+ error("Unexpected error editing file: #{e.class.name} - #{e.message}")
136
+ end
137
+
138
+ private
139
+
140
+ # Helper methods
141
+ def validation_error(message)
142
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
143
+ end
144
+
145
+ def error(message)
146
+ "Error: #{message}"
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ # Glob tool for fast file and directory pattern matching
6
+ #
7
+ # Finds files and directories matching glob patterns, sorted by modification time.
8
+ # Works efficiently with any codebase size.
9
+ class Glob < RubyLLM::Tool
10
+ include PathResolver
11
+
12
+ def initialize(directory:)
13
+ super()
14
+ @directory = File.expand_path(directory)
15
+ end
16
+
17
+ define_method(:name) { "Glob" }
18
+
19
+ description <<~DESC
20
+ - Fast file pattern matching tool that works with any directory size
21
+
22
+ - Supports glob patterns like "**/*.js" or "src/**/*.ts"
23
+
24
+ - Returns matching file paths sorted by modification time
25
+
26
+ - Use this tool when you need to find files by name patterns
27
+
28
+ - When you are doing an open ended search that may require multiple rounds of
29
+ globbing and grepping, use the Agent tool instead
30
+
31
+ - You have the capability to call multiple tools in a single response. It is
32
+ always better to speculatively perform multiple searches as a batch that are
33
+ potentially useful.
34
+ DESC
35
+
36
+ param :pattern,
37
+ type: "string",
38
+ desc: "The glob pattern to match files against",
39
+ required: true
40
+
41
+ param :path,
42
+ type: "string",
43
+ desc: "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided.",
44
+ required: false
45
+
46
+ MAX_RESULTS = 1000 # Limit results to prevent overwhelming output
47
+
48
+ def execute(pattern:, path: nil)
49
+ # Validate inputs
50
+ return validation_error("pattern is required") if pattern.nil? || pattern.to_s.strip.empty?
51
+
52
+ # Validate path if provided
53
+ if path && !path.to_s.strip.empty?
54
+ # Check for literal "undefined" or "null" strings
55
+ if path.to_s.strip.downcase == "undefined" || path.to_s.strip.downcase == "null"
56
+ return validation_error("Invalid path value. Omit the path parameter entirely to use the current working directory.")
57
+ end
58
+
59
+ unless File.exist?(path)
60
+ return validation_error("Path does not exist: #{path}")
61
+ end
62
+
63
+ unless File.directory?(path)
64
+ return validation_error("Path is not a directory: #{path}")
65
+ end
66
+
67
+ # CRITICAL: Resolve relative paths against agent directory
68
+ search_path = resolve_path(path)
69
+ else
70
+ # CRITICAL: Use agent's directory as default (NOT Dir.pwd)
71
+ search_path = @directory
72
+ end
73
+
74
+ # Execute glob from specified path
75
+ begin
76
+ # Build full pattern by joining search path with pattern
77
+ # If pattern is already absolute, File.join will use it as-is
78
+ full_pattern = if pattern.start_with?("/")
79
+ # Pattern is absolute, use it directly
80
+ pattern
81
+ else
82
+ # Pattern is relative, join with search path
83
+ File.join(search_path, pattern)
84
+ end
85
+
86
+ matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
87
+
88
+ # Remove . and .. entries (handle both with and without trailing slashes)
89
+ matches.reject! do |f|
90
+ basename = File.basename(f.chomp("/"))
91
+ basename == "." || basename == ".."
92
+ end
93
+
94
+ # Handle no matches
95
+ if matches.empty?
96
+ return "No matches found for pattern: #{pattern}"
97
+ end
98
+
99
+ # Sort by modification time (most recent first)
100
+ matches.sort_by! { |f| -File.mtime(f).to_i }
101
+
102
+ # Limit results
103
+ if matches.count > MAX_RESULTS
104
+ matches = matches.take(MAX_RESULTS)
105
+ truncated = true
106
+ else
107
+ truncated = false
108
+ end
109
+
110
+ # Format output
111
+ output = matches.join("\n")
112
+
113
+ # Add system reminder if truncated
114
+ if truncated
115
+ output += <<~REMINDER
116
+
117
+ <system-reminder>
118
+ Results limited to first #{MAX_RESULTS} matches (sorted by most recently modified).
119
+ Consider using a more specific pattern to narrow your search.
120
+ </system-reminder>
121
+ REMINDER
122
+ end
123
+
124
+ # Add usage reminder
125
+ output += "\n\n" + build_usage_reminder(matches.count, pattern)
126
+
127
+ output
128
+ rescue Errno::EACCES => e
129
+ error("Permission denied: #{e.message}")
130
+ rescue StandardError => e
131
+ error("Failed to execute glob: #{e.class.name} - #{e.message}")
132
+ end
133
+ rescue StandardError => e
134
+ error("Unexpected error during glob: #{e.class.name} - #{e.message}")
135
+ end
136
+
137
+ private
138
+
139
+ def validation_error(message)
140
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
141
+ end
142
+
143
+ def error(message)
144
+ "Error: #{message}"
145
+ end
146
+
147
+ def build_usage_reminder(count, pattern)
148
+ <<~REMINDER
149
+ <system-reminder>
150
+ Found #{count} match#{"es" if count != 1} for '#{pattern}' (files and directories).
151
+ These paths are sorted by modification time (most recent first).
152
+ You can now use the Read tool to examine specific files, or use Grep to search within these files.
153
+ </system-reminder>
154
+ REMINDER
155
+ end
156
+ end
157
+ end
158
+ end