swarm_sdk 2.0.3 → 2.0.5

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +41 -0
  3. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +22 -5
  4. data/lib/swarm_sdk/agent/definition.rb +52 -6
  5. data/lib/swarm_sdk/configuration.rb +3 -1
  6. data/lib/swarm_sdk/prompts/memory.md.erb +480 -0
  7. data/lib/swarm_sdk/swarm/agent_initializer.rb +16 -3
  8. data/lib/swarm_sdk/swarm/builder.rb +9 -1
  9. data/lib/swarm_sdk/swarm/tool_configurator.rb +73 -23
  10. data/lib/swarm_sdk/swarm.rb +51 -7
  11. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
  12. data/lib/swarm_sdk/tools/memory/memory_delete.rb +64 -0
  13. data/lib/swarm_sdk/tools/memory/memory_edit.rb +145 -0
  14. data/lib/swarm_sdk/tools/memory/memory_glob.rb +94 -0
  15. data/lib/swarm_sdk/tools/memory/memory_grep.rb +147 -0
  16. data/lib/swarm_sdk/tools/memory/memory_multi_edit.rb +228 -0
  17. data/lib/swarm_sdk/tools/memory/memory_read.rb +82 -0
  18. data/lib/swarm_sdk/tools/memory/memory_write.rb +90 -0
  19. data/lib/swarm_sdk/tools/registry.rb +11 -3
  20. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
  21. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
  22. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
  23. data/lib/swarm_sdk/tools/stores/memory_storage.rb +300 -0
  24. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
  25. data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
  26. data/lib/swarm_sdk/tools/stores/storage_read_tracker.rb +61 -0
  27. data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
  28. data/lib/swarm_sdk/version.rb +1 -1
  29. data/lib/swarm_sdk.rb +39 -0
  30. metadata +18 -5
  31. data/lib/swarm_sdk/tools/scratchpad_list.rb +0 -88
  32. data/lib/swarm_sdk/tools/scratchpad_read.rb +0 -59
  33. data/lib/swarm_sdk/tools/scratchpad_write.rb +0 -88
  34. data/lib/swarm_sdk/tools/stores/scratchpad.rb +0 -153
@@ -62,6 +62,13 @@ module SwarmSDK
62
62
  DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
63
63
 
64
64
  attr_reader :name, :agents, :lead_agent, :mcp_clients
65
+
66
+ # Check if scratchpad tools are enabled
67
+ #
68
+ # @return [Boolean]
69
+ def scratchpad_enabled?
70
+ @scratchpad_enabled
71
+ end
65
72
  attr_writer :config_for_hooks
66
73
 
67
74
  # Class-level MCP log level configuration
@@ -129,16 +136,24 @@ module SwarmSDK
129
136
  # @param name [String] Human-readable swarm name
130
137
  # @param global_concurrency [Integer] Max concurrent LLM calls across entire swarm
131
138
  # @param default_local_concurrency [Integer] Default max concurrent tool calls per agent
132
- def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY)
139
+ # @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing)
140
+ # @param scratchpad_enabled [Boolean] Whether to enable scratchpad tools (default: true)
141
+ def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY, scratchpad: nil, scratchpad_enabled: true)
133
142
  @name = name
134
143
  @global_concurrency = global_concurrency
135
144
  @default_local_concurrency = default_local_concurrency
145
+ @scratchpad_enabled = scratchpad_enabled
136
146
 
137
147
  # Shared semaphore for all agents
138
148
  @global_semaphore = Async::Semaphore.new(@global_concurrency)
139
149
 
140
- # Shared scratchpad for all agents
141
- @scratchpad = Tools::Stores::Scratchpad.new
150
+ # Shared scratchpad storage for all agents (volatile)
151
+ # Use provided scratchpad storage (for testing) or create volatile one
152
+ @scratchpad_storage = scratchpad || Tools::Stores::ScratchpadStorage.new
153
+
154
+ # Per-agent memory storage (persistent)
155
+ # Will be populated when agents are initialized
156
+ @memory_storages = {}
142
157
 
