swarm_sdk 2.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2cedd1197d0fea1e52b40531ecb6d6f030524436c3164c109bce37da1db883b
4
- data.tar.gz: ac77a97bd54374a9049de888e0dfc4a1b1c99d5a4fdc2c01fabe20e54a7cd83b
3
+ metadata.gz: 2d1058470f6d37e95003807ae0ea2201aa587c8026417a5d8a793cbd59b80b46
4
+ data.tar.gz: 266b8559bf752f72a6db3a8b47fae0890d6aa34f3edf7d66a61e6a4cb9c28d6c
5
5
  SHA512:
6
- metadata.gz: 9d1dfdd64d1247e15a3fea9ce56b0d28657e85a5f9d706d1a2ce9c72700226cf443dcdc691596710d2e948816a3fb5d93fe1520f3bc798df78a2b6a91e9a2085
7
- data.tar.gz: b54ae2918f3cb04eb2a49a80faecc2aac1a878cd14828cc3287705eae22ba4da09780c8e38a54c5d048cf4eb1fec75e523dc609192758f6ad60294467d68df01
6
+ metadata.gz: cb6a9ccd6fa58cf55c062201f23391ebe10476894d5fc7f4838fbf90ade073d9e438257c0a007e20a636bba35764de8c434e381fdbc9114354e76c9d87252239
7
+ data.tar.gz: f281689946db6a04ab879f23fc922d7d06d3719a68f515e316b3497cc0ce0e075428623518e6f3ba9cee235f5b0389e2360406c49479a1b9e7c7a22ec533fcec
@@ -44,7 +44,7 @@ module SwarmSDK
44
44
  @headers = {}
45
45
  @timeout = nil
46
46
  @mcp_servers = []
47
- @include_default_tools = true
47
+ @disable_default_tools = nil # nil = include all default tools
48
48
  @bypass_permissions = false
49
49
  @coding_agent = nil # nil = not set (will default to false in Definition)
50
50
  @assume_model_exists = nil
@@ -124,9 +124,19 @@ module SwarmSDK
124
124
  @mcp_servers << server_config
125
125
  end
126
126
 
127
- # Set include_default_tools flag (deprecated - use tools method with include_default parameter)
128
- def include_default_tools(enabled)
129
- @include_default_tools = enabled
127
+ # Disable default tools
128
+ #
129
+ # @param value [Boolean, Array<Symbol>]
130
+ # - true: Disable ALL default tools
131
+ # - Array of symbols: Disable specific tools (e.g., [:Think, :TodoWrite])
132
+ #
133
+ # @example Disable all default tools
134
+ # disable_default_tools true
135
+ #
136
+ # @example Disable specific tools
137
+ # disable_default_tools [:Think, :TodoWrite]
138
+ def disable_default_tools(value)
139
+ @disable_default_tools = value
130
140
  end
131
141
 
132
142
  # Set bypass_permissions flag
@@ -188,7 +198,8 @@ module SwarmSDK
188
198
  def tools(*tool_names, include_default: true, replace: false)
189
199
  @tools = Set.new if replace
190
200
  @tools.merge(tool_names.map(&:to_sym))
191
- @include_default_tools = include_default
201
+ # When include_default is false, disable all default tools
202
+ @disable_default_tools = true unless include_default
192
203
  end
193
204
 
194
205
  # Add tools from all_agents configuration
@@ -342,7 +353,7 @@ module SwarmSDK
342
353
  agent_config[:headers] = @headers if @headers.any?
343
354
  agent_config[:timeout] = @timeout if @timeout
344
355
  agent_config[:mcp_servers] = @mcp_servers if @mcp_servers.any?
345
- agent_config[:include_default_tools] = @include_default_tools
356
+ agent_config[:disable_default_tools] = @disable_default_tools unless @disable_default_tools.nil?
346
357
  agent_config[:bypass_permissions] = @bypass_permissions
347
358
  agent_config[:coding_agent] = @coding_agent
348
359
  agent_config[:assume_model_exists] = @assume_model_exists unless @assume_model_exists.nil?
@@ -38,7 +38,7 @@ module SwarmSDK
38
38
  :parameters,
39
39
  :headers,
40
40
  :timeout,
41
- :include_default_tools,
41
+ :disable_default_tools,
42
42
  :coding_agent,
43
43
  :default_permissions,
44
44
  :agent_permissions,
@@ -77,8 +77,11 @@ module SwarmSDK
77
77
  # This prevents RubyLLM from trying to validate models in its registry
78
78
  @assume_model_exists = true
79
79
 
80
- # include_default_tools defaults to true if not specified
81
- @include_default_tools = config.key?(:include_default_tools) ? config[:include_default_tools] : true
80
+ # disable_default_tools can be:
81
+ # - nil/not set: include all default tools (default behavior)
82
+ # - true: disable ALL default tools
83
+ # - Array of symbols: disable specific tools (e.g., [:Think, :TodoWrite])
84
+ @disable_default_tools = config[:disable_default_tools]
82
85
 
83
86
  # coding_agent defaults to false if not specified
84
87
  # When true, includes the base system prompt for coding tasks
@@ -117,7 +120,7 @@ module SwarmSDK
117
120
  {
118
121
  name: @name,
119
122
  description: @description,
120
- model: @model,
123
+ model: SwarmSDK::Models.resolve_alias(@model), # Resolve model aliases
121
124
  directory: @directory,
122
125
  tools: @tools,
123
126
  delegates_to: @delegates_to,
@@ -130,7 +133,7 @@ module SwarmSDK
130
133
  headers: @headers,
131
134
  timeout: @timeout,
132
135
  bypass_permissions: @bypass_permissions,
133
- include_default_tools: @include_default_tools,
136
+ disable_default_tools: @disable_default_tools,
134
137
  coding_agent: @coding_agent,
135
138
  assume_model_exists: @assume_model_exists,
136
139
  max_concurrent_tools: @max_concurrent_tools,
@@ -224,7 +227,7 @@ module SwarmSDK
224
227
  else
225
228
  rendered_base
226
229
  end
227
- elsif @include_default_tools
230
+ elsif default_tools_enabled?
228
231
  # Non-coding agent: optionally include TODO/Scratchpad sections if default tools available
229
232
  non_coding_base = render_non_coding_base_prompt
230
233
 
@@ -242,6 +245,13 @@ module SwarmSDK
242
245
  end
243
246
  end
244
247
 
248
+ # Check if default tools are enabled (i.e., not disabled)
249
+ #
250
+ # @return [Boolean] True if default tools should be included
251
+ def default_tools_enabled?
252
+ @disable_default_tools != true
253
+ end
254
+
245
255
  def render_base_system_prompt
246
256
  cwd = @directory || Dir.pwd
247
257
  platform = RUBY_PLATFORM
@@ -116,6 +116,11 @@ module SwarmSDK
116
116
  return unless @config[:swarm]
117
117
 
118
118
  @all_agents_config = @config[:swarm][:all_agents] || {}
119
+
120
+ # Convert disable_default_tools array elements to symbols
121
+ if @all_agents_config[:disable_default_tools].is_a?(Array)
122
+ @all_agents_config[:disable_default_tools] = @all_agents_config[:disable_default_tools].map(&:to_sym)
123
+ end
119
124
  end
120
125
 
121
126
  def load_hooks_config
@@ -212,6 +217,9 @@ module SwarmSDK
212
217
  # Merge headers: all_agents.headers + agent.headers
213
218
  # Agent values override all_agents values for same keys
214
219
  merged[:headers] = (merged[:headers] || {}).merge(value || {})
220
+ when :disable_default_tools
221
+ # Convert array elements to symbols if it's an array
222
+ merged[key] = value.is_a?(Array) ? value.map(&:to_sym) : value
215
223
  else
216
224
  # For everything else (model, provider, base_url, timeout, coding_agent, etc.),
217
225
  # agent value overrides all_agents value
@@ -278,7 +278,7 @@ module SwarmSDK
278
278
  # Don't apply assume_model_exists from markdown - let DSL overrides or auto-enable handle it
279
279
  # builder.assume_model_exists(config[:assume_model_exists]) unless config[:assume_model_exists].nil?
280
280
  builder.bypass_permissions(config[:bypass_permissions]) if config[:bypass_permissions]
281
- builder.include_default_tools(config[:include_default_tools]) unless config[:include_default_tools].nil?
281
+ builder.disable_default_tools(config[:disable_default_tools]) unless config[:disable_default_tools].nil?
282
282
 
283
283
  # Add tools from markdown
284
284
  if config[:tools]&.any?
@@ -12,7 +12,7 @@ module SwarmSDK
12
12
  #
13
13
  # This encapsulates all tool-related logic that was previously in Swarm.
14
14
  class ToolConfigurator
15
- # Default tools available to all agents (unless include_default_tools: false)
15
+ # Default tools available to all agents (unless disable_default_tools is set)
16
16
  DEFAULT_TOOLS = [
17
17
  :Read,
18
18
  :Grep,
@@ -20,7 +20,9 @@ module SwarmSDK
20
20
  :TodoWrite,
21
21
  :ScratchpadWrite,
22
22
  :ScratchpadRead,
23
- :ScratchpadList,
23
+ :ScratchpadGlob,
24
+ :ScratchpadGrep,
25
+ :Think,
24
26
  ].freeze
25
27
 
26
28
  def initialize(swarm, scratchpad)
@@ -72,9 +74,17 @@ module SwarmSDK
72
74
  when :ScratchpadWrite
73
75
  Tools::ScratchpadWrite.create_for_scratchpad(@scratchpad)
74
76
  when :ScratchpadRead
75
- Tools::ScratchpadRead.create_for_scratchpad(@scratchpad)
76
- when :ScratchpadList
77
- Tools::ScratchpadList.create_for_scratchpad(@scratchpad)
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)
86
+ when :Think
87
+ Tools::Think.new
78
88
  else
79
89
  # Regular tools - get class from registry and instantiate
80
90
  tool_class = Tools::Registry.get(tool_name_sym)
@@ -133,13 +143,14 @@ module SwarmSDK
133
143
  end
134
144
  end
135
145
 
136
- # Register default tools for agents that have include_default_tools enabled
146
+ # Register default tools for agents (unless disabled)
137
147
  #
138
148
  # @param chat [AgentChat] The chat instance
139
149
  # @param agent_name [Symbol] Agent name
140
150
  # @param agent_definition [AgentDefinition] Agent definition
141
151
  def register_default_tools(chat, agent_name:, agent_definition:)
142
- return unless agent_definition.include_default_tools
152
+ # If disable_default_tools is true, skip all default tools
153
+ return if agent_definition.disable_default_tools == true
143
154
 
144
155
  # Get explicit tool names to avoid duplicates
145
156
  explicit_tool_names = agent_definition.tools.map { |t| t[:name] }.to_set
@@ -148,6 +159,9 @@ module SwarmSDK
148
159
  # Skip if already registered explicitly
149
160
  next if explicit_tool_names.include?(tool_name)
150
161
 
162
+ # Skip if tool is in the disable list
163
+ next if tool_disabled?(tool_name, agent_definition.disable_default_tools)
164
+
151
165
  tool_instance = create_tool_instance(tool_name, agent_name, agent_definition.directory)
152
166
 
153
167
  # Resolve permissions for default tool (same logic as AgentDefinition)
@@ -166,6 +180,25 @@ module SwarmSDK
166
180
  end
167
181
  end
168
182
 
183
+ # Check if a tool should be disabled based on disable_default_tools config
184
+ #
185
+ # @param tool_name [Symbol] Tool name to check
186
+ # @param disable_config [nil, Boolean, Array<Symbol>] Disable configuration
187
+ # @return [Boolean] True if tool should be disabled
188
+ def tool_disabled?(tool_name, disable_config)
189
+ return false if disable_config.nil?
190
+
191
+ if disable_config == true
192
+ # Disable all default tools
193
+ true
194
+ elsif disable_config.is_a?(Array)
195
+ # Disable only tools in the array
196
+ disable_config.include?(tool_name)
197
+ else
198
+ false
199
+ end
200
+ end
201
+
169
202
  # Register agent delegation tools
170
203
  #
171
204
  # Creates delegation tools that allow one agent to call another.
@@ -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
- def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY)
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
- @scratchpad = Tools::Stores::Scratchpad.new
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,9 @@ 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
- ScratchpadList: :special, # Requires scratchpad instance
22
+ ScratchpadGlob: :special, # Requires scratchpad instance
23
+ ScratchpadGrep: :special, # Requires scratchpad instance
24
+ Think: SwarmSDK::Tools::Think,
23
25
  }.freeze
24
26
 
25
27
  class << self
@@ -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 listing scratchpad entries with metadata
5
+ # Tool for searching scratchpad entries by glob pattern
6
6
  #
7
- # Lists available scratchpad entries with titles and sizes.
8
- # Supports filtering by path prefix.
9
- class ScratchpadList < RubyLLM::Tool
10
- define_method(:name) { "ScratchpadList" }
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
- List available scratchpad entries with titles and metadata.
14
- Use this to discover what content is available in scratchpad memory.
15
- Optionally filter by path prefix.
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 :prefix,
19
- desc: "Filter by path prefix (e.g., 'parallel/', 'analysis/'). Leave empty to list all entries.",
20
- required: false
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 ScratchpadList tool for a specific scratchpad instance
29
+ # Create a ScratchpadGlob tool for a specific scratchpad instance
24
30
  #
25
31
  # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
26
- # @return [ScratchpadList] Tool instance
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 prefix [String, nil] Optional path prefix filter
43
- # @return [String] Formatted list of entries
44
- def execute(prefix: nil)
45
- entries = scratchpad.list(prefix: prefix)
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 "Scratchpad is empty" if prefix.nil? || prefix.empty?
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 contents (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
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