swarm_sdk 2.0.4 → 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 +75 -32
- data/lib/swarm_sdk/swarm.rb +50 -11
- 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 -4
- 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/{scratchpad.rb → memory_storage.rb} +72 -88
- 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/{scratchpad_read_tracker.rb → storage_read_tracker.rb} +7 -7
- 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 -9
- data/lib/swarm_sdk/tools/scratchpad_edit.rb +0 -143
- data/lib/swarm_sdk/tools/scratchpad_glob.rb +0 -92
- data/lib/swarm_sdk/tools/scratchpad_grep.rb +0 -145
- data/lib/swarm_sdk/tools/scratchpad_multi_edit.rb +0 -226
- data/lib/swarm_sdk/tools/scratchpad_read.rb +0 -80
- data/lib/swarm_sdk/tools/scratchpad_write.rb +0 -88
@@ -3,22 +3,22 @@
|
|
3
3
|
module SwarmSDK
|
4
4
|
module Tools
|
5
5
|
module Stores
|
6
|
-
#
|
6
|
+
# StorageReadTracker manages read-entry tracking for all agents
|
7
7
|
#
|
8
|
-
# This module maintains a global registry of which
|
8
|
+
# This module maintains a global registry of which memory entries each agent
|
9
9
|
# has read during their conversation. This enables enforcement of the
|
10
10
|
# "read-before-edit" rule that ensures agents have context before modifying entries.
|
11
11
|
#
|
12
12
|
# Each agent maintains an independent set of read entries, keyed by agent identifier.
|
13
|
-
module
|
13
|
+
module StorageReadTracker
|
14
14
|
@read_entries = {}
|
15
15
|
@mutex = Mutex.new
|
16
16
|
|
17
17
|
class << self
|
18
|
-
# Register that an agent has read a
|
18
|
+
# Register that an agent has read a storage entry
|
19
19
|
#
|
20
20
|
# @param agent_id [Symbol] The agent identifier
|
21
|
-
# @param entry_path [String] The
|
21
|
+
# @param entry_path [String] The storage entry path
|
22
22
|
def register_read(agent_id, entry_path)
|
23
23
|
@mutex.synchronize do
|
24
24
|
@read_entries[agent_id] ||= Set.new
|
@@ -26,10 +26,10 @@ module SwarmSDK
|
|
26
26
|
end
|
27
27
|
end
|
28
28
|
|
29
|
-
# Check if an agent has read a
|
29
|
+
# Check if an agent has read a storage entry
|
30
30
|
#
|
31
31
|
# @param agent_id [Symbol] The agent identifier
|
32
|
-
# @param entry_path [String] The
|
32
|
+
# @param entry_path [String] The storage entry path
|
33
33
|
# @return [Boolean] true if the agent has read this entry
|
34
34
|
def entry_read?(agent_id, entry_path)
|
35
35
|
@mutex.synchronize do
|
@@ -0,0 +1,261 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# WebFetch tool for fetching and processing web content
|
6
|
+
#
|
7
|
+
# Fetches content from URLs, converts HTML to markdown, and processes it
|
8
|
+
# using an AI model to extract information based on a provided prompt.
|
9
|
+
class WebFetch < RubyLLM::Tool
|
10
|
+
def initialize
|
11
|
+
super()
|
12
|
+
@cache = {}
|
13
|
+
@cache_ttl = 900 # 15 minutes in seconds
|
14
|
+
@llm_enabled = SwarmSDK.settings.webfetch_llm_enabled?
|
15
|
+
end
|
16
|
+
|
17
|
+
def name
|
18
|
+
"WebFetch"
|
19
|
+
end
|
20
|
+
|
21
|
+
description <<~DESC
|
22
|
+
- Fetches content from a specified URL and converts it to markdown
|
23
|
+
- Optionally processes the content with an LLM if configured
|
24
|
+
- Fetches the URL content, converts HTML to markdown
|
25
|
+
- Returns markdown content or LLM analysis (based on configuration)
|
26
|
+
- Use this tool when you need to retrieve and analyze web content
|
27
|
+
|
28
|
+
Usage notes:
|
29
|
+
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".
|
30
|
+
- The URL must be a fully-formed valid URL
|
31
|
+
- HTTP URLs will be automatically upgraded to HTTPS
|
32
|
+
- This tool is read-only and does not modify any files
|
33
|
+
- Content will be truncated if very large
|
34
|
+
- Includes a self-cleaning 15-minute cache for faster responses
|
35
|
+
- When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.
|
36
|
+
|
37
|
+
LLM Processing:
|
38
|
+
- When SwarmSDK is configured with webfetch_provider and webfetch_model, the 'prompt' parameter is required
|
39
|
+
- The tool will process the markdown content with the configured LLM using your prompt
|
40
|
+
- Without this configuration, the tool returns raw markdown and the 'prompt' parameter is optional (ignored if provided)
|
41
|
+
- Configure with: SwarmSDK.configure { |c| c.webfetch_provider = "anthropic"; c.webfetch_model = "claude-3-5-haiku-20241022" }
|
42
|
+
DESC
|
43
|
+
|
44
|
+
param :url,
|
45
|
+
type: "string",
|
46
|
+
desc: "The URL to fetch content from",
|
47
|
+
required: true
|
48
|
+
|
49
|
+
param :prompt,
|
50
|
+
type: "string",
|
51
|
+
desc: "The prompt to run on the fetched content. Required when SwarmSDK is configured with webfetch_provider and webfetch_model. Optional otherwise (ignored if LLM processing not configured).",
|
52
|
+
required: false
|
53
|
+
|
54
|
+
MAX_CONTENT_LENGTH = 100_000 # characters
|
55
|
+
USER_AGENT = "SwarmSDK WebFetch Tool (https://github.com/parruda/claude-swarm)"
|
56
|
+
TIMEOUT = 30 # seconds
|
57
|
+
|
58
|
+
def execute(url:, prompt: nil)
|
59
|
+
# Validate inputs
|
60
|
+
return validation_error("url is required") if url.nil? || url.empty?
|
61
|
+
|
62
|
+
# Validate prompt when LLM processing is enabled
|
63
|
+
if @llm_enabled && (prompt.nil? || prompt.empty?)
|
64
|
+
return validation_error("prompt is required when LLM processing is configured")
|
65
|
+
end
|
66
|
+
|
67
|
+
# Validate and normalize URL
|
68
|
+
normalized_url = normalize_url(url)
|
69
|
+
return validation_error("Invalid URL format: #{url}") unless normalized_url
|
70
|
+
|
71
|
+
# Check cache first (cache key includes prompt if LLM is enabled)
|
72
|
+
cache_key = @llm_enabled ? "#{normalized_url}:#{prompt}" : normalized_url
|
73
|
+
cached = get_from_cache(cache_key)
|
74
|
+
return cached if cached
|
75
|
+
|
76
|
+
# Fetch the URL
|
77
|
+
fetch_result = fetch_url(normalized_url)
|
78
|
+
return fetch_result if fetch_result.is_a?(String) && fetch_result.start_with?("Error")
|
79
|
+
|
80
|
+
# Check for redirects to different hosts
|
81
|
+
if fetch_result[:redirect_url] && different_host?(normalized_url, fetch_result[:redirect_url])
|
82
|
+
return format_redirect_message(fetch_result[:redirect_url])
|
83
|
+
end
|
84
|
+
|
85
|
+
# Convert HTML to markdown
|
86
|
+
markdown_content = html_to_markdown(fetch_result[:body])
|
87
|
+
|
88
|
+
# Truncate if too long
|
89
|
+
if markdown_content.length > MAX_CONTENT_LENGTH
|
90
|
+
markdown_content = markdown_content[0...MAX_CONTENT_LENGTH]
|
91
|
+
markdown_content += "\n\n[Content truncated due to length]"
|
92
|
+
end
|
93
|
+
|
94
|
+
# Process with AI model if LLM is enabled, otherwise return markdown
|
95
|
+
result = if @llm_enabled
|
96
|
+
process_with_llm(markdown_content, prompt, normalized_url)
|
97
|
+
else
|
98
|
+
markdown_content
|
99
|
+
end
|
100
|
+
|
101
|
+
# Cache the result
|
102
|
+
store_in_cache(cache_key, result)
|
103
|
+
|
104
|
+
result
|
105
|
+
rescue StandardError => e
|
106
|
+
error("Unexpected error fetching URL: #{e.class.name} - #{e.message}")
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def validation_error(message)
|
112
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
113
|
+
end
|
114
|
+
|
115
|
+
def error(message)
|
116
|
+
"Error: #{message}"
|
117
|
+
end
|
118
|
+
|
119
|
+
def normalize_url(url)
|
120
|
+
# Upgrade HTTP to HTTPS
|
121
|
+
url = url.sub(%r{^http://}, "https://")
|
122
|
+
|
123
|
+
# Validate URL format
|
124
|
+
uri = URI.parse(url)
|
125
|
+
return unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
126
|
+
return unless uri.host
|
127
|
+
|
128
|
+
uri.to_s
|
129
|
+
rescue URI::InvalidURIError
|
130
|
+
nil
|
131
|
+
end
|
132
|
+
|
133
|
+
def different_host?(url1, url2)
|
134
|
+
uri1 = URI.parse(url1)
|
135
|
+
uri2 = URI.parse(url2)
|
136
|
+
uri1.host != uri2.host
|
137
|
+
rescue URI::InvalidURIError
|
138
|
+
false
|
139
|
+
end
|
140
|
+
|
141
|
+
def fetch_url(url)
|
142
|
+
require "faraday"
|
143
|
+
require "faraday/follow_redirects"
|
144
|
+
|
145
|
+
response = Faraday.new(url: url) do |conn|
|
146
|
+
conn.request(:url_encoded)
|
147
|
+
conn.response(:follow_redirects, limit: 5)
|
148
|
+
conn.adapter(Faraday.default_adapter)
|
149
|
+
conn.options.timeout = TIMEOUT
|
150
|
+
conn.options.open_timeout = TIMEOUT
|
151
|
+
end.get do |req|
|
152
|
+
req.headers["User-Agent"] = USER_AGENT
|
153
|
+
req.headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
154
|
+
end
|
155
|
+
|
156
|
+
unless response.success?
|
157
|
+
return error("HTTP #{response.status}: Failed to fetch URL")
|
158
|
+
end
|
159
|
+
|
160
|
+
# Check final URL for redirects
|
161
|
+
final_url = response.env.url.to_s
|
162
|
+
redirect_url = final_url if final_url != url
|
163
|
+
|
164
|
+
{
|
165
|
+
body: response.body,
|
166
|
+
redirect_url: redirect_url,
|
167
|
+
}
|
168
|
+
rescue Faraday::TimeoutError
|
169
|
+
error("Request timed out after #{TIMEOUT} seconds")
|
170
|
+
rescue Faraday::ConnectionFailed => e
|
171
|
+
error("Connection failed: #{e.message}")
|
172
|
+
rescue StandardError => e
|
173
|
+
error("Failed to fetch URL: #{e.class.name} - #{e.message}")
|
174
|
+
end
|
175
|
+
|
176
|
+
def html_to_markdown(html)
|
177
|
+
# Use HtmlConverter to handle conversion with optional reverse_markdown gem
|
178
|
+
converter = DocumentConverters::HtmlConverter.new
|
179
|
+
converter.convert_string(html)
|
180
|
+
end
|
181
|
+
|
182
|
+
def process_with_llm(content, prompt, url)
|
183
|
+
# Use configured model for processing
|
184
|
+
# Format the prompt to include the content
|
185
|
+
full_prompt = <<~PROMPT
|
186
|
+
You are analyzing content from the URL: #{url}
|
187
|
+
|
188
|
+
User request: #{prompt}
|
189
|
+
|
190
|
+
Content:
|
191
|
+
#{content}
|
192
|
+
|
193
|
+
Please respond to the user's request based on the content above.
|
194
|
+
PROMPT
|
195
|
+
|
196
|
+
# Get settings
|
197
|
+
config = SwarmSDK.settings
|
198
|
+
|
199
|
+
# Build chat with configured provider and model
|
200
|
+
chat_params = {
|
201
|
+
model: config.webfetch_model,
|
202
|
+
provider: config.webfetch_provider.to_sym,
|
203
|
+
}
|
204
|
+
chat_params[:base_url] = config.webfetch_base_url if config.webfetch_base_url
|
205
|
+
|
206
|
+
chat = RubyLLM.chat(**chat_params).with_params(max_tokens: config.webfetch_max_tokens)
|
207
|
+
|
208
|
+
response = chat.ask(full_prompt)
|
209
|
+
|
210
|
+
# Extract the text response
|
211
|
+
response_text = response.content
|
212
|
+
return error("Failed to process content with LLM: No response text") unless response_text
|
213
|
+
|
214
|
+
response_text
|
215
|
+
rescue StandardError => e
|
216
|
+
error("Failed to process content with LLM: #{e.class.name} - #{e.message}")
|
217
|
+
end
|
218
|
+
|
219
|
+
def format_redirect_message(redirect_url)
|
220
|
+
<<~MESSAGE
|
221
|
+
This URL redirected to a different host.
|
222
|
+
|
223
|
+
Redirect URL: #{redirect_url}
|
224
|
+
|
225
|
+
<system-reminder>
|
226
|
+
The requested URL redirected to a different host. To fetch the content from the redirect URL,
|
227
|
+
make a new WebFetch request with the redirect URL provided above.
|
228
|
+
</system-reminder>
|
229
|
+
MESSAGE
|
230
|
+
end
|
231
|
+
|
232
|
+
def get_from_cache(key)
|
233
|
+
entry = @cache[key]
|
234
|
+
return unless entry
|
235
|
+
|
236
|
+
# Check if cache entry is still valid
|
237
|
+
if Time.now.to_i - entry[:timestamp] > @cache_ttl
|
238
|
+
@cache.delete(key)
|
239
|
+
return
|
240
|
+
end
|
241
|
+
|
242
|
+
entry[:value]
|
243
|
+
end
|
244
|
+
|
245
|
+
def store_in_cache(key, value)
|
246
|
+
# Clean old cache entries
|
247
|
+
clean_cache
|
248
|
+
|
249
|
+
@cache[key] = {
|
250
|
+
value: value,
|
251
|
+
timestamp: Time.now.to_i,
|
252
|
+
}
|
253
|
+
end
|
254
|
+
|
255
|
+
def clean_cache
|
256
|
+
now = Time.now.to_i
|
257
|
+
@cache.delete_if { |_key, entry| now - entry[:timestamp] > @cache_ttl }
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
data/lib/swarm_sdk/version.rb
CHANGED
data/lib/swarm_sdk.rb
CHANGED
@@ -41,11 +41,50 @@ module SwarmSDK
|
|
41
41
|
class StateError < Error; end
|
42
42
|
|
43
43
|
class << self
|
44
|
+
# Settings for SwarmSDK (global configuration)
|
45
|
+
attr_accessor :settings
|
46
|
+
|
44
47
|
# Main entry point for DSL
|
45
48
|
def build(&block)
|
46
49
|
Swarm::Builder.build(&block)
|
47
50
|
end
|
51
|
+
|
52
|
+
# Configure SwarmSDK global settings
|
53
|
+
def configure
|
54
|
+
self.settings ||= Settings.new
|
55
|
+
yield(settings)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Reset settings to defaults
|
59
|
+
def reset_settings!
|
60
|
+
self.settings = Settings.new
|
61
|
+
end
|
62
|
+
|
63
|
+
# Alias for backward compatibility
|
64
|
+
alias_method :configuration, :settings
|
65
|
+
alias_method :reset_configuration!, :reset_settings!
|
48
66
|
end
|
67
|
+
|
68
|
+
# Settings class for SwarmSDK global settings (not to be confused with Configuration for YAML loading)
|
69
|
+
class Settings
|
70
|
+
# WebFetch tool LLM processing configuration
|
71
|
+
attr_accessor :webfetch_provider, :webfetch_model, :webfetch_base_url, :webfetch_max_tokens
|
72
|
+
|
73
|
+
def initialize
|
74
|
+
@webfetch_provider = nil
|
75
|
+
@webfetch_model = nil
|
76
|
+
@webfetch_base_url = nil
|
77
|
+
@webfetch_max_tokens = 4096
|
78
|
+
end
|
79
|
+
|
80
|
+
# Check if WebFetch LLM processing is enabled
|
81
|
+
def webfetch_llm_enabled?
|
82
|
+
!@webfetch_provider.nil? && !@webfetch_model.nil?
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Initialize default settings
|
87
|
+
self.settings = Settings.new
|
49
88
|
end
|
50
89
|
|
51
90
|
# Automatically configure RubyLLM from environment variables
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: swarm_sdk
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0.
|
4
|
+
version: 2.0.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Paulo Arruda
|
@@ -118,6 +118,7 @@ files:
|
|
118
118
|
- lib/swarm_sdk/permissions/validator.rb
|
119
119
|
- lib/swarm_sdk/permissions_builder.rb
|
120
120
|
- lib/swarm_sdk/prompts/base_system_prompt.md.erb
|
121
|
+
- lib/swarm_sdk/prompts/memory.md.erb
|
121
122
|
- lib/swarm_sdk/providers/openai_with_responses.rb
|
122
123
|
- lib/swarm_sdk/result.rb
|
123
124
|
- lib/swarm_sdk/swarm.rb
|
@@ -130,6 +131,7 @@ files:
|
|
130
131
|
- lib/swarm_sdk/tools/delegate.rb
|
131
132
|
- lib/swarm_sdk/tools/document_converters/base_converter.rb
|
132
133
|
- lib/swarm_sdk/tools/document_converters/docx_converter.rb
|
134
|
+
- lib/swarm_sdk/tools/document_converters/html_converter.rb
|
133
135
|
- lib/swarm_sdk/tools/document_converters/pdf_converter.rb
|
134
136
|
- lib/swarm_sdk/tools/document_converters/xlsx_converter.rb
|
135
137
|
- lib/swarm_sdk/tools/edit.rb
|
@@ -138,22 +140,29 @@ files:
|
|
138
140
|
- lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb
|
139
141
|
- lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb
|
140
142
|
- lib/swarm_sdk/tools/image_formats/tiff_builder.rb
|
143
|
+
- lib/swarm_sdk/tools/memory/memory_delete.rb
|
144
|
+
- lib/swarm_sdk/tools/memory/memory_edit.rb
|
145
|
+
- lib/swarm_sdk/tools/memory/memory_glob.rb
|
146
|
+
- lib/swarm_sdk/tools/memory/memory_grep.rb
|
147
|
+
- lib/swarm_sdk/tools/memory/memory_multi_edit.rb
|
148
|
+
- lib/swarm_sdk/tools/memory/memory_read.rb
|
149
|
+
- lib/swarm_sdk/tools/memory/memory_write.rb
|
141
150
|
- lib/swarm_sdk/tools/multi_edit.rb
|
142
151
|
- lib/swarm_sdk/tools/path_resolver.rb
|
143
152
|
- lib/swarm_sdk/tools/read.rb
|
144
153
|
- lib/swarm_sdk/tools/registry.rb
|
145
|
-
- lib/swarm_sdk/tools/
|
146
|
-
- lib/swarm_sdk/tools/
|
147
|
-
- lib/swarm_sdk/tools/
|
148
|
-
- lib/swarm_sdk/tools/
|
149
|
-
- lib/swarm_sdk/tools/scratchpad_read.rb
|
150
|
-
- lib/swarm_sdk/tools/scratchpad_write.rb
|
154
|
+
- lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb
|
155
|
+
- lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb
|
156
|
+
- lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb
|
157
|
+
- lib/swarm_sdk/tools/stores/memory_storage.rb
|
151
158
|
- lib/swarm_sdk/tools/stores/read_tracker.rb
|
152
|
-
- lib/swarm_sdk/tools/stores/
|
153
|
-
- lib/swarm_sdk/tools/stores/
|
159
|
+
- lib/swarm_sdk/tools/stores/scratchpad_storage.rb
|
160
|
+
- lib/swarm_sdk/tools/stores/storage.rb
|
161
|
+
- lib/swarm_sdk/tools/stores/storage_read_tracker.rb
|
154
162
|
- lib/swarm_sdk/tools/stores/todo_manager.rb
|
155
163
|
- lib/swarm_sdk/tools/think.rb
|
156
164
|
- lib/swarm_sdk/tools/todo_write.rb
|
165
|
+
- lib/swarm_sdk/tools/web_fetch.rb
|
157
166
|
- lib/swarm_sdk/tools/write.rb
|
158
167
|
- lib/swarm_sdk/utils.rb
|
159
168
|
- lib/swarm_sdk/version.rb
|
@@ -1,143 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module SwarmSDK
|
4
|
-
module Tools
|
5
|
-
# Tool for editing scratchpad entries with exact string replacement
|
6
|
-
#
|
7
|
-
# Performs exact string replacements in scratchpad content.
|
8
|
-
# All agents in the swarm share the same scratchpad.
|
9
|
-
class ScratchpadEdit < RubyLLM::Tool
|
10
|
-
define_method(:name) { "ScratchpadEdit" }
|
11
|
-
|
12
|
-
description <<~DESC
|
13
|
-
Performs exact string replacements in scratchpad entries.
|
14
|
-
Works like the Edit tool but operates on scratchpad content instead of files.
|
15
|
-
You must use ScratchpadRead on the entry before editing it.
|
16
|
-
When editing text from ScratchpadRead output, ensure you preserve the exact indentation as it appears AFTER the line number prefix.
|
17
|
-
The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual content to match.
|
18
|
-
Never include any part of the line number prefix in the old_string or new_string.
|
19
|
-
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.
|
20
|
-
Use replace_all for replacing and renaming strings across the entry.
|
21
|
-
DESC
|
22
|
-
|
23
|
-
param :file_path,
|
24
|
-
desc: "Path to the scratchpad entry (e.g., 'analysis/report', 'parallel/batch1/task_0')",
|
25
|
-
required: true
|
26
|
-
|
27
|
-
param :old_string,
|
28
|
-
desc: "The exact text to replace (must match exactly including whitespace)",
|
29
|
-
required: true
|
30
|
-
|
31
|
-
param :new_string,
|
32
|
-
desc: "The text to replace it with (must be different from old_string)",
|
33
|
-
required: true
|
34
|
-
|
35
|
-
param :replace_all,
|
36
|
-
desc: "Replace all occurrences of old_string (default false)",
|
37
|
-
required: false
|
38
|
-
|
39
|
-
class << self
|
40
|
-
# Create a ScratchpadEdit tool for a specific scratchpad instance
|
41
|
-
#
|
42
|
-
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
43
|
-
# @param agent_name [Symbol, String] Agent identifier for tracking reads
|
44
|
-
# @return [ScratchpadEdit] Tool instance
|
45
|
-
def create_for_scratchpad(scratchpad, agent_name)
|
46
|
-
new(scratchpad, agent_name)
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
# Initialize with scratchpad instance and agent name
|
51
|
-
#
|
52
|
-
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
53
|
-
# @param agent_name [Symbol, String] Agent identifier
|
54
|
-
def initialize(scratchpad, agent_name)
|
55
|
-
super() # Call RubyLLM::Tool's initialize
|
56
|
-
@scratchpad = scratchpad
|
57
|
-
@agent_name = agent_name.to_sym
|
58
|
-
end
|
59
|
-
|
60
|
-
# Execute the tool
|
61
|
-
#
|
62
|
-
# @param file_path [String] Path to scratchpad entry
|
63
|
-
# @param old_string [String] Text to replace
|
64
|
-
# @param new_string [String] Replacement text
|
65
|
-
# @param replace_all [Boolean] Replace all occurrences
|
66
|
-
# @return [String] Success message or error
|
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
|
-
# Read current content (this will raise ArgumentError if entry doesn't exist)
|
79
|
-
content = scratchpad.read(file_path: file_path)
|
80
|
-
|
81
|
-
# Enforce read-before-edit
|
82
|
-
unless Stores::ScratchpadReadTracker.entry_read?(@agent_name, file_path)
|
83
|
-
return validation_error(
|
84
|
-
"Cannot edit scratchpad entry without reading it first. " \
|
85
|
-
"You must use ScratchpadRead on 'scratchpad://#{file_path}' before editing it. " \
|
86
|
-
"This ensures you have the current content to match against.",
|
87
|
-
)
|
88
|
-
end
|
89
|
-
|
90
|
-
# Check if old_string exists in content
|
91
|
-
unless content.include?(old_string)
|
92
|
-
return validation_error(<<~ERROR.chomp)
|
93
|
-
old_string not found in scratchpad entry. Make sure it matches exactly, including all whitespace and indentation.
|
94
|
-
Do not include line number prefixes from ScratchpadRead tool output.
|
95
|
-
ERROR
|
96
|
-
end
|
97
|
-
|
98
|
-
# Count occurrences
|
99
|
-
occurrences = content.scan(old_string).count
|
100
|
-
|
101
|
-
# If not replace_all and multiple occurrences, error
|
102
|
-
if !replace_all && occurrences > 1
|
103
|
-
return validation_error(<<~ERROR.chomp)
|
104
|
-
Found #{occurrences} occurrences of old_string.
|
105
|
-
Either provide more surrounding context to make the match unique, or use replace_all: true to replace all occurrences.
|
106
|
-
ERROR
|
107
|
-
end
|
108
|
-
|
109
|
-
# Perform replacement
|
110
|
-
new_content = if replace_all
|
111
|
-
content.gsub(old_string, new_string)
|
112
|
-
else
|
113
|
-
content.sub(old_string, new_string)
|
114
|
-
end
|
115
|
-
|
116
|
-
# Get existing entry metadata
|
117
|
-
entries = scratchpad.list
|
118
|
-
existing_entry = entries.find { |e| e[:path] == file_path }
|
119
|
-
|
120
|
-
# Write updated content back (preserving the title)
|
121
|
-
scratchpad.write(
|
122
|
-
file_path: file_path,
|
123
|
-
content: new_content,
|
124
|
-
title: existing_entry[:title],
|
125
|
-
)
|
126
|
-
|
127
|
-
# Build success message
|
128
|
-
replaced_count = replace_all ? occurrences : 1
|
129
|
-
"Successfully replaced #{replaced_count} occurrence(s) in scratchpad://#{file_path}"
|
130
|
-
rescue ArgumentError => e
|
131
|
-
validation_error(e.message)
|
132
|
-
end
|
133
|
-
|
134
|
-
private
|
135
|
-
|
136
|
-
attr_reader :scratchpad
|
137
|
-
|
138
|
-
def validation_error(message)
|
139
|
-
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
140
|
-
end
|
141
|
-
end
|
142
|
-
end
|
143
|
-
end
|
@@ -1,92 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module SwarmSDK
|
4
|
-
module Tools
|
5
|
-
# Tool for searching scratchpad entries by glob pattern
|
6
|
-
#
|
7
|
-
# Finds scratchpad entries matching a glob pattern (like filesystem glob).
|
8
|
-
# All agents in the swarm share the same scratchpad.
|
9
|
-
class ScratchpadGlob < RubyLLM::Tool
|
10
|
-
define_method(:name) { "ScratchpadGlob" }
|
11
|
-
|
12
|
-
description <<~DESC
|
13
|
-
Search scratchpad entries by glob pattern.
|
14
|
-
Works like filesystem glob - use * for wildcards, ** for recursive matching.
|
15
|
-
Use this to discover entries matching specific patterns.
|
16
|
-
|
17
|
-
Examples:
|
18
|
-
- "parallel/*" - all entries directly under parallel/
|
19
|
-
- "parallel/**" - all entries under parallel/ (recursive)
|
20
|
-
- "*/report" - all entries named "report" in any top-level directory
|
21
|
-
- "analysis/*/result_*" - entries like "analysis/foo/result_1"
|
22
|
-
DESC
|
23
|
-
|
24
|
-
param :pattern,
|
25
|
-
desc: "Glob pattern to match (e.g., '**/*.txt', 'parallel/*/task_*')",
|
26
|
-
required: true
|
27
|
-
|
28
|
-
class << self
|
29
|
-
# Create a ScratchpadGlob tool for a specific scratchpad instance
|
30
|
-
#
|
31
|
-
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
32
|
-
# @return [ScratchpadGlob] Tool instance
|
33
|
-
def create_for_scratchpad(scratchpad)
|
34
|
-
new(scratchpad)
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
# Initialize with scratchpad instance
|
39
|
-
#
|
40
|
-
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
41
|
-
def initialize(scratchpad)
|
42
|
-
super() # Call RubyLLM::Tool's initialize
|
43
|
-
@scratchpad = scratchpad
|
44
|
-
end
|
45
|
-
|
46
|
-
# Execute the tool
|
47
|
-
#
|
48
|
-
# @param pattern [String] Glob pattern to match
|
49
|
-
# @return [String] Formatted list of matching entries
|
50
|
-
def execute(pattern:)
|
51
|
-
entries = scratchpad.glob(pattern: pattern)
|
52
|
-
|
53
|
-
if entries.empty?
|
54
|
-
return "No entries found matching pattern '#{pattern}'"
|
55
|
-
end
|
56
|
-
|
57
|
-
result = []
|
58
|
-
result << "Scratchpad entries matching '#{pattern}' (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
|
59
|
-
|
60
|
-
entries.each do |entry|
|
61
|
-
result << " scratchpad://#{entry[:path]} - \"#{entry[:title]}\" (#{format_bytes(entry[:size])})"
|
62
|
-
end
|
63
|
-
|
64
|
-
result.join("\n")
|
65
|
-
rescue ArgumentError => e
|
66
|
-
validation_error(e.message)
|
67
|
-
end
|
68
|
-
|
69
|
-
private
|
70
|
-
|
71
|
-
attr_reader :scratchpad
|
72
|
-
|
73
|
-
def validation_error(message)
|
74
|
-
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
75
|
-
end
|
76
|
-
|
77
|
-
# Format bytes to human-readable size
|
78
|
-
#
|
79
|
-
# @param bytes [Integer] Number of bytes
|
80
|
-
# @return [String] Formatted size
|
81
|
-
def format_bytes(bytes)
|
82
|
-
if bytes >= 1_000_000
|
83
|
-
"#{(bytes.to_f / 1_000_000).round(1)}MB"
|
84
|
-
elsif bytes >= 1_000
|
85
|
-
"#{(bytes.to_f / 1_000).round(1)}KB"
|
86
|
-
else
|
87
|
-
"#{bytes}B"
|
88
|
-
end
|
89
|
-
end
|
90
|
-
end
|
91
|
-
end
|
92
|
-
end
|