143
158
  # Hook registry for named hooks and swarm defaults
144
159
  @hook_registry = Hooks::Registry.new
@@ -535,13 +550,42 @@ module SwarmSDK
535
550
  @agent_definitions,
536
551
  @global_semaphore,
537
552
  @hook_registry,
538
- @scratchpad,
553
+ @scratchpad_storage,
554
+ @memory_storages,
539
555
  config_for_hooks: @config_for_hooks,
540
556
  )
541
557
 
542
558
  @agents = initializer.initialize_all
543
559
  @agent_contexts = initializer.agent_contexts
544
560
  @agents_initialized = true
561
+
562
+ # Emit agent_start events for all agents
563
+ emit_agent_start_events
564
+ end
565
+
566
+ # Emit agent_start events for all initialized agents
567
+ def emit_agent_start_events
568
+ # Only emit if LogStream is enabled
569
+ return unless LogStream.emitter
570
+
571
+ @agents.each do |agent_name, chat|
572
+ agent_def = @agent_definitions[agent_name]
573
+
574
+ LogStream.emit(
575
+ type: "agent_start",
576
+ agent: agent_name,
577
+ swarm_name: @name,
578
+ model: agent_def.model,
579
+ provider: agent_def.provider || "openai",
580
+ directory: agent_def.directory,
581
+ system_prompt: agent_def.system_prompt,
582
+ tools: chat.tools.keys,
583
+ delegates_to: agent_def.delegates_to,
584
+ memory_enabled: agent_def.memory_enabled?,
585
+ memory_directory: agent_def.memory_enabled? ? agent_def.memory.directory : nil,
586
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
587
+ )
588
+ end
545
589
  end
546
590
 
547
591
  # Normalize tools to internal format (kept for add_agent)
@@ -576,12 +620,12 @@ module SwarmSDK
576
620
 
577
621
  # Create a tool instance (delegates to ToolConfigurator)
578
622
  def create_tool_instance(tool_name, agent_name, directory)
579
- ToolConfigurator.new(self, @scratchpad).create_tool_instance(tool_name, agent_name, directory)
623
+ ToolConfigurator.new(self, @scratchpad_storage, @memory_storages).create_tool_instance(tool_name, agent_name, directory)
580
624
  end
581
625
 
582
626
  # Wrap tool with permissions (delegates to ToolConfigurator)
583
627
  def wrap_tool_with_permissions(tool_instance, permissions_config, agent_definition)
584
- ToolConfigurator.new(self, @scratchpad).wrap_tool_with_permissions(tool_instance, permissions_config, agent_definition)
628
+ ToolConfigurator.new(self, @scratchpad_storage, @memory_storages).wrap_tool_with_permissions(tool_instance, permissions_config, agent_definition)
585
629
  end
586
630
 
587
631
  # Build MCP transport config (delegates to McpConfigurator)
@@ -591,7 +635,7 @@ module SwarmSDK
591
635
 
592
636
  # Create delegation tool (delegates to AgentInitializer)
593
637
  def create_delegation_tool(name:, description:, delegate_chat:, agent_name:)
594
- AgentInitializer.new(self, @agent_definitions, @global_semaphore, @hook_registry, @scratchpad)
638
+ AgentInitializer.new(self, @agent_definitions, @global_semaphore, @hook_registry, @scratchpad_storage, @memory_storages)
595
639
  .create_delegation_tool(name: name, description: description, delegate_chat: delegate_chat, agent_name: agent_name)
596
640
  end
597
641
 
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module DocumentConverters
6
+ # Converter for HTML to Markdown
7
+ # Uses reverse_markdown gem if available, otherwise falls back to simple regex-based conversion
8
+ class HtmlConverter < BaseConverter
9
+ class << self
10
+ def gem_name
11
+ "reverse_markdown"
12
+ end
13
+
14
+ def format_name
15
+ "HTML"
16
+ end
17
+
18
+ def extensions
19
+ [".html", ".htm"]
20
+ end
21
+ end
22
+
23
+ # Convert HTML string to Markdown
24
+ # @param html [String] HTML content to convert
25
+ # @return [String] Markdown content
26
+ def convert_string(html)
27
+ if self.class.available?
28
+ convert_with_gem(html)
29
+ else
30
+ convert_simple(html)
31
+ end
32
+ end
33
+
34
+ # Convert HTML file to Markdown
35
+ # @param file_path [String] Path to HTML file
36
+ # @return [String] Markdown content
37
+ def convert(file_path)
38
+ html = File.read(file_path)
39
+ convert_string(html)
40
+ rescue StandardError => e
41
+ error("Failed to read HTML file: #{e.message}")
42
+ end
43
+
44
+ private
45
+
46
+ # Convert HTML to Markdown using reverse_markdown gem
47
+ # @param html [String] HTML content
48
+ # @return [String] Markdown content
49
+ def convert_with_gem(html)
50
+ require "reverse_markdown"
51
+
52
+ ReverseMarkdown.convert(html, unknown_tags: :bypass, github_flavored: true)
53
+ rescue StandardError
54
+ # Fallback to simple conversion if gem conversion fails
55
+ convert_simple(html)
56
+ end
57
+
58
+ # Simple regex-based HTML to Markdown conversion (fallback)
59
+ # @param html [String] HTML content
60
+ # @return [String] Markdown content
61
+ def convert_simple(html)
62
+ # Remove script and style tags
63
+ content = html.gsub(%r{<script[^>]*>.*?</script>}im, "")
64
+ content = content.gsub(%r{<style[^>]*>.*?</style>}im, "")
65
+
66
+ # Convert common HTML elements
67
+ content = content.gsub(%r{<h1[^>]*>(.*?)</h1>}im, "\n# \\1\n")
68
+ content = content.gsub(%r{<h2[^>]*>(.*?)</h2>}im, "\n## \\1\n")
69
+ content = content.gsub(%r{<h3[^>]*>(.*?)</h3>}im, "\n### \\1\n")
70
+ content = content.gsub(%r{<h4[^>]*>(.*?)</h4>}im, "\n#### \\1\n")
71
+ content = content.gsub(%r{<h5[^>]*>(.*?)</h5>}im, "\n##### \\1\n")
72
+ content = content.gsub(%r{<h6[^>]*>(.*?)</h6>}im, "\n###### \\1\n")
73
+ content = content.gsub(%r{<p[^>]*>(.*?)</p>}im, "\n\\1\n")
74
+ content = content.gsub(%r{<br\s*/?>}i, "\n")
75
+ content = content.gsub(%r{<strong[^>]*>(.*?)</strong>}im, "**\\1**")
76
+ content = content.gsub(%r{<b[^>]*>(.*?)</b>}im, "**\\1**")
77
+ content = content.gsub(%r{<em[^>]*>(.*?)</em>}im, "_\\1_")
78
+ content = content.gsub(%r{<i[^>]*>(.*?)</i>}im, "_\\1_")
79
+ content = content.gsub(%r{<code[^>]*>(.*?)</code>}im, "`\\1`")
80
+ content = content.gsub(%r{<a[^>]*href=["']([^"']*)["'][^>]*>(.*?)</a>}im, "[\\2](\\1)")
81
+ content = content.gsub(%r{<li[^>]*>(.*?)</li>}im, "- \\1\n")
82
+
83
+ # Remove remaining HTML tags
84
+ content = content.gsub(/<[^>]+>/, "")
85
+
86
+ # Decode HTML entities
87
+ content = content.gsub("&lt;", "<")
88
+ content = content.gsub("&gt;", ">")
89
+ content = content.gsub("&amp;", "&")
90
+ content = content.gsub("&quot;", "\"")
91
+ content = content.gsub("&#39;", "'")
92
+ content = content.gsub("&nbsp;", " ")
93
+
94
+ # Clean up whitespace
95
+ content = content.gsub(/\n\n\n+/, "\n\n")
96
+ content.strip
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module Memory
6
+ # Tool for deleting content from memory storage
7
+ #
8
+ # Removes entries that are no longer relevant.
9
+ # Each agent has its own isolated memory storage.
10
+ class MemoryDelete < RubyLLM::Tool
11
+ define_method(:name) { "MemoryDelete" }
12
+
13
+ description <<~DESC
14
+ Delete content from your memory when it's no longer relevant.
15
+ Use this to remove outdated information, completed tasks, or data that's no longer needed.
16
+ This helps keep your memory organized and prevents it from filling up.
17
+
18
+ IMPORTANT: Only delete entries that are truly no longer needed. Once deleted, the content cannot be recovered.
19
+ DESC
20
+
21
+ param :file_path,
22
+ desc: "Path to delete from memory (e.g., 'analysis/old_report', 'parallel/batch1/task_0')",
23
+ required: true
24
+
25
+ class << self
26
+ # Create a MemoryDelete tool for a specific memory storage instance
27
+ #
28
+ # @param memory_storage [Stores::MemoryStorage] Per-agent memory storage instance
29
+ # @return [MemoryDelete] Tool instance
30
+ def create_for_memory(memory_storage)
31
+ new(memory_storage)
32
+ end
33
+ end
34
+
35
+ # Initialize with memory storage instance
36
+ #
37
+ # @param memory_storage [Stores::MemoryStorage] Per-agent memory storage instance
38
+ def initialize(memory_storage)
39
+ super() # Call RubyLLM::Tool's initialize
40
+ @memory_storage = memory_storage
41
+ end
42
+
43
+ # Execute the tool
44
+ #
45
+ # @param file_path [String] Path to delete from
46
+ # @return [String] Success message
47
+ def execute(file_path:)
48
+ memory_storage.delete(file_path: file_path)
49
+ "Deleted memory://#{file_path}"
50
+ rescue ArgumentError => e
51
+ validation_error(e.message)
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :memory_storage
57
+
58
+ def validation_error(message)
59
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module Memory
6
+ # Tool for editing memory entries with exact string replacement
7
+ #
8
+ # Performs exact string replacements in memory content.
9
+ # Each agent has its own isolated memory storage.
10
+ class MemoryEdit < RubyLLM::Tool
11
+ define_method(:name) { "MemoryEdit" }
12
+
13
+ description <<~DESC
14
+ Performs exact string replacements in memory entries.
15
+ Works like the Edit tool but operates on memory content instead of files.
16
+ You must use MemoryRead on the entry before editing it.
17
+ When editing text from MemoryRead output, ensure you preserve the exact indentation 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 content to match.
19
+ Never include any part of the line number prefix in the old_string or new_string.
20
+ The edit will FAIL if old_string is not unique in the entry. Either provide a larger string with more surrounding context to make it unique or use replace_all to change every instance of old_string.
21
+ Use replace_all for replacing and renaming strings across the entry.
22
+ DESC
23
+
24
+ param :file_path,
25
+ desc: "Path to the memory entry (e.g., 'analysis/report', 'parallel/batch1/task_0')",
26
+ required: true
27
+
28
+ param :old_string,
29
+ desc: "The exact text to replace (must match exactly including whitespace)",
30
+ required: true
31
+
32
+ param :new_string,
33
+ desc: "The text to replace it with (must be different from old_string)",
34
+ required: true
35
+
36
+ param :replace_all,
37
+ desc: "Replace all occurrences of old_string (default false)",
38
+ required: false
39
+
40
+ class << self
41
+ # Create a MemoryEdit tool for a specific memory storage instance
42
+ #
43
+ # @param memory_storage [Stores::MemoryStorage] Per-agent memory storage instance
44
+ # @param agent_name [Symbol, String] Agent identifier for tracking reads
45
+ # @return [MemoryEdit] Tool instance
46
+ def create_for_memory(memory_storage, agent_name)
47
+ new(memory_storage, agent_name)
48
+ end
49
+ end
50
+
51
+ # Initialize with memory storage instance and agent name
52
+ #
53
+ # @param memory_storage [Stores::MemoryStorage] Per-agent memory storage instance
54
+ # @param agent_name [Symbol, String] Agent identifier
55
+ def initialize(memory_storage, agent_name)
56
+ super() # Call RubyLLM::Tool's initialize
57
+ @memory_storage = memory_storage
58
+ @agent_name = agent_name.to_sym
59
+ end
60
+
61
+ # Execute the tool
62
+ #
63
+ # @param file_path [String] Path to memory entry
64
+ # @param old_string [String] Text to replace
65
+ # @param new_string [String] Replacement text
66
+ # @param replace_all [Boolean] Replace all occurrences
67
+ # @return [String] Success message or error
68
+ def execute(file_path:, old_string:, new_string:, replace_all: false)
69
+ # Validate inputs
70
+ return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
71
+ return validation_error("old_string is required") if old_string.nil? || old_string.empty?
72
+ return validation_error("new_string is required") if new_string.nil?
73
+
74
+ # old_string and new_string must be different
75
+ if old_string == new_string
76
+ return validation_error("old_string and new_string must be different. They are currently identical.")
77
+ end
78
+
79
+ # Read current content (this will raise ArgumentError if entry doesn't exist)
80
+ content = memory_storage.read(file_path: file_path)
81
+
82
+ # Enforce read-before-edit
83
+ unless Stores::StorageReadTracker.entry_read?(@agent_name, file_path)
84
+ return validation_error(
85
+ "Cannot edit memory entry without reading it first. " \
86
+ "You must use MemoryRead on 'memory://#{file_path}' before editing it. " \
87
+ "This ensures you have the current content to match against.",
88
+ )
89
+ end
90
+
91
+ # Check if old_string exists in content
92
+ unless content.include?(old_string)
93
+ return validation_error(<<~ERROR.chomp)
94
+ old_string not found in memory entry. Make sure it matches exactly, including all whitespace and indentation.
95
+ Do not include line number prefixes from MemoryRead tool output.
96
+ ERROR
97
+ end
98
+
99
+ # Count occurrences
100
+ occurrences = content.scan(old_string).count
101
+
102
+ # If not replace_all and multiple occurrences, error
103
+ if !replace_all && occurrences > 1
104
+ return validation_error(<<~ERROR.chomp)
105
+ Found #{occurrences} occurrences of old_string.
106
+ Either provide more surrounding context to make the match unique, or use replace_all: true to replace all occurrences.
107
+ ERROR
108
+ end
109
+
110
+ # Perform replacement
111
+ new_content = if replace_all
112
+ content.gsub(old_string, new_string)
113
+ else
114
+ content.sub(old_string, new_string)
115
+ end
116
+
117
+ # Get existing entry metadata
118
+ entries = memory_storage.list
119
+ existing_entry = entries.find { |e| e[:path] == file_path }
120
+
121
+ # Write updated content back (preserving the title)
122
+ memory_storage.write(
123
+ file_path: file_path,
124
+ content: new_content,
125
+ title: existing_entry[:title],
126
+ )
127
+
128
+ # Build success message
129
+ replaced_count = replace_all ? occurrences : 1
130
+ "Successfully replaced #{replaced_count} occurrence(s) in memory://#{file_path}"
131
+ rescue ArgumentError => e
132
+ validation_error(e.message)
133
+ end
134
+
135
+ private
136
+
137
+ attr_reader :memory_storage
138
+
139
+ def validation_error(message)
140
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module Memory
6
+ # Tool for searching memory entries by glob pattern
7
+ #
8
+ # Finds memory entries matching a glob pattern (like filesystem glob).
9
+ # Each agent has its own isolated memory storage.
10
+ class MemoryGlob < RubyLLM::Tool
11
+ define_method(:name) { "MemoryGlob" }
12
+
13
+ description <<~DESC
14
+ Search your memory entries by glob pattern.
15
+ Works like filesystem glob - use * for wildcards, ** for recursive matching.
16
+ Use this to discover entries matching specific patterns.
17
+
18
+ Examples:
19
+ - "parallel/*" - all entries directly under parallel/
20
+ - "parallel/**" - all entries under parallel/ (recursive)
21
+ - "*/report" - all entries named "report" in any top-level directory
22
+ - "analysis/*/result_*" - entries like "analysis/foo/result_1"
23
+ DESC
24
+
25
+ param :pattern,
26
+ desc: "Glob pattern to match (e.g., '**/*.txt', 'parallel/*/task_*')",
27
+ required: true
28
+
29
+ class << self
30
+ # Create a MemoryGlob tool for a specific memory storage instance
31
+ #
32
+ # @param memory_storage [Stores::MemoryStorage] Per-agent memory storage instance
33
+ # @return [MemoryGlob] Tool instance
34
+ def create_for_memory(memory_storage)
35
+ new(memory_storage)
36
+ end
37
+ end
38
+
39
+ # Initialize with memory storage instance
40
+ #
41
+ # @param memory_storage [Stores::MemoryStorage] Per-agent memory storage instance
42
+ def initialize(memory_storage)
43
+ super() # Call RubyLLM::Tool's initialize
44
+ @memory_storage = memory_storage
45
+ end
46
+
47
+ # Execute the tool
48
+ #
49
+ # @param pattern [String] Glob pattern to match
50
+ # @return [String] Formatted list of matching entries
51
+ def execute(pattern:)
52
+ entries = memory_storage.glob(pattern: pattern)
53
+
54
+ if entries.empty?
55
+ return "No entries found matching pattern '#{pattern}'"
56
+ end
57
+
58
+ result = []
59
+ result << "Memory entries matching '#{pattern}' (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
60
+
61
+ entries.each do |entry|
62
+ result << " memory://#{entry[:path]} - \"#{entry[:title]}\" (#{format_bytes(entry[:size])})"
63
+ end
64
+
65
+ result.join("\n")
66
+ rescue ArgumentError => e
67
+ validation_error(e.message)
68
+ end
69
+
70
+ private
71
+
72
+ attr_reader :memory_storage
73
+
74
+ def validation_error(message)
75
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
76
+ end
77
+
78
+ # Format bytes to human-readable size
79
+ #
80
+ # @param bytes [Integer] Number of bytes
81
+ # @return [String] Formatted size
82
+ def format_bytes(bytes)
83
+ if bytes >= 1_000_000
84
+ "#{(bytes.to_f / 1_000_000).round(1)}MB"
85
+ elsif bytes >= 1_000
86
+ "#{(bytes.to_f / 1_000).round(1)}KB"
87
+ else
88
+ "#{bytes}B"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module Memory
6
+ # Tool for searching memory content by pattern
7
+ #
8
+ # Searches content stored in memory entries using regex patterns.
9
+ # Each agent has its own isolated memory storage.
10
+ class MemoryGrep < RubyLLM::Tool
11
+ define_method(:name) { "MemoryGrep" }
12
+
13
+ description <<~DESC
14
+ Search your memory content by pattern (like grep).
15
+ Use regex patterns to search content within memory entries.
16
+ Returns matching entries and optionally line numbers and content.
17
+
18
+ Output modes:
19
+ - files_with_matches: Only list paths containing matches (default)
20
+ - content: Show matching lines with line numbers
21
+ - count: Show number of matches per file
22
+ DESC
23
+
24
+ param :pattern,
25
+ desc: "Regular expression pattern to search for",
26
+ required: true
27
+
28
+ param :case_insensitive,
29
+ desc: "Perform case-insensitive search (default: false)",
30
+ required: false
31
+
32
+ param :output_mode,
33
+ desc: "Output mode: 'files_with_matches' (default), 'content', or 'count'",
34
+ required: false
35
+
36
+ class << self
37
+ # Create a MemoryGrep tool for a specific memory storage instance
38
+ #
39
+ # @param memory_storage [Stores::MemoryStorage] Per-agent memory storage instance
40
+ # @return [MemoryGrep] Tool instance
41
+ def create_for_memory(memory_storage)
42
+ new(memory_storage)
43
+ end
44
+ end
45
+
46
+ # Initialize with memory storage instance
47
+ #
48
+ # @param memory_storage [Stores::MemoryStorage] Per-agent memory storage instance
49
+ def initialize(memory_storage)
50
+ super() # Call RubyLLM::Tool's initialize
51
+ @memory_storage = memory_storage
52
+ end
53
+
54
+ # Execute the tool
55
+ #
56
+ # @param pattern [String] Regex pattern to search for
57
+ # @param case_insensitive [Boolean] Whether to perform case-insensitive search
58
+ # @param output_mode [String] Output mode
59
+ # @return [String] Formatted search results
60
+ def execute(pattern:, case_insensitive: false, output_mode: "files_with_matches")
61
+ results = memory_storage.grep(
62
+ pattern: pattern,
63
+ case_insensitive: case_insensitive,
64
+ output_mode: output_mode,
65
+ )
66
+
67
+ format_results(results, pattern, output_mode)
68
+ rescue ArgumentError => e
69
+ validation_error(e.message)
70
+ rescue RegexpError => e
71
+ validation_error("Invalid regex pattern: #{e.message}")
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :memory_storage
77
+
78
+ def validation_error(message)
79
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
80
+ end
81
+
82
+ def format_results(results, pattern, output_mode)
83
+ case output_mode
84
+ when "files_with_matches"
85
+ format_files_with_matches(results, pattern)
86
+ when "content"
87
+ format_content(results, pattern)
88
+ when "count"
89
+ format_count(results, pattern)
90
+ else
91
+ validation_error("Invalid output_mode: #{output_mode}")
92
+ end
93
+ end
94
+
95
+ def format_files_with_matches(paths, pattern)
96
+ if paths.empty?
97
+ return "No matches found for pattern '#{pattern}'"
98
+ end
99
+
100
+ result = []
101
+ result << "Memory entries matching '#{pattern}' (#{paths.size} #{paths.size == 1 ? "entry" : "entries"}):"
102
+ paths.each do |path|
103
+ result << " memory://#{path}"
104
+ end
105
+ result.join("\n")
106
+ end
107
+
108
+ def format_content(results, pattern)
109
+ if results.empty?
110
+ return "No matches found for pattern '#{pattern}'"
111
+ end
112
+
113
+ total_matches = results.sum { |r| r[:matches].size }
114
+ output = []
115
+ output << "Memory entries matching '#{pattern}' (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} #{total_matches == 1 ? "match" : "matches"}):"
116
+ output << ""
117
+
118
+ results.each do |result|
119
+ output << "memory://#{result[:path]}:"
120
+ result[:matches].each do |match|
121
+ output << " #{match[:line_number]}: #{match[:content]}"
122
+ end
123
+ output << ""
124
+ end
125
+
126
+ output.join("\n").rstrip
127
+ end
128
+
129
+ def format_count(results, pattern)
130
+ if results.empty?
131
+ return "No matches found for pattern '#{pattern}'"
132
+ end
133
+
134
+ total_matches = results.sum { |r| r[:count] }
135
+ output = []
136
+ output << "Memory entries matching '#{pattern}' (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} total #{total_matches == 1 ? "match" : "matches"}):"
137
+
138
+ results.each do |result|
139
+ output << " memory://#{result[:path]}: #{result[:count]} #{result[:count] == 1 ? "match" : "matches"}"
140
+ end
141
+
142
+ output.join("\n")
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end