swarm_sdk 2.0.3 → 2.0.4
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/swarm/tool_configurator.rb +11 -4
- data/lib/swarm_sdk/swarm.rb +7 -2
- data/lib/swarm_sdk/tools/registry.rb +2 -1
- data/lib/swarm_sdk/tools/scratchpad_edit.rb +143 -0
- data/lib/swarm_sdk/tools/{scratchpad_list.rb → scratchpad_glob.rb} +25 -21
- data/lib/swarm_sdk/tools/scratchpad_grep.rb +145 -0
- data/lib/swarm_sdk/tools/scratchpad_multi_edit.rb +226 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +27 -6
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +196 -33
- data/lib/swarm_sdk/tools/stores/scratchpad_read_tracker.rb +61 -0
- data/lib/swarm_sdk/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2d1058470f6d37e95003807ae0ea2201aa587c8026417a5d8a793cbd59b80b46
|
4
|
+
data.tar.gz: 266b8559bf752f72a6db3a8b47fae0890d6aa34f3edf7d66a61e6a4cb9c28d6c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cb6a9ccd6fa58cf55c062201f23391ebe10476894d5fc7f4838fbf90ade073d9e438257c0a007e20a636bba35764de8c434e381fdbc9114354e76c9d87252239
|
7
|
+
data.tar.gz: f281689946db6a04ab879f23fc922d7d06d3719a68f515e316b3497cc0ce0e075428623518e6f3ba9cee235f5b0389e2360406c49479a1b9e7c7a22ec533fcec
|
@@ -20,7 +20,8 @@ module SwarmSDK
|
|
20
20
|
:TodoWrite,
|
21
21
|
:ScratchpadWrite,
|
22
22
|
:ScratchpadRead,
|
23
|
-
:
|
23
|
+
:ScratchpadGlob,
|
24
|
+
:ScratchpadGrep,
|
24
25
|
:Think,
|
25
26
|
].freeze
|
26
27
|
|
@@ -73,9 +74,15 @@ module SwarmSDK
|
|
73
74
|
when :ScratchpadWrite
|
74
75
|
Tools::ScratchpadWrite.create_for_scratchpad(@scratchpad)
|
75
76
|
when :ScratchpadRead
|
76
|
-
Tools::ScratchpadRead.create_for_scratchpad(@scratchpad)
|
77
|
-
when :
|
78
|
-
Tools::
|
77
|
+
Tools::ScratchpadRead.create_for_scratchpad(@scratchpad, agent_name)
|
78
|
+
when :ScratchpadEdit
|
79
|
+
Tools::ScratchpadEdit.create_for_scratchpad(@scratchpad, agent_name)
|
80
|
+
when :ScratchpadMultiEdit
|
81
|
+
Tools::ScratchpadMultiEdit.create_for_scratchpad(@scratchpad, agent_name)
|
82
|
+
when :ScratchpadGlob
|
83
|
+
Tools::ScratchpadGlob.create_for_scratchpad(@scratchpad)
|
84
|
+
when :ScratchpadGrep
|
85
|
+
Tools::ScratchpadGrep.create_for_scratchpad(@scratchpad)
|
79
86
|
when :Think
|
80
87
|
Tools::Think.new
|
81
88
|
else
|
data/lib/swarm_sdk/swarm.rb
CHANGED
@@ -129,7 +129,8 @@ module SwarmSDK
|
|
129
129
|
# @param name [String] Human-readable swarm name
|
130
130
|
# @param global_concurrency [Integer] Max concurrent LLM calls across entire swarm
|
131
131
|
# @param default_local_concurrency [Integer] Default max concurrent tool calls per agent
|
132
|
-
|
132
|
+
# @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing)
|
133
|
+
def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY, scratchpad: nil)
|
133
134
|
@name = name
|
134
135
|
@global_concurrency = global_concurrency
|
135
136
|
@default_local_concurrency = default_local_concurrency
|
@@ -138,7 +139,11 @@ module SwarmSDK
|
|
138
139
|
@global_semaphore = Async::Semaphore.new(@global_concurrency)
|
139
140
|
|
140
141
|
# Shared scratchpad for all agents
|
141
|
-
|
142
|
+
# Use provided scratchpad (for testing) or create persistent one
|
143
|
+
@scratchpad = scratchpad || begin
|
144
|
+
scratchpad_path = File.join(Dir.pwd, ".swarm", "scratchpad.json")
|
145
|
+
Tools::Stores::Scratchpad.new(persist_to: scratchpad_path)
|
146
|
+
end
|
142
147
|
|
143
148
|
# Hook registry for named hooks and swarm defaults
|
144
149
|
@hook_registry = Hooks::Registry.new
|
@@ -19,7 +19,8 @@ module SwarmSDK
|
|
19
19
|
TodoWrite: :special, # Requires agent context for todo tracking
|
20
20
|
ScratchpadWrite: :special, # Requires scratchpad instance
|
21
21
|
ScratchpadRead: :special, # Requires scratchpad instance
|
22
|
-
|
22
|
+
ScratchpadGlob: :special, # Requires scratchpad instance
|
23
|
+
ScratchpadGrep: :special, # Requires scratchpad instance
|
23
24
|
Think: SwarmSDK::Tools::Think,
|
24
25
|
}.freeze
|
25
26
|
|
@@ -0,0 +1,143 @@
|
|
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
|
@@ -2,28 +2,34 @@
|
|
2
2
|
|
3
3
|
module SwarmSDK
|
4
4
|
module Tools
|
5
|
-
# Tool for
|
5
|
+
# Tool for searching scratchpad entries by glob pattern
|
6
6
|
#
|
7
|
-
#
|
8
|
-
#
|
9
|
-
class
|
10
|
-
define_method(:name) { "
|
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
11
|
|
12
12
|
description <<~DESC
|
13
|
-
|
14
|
-
|
15
|
-
|
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"
|
16
22
|
DESC
|
17
23
|
|
18
|
-
param :
|
19
|
-
desc: "
|
20
|
-
required:
|
24
|
+
param :pattern,
|
25
|
+
desc: "Glob pattern to match (e.g., '**/*.txt', 'parallel/*/task_*')",
|
26
|
+
required: true
|
21
27
|
|
22
28
|
class << self
|
23
|
-
# Create a
|
29
|
+
# Create a ScratchpadGlob tool for a specific scratchpad instance
|
24
30
|
#
|
25
31
|
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
26
|
-
# @return [
|
32
|
+
# @return [ScratchpadGlob] Tool instance
|
27
33
|
def create_for_scratchpad(scratchpad)
|
28
34
|
new(scratchpad)
|
29
35
|
end
|
@@ -39,19 +45,17 @@ module SwarmSDK
|
|
39
45
|
|
40
46
|
# Execute the tool
|
41
47
|
#
|
42
|
-
# @param
|
43
|
-
# @return [String] Formatted list of entries
|
44
|
-
def execute(
|
45
|
-
entries = scratchpad.
|
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)
|
46
52
|
|
47
53
|
if entries.empty?
|
48
|
-
return "
|
49
|
-
|
50
|
-
return "No entries found with prefix '#{prefix}'"
|
54
|
+
return "No entries found matching pattern '#{pattern}'"
|
51
55
|
end
|
52
56
|
|
53
57
|
result = []
|
54
|
-
result << "Scratchpad
|
58
|
+
result << "Scratchpad entries matching '#{pattern}' (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
|
55
59
|
|
56
60
|
entries.each do |entry|
|
57
61
|
result << " scratchpad://#{entry[:path]} - \"#{entry[:title]}\" (#{format_bytes(entry[:size])})"
|
@@ -0,0 +1,145 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Tool for searching scratchpad content by pattern
|
6
|
+
#
|
7
|
+
# Searches content stored in scratchpad entries using regex patterns.
|
8
|
+
# All agents in the swarm share the same scratchpad.
|
9
|
+
class ScratchpadGrep < RubyLLM::Tool
|
10
|
+
define_method(:name) { "ScratchpadGrep" }
|
11
|
+
|
12
|
+
description <<~DESC
|
13
|
+
Search scratchpad content by pattern (like grep).
|
14
|
+
Use regex patterns to search content within scratchpad entries.
|
15
|
+
Returns matching entries and optionally line numbers and content.
|
16
|
+
|
17
|
+
Output modes:
|
18
|
+
- files_with_matches: Only list paths containing matches (default)
|
19
|
+
- content: Show matching lines with line numbers
|
20
|
+
- count: Show number of matches per file
|
21
|
+
DESC
|
22
|
+
|
23
|
+
param :pattern,
|
24
|
+
desc: "Regular expression pattern to search for",
|
25
|
+
required: true
|
26
|
+
|
27
|
+
param :case_insensitive,
|
28
|
+
desc: "Perform case-insensitive search (default: false)",
|
29
|
+
required: false
|
30
|
+
|
31
|
+
param :output_mode,
|
32
|
+
desc: "Output mode: 'files_with_matches' (default), 'content', or 'count'",
|
33
|
+
required: false
|
34
|
+
|
35
|
+
class << self
|
36
|
+
# Create a ScratchpadGrep tool for a specific scratchpad instance
|
37
|
+
#
|
38
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
39
|
+
# @return [ScratchpadGrep] Tool instance
|
40
|
+
def create_for_scratchpad(scratchpad)
|
41
|
+
new(scratchpad)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Initialize with scratchpad instance
|
46
|
+
#
|
47
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
48
|
+
def initialize(scratchpad)
|
49
|
+
super() # Call RubyLLM::Tool's initialize
|
50
|
+
@scratchpad = scratchpad
|
51
|
+
end
|
52
|
+
|
53
|
+
# Execute the tool
|
54
|
+
#
|
55
|
+
# @param pattern [String] Regex pattern to search for
|
56
|
+
# @param case_insensitive [Boolean] Whether to perform case-insensitive search
|
57
|
+
# @param output_mode [String] Output mode
|
58
|
+
# @return [String] Formatted search results
|
59
|
+
def execute(pattern:, case_insensitive: false, output_mode: "files_with_matches")
|
60
|
+
results = scratchpad.grep(
|
61
|
+
pattern: pattern,
|
62
|
+
case_insensitive: case_insensitive,
|
63
|
+
output_mode: output_mode,
|
64
|
+
)
|
65
|
+
|
66
|
+
format_results(results, pattern, output_mode)
|
67
|
+
rescue ArgumentError => e
|
68
|
+
validation_error(e.message)
|
69
|
+
rescue RegexpError => e
|
70
|
+
validation_error("Invalid regex pattern: #{e.message}")
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
attr_reader :scratchpad
|
76
|
+
|
77
|
+
def validation_error(message)
|
78
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
79
|
+
end
|
80
|
+
|
81
|
+
def format_results(results, pattern, output_mode)
|
82
|
+
case output_mode
|
83
|
+
when "files_with_matches"
|
84
|
+
format_files_with_matches(results, pattern)
|
85
|
+
when "content"
|
86
|
+
format_content(results, pattern)
|
87
|
+
when "count"
|
88
|
+
format_count(results, pattern)
|
89
|
+
else
|
90
|
+
validation_error("Invalid output_mode: #{output_mode}")
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def format_files_with_matches(paths, pattern)
|
95
|
+
if paths.empty?
|
96
|
+
return "No matches found for pattern '#{pattern}'"
|
97
|
+
end
|
98
|
+
|
99
|
+
result = []
|
100
|
+
result << "Scratchpad entries matching '#{pattern}' (#{paths.size} #{paths.size == 1 ? "entry" : "entries"}):"
|
101
|
+
paths.each do |path|
|
102
|
+
result << " scratchpad://#{path}"
|
103
|
+
end
|
104
|
+
result.join("\n")
|
105
|
+
end
|
106
|
+
|
107
|
+
def format_content(results, pattern)
|
108
|
+
if results.empty?
|
109
|
+
return "No matches found for pattern '#{pattern}'"
|
110
|
+
end
|
111
|
+
|
112
|
+
total_matches = results.sum { |r| r[:matches].size }
|
113
|
+
output = []
|
114
|
+
output << "Scratchpad entries matching '#{pattern}' (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} #{total_matches == 1 ? "match" : "matches"}):"
|
115
|
+
output << ""
|
116
|
+
|
117
|
+
results.each do |result|
|
118
|
+
output << "scratchpad://#{result[:path]}:"
|
119
|
+
result[:matches].each do |match|
|
120
|
+
output << " #{match[:line_number]}: #{match[:content]}"
|
121
|
+
end
|
122
|
+
output << ""
|
123
|
+
end
|
124
|
+
|
125
|
+
output.join("\n").rstrip
|
126
|
+
end
|
127
|
+
|
128
|
+
def format_count(results, pattern)
|
129
|
+
if results.empty?
|
130
|
+
return "No matches found for pattern '#{pattern}'"
|
131
|
+
end
|
132
|
+
|
133
|
+
total_matches = results.sum { |r| r[:count] }
|
134
|
+
output = []
|
135
|
+
output << "Scratchpad entries matching '#{pattern}' (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} total #{total_matches == 1 ? "match" : "matches"}):"
|
136
|
+
|
137
|
+
results.each do |result|
|
138
|
+
output << " scratchpad://#{result[:path]}: #{result[:count]} #{result[:count] == 1 ? "match" : "matches"}"
|
139
|
+
end
|
140
|
+
|
141
|
+
output.join("\n")
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Tool for performing multiple edits to a scratchpad entry
|
6
|
+
#
|
7
|
+
# Applies multiple edit operations sequentially to a single scratchpad entry.
|
8
|
+
# Each edit sees the result of all previous edits, allowing for
|
9
|
+
# coordinated multi-step transformations.
|
10
|
+
# All agents in the swarm share the same scratchpad.
|
11
|
+
class ScratchpadMultiEdit < RubyLLM::Tool
|
12
|
+
define_method(:name) { "ScratchpadMultiEdit" }
|
13
|
+
|
14
|
+
description <<~DESC
|
15
|
+
Performs multiple exact string replacements in a single scratchpad entry.
|
16
|
+
Edits are applied sequentially, so later edits see the results of earlier ones.
|
17
|
+
You must use ScratchpadRead on the entry before editing it.
|
18
|
+
When editing text from ScratchpadRead output, ensure you preserve the exact indentation as it appears AFTER the line number prefix.
|
19
|
+
The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual content to match.
|
20
|
+
Never include any part of the line number prefix in the old_string or new_string.
|
21
|
+
Each 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.
|
22
|
+
Use replace_all for replacing and renaming strings across the entry.
|
23
|
+
DESC
|
24
|
+
|
25
|
+
param :file_path,
|
26
|
+
desc: "Path to the scratchpad entry (e.g., 'analysis/report', 'parallel/batch1/task_0')",
|
27
|
+
required: true
|
28
|
+
|
29
|
+
param :edits_json,
|
30
|
+
type: "string",
|
31
|
+
desc: <<~DESC.chomp,
|
32
|
+
JSON array of edit operations. Each edit must have:
|
33
|
+
old_string (exact text to replace),
|
34
|
+
new_string (replacement text),
|
35
|
+
and optionally replace_all (boolean, default false).
|
36
|
+
Example: [{"old_string":"foo","new_string":"bar","replace_all":false}]
|
37
|
+
DESC
|
38
|
+
required: true
|
39
|
+
|
40
|
+
class << self
|
41
|
+
# Create a ScratchpadMultiEdit tool for a specific scratchpad instance
|
42
|
+
#
|
43
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
44
|
+
# @param agent_name [Symbol, String] Agent identifier for tracking reads
|
45
|
+
# @return [ScratchpadMultiEdit] Tool instance
|
46
|
+
def create_for_scratchpad(scratchpad, agent_name)
|
47
|
+
new(scratchpad, agent_name)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Initialize with scratchpad instance and agent name
|
52
|
+
#
|
53
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
54
|
+
# @param agent_name [Symbol, String] Agent identifier
|
55
|
+
def initialize(scratchpad, agent_name)
|
56
|
+
super() # Call RubyLLM::Tool's initialize
|
57
|
+
@scratchpad = scratchpad
|
58
|
+
@agent_name = agent_name.to_sym
|
59
|
+
end
|
60
|
+
|
61
|
+
# Execute the tool
|
62
|
+
#
|
63
|
+
# @param file_path [String] Path to scratchpad entry
|
64
|
+
# @param edits_json [String] JSON array of edit operations
|
65
|
+
# @return [String] Success message or error
|
66
|
+
def execute(file_path:, edits_json:)
|
67
|
+
# Validate inputs
|
68
|
+
return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
|
69
|
+
|
70
|
+
# Parse JSON
|
71
|
+
edits = begin
|
72
|
+
JSON.parse(edits_json)
|
73
|
+
rescue JSON::ParserError
|
74
|
+
nil
|
75
|
+
end
|
76
|
+
|
77
|
+
return validation_error("Invalid JSON format. Please provide a valid JSON array of edit operations.") if edits.nil?
|
78
|
+
|
79
|
+
return validation_error("edits must be an array") unless edits.is_a?(Array)
|
80
|
+
return validation_error("edits array cannot be empty") if edits.empty?
|
81
|
+
|
82
|
+
# Read current content (this will raise ArgumentError if entry doesn't exist)
|
83
|
+
content = scratchpad.read(file_path: file_path)
|
84
|
+
|
85
|
+
# Enforce read-before-edit
|
86
|
+
unless Stores::ScratchpadReadTracker.entry_read?(@agent_name, file_path)
|
87
|
+
return validation_error(
|
88
|
+
"Cannot edit scratchpad entry without reading it first. " \
|
89
|
+
"You must use ScratchpadRead on 'scratchpad://#{file_path}' before editing it. " \
|
90
|
+
"This ensures you have the current content to match against.",
|
91
|
+
)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Validate edit operations
|
95
|
+
validated_edits = []
|
96
|
+
edits.each_with_index do |edit, index|
|
97
|
+
unless edit.is_a?(Hash)
|
98
|
+
return validation_error("Edit at index #{index} must be a hash/object with old_string and new_string")
|
99
|
+
end
|
100
|
+
|
101
|
+
# Convert string keys to symbols for consistency
|
102
|
+
edit = edit.transform_keys(&:to_sym)
|
103
|
+
|
104
|
+
unless edit[:old_string]
|
105
|
+
return validation_error("Edit at index #{index} missing required field 'old_string'")
|
106
|
+
end
|
107
|
+
|
108
|
+
unless edit[:new_string]
|
109
|
+
return validation_error("Edit at index #{index} missing required field 'new_string'")
|
110
|
+
end
|
111
|
+
|
112
|
+
# old_string and new_string must be different
|
113
|
+
if edit[:old_string] == edit[:new_string]
|
114
|
+
return validation_error("Edit at index #{index}: old_string and new_string must be different")
|
115
|
+
end
|
116
|
+
|
117
|
+
validated_edits << {
|
118
|
+
old_string: edit[:old_string].to_s,
|
119
|
+
new_string: edit[:new_string].to_s,
|
120
|
+
replace_all: edit[:replace_all] == true,
|
121
|
+
index: index,
|
122
|
+
}
|
123
|
+
end
|
124
|
+
|
125
|
+
# Apply edits sequentially
|
126
|
+
results = []
|
127
|
+
current_content = content
|
128
|
+
|
129
|
+
validated_edits.each do |edit|
|
130
|
+
# Check if old_string exists in current content
|
131
|
+
unless current_content.include?(edit[:old_string])
|
132
|
+
return error_with_results(
|
133
|
+
<<~ERROR.chomp,
|
134
|
+
Edit #{edit[:index]}: old_string not found in scratchpad entry.
|
135
|
+
Make sure it matches exactly, including all whitespace and indentation.
|
136
|
+
Do not include line number prefixes from ScratchpadRead tool output.
|
137
|
+
Note: This edit follows #{edit[:index]} previous edit(s) which may have changed the content.
|
138
|
+
ERROR
|
139
|
+
results,
|
140
|
+
)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Count occurrences
|
144
|
+
occurrences = current_content.scan(edit[:old_string]).count
|
145
|
+
|
146
|
+
# If not replace_all and multiple occurrences, error
|
147
|
+
if !edit[:replace_all] && occurrences > 1
|
148
|
+
return error_with_results(
|
149
|
+
<<~ERROR.chomp,
|
150
|
+
Edit #{edit[:index]}: Found #{occurrences} occurrences of old_string.
|
151
|
+
Either provide more surrounding context to make the match unique, or set replace_all: true to replace all occurrences.
|
152
|
+
ERROR
|
153
|
+
results,
|
154
|
+
)
|
155
|
+
end
|
156
|
+
|
157
|
+
# Perform replacement
|
158
|
+
new_content = if edit[:replace_all]
|
159
|
+
current_content.gsub(edit[:old_string], edit[:new_string])
|
160
|
+
else
|
161
|
+
current_content.sub(edit[:old_string], edit[:new_string])
|
162
|
+
end
|
163
|
+
|
164
|
+
# Record result
|
165
|
+
replaced_count = edit[:replace_all] ? occurrences : 1
|
166
|
+
results << {
|
167
|
+
index: edit[:index],
|
168
|
+
status: "success",
|
169
|
+
occurrences: replaced_count,
|
170
|
+
message: "Replaced #{replaced_count} occurrence(s)",
|
171
|
+
}
|
172
|
+
|
173
|
+
# Update content for next edit
|
174
|
+
current_content = new_content
|
175
|
+
end
|
176
|
+
|
177
|
+
# Get existing entry metadata
|
178
|
+
entries = scratchpad.list
|
179
|
+
existing_entry = entries.find { |e| e[:path] == file_path }
|
180
|
+
|
181
|
+
# Write updated content back (preserving the title)
|
182
|
+
scratchpad.write(
|
183
|
+
file_path: file_path,
|
184
|
+
content: current_content,
|
185
|
+
title: existing_entry[:title],
|
186
|
+
)
|
187
|
+
|
188
|
+
# Build success message
|
189
|
+
total_replacements = results.sum { |r| r[:occurrences] }
|
190
|
+
message = "Successfully applied #{validated_edits.size} edit(s) to scratchpad://#{file_path}\n"
|
191
|
+
message += "Total replacements: #{total_replacements}\n\n"
|
192
|
+
message += "Details:\n"
|
193
|
+
results.each do |result|
|
194
|
+
message += " Edit #{result[:index]}: #{result[:message]}\n"
|
195
|
+
end
|
196
|
+
|
197
|
+
message
|
198
|
+
rescue ArgumentError => e
|
199
|
+
validation_error(e.message)
|
200
|
+
end
|
201
|
+
|
202
|
+
private
|
203
|
+
|
204
|
+
attr_reader :scratchpad
|
205
|
+
|
206
|
+
def validation_error(message)
|
207
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
208
|
+
end
|
209
|
+
|
210
|
+
def error_with_results(message, results)
|
211
|
+
output = "<tool_use_error>InputValidationError: #{message}\n\n"
|
212
|
+
|
213
|
+
if results.any?
|
214
|
+
output += "Previous successful edits before error:\n"
|
215
|
+
results.each do |result|
|
216
|
+
output += " Edit #{result[:index]}: #{result[:message]}\n"
|
217
|
+
end
|
218
|
+
output += "\n"
|
219
|
+
end
|
220
|
+
|
221
|
+
output += "Note: The scratchpad entry has NOT been modified. All or nothing approach - if any edit fails, no changes are saved.</tool_use_error>"
|
222
|
+
output
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
@@ -23,26 +23,33 @@ module SwarmSDK
|
|
23
23
|
# Create a ScratchpadRead tool for a specific scratchpad instance
|
24
24
|
#
|
25
25
|
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
26
|
+
# @param agent_name [Symbol, String] Agent identifier for tracking reads
|
26
27
|
# @return [ScratchpadRead] Tool instance
|
27
|
-
def create_for_scratchpad(scratchpad)
|
28
|
-
new(scratchpad)
|
28
|
+
def create_for_scratchpad(scratchpad, agent_name)
|
29
|
+
new(scratchpad, agent_name)
|
29
30
|
end
|
30
31
|
end
|
31
32
|
|
32
|
-
# Initialize with scratchpad instance
|
33
|
+
# Initialize with scratchpad instance and agent name
|
33
34
|
#
|
34
35
|
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
35
|
-
|
36
|
+
# @param agent_name [Symbol, String] Agent identifier
|
37
|
+
def initialize(scratchpad, agent_name)
|
36
38
|
super() # Call RubyLLM::Tool's initialize
|
37
39
|
@scratchpad = scratchpad
|
40
|
+
@agent_name = agent_name.to_sym
|
38
41
|
end
|
39
42
|
|
40
43
|
# Execute the tool
|
41
44
|
#
|
42
45
|
# @param file_path [String] Path to read from
|
43
|
-
# @return [String] Content at the path or error message
|
46
|
+
# @return [String] Content at the path with line numbers, or error message
|
44
47
|
def execute(file_path:)
|
45
|
-
|
48
|
+
# Register this read in the tracker
|
49
|
+
Stores::ScratchpadReadTracker.register_read(@agent_name, file_path)
|
50
|
+
|
51
|
+
content = scratchpad.read(file_path: file_path)
|
52
|
+
format_with_line_numbers(content)
|
46
53
|
rescue ArgumentError => e
|
47
54
|
validation_error(e.message)
|
48
55
|
end
|
@@ -54,6 +61,20 @@ module SwarmSDK
|
|
54
61
|
def validation_error(message)
|
55
62
|
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
56
63
|
end
|
64
|
+
|
65
|
+
# Format content with line numbers (same format as Read tool)
|
66
|
+
#
|
67
|
+
# @param content [String] Content to format
|
68
|
+
# @return [String] Content with line numbers
|
69
|
+
def format_with_line_numbers(content)
|
70
|
+
lines = content.lines
|
71
|
+
output_lines = lines.each_with_index.map do |line, idx|
|
72
|
+
line_number = idx + 1
|
73
|
+
display_line = line.chomp
|
74
|
+
"#{line_number.to_s.rjust(6)}→#{display_line}"
|
75
|
+
end
|
76
|
+
output_lines.join("\n")
|
77
|
+
end
|
57
78
|
end
|
58
79
|
end
|
59
80
|
end
|
@@ -22,9 +22,17 @@ module SwarmSDK
|
|
22
22
|
# Represents a single scratchpad entry with metadata
|
23
23
|
Entry = Struct.new(:content, :title, :created_at, :size, keyword_init: true)
|
24
24
|
|
25
|
-
|
25
|
+
# Initialize scratchpad with optional persistence
|
26
|
+
#
|
27
|
+
# @param persist_to [String, nil] Path to JSON file for persistence (nil = no persistence)
|
28
|
+
def initialize(persist_to: nil)
|
26
29
|
@entries = {}
|
27
30
|
@total_size = 0
|
31
|
+
@persist_to = persist_to
|
32
|
+
@mutex = Mutex.new
|
33
|
+
|
34
|
+
# Load existing data if persistence is enabled
|
35
|
+
load_from_file if @persist_to && File.exist?(@persist_to)
|
28
36
|
end
|
29
37
|
|
30
38
|
# Write content to scratchpad
|
@@ -35,44 +43,49 @@ module SwarmSDK
|
|
35
43
|
# @raise [ArgumentError] If size limits are exceeded
|
36
44
|
# @return [Entry] The created entry
|
37
45
|
def write(file_path:, content:, title:)
|
38
|
-
|
39
|
-
|
40
|
-
|
46
|
+
@mutex.synchronize do
|
47
|
+
raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
|
48
|
+
raise ArgumentError, "content is required" if content.nil?
|
49
|
+
raise ArgumentError, "title is required" if title.nil? || title.to_s.strip.empty?
|
41
50
|
|
42
|
-
|
51
|
+
content_size = content.bytesize
|
43
52
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
53
|
+
# Check entry size limit
|
54
|
+
if content_size > MAX_ENTRY_SIZE
|
55
|
+
raise ArgumentError, "Content exceeds maximum size (#{format_bytes(MAX_ENTRY_SIZE)}). " \
|
56
|
+
"Current: #{format_bytes(content_size)}"
|
57
|
+
end
|
49
58
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
59
|
+
# Calculate new total size
|
60
|
+
existing_entry = @entries[file_path]
|
61
|
+
existing_size = existing_entry ? existing_entry.size : 0
|
62
|
+
new_total_size = @total_size - existing_size + content_size
|
63
|
+
|
64
|
+
# Check total size limit
|
65
|
+
if new_total_size > MAX_TOTAL_SIZE
|
66
|
+
raise ArgumentError, "Scratchpad full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
|
67
|
+
"Current: #{format_bytes(@total_size)}, " \
|
68
|
+
"Would be: #{format_bytes(new_total_size)}. " \
|
69
|
+
"Clear old entries or use smaller content."
|
70
|
+
end
|
71
|
+
|
72
|
+
# Create entry
|
73
|
+
entry = Entry.new(
|
74
|
+
content: content,
|
75
|
+
title: title,
|
76
|
+
created_at: Time.now,
|
77
|
+
size: content_size,
|
78
|
+
)
|
62
79
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
title: title,
|
67
|
-
created_at: Time.now,
|
68
|
-
size: content_size,
|
69
|
-
)
|
80
|
+
# Update storage
|
81
|
+
@entries[file_path] = entry
|
82
|
+
@total_size = new_total_size
|
70
83
|
|
71
|
-
|
72
|
-
|
73
|
-
@total_size = new_total_size
|
84
|
+
# Persist to file if enabled
|
85
|
+
save_to_file if @persist_to
|
74
86
|
|
75
|
-
|
87
|
+
entry
|
88
|
+
end
|
76
89
|
end
|
77
90
|
|
78
91
|
# Read content from scratchpad
|
@@ -112,6 +125,74 @@ module SwarmSDK
|
|
112
125
|
end.sort_by { |e| e[:path] }
|
113
126
|
end
|
114
127
|
|
128
|
+
# Search entries by glob pattern (like filesystem glob)
|
129
|
+
#
|
130
|
+
# @param pattern [String] Glob pattern (e.g., "**/*.txt", "parallel/*/task_*")
|
131
|
+
# @return [Array<Hash>] Array of matching entry metadata (path, title, size, created_at)
|
132
|
+
def glob(pattern:)
|
133
|
+
raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
|
134
|
+
|
135
|
+
# Convert glob pattern to regex
|
136
|
+
regex = glob_to_regex(pattern)
|
137
|
+
|
138
|
+
# Filter entries by pattern
|
139
|
+
matching_entries = @entries.select { |path, _| regex.match?(path) }
|
140
|
+
|
141
|
+
# Return metadata sorted by path
|
142
|
+
matching_entries.map do |path, entry|
|
143
|
+
{
|
144
|
+
path: path,
|
145
|
+
title: entry.title,
|
146
|
+
size: entry.size,
|
147
|
+
created_at: entry.created_at,
|
148
|
+
}
|
149
|
+
end.sort_by { |e| e[:path] }
|
150
|
+
end
|
151
|
+
|
152
|
+
# Search entry content by pattern (like grep)
|
153
|
+
#
|
154
|
+
# @param pattern [String] Regular expression pattern to search for
|
155
|
+
# @param case_insensitive [Boolean] Whether to perform case-insensitive search
|
156
|
+
# @param output_mode [String] Output mode: "files_with_matches" (default), "content", or "count"
|
157
|
+
# @return [Array<Hash>, String] Results based on output_mode
|
158
|
+
def grep(pattern:, case_insensitive: false, output_mode: "files_with_matches")
|
159
|
+
raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
|
160
|
+
|
161
|
+
# Create regex from pattern
|
162
|
+
flags = case_insensitive ? Regexp::IGNORECASE : 0
|
163
|
+
regex = Regexp.new(pattern, flags)
|
164
|
+
|
165
|
+
case output_mode
|
166
|
+
when "files_with_matches"
|
167
|
+
# Return just the paths that match
|
168
|
+
matching_paths = @entries.select { |_path, entry| regex.match?(entry.content) }
|
169
|
+
.map { |path, _| path }
|
170
|
+
.sort
|
171
|
+
matching_paths
|
172
|
+
when "content"
|
173
|
+
# Return paths with matching lines
|
174
|
+
results = []
|
175
|
+
@entries.each do |path, entry|
|
176
|
+
matching_lines = []
|
177
|
+
entry.content.each_line.with_index(1) do |line, line_num|
|
178
|
+
matching_lines << { line_number: line_num, content: line.chomp } if regex.match?(line)
|
179
|
+
end
|
180
|
+
results << { path: path, matches: matching_lines } unless matching_lines.empty?
|
181
|
+
end
|
182
|
+
results.sort_by { |r| r[:path] }
|
183
|
+
when "count"
|
184
|
+
# Return paths with match counts
|
185
|
+
results = []
|
186
|
+
@entries.each do |path, entry|
|
187
|
+
count = entry.content.scan(regex).size
|
188
|
+
results << { path: path, count: count } if count > 0
|
189
|
+
end
|
190
|
+
results.sort_by { |r| r[:path] }
|
191
|
+
else
|
192
|
+
raise ArgumentError, "Invalid output_mode: #{output_mode}. Must be 'files_with_matches', 'content', or 'count'"
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
115
196
|
# Clear all entries
|
116
197
|
#
|
117
198
|
# @return [void]
|
@@ -134,6 +215,26 @@ module SwarmSDK
|
|
134
215
|
|
135
216
|
private
|
136
217
|
|
218
|
+
# Convert glob pattern to regex
|
219
|
+
#
|
220
|
+
# @param pattern [String] Glob pattern
|
221
|
+
# @return [Regexp] Regular expression
|
222
|
+
def glob_to_regex(pattern)
|
223
|
+
# Escape special regex characters except glob wildcards
|
224
|
+
escaped = Regexp.escape(pattern)
|
225
|
+
|
226
|
+
# Convert glob wildcards to regex
|
227
|
+
# ** matches any number of directories (including zero)
|
228
|
+
escaped = escaped.gsub('\*\*', ".*")
|
229
|
+
# * matches anything except directory separator
|
230
|
+
escaped = escaped.gsub('\*', "[^/]*")
|
231
|
+
# ? matches single character except directory separator
|
232
|
+
escaped = escaped.gsub('\?', "[^/]")
|
233
|
+
|
234
|
+
# Anchor to start and end
|
235
|
+
Regexp.new("\\A#{escaped}\\z")
|
236
|
+
end
|
237
|
+
|
137
238
|
# Format bytes to human-readable size
|
138
239
|
#
|
139
240
|
# @param bytes [Integer] Number of bytes
|
@@ -147,6 +248,68 @@ module SwarmSDK
|
|
147
248
|
"#{bytes}B"
|
148
249
|
end
|
149
250
|
end
|
251
|
+
|
252
|
+
# Save scratchpad data to JSON file
|
253
|
+
#
|
254
|
+
# @return [void]
|
255
|
+
def save_to_file
|
256
|
+
return unless @persist_to
|
257
|
+
|
258
|
+
# Convert entries to serializable format
|
259
|
+
data = {
|
260
|
+
version: 1,
|
261
|
+
total_size: @total_size,
|
262
|
+
entries: @entries.transform_values do |entry|
|
263
|
+
{
|
264
|
+
content: entry.content,
|
265
|
+
title: entry.title,
|
266
|
+
created_at: entry.created_at.iso8601,
|
267
|
+
size: entry.size,
|
268
|
+
}
|
269
|
+
end,
|
270
|
+
}
|
271
|
+
|
272
|
+
# Ensure directory exists
|
273
|
+
dir = File.dirname(@persist_to)
|
274
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
275
|
+
|
276
|
+
# Write to file atomically (write to temp file, then rename)
|
277
|
+
temp_file = "#{@persist_to}.tmp"
|
278
|
+
File.write(temp_file, JSON.pretty_generate(data))
|
279
|
+
File.rename(temp_file, @persist_to)
|
280
|
+
end
|
281
|
+
|
282
|
+
# Load scratchpad data from JSON file
|
283
|
+
#
|
284
|
+
# @return [void]
|
285
|
+
def load_from_file
|
286
|
+
return unless @persist_to && File.exist?(@persist_to)
|
287
|
+
|
288
|
+
data = JSON.parse(File.read(@persist_to))
|
289
|
+
|
290
|
+
# Restore entries
|
291
|
+
@entries = data["entries"].transform_values do |entry_data|
|
292
|
+
Entry.new(
|
293
|
+
content: entry_data["content"],
|
294
|
+
title: entry_data["title"],
|
295
|
+
created_at: Time.parse(entry_data["created_at"]),
|
296
|
+
size: entry_data["size"],
|
297
|
+
)
|
298
|
+
end
|
299
|
+
|
300
|
+
# Restore total size
|
301
|
+
@total_size = data["total_size"]
|
302
|
+
rescue JSON::ParserError => e
|
303
|
+
# If file is corrupted, log warning and start fresh
|
304
|
+
warn("Warning: Failed to load scratchpad from #{@persist_to}: #{e.message}. Starting with empty scratchpad.")
|
305
|
+
@entries = {}
|
306
|
+
@total_size = 0
|
307
|
+
rescue StandardError => e
|
308
|
+
# If any other error occurs, log warning and start fresh
|
309
|
+
warn("Warning: Failed to load scratchpad from #{@persist_to}: #{e.message}. Starting with empty scratchpad.")
|
310
|
+
@entries = {}
|
311
|
+
@total_size = 0
|
312
|
+
end
|
150
313
|
end
|
151
314
|
end
|
152
315
|
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
module Stores
|
6
|
+
# ScratchpadReadTracker manages read-entry tracking for all agents
|
7
|
+
#
|
8
|
+
# This module maintains a global registry of which scratchpad entries each agent
|
9
|
+
# has read during their conversation. This enables enforcement of the
|
10
|
+
# "read-before-edit" rule that ensures agents have context before modifying entries.
|
11
|
+
#
|
12
|
+
# Each agent maintains an independent set of read entries, keyed by agent identifier.
|
13
|
+
module ScratchpadReadTracker
|
14
|
+
@read_entries = {}
|
15
|
+
@mutex = Mutex.new
|
16
|
+
|
17
|
+
class << self
|
18
|
+
# Register that an agent has read a scratchpad entry
|
19
|
+
#
|
20
|
+
# @param agent_id [Symbol] The agent identifier
|
21
|
+
# @param entry_path [String] The scratchpad entry path
|
22
|
+
def register_read(agent_id, entry_path)
|
23
|
+
@mutex.synchronize do
|
24
|
+
@read_entries[agent_id] ||= Set.new
|
25
|
+
@read_entries[agent_id] << entry_path
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Check if an agent has read a scratchpad entry
|
30
|
+
#
|
31
|
+
# @param agent_id [Symbol] The agent identifier
|
32
|
+
# @param entry_path [String] The scratchpad entry path
|
33
|
+
# @return [Boolean] true if the agent has read this entry
|
34
|
+
def entry_read?(agent_id, entry_path)
|
35
|
+
@mutex.synchronize do
|
36
|
+
return false unless @read_entries[agent_id]
|
37
|
+
|
38
|
+
@read_entries[agent_id].include?(entry_path)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Clear read history for an agent (useful for testing)
|
43
|
+
#
|
44
|
+
# @param agent_id [Symbol] The agent identifier
|
45
|
+
def clear(agent_id)
|
46
|
+
@mutex.synchronize do
|
47
|
+
@read_entries.delete(agent_id)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Clear all read history (useful for testing)
|
52
|
+
def clear_all
|
53
|
+
@mutex.synchronize do
|
54
|
+
@read_entries.clear
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
data/lib/swarm_sdk/version.rb
CHANGED
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.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Paulo Arruda
|
@@ -142,11 +142,15 @@ files:
|
|
142
142
|
- lib/swarm_sdk/tools/path_resolver.rb
|
143
143
|
- lib/swarm_sdk/tools/read.rb
|
144
144
|
- lib/swarm_sdk/tools/registry.rb
|
145
|
-
- lib/swarm_sdk/tools/
|
145
|
+
- lib/swarm_sdk/tools/scratchpad_edit.rb
|
146
|
+
- lib/swarm_sdk/tools/scratchpad_glob.rb
|
147
|
+
- lib/swarm_sdk/tools/scratchpad_grep.rb
|
148
|
+
- lib/swarm_sdk/tools/scratchpad_multi_edit.rb
|
146
149
|
- lib/swarm_sdk/tools/scratchpad_read.rb
|
147
150
|
- lib/swarm_sdk/tools/scratchpad_write.rb
|
148
151
|
- lib/swarm_sdk/tools/stores/read_tracker.rb
|
149
152
|
- lib/swarm_sdk/tools/stores/scratchpad.rb
|
153
|
+
- lib/swarm_sdk/tools/stores/scratchpad_read_tracker.rb
|
150
154
|
- lib/swarm_sdk/tools/stores/todo_manager.rb
|
151
155
|
- lib/swarm_sdk/tools/think.rb
|
152
156
|
- lib/swarm_sdk/tools/todo_write.rb
|