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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +41 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +22 -5
- data/lib/swarm_sdk/agent/definition.rb +52 -6
- data/lib/swarm_sdk/configuration.rb +3 -1
- data/lib/swarm_sdk/prompts/memory.md.erb +480 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +16 -3
- data/lib/swarm_sdk/swarm/builder.rb +9 -1
- data/lib/swarm_sdk/swarm/tool_configurator.rb +73 -23
- data/lib/swarm_sdk/swarm.rb +51 -7
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
- data/lib/swarm_sdk/tools/memory/memory_delete.rb +64 -0
- data/lib/swarm_sdk/tools/memory/memory_edit.rb +145 -0
- data/lib/swarm_sdk/tools/memory/memory_glob.rb +94 -0
- data/lib/swarm_sdk/tools/memory/memory_grep.rb +147 -0
- data/lib/swarm_sdk/tools/memory/memory_multi_edit.rb +228 -0
- data/lib/swarm_sdk/tools/memory/memory_read.rb +82 -0
- data/lib/swarm_sdk/tools/memory/memory_write.rb +90 -0
- data/lib/swarm_sdk/tools/registry.rb +11 -3
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
- data/lib/swarm_sdk/tools/stores/memory_storage.rb +300 -0
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
- data/lib/swarm_sdk/tools/stores/storage_read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +39 -0
- metadata +18 -5
- data/lib/swarm_sdk/tools/scratchpad_list.rb +0 -88
- data/lib/swarm_sdk/tools/scratchpad_read.rb +0 -59
- data/lib/swarm_sdk/tools/scratchpad_write.rb +0 -88
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +0 -153
data/lib/swarm_sdk/swarm.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
@
|
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, @
|
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, @
|
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, @
|
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("<", "<")
|
88
|
+
content = content.gsub(">", ">")
|
89
|
+
content = content.gsub("&", "&")
|
90
|
+
content = content.gsub(""", "\"")
|
91
|
+
content = content.gsub("'", "'")
|
92
|
+
content = content.gsub(" ", " ")
|
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
|