swarm_memory 2.1.2 → 2.1.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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/claude_mcp_server.rb +1 -0
  3. data/lib/claude_swarm/cli.rb +5 -18
  4. data/lib/claude_swarm/configuration.rb +30 -19
  5. data/lib/claude_swarm/mcp_generator.rb +5 -10
  6. data/lib/claude_swarm/openai/chat_completion.rb +4 -12
  7. data/lib/claude_swarm/openai/executor.rb +3 -1
  8. data/lib/claude_swarm/openai/responses.rb +13 -32
  9. data/lib/claude_swarm/version.rb +1 -1
  10. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  11. data/lib/swarm_cli/commands/run.rb +2 -2
  12. data/lib/swarm_cli/config_loader.rb +14 -14
  13. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  14. data/lib/swarm_cli/interactive_repl.rb +11 -5
  15. data/lib/swarm_cli/ui/icons.rb +0 -23
  16. data/lib/swarm_cli/version.rb +1 -1
  17. data/lib/swarm_memory/adapters/base.rb +4 -4
  18. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  19. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  20. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  21. data/lib/swarm_memory/integration/sdk_plugin.rb +98 -12
  22. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  23. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  24. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  25. data/lib/swarm_memory/version.rb +1 -1
  26. data/lib/swarm_memory.rb +6 -1
  27. data/lib/swarm_sdk/agent/builder.rb +91 -0
  28. data/lib/swarm_sdk/agent/chat.rb +540 -925
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
  30. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  31. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
  32. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  33. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  34. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  35. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  36. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
  37. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  38. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  39. data/lib/swarm_sdk/agent/context.rb +8 -4
  40. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  41. data/lib/swarm_sdk/agent/definition.rb +79 -174
  42. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
  43. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  44. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  45. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  46. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  47. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  48. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  49. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  50. data/lib/swarm_sdk/configuration.rb +100 -261
  51. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  52. data/lib/swarm_sdk/context_compactor.rb +6 -11
  53. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  54. data/lib/swarm_sdk/context_management/context.rb +328 -0
  55. data/lib/swarm_sdk/defaults.rb +196 -0
  56. data/lib/swarm_sdk/events_to_messages.rb +199 -0
  57. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  58. data/lib/swarm_sdk/log_collector.rb +192 -16
  59. data/lib/swarm_sdk/log_stream.rb +66 -8
  60. data/lib/swarm_sdk/model_aliases.json +4 -1
  61. data/lib/swarm_sdk/node_context.rb +1 -1
  62. data/lib/swarm_sdk/observer/builder.rb +81 -0
  63. data/lib/swarm_sdk/observer/config.rb +45 -0
  64. data/lib/swarm_sdk/observer/manager.rb +236 -0
  65. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  66. data/lib/swarm_sdk/plugin.rb +93 -3
  67. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  68. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  69. data/lib/swarm_sdk/restore_result.rb +65 -0
  70. data/lib/swarm_sdk/snapshot.rb +156 -0
  71. data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
  72. data/lib/swarm_sdk/state_restorer.rb +476 -0
  73. data/lib/swarm_sdk/state_snapshot.rb +334 -0
  74. data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
  75. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  76. data/lib/swarm_sdk/swarm/builder.rb +69 -407
  77. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  78. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  79. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  80. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  81. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  82. data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
  83. data/lib/swarm_sdk/swarm.rb +366 -631
  84. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  85. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  86. data/lib/swarm_sdk/tools/bash.rb +11 -3
  87. data/lib/swarm_sdk/tools/delegate.rb +127 -24
  88. data/lib/swarm_sdk/tools/edit.rb +8 -13
  89. data/lib/swarm_sdk/tools/glob.rb +9 -1
  90. data/lib/swarm_sdk/tools/grep.rb +7 -0
  91. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  92. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  93. data/lib/swarm_sdk/tools/read.rb +28 -18
  94. data/lib/swarm_sdk/tools/registry.rb +122 -10
  95. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  96. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  97. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  98. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  99. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
  100. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  101. data/lib/swarm_sdk/tools/think.rb +4 -1
  102. data/lib/swarm_sdk/tools/todo_write.rb +27 -8
  103. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  104. data/lib/swarm_sdk/tools/write.rb +8 -13
  105. data/lib/swarm_sdk/utils.rb +18 -0
  106. data/lib/swarm_sdk/validation_result.rb +33 -0
  107. data/lib/swarm_sdk/version.rb +1 -1
  108. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
  109. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  110. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  111. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
  112. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  113. data/lib/swarm_sdk/workflow.rb +554 -0
  114. data/lib/swarm_sdk.rb +393 -22
  115. metadata +51 -16
  116. data/lib/swarm_memory/chat_extension.rb +0 -34
  117. data/lib/swarm_sdk/node_orchestrator.rb +0 -591
  118. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
@@ -11,6 +11,13 @@ module SwarmSDK
11
11
  class MultiEdit < RubyLLM::Tool
12
12
  include PathResolver
13
13
 
14
+ # Factory pattern: declare what parameters this tool needs for instantiation
15
+ class << self
16
+ def creation_requirements
17
+ [:agent_name, :directory]
18
+ end
19
+ end
20
+
14
21
  description <<~DESC
15
22
  Performs multiple exact string replacements in a single file.
16
23
  Edits are applied sequentially, so later edits see the results of earlier ones.
@@ -53,8 +60,7 @@ module SwarmSDK
53
60
  # @param directory [String] Agent's working directory
54
61
  def initialize(agent_name:, directory:)
55
62
  super()
56
- @agent_name = agent_name.to_sym
57
- @directory = File.expand_path(directory)
63
+ initialize_agent_context(agent_name: agent_name, directory: directory)
58
64
  end
59
65
 
60
66
  # Override name to return simple "MultiEdit" instead of full class path
@@ -204,15 +210,13 @@ module SwarmSDK
204
210
 
205
211
  private
206
212
 
207
- # Helper methods
208
- def validation_error(message)
209
- "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
210
- end
211
-
212
- def error(message)
213
- "Error: #{message}"
214
- end
215
-
213
+ # Format an error that includes partial results
214
+ #
215
+ # Shows what edits succeeded before the error occurred.
216
+ #
217
+ # @param message [String] Error description
218
+ # @param results [Array<Hash>] Successful edit results before failure
219
+ # @return [String] Formatted error message with results summary
216
220
  def error_with_results(message, results)
217
221
  output = "<tool_use_error>InputValidationError: #{message}\n\n"
218
222
 
@@ -2,7 +2,12 @@
2
2
 
3
3
  module SwarmSDK
4
4
  module Tools
5
- # Shared path resolution logic for all file tools
5
+ # Shared path resolution and agent context logic for file tools
6
+ #
7
+ # This module provides:
8
+ # - Path resolution (relative to agent's working directory)
9
+ # - Agent context initialization (agent_name, directory expansion)
10
+ # - Standard error message formatting
6
11
  #
7
12
  # Tools resolve relative paths against the agent's directory.
8
13
  # Absolute paths are used as-is.
@@ -12,17 +17,41 @@ module SwarmSDK
12
17
  # include PathResolver
13
18
  #
14
19
  # def initialize(agent_name:, directory:)
15
- # @directory = File.expand_path(directory)
20
+ # super()
21
+ # initialize_agent_context(agent_name: agent_name, directory: directory)
16
22
  # end
17
23
  #
18
24
  # def execute(file_path:)
19
25
  # resolved_path = resolve_path(file_path)
20
26
  # File.read(resolved_path)
27
+ # rescue StandardError => e
28
+ # error("Failed to read: #{e.message}")
21
29
  # end
22
30
  # end
23
31
  module PathResolver
32
+ # Agent context attributes
33
+ # @return [Symbol] The agent identifier
34
+ attr_reader :agent_name
35
+
36
+ # @return [String] Absolute path to agent's working directory
37
+ attr_reader :directory
38
+
24
39
  private
25
40
 
41
+ # Initialize agent context for file tools
42
+ #
43
+ # Sets up the common agent context needed by file tools:
44
+ # - Normalizes agent_name to symbol
45
+ # - Expands directory to absolute path
46
+ #
47
+ # @param agent_name [Symbol, String] The agent identifier
48
+ # @param directory [String] Agent's working directory (will be expanded)
49
+ # @return [void]
50
+ def initialize_agent_context(agent_name:, directory:)
51
+ @agent_name = agent_name.to_sym
52
+ @directory = File.expand_path(directory)
53
+ end
54
+
26
55
  # Resolve a path relative to the agent's directory
27
56
  #
28
57
  # - Absolute paths (starting with /) are returned as-is
@@ -38,6 +67,26 @@ module SwarmSDK
38
67
 
39
68
  File.expand_path(path, @directory)
40
69
  end
70
+
71
+ # Format a validation error response
72
+ #
73
+ # Used for input validation failures (missing required params, invalid formats, etc.)
74
+ #
75
+ # @param message [String] Error description
76
+ # @return [String] Formatted error message wrapped in tool_use_error tags
77
+ def validation_error(message)
78
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
79
+ end
80
+
81
+ # Format a general error response
82
+ #
83
+ # Used for runtime errors (permission denied, file not found, etc.)
84
+ #
85
+ # @param message [String] Error description
86
+ # @return [String] Formatted error message prefixed with "Error:"
87
+ def error(message)
88
+ "Error: #{message}"
89
+ end
41
90
  end
42
91
  end
43
92
  end
@@ -10,8 +10,9 @@ module SwarmSDK
10
10
  class Read < RubyLLM::Tool
11
11
  include PathResolver
12
12
 
13
- MAX_LINE_LENGTH = 2000
14
- DEFAULT_LIMIT = 2000
13
+ # Backward compatibility aliases - use Defaults module for new code
14
+ MAX_LINE_LENGTH = Defaults::Limits::LINE_CHARACTERS
15
+ DEFAULT_LIMIT = Defaults::Limits::READ_LINES
15
16
 
16
17
  # List of available document converters
17
18
  CONVERTERS = [
@@ -28,6 +29,13 @@ module SwarmSDK
28
29
  ""
29
30
  end
30
31
 
32
+ # Factory pattern: declare what parameters this tool needs for instantiation
33
+ class << self
34
+ def creation_requirements
35
+ [:agent_name, :directory]
36
+ end
37
+ end
38
+
31
39
  description <<~DESC
32
40
  Reads a file from the local filesystem. You can access any file directly by using this tool.
33
41
  Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid.
@@ -67,8 +75,7 @@ module SwarmSDK
67
75
  # @param directory [String] Agent's working directory
68
76
  def initialize(agent_name:, directory:)
69
77
  super()
70
- @agent_name = agent_name.to_sym
71
- @directory = File.expand_path(directory)
78
+ initialize_agent_context(agent_name: agent_name, directory: directory)
72
79
  end
73
80
 
74
81
  # Override name to return simple "Read" instead of full class path
@@ -92,25 +99,37 @@ module SwarmSDK
92
99
  return validation_error("Path is a directory, not a file. Use Bash with ls to read directories.")
93
100
  end
94
101
 
95
- # Register this read in the tracker (use resolved path)
96
- Stores::ReadTracker.register_read(@agent_name, resolved_path)
97
-
98
102
  # Check if it's a document and try to convert it
99
103
  converter = find_converter_for_file(resolved_path)
100
104
  if converter
101
105
  result = converter.new.convert(resolved_path)
106
+ # For document files, register the converted text content
107
+ # Extract text from result (may be wrapped in system-reminder tags)
108
+ if result.is_a?(String)
109
+ # Remove system-reminder wrapper if present to get clean text for digest
110
+ text_content = result.gsub(%r{<system-reminder>.*?</system-reminder>}m, "").strip
111
+ Stores::ReadTracker.register_read(@agent_name, resolved_path, text_content)
112
+ end
102
113
  return result
103
114
  end
104
115
 
105
116
  # Try to read as text, handle binary files separately
106
117
  content = read_file_content(resolved_path)
107
118
 
108
- # If content is a Content object (binary file), return it directly
109
- return content if content.is_a?(RubyLLM::Content)
119
+ # If content is a Content object (binary file), track with binary digest and return
120
+ if content.is_a?(RubyLLM::Content)
121
+ # For binary files, read raw bytes for digest
122
+ binary_content = File.binread(resolved_path)
123
+ Stores::ReadTracker.register_read(@agent_name, resolved_path, binary_content)
124
+ return content
125
+ end
110
126
 
111
127
  # Return early if we got an error message or system reminder
112
128
  return content if content.is_a?(String) && (content.start_with?("Error:") || content.start_with?("<system-reminder>"))
113
129
 
130
+ # At this point, we have valid text content - register the read with digest
131
+ Stores::ReadTracker.register_read(@agent_name, resolved_path, content)
132
+
114
133
  # Check if file is empty
115
134
  if content.empty?
116
135
  return format_with_reminder(
@@ -170,15 +189,6 @@ module SwarmSDK
170
189
  CONVERTERS.find { |converter| converter.extensions.include?(ext) }
171
190
  end
172
191
 
173
- # Helper methods
174
- def validation_error(message)
175
- "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
176
- end
177
-
178
- def error(message)
179
- "Error: #{message}"
180
- end
181
-
182
192
  def format_with_reminder(content, reminder)
183
193
  return content if reminder.nil? || reminder.empty?
184
194
 
@@ -5,24 +5,51 @@ module SwarmSDK
5
5
  # Registry for built-in SwarmSDK tools
6
6
  #
7
7
  # Maps tool names (symbols) to their RubyLLM::Tool classes.
8
- # Provides validation and lookup functionality for tool registration.
8
+ # Provides validation, lookup, and factory functionality for tool registration.
9
+ #
10
+ # ## Tool Creation Pattern
11
+ #
12
+ # Tools register themselves with their creation requirements via the `tool_factory` method.
13
+ # This eliminates the need for a giant case statement in ToolConfigurator.
14
+ #
15
+ # Tools fall into three categories:
16
+ # 1. **No params**: Simple tools with no initialization requirements (Think, Clock)
17
+ # 2. **Directory only**: Tools needing working directory (Bash, Grep, Glob)
18
+ # 3. **Agent context**: Tools needing agent tracking (Read, Write, Edit, MultiEdit)
19
+ # 4. **Scratchpad**: Tools needing scratchpad storage instance
20
+ #
21
+ # @example Adding a new tool with creation requirements
22
+ # # In the tool class:
23
+ # class MyTool < RubyLLM::Tool
24
+ # def self.creation_requirements
25
+ # [:agent_name, :directory]
26
+ # end
27
+ # end
28
+ #
29
+ # # In registry:
30
+ # BUILTIN_TOOLS = {
31
+ # MyTool: SwarmSDK::Tools::MyTool,
32
+ # }
9
33
  #
10
34
  # Note: Plugin-provided tools (e.g., memory tools) are NOT in this registry.
11
35
  # They are registered via SwarmSDK::PluginRegistry instead.
12
36
  class Registry
13
37
  # All available built-in tools
38
+ #
39
+ # Maps tool names to their classes. The class must respond to `creation_requirements`
40
+ # to specify what parameters are needed for instantiation.
14
41
  BUILTIN_TOOLS = {
15
- Read: :special, # Requires agent context for read tracking
16
- Write: :special, # Requires agent context for read-before-write enforcement
17
- Edit: :special, # Requires agent context for read-before-edit enforcement
42
+ Read: SwarmSDK::Tools::Read,
43
+ Write: SwarmSDK::Tools::Write,
44
+ Edit: SwarmSDK::Tools::Edit,
45
+ MultiEdit: SwarmSDK::Tools::MultiEdit,
18
46
  Bash: SwarmSDK::Tools::Bash,
19
47
  Grep: SwarmSDK::Tools::Grep,
20
48
  Glob: SwarmSDK::Tools::Glob,
21
- MultiEdit: :special, # Requires agent context for read-before-edit enforcement
22
- TodoWrite: :special, # Requires agent context for todo tracking
23
- ScratchpadWrite: :special, # Requires scratchpad storage instance
24
- ScratchpadRead: :special, # Requires scratchpad storage instance
25
- ScratchpadList: :special, # Requires scratchpad storage instance
49
+ TodoWrite: SwarmSDK::Tools::TodoWrite,
50
+ ScratchpadWrite: :scratchpad, # Requires scratchpad storage instance
51
+ ScratchpadRead: :scratchpad, # Requires scratchpad storage instance
52
+ ScratchpadList: :scratchpad, # Requires scratchpad storage instance
26
53
  Think: SwarmSDK::Tools::Think,
27
54
  WebFetch: SwarmSDK::Tools::WebFetch,
28
55
  Clock: SwarmSDK::Tools::Clock,
@@ -35,12 +62,49 @@ module SwarmSDK
35
62
  # They are managed by SwarmSDK::PluginRegistry instead.
36
63
  #
37
64
  # @param name [Symbol, String] Tool name
38
- # @return [Class, Symbol, nil] Tool class, :special, or nil if not found
65
+ # @return [Class, Symbol, nil] Tool class, :scratchpad marker, or nil if not found
39
66
  def get(name)
40
67
  name_sym = name.to_sym
41
68
  BUILTIN_TOOLS[name_sym]
42
69
  end
43
70
 
71
+ # Create a tool instance using the Factory Pattern
72
+ #
73
+ # Uses the tool's `creation_requirements` class method to determine
74
+ # what parameters to pass to the constructor.
75
+ #
76
+ # @param name [Symbol, String] Tool name
77
+ # @param context [Hash] Available context for tool creation
78
+ # @option context [Symbol] :agent_name Agent identifier
79
+ # @option context [String] :directory Agent's working directory
80
+ # @option context [Object] :scratchpad_storage Scratchpad storage instance
81
+ # @return [RubyLLM::Tool] Instantiated tool
82
+ # @raise [ConfigurationError] If tool is unknown or has unmet requirements
83
+ def create(name, context = {})
84
+ name_sym = name.to_sym
85
+ tool_entry = BUILTIN_TOOLS[name_sym]
86
+
87
+ raise ConfigurationError, "Unknown tool: #{name}" unless tool_entry
88
+
89
+ # Handle scratchpad tools specially (they use factory methods)
90
+ if tool_entry == :scratchpad
91
+ return create_scratchpad_tool(name_sym, context[:scratchpad_storage])
92
+ end
93
+
94
+ # Get the tool class and its requirements
95
+ tool_class = tool_entry
96
+
97
+ # Check if tool defines creation requirements
98
+ if tool_class.respond_to?(:creation_requirements)
99
+ requirements = tool_class.creation_requirements
100
+ params = extract_params(requirements, context, name)
101
+ tool_class.new(**params)
102
+ else
103
+ # No requirements - simple instantiation
104
+ tool_class.new
105
+ end
106
+ end
107
+
44
108
  # Get multiple tool classes by names
45
109
  #
46
110
  # @param names [Array<Symbol, String>] Tool names
@@ -87,6 +151,54 @@ module SwarmSDK
87
151
  def validate(names)
88
152
  names.reject { |name| exists?(name) }
89
153
  end
154
+
155
+ private
156
+
157
+ # Extract required parameters from context
158
+ #
159
+ # @param requirements [Array<Symbol>] Required parameter names
160
+ # @param context [Hash] Available context
161
+ # @param tool_name [Symbol] Tool name for error messages
162
+ # @return [Hash] Parameters to pass to tool constructor
163
+ # @raise [ConfigurationError] If required parameter is missing
164
+ def extract_params(requirements, context, tool_name)
165
+ params = {}
166
+
167
+ requirements.each do |req|
168
+ unless context.key?(req)
169
+ raise ConfigurationError,
170
+ "Tool #{tool_name} requires #{req} but it was not provided in context"
171
+ end
172
+
173
+ params[req] = context[req]
174
+ end
175
+
176
+ params
177
+ end
178
+
179
+ # Create a scratchpad tool using its factory method
180
+ #
181
+ # @param name [Symbol] Scratchpad tool name
182
+ # @param storage [Object] Scratchpad storage instance
183
+ # @return [RubyLLM::Tool] Instantiated scratchpad tool
184
+ # @raise [ConfigurationError] If storage is not provided
185
+ def create_scratchpad_tool(name, storage)
186
+ unless storage
187
+ raise ConfigurationError,
188
+ "Scratchpad tool #{name} requires scratchpad_storage in context"
189
+ end
190
+
191
+ case name
192
+ when :ScratchpadWrite
193
+ Tools::Scratchpad::ScratchpadWrite.create_for_scratchpad(storage)
194
+ when :ScratchpadRead
195
+ Tools::Scratchpad::ScratchpadRead.create_for_scratchpad(storage)
196
+ when :ScratchpadList
197
+ Tools::Scratchpad::ScratchpadList.create_for_scratchpad(storage)
198
+ else
199
+ raise ConfigurationError, "Unknown scratchpad tool: #{name}"
200
+ end
201
+ end
90
202
  end
91
203
  end
92
204
  end
@@ -12,8 +12,29 @@ module SwarmSDK
12
12
 
13
13
  description <<~DESC
14
14
  List all entries in scratchpad with their metadata.
15
- Shows path, title, size, and last updated time for each entry.
16
- Use this to discover what's stored in the scratchpad.
15
+
16
+ ## When to Use ScratchpadList
17
+
18
+ Use ScratchpadList to:
19
+ - Discover what content is available in the scratchpad
20
+ - Check what other agents have stored
21
+ - Find relevant entries before reading them
22
+ - Review all stored outputs and analysis
23
+ - Check entry sizes and last update times
24
+
25
+ ## Best Practices
26
+
27
+ - Use this before ScratchpadRead if you don't know what's stored
28
+ - Filter by prefix to narrow down results (e.g., 'notes/' lists all notes)
29
+ - Shows path, title, size, and last updated time for each entry
30
+ - Any agent can see all scratchpad entries
31
+ - Helps coordinate multi-agent workflows
32
+
33
+ ## Examples
34
+
35
+ - List all entries: (no prefix parameter)
36
+ - List notes only: prefix='notes/'
37
+ - List analysis results: prefix='analysis/'
17
38
  DESC
18
39
 
19
40
  param :prefix,
@@ -12,8 +12,29 @@ module SwarmSDK
12
12
 
13
13
  description <<~DESC
14
14
  Read content from scratchpad.
15
- Use this to retrieve temporary notes, results, or messages stored by any agent.
16
- Any agent can read any scratchpad content.
15
+
16
+ ## When to Use ScratchpadRead
17
+
18
+ Use ScratchpadRead to:
19
+ - Retrieve previously stored content and outputs
20
+ - Access detailed analysis or results from earlier steps
21
+ - Read messages or notes left by other agents
22
+ - Access cached computed data
23
+ - Retrieve content that was too long for direct responses
24
+
25
+ ## Best Practices
26
+
27
+ - Any agent can read any scratchpad content
28
+ - Content is returned with line numbers for easy reference
29
+ - Use ScratchpadList first if you don't know what's stored
30
+ - Scratchpad data is temporary and lost when swarm ends
31
+ - For persistent data, use MemoryRead instead
32
+
33
+ ## Examples
34
+
35
+ - Read status: file_path='status'
36
+ - Read analysis: file_path='api_analysis'
37
+ - Read agent notes: file_path='notes/backend'
17
38
  DESC
18
39
 
19
40
  param :file_path,
@@ -13,12 +13,29 @@ module SwarmSDK
13
13
 
14
14
  description <<~DESC
15
15
  Store content in scratchpad for temporary cross-agent communication.
16
- Use this for quick notes, intermediate results, or coordination messages.
17
- Any agent can read this content. Data is lost when the swarm ends.
18
16
 
19
- For persistent storage that survives across sessions, use MemoryWrite instead.
17
+ ## When to Use Scratchpad
20
18
 
21
- Choose a simple, descriptive path. Examples: 'status', 'result', 'notes/agent_x'
19
+ Use ScratchpadWrite to:
20
+ - Store detailed outputs, analysis, or results that are too long for direct responses
21
+ - Share information that would otherwise clutter your responses
22
+ - Store intermediate results during multi-step tasks
23
+ - Leave coordination messages for other agents
24
+ - Cache computed data for quick retrieval
25
+
26
+ ## Best Practices
27
+
28
+ - Choose simple, descriptive paths: 'status', 'result', 'notes/agent_x'
29
+ - Use hierarchical paths for organization: 'analysis/step1', 'analysis/step2'
30
+ - Keep entries focused - one piece of information per entry
31
+ - Any agent can read scratchpad content
32
+ - Data is lost when the swarm ends (use MemoryWrite for persistent storage)
33
+ - Maximum 1MB per entry
34
+
35
+ ## Examples
36
+
37
+ Good paths: 'status', 'api_analysis', 'test_results', 'notes/backend'
38
+ Bad paths: 'scratch/temp/file123.txt', 'output.log'
22
39
  DESC
23
40
 
24
41
  param :file_path,
@@ -3,39 +3,74 @@
3
3
  module SwarmSDK
4
4
  module Tools
5
5
  module Stores
6
- # ReadTracker manages read-file tracking for all agents
6
+ # ReadTracker manages read-file tracking for all agents with content digest verification
7
7
  #
8
8
  # This module maintains a global registry of which files each agent has read
9
- # during their conversation. This enables enforcement of the "read-before-write"
10
- # and "read-before-edit" rules that ensure agents have context before modifying files.
9
+ # during their conversation along with SHA256 digests of the content. This enables
10
+ # enforcement of the "read-before-write" and "read-before-edit" rules that ensure
11
+ # agents have context before modifying files, AND prevents editing files that have
12
+ # changed externally since being read.
11
13
  #
12
- # Each agent maintains an independent set of read files, keyed by agent identifier.
14
+ # Each agent maintains an independent map of read files to content digests.
13
15
  module ReadTracker
14
- @read_files = {}
16
+ @read_files = {} # { agent_id => { file_path => sha256_digest } }
15
17
  @mutex = Mutex.new
16
18
 
17
19
  class << self
18
- # Register that an agent has read a file
20
+ # Register that an agent has read a file with content digest
19
21
  #
20
22
  # @param agent_id [Symbol] The agent identifier
21
23
  # @param file_path [String] The absolute path to the file
22
- def register_read(agent_id, file_path)
24
+ # @param content [String] File content (for digest calculation)
25
+ # @return [String] The calculated SHA256 digest
26
+ def register_read(agent_id, file_path, content)
23
27
  @mutex.synchronize do
24
- @read_files[agent_id] ||= Set.new
25
- @read_files[agent_id] << File.expand_path(file_path)
28
+ @read_files[agent_id] ||= {}
29
+ digest = Digest::SHA256.hexdigest(content)
30
+ @read_files[agent_id][File.expand_path(file_path)] = digest
31
+ digest
26
32
  end
27
33
  end
28
34
 
29
- # Check if an agent has read a file
35
+ # Check if an agent has read a file AND content hasn't changed
30
36
  #
31
37
  # @param agent_id [Symbol] The agent identifier
32
38
  # @param file_path [String] The absolute path to the file
33
- # @return [Boolean] true if the agent has read this file
39
+ # @return [Boolean] true if agent read file and content matches
34
40
  def file_read?(agent_id, file_path)
35
41
  @mutex.synchronize do
36
42
  return false unless @read_files[agent_id]
37
43
 
38
- @read_files[agent_id].include?(File.expand_path(file_path))
44
+ expanded_path = File.expand_path(file_path)
45
+ stored_digest = @read_files[agent_id][expanded_path]
46
+ return false unless stored_digest
47
+
48
+ # Check if file still exists and matches stored digest
49
+ return false unless File.exist?(expanded_path)
50
+
51
+ current_digest = Digest::SHA256.hexdigest(File.read(expanded_path))
52
+ current_digest == stored_digest
53
+ end
54
+ end
55
+
56
+ # Get all read files with digests for snapshot
57
+ #
58
+ # @param agent_id [Symbol] The agent identifier
59
+ # @return [Hash] { file_path => digest }
60
+ def get_read_files(agent_id)
61
+ @mutex.synchronize do
62
+ @read_files[agent_id]&.dup || {}
63
+ end
64
+ end
65
+
66
+ # Restore read files with digests from snapshot
67
+ #
68
+ # @param agent_id [Symbol] The agent identifier
69
+ # @param files_with_digests [Hash] { file_path => digest }
70
+ # @return [void]
71
+ def restore_read_files(agent_id, files_with_digests)
72
+ @mutex.synchronize do
73
+ @read_files[agent_id] = files_with_digests.dup
39
74
  end
40
75
  end
41
76
 
@@ -15,10 +15,13 @@ module SwarmSDK
15
15
  # Use for temporary, cross-agent communication within a single session.
16
16
  class ScratchpadStorage < Storage
17
17
  # Initialize scratchpad storage (always volatile)
18
- def initialize
18
+ #
19
+ # @param total_size_limit [Integer, nil] Maximum total size in bytes (defaults to Defaults::Storage::TOTAL_SIZE_BYTES)
20
+ def initialize(total_size_limit: nil)
19
21
  super() # Initialize parent Storage class
20
22
  @entries = {}
21
23
  @total_size = 0
24
+ @total_size_limit = total_size_limit || Defaults::Storage::TOTAL_SIZE_BYTES
22
25
  @mutex = Mutex.new
23
26
  end
24
27
 
@@ -38,8 +41,8 @@ module SwarmSDK
38
41
  content_size = content.bytesize
39
42
 
40
43
  # Check entry size limit
41
- if content_size > MAX_ENTRY_SIZE
42
- raise ArgumentError, "Content exceeds maximum size (#{format_bytes(MAX_ENTRY_SIZE)}). " \
44
+ if content_size > Defaults::Storage::ENTRY_SIZE_BYTES
45
+ raise ArgumentError, "Content exceeds maximum size (#{format_bytes(Defaults::Storage::ENTRY_SIZE_BYTES)}). " \
43
46
  "Current: #{format_bytes(content_size)}"
44
47
  end
45
48
 
@@ -49,8 +52,8 @@ module SwarmSDK
49
52
  new_total_size = @total_size - existing_size + content_size
50
53
 
51
54
  # Check total size limit
52
- if new_total_size > MAX_TOTAL_SIZE
53
- raise ArgumentError, "Scratchpad full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
55
+ if new_total_size > @total_size_limit
56
+ raise ArgumentError, "Scratchpad full (#{format_bytes(@total_size_limit)} limit). " \
54
57
  "Current: #{format_bytes(@total_size)}, " \
55
58
  "Would be: #{format_bytes(new_total_size)}. " \
56
59
  "Clear old entries or use smaller content."
@@ -218,6 +221,51 @@ module SwarmSDK
218
221
  def size
219
222
  @entries.size
220
223
  end
224
+
225
+ # Get all entries with content for snapshot
226
+ #
227
+ # Thread-safe method that returns a copy of all entries.
228
+ # Used by snapshot/restore functionality.
229
+ #
230
+ # @return [Hash] { path => Entry }
231
+ def all_entries
232
+ @mutex.synchronize do
233
+ @entries.dup
234
+ end
235
+ end
236
+
237
+ # Restore entries from snapshot
238
+ #
239
+ # Restores entries directly without using write() to preserve timestamps.
240
+ # This ensures entry ordering and metadata accuracy after restore.
241
+ #
242
+ # @param entries_data [Hash] { path => { content:, title:, updated_at:, size: } }
243
+ # @return [void]
244
+ def restore_entries(entries_data)
245
+ @mutex.synchronize do
246
+ entries_data.each do |path, data|
247
+ # Handle both symbol and string keys from JSON
248
+ content = data[:content] || data["content"]
249
+ title = data[:title] || data["title"]
250
+ updated_at_str = data[:updated_at] || data["updated_at"]
251
+
252
+ # Parse timestamp from ISO8601 string
253
+ updated_at = Time.parse(updated_at_str)
254
+
255
+ # Create entry with preserved timestamp
256
+ entry = Entry.new(
257
+ content: content,
258
+ title: title,
259
+ updated_at: updated_at,
260
+ size: content.bytesize,
261
+ )
262
+
263
+ # Update storage
264
+ @entries[path] = entry
265
+ @total_size += entry.size
266
+ end
267
+ end
268
+ end
221
269
  end
222
270
  end
223
271
  end
@@ -14,12 +14,6 @@ module SwarmSDK
14
14
  # - Search capabilities: Glob patterns and grep-style content search
15
15
  # - Thread-safe: Mutex-protected operations
16
16
  class Storage
17
- # Maximum size per entry (1MB)
18
- MAX_ENTRY_SIZE = 1_000_000
19
-
20
- # Maximum total storage size (100MB)
21
- MAX_TOTAL_SIZE = 100_000_000
22
-
23
17
  # Represents a single storage entry with metadata
24
18
  Entry = Struct.new(:content, :title, :updated_at, :size, keyword_init: true)
25
19