swarm_memory 2.1.2 → 2.1.3

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/configuration.rb +28 -4
  3. data/lib/claude_swarm/mcp_generator.rb +4 -10
  4. data/lib/claude_swarm/version.rb +1 -1
  5. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  6. data/lib/swarm_cli/config_loader.rb +3 -3
  7. data/lib/swarm_cli/version.rb +1 -1
  8. data/lib/swarm_memory/adapters/base.rb +4 -4
  9. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  10. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  11. data/lib/swarm_memory/integration/sdk_plugin.rb +11 -5
  12. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  13. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  14. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  15. data/lib/swarm_memory/version.rb +1 -1
  16. data/lib/swarm_memory.rb +5 -0
  17. data/lib/swarm_sdk/agent/builder.rb +33 -0
  18. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  19. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  20. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  21. data/lib/swarm_sdk/agent/chat.rb +198 -51
  22. data/lib/swarm_sdk/agent/context.rb +6 -2
  23. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  24. data/lib/swarm_sdk/agent/definition.rb +15 -22
  25. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  26. data/lib/swarm_sdk/configuration.rb +420 -103
  27. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  28. data/lib/swarm_sdk/log_collector.rb +31 -5
  29. data/lib/swarm_sdk/log_stream.rb +37 -8
  30. data/lib/swarm_sdk/model_aliases.json +4 -1
  31. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  32. data/lib/swarm_sdk/node/builder.rb +39 -18
  33. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  34. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  35. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  36. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  37. data/lib/swarm_sdk/restore_result.rb +65 -0
  38. data/lib/swarm_sdk/snapshot.rb +156 -0
  39. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  40. data/lib/swarm_sdk/state_restorer.rb +491 -0
  41. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  42. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  43. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  44. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  45. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  46. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  47. data/lib/swarm_sdk/swarm.rb +367 -90
  48. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  49. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  50. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  51. data/lib/swarm_sdk/tools/read.rb +17 -5
  52. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  53. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  54. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  55. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  56. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  57. data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
  58. data/lib/swarm_sdk/tools/think.rb +4 -1
  59. data/lib/swarm_sdk/tools/todo_write.rb +20 -8
  60. data/lib/swarm_sdk/utils.rb +18 -0
  61. data/lib/swarm_sdk/validation_result.rb +33 -0
  62. data/lib/swarm_sdk/version.rb +1 -1
  63. data/lib/swarm_sdk.rb +362 -21
  64. metadata +17 -5
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Loader for creating swarm instances from multiple sources
5
+ #
6
+ # SwarmLoader loads swarm configurations from:
7
+ # - Files: .rb (DSL) or .yml (YAML)
8
+ # - YAML strings: Direct YAML content
9
+ # - DSL blocks: Inline Ruby blocks
10
+ #
11
+ # All loaded swarms get hierarchical swarm_id and parent_swarm_id.
12
+ #
13
+ # ## Features
14
+ # - Supports Ruby DSL (.rb files or blocks)
15
+ # - Supports YAML (.yml/.yaml files or strings)
16
+ # - Sets hierarchical swarm_id based on parent + registration name
17
+ # - Isolates loading in separate context
18
+ # - Proper error handling for missing/invalid sources
19
+ #
20
+ # ## Examples
21
+ #
22
+ # # From file
23
+ # swarm = SwarmLoader.load_from_file(
24
+ # "./swarms/code_review.rb",
25
+ # swarm_id: "main/code_review",
26
+ # parent_swarm_id: "main"
27
+ # )
28
+ #
29
+ # # From YAML string
30
+ # swarm = SwarmLoader.load_from_yaml_string(
31
+ # "version: 2\nswarm:\n name: Test\n...",
32
+ # swarm_id: "main/testing",
33
+ # parent_swarm_id: "main"
34
+ # )
35
+ #
36
+ # # From block
37
+ # swarm = SwarmLoader.load_from_block(
38
+ # proc { id "team"; name "Team"; agent :dev { ... } },
39
+ # swarm_id: "main/team",
40
+ # parent_swarm_id: "main"
41
+ # )
42
+ #
43
+ class SwarmLoader
44
+ class << self
45
+ # Load a swarm from a file (.rb or .yml)
46
+ #
47
+ # @param file_path [String] Path to swarm file
48
+ # @param swarm_id [String] Hierarchical swarm ID to assign
49
+ # @param parent_swarm_id [String] Parent swarm ID
50
+ # @return [Swarm] Loaded swarm instance with overridden IDs
51
+ # @raise [ConfigurationError] If file not found or unsupported type
52
+ def load_from_file(file_path, swarm_id:, parent_swarm_id:)
53
+ path = Pathname.new(file_path).expand_path
54
+
55
+ raise ConfigurationError, "Swarm file not found: #{path}" unless path.exist?
56
+
57
+ # Determine file type and load
58
+ case path.extname
59
+ when ".rb"
60
+ load_from_ruby_file(path, swarm_id, parent_swarm_id)
61
+ when ".yml", ".yaml"
62
+ load_from_yaml_file(path, swarm_id, parent_swarm_id)
63
+ else
64
+ raise ConfigurationError, "Unsupported swarm file type: #{path.extname}. Use .rb, .yml, or .yaml"
65
+ end
66
+ end
67
+
68
+ # Load a swarm from YAML string
69
+ #
70
+ # @param yaml_content [String] YAML configuration content
71
+ # @param swarm_id [String] Hierarchical swarm ID to assign
72
+ # @param parent_swarm_id [String] Parent swarm ID
73
+ # @return [Swarm] Loaded swarm instance with overridden IDs
74
+ # @raise [ConfigurationError] If YAML is invalid
75
+ def load_from_yaml_string(yaml_content, swarm_id:, parent_swarm_id:)
76
+ # Use Configuration to parse YAML string
77
+ config = Configuration.new(yaml_content, base_dir: Dir.pwd)
78
+ config.load_and_validate
79
+ swarm = config.to_swarm
80
+
81
+ # Override swarm_id and parent_swarm_id
82
+ swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
83
+
84
+ swarm
85
+ end
86
+
87
+ # Load a swarm from DSL block
88
+ #
89
+ # @param block [Proc] Block containing SwarmSDK DSL
90
+ # @param swarm_id [String] Hierarchical swarm ID to assign
91
+ # @param parent_swarm_id [String] Parent swarm ID
92
+ # @return [Swarm] Loaded swarm instance with overridden IDs
93
+ def load_from_block(block, swarm_id:, parent_swarm_id:)
94
+ # Execute block in Builder context
95
+ builder = Swarm::Builder.new
96
+ builder.instance_eval(&block)
97
+ swarm = builder.build_swarm
98
+
99
+ # Override swarm_id and parent_swarm_id
100
+ swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
101
+
102
+ swarm
103
+ end
104
+
105
+ private
106
+
107
+ # Load swarm from Ruby DSL file
108
+ #
109
+ # @param path [Pathname] Path to .rb file
110
+ # @param swarm_id [String] Swarm ID to assign
111
+ # @param parent_swarm_id [String] Parent swarm ID
112
+ # @return [Swarm] Loaded swarm with overridden IDs
113
+ def load_from_ruby_file(path, swarm_id, parent_swarm_id)
114
+ content = File.read(path)
115
+
116
+ # Execute DSL in isolated context
117
+ # The DSL should return a swarm via SwarmSDK.build { ... }
118
+ swarm = eval(content, binding, path.to_s) # rubocop:disable Security/Eval
119
+
120
+ # Override swarm_id and parent_swarm_id
121
+ # These must be set after build to ensure hierarchical structure
122
+ swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
123
+
124
+ swarm
125
+ end
126
+
127
+ # Load swarm from YAML file
128
+ #
129
+ # @param path [Pathname] Path to .yml file
130
+ # @param swarm_id [String] Swarm ID to assign
131
+ # @param parent_swarm_id [String] Parent swarm ID
132
+ # @return [Swarm] Loaded swarm with overridden IDs
133
+ def load_from_yaml_file(path, swarm_id, parent_swarm_id)
134
+ # Use Configuration to load and convert YAML to swarm
135
+ config = Configuration.load_file(path.to_s)
136
+ swarm = config.to_swarm
137
+
138
+ # Override swarm_id and parent_swarm_id
139
+ swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
140
+
141
+ swarm
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Registry for managing sub-swarms in composable swarms
5
+ #
6
+ # SwarmRegistry handles lazy loading, caching, and lifecycle management
7
+ # of child swarms registered via the `swarms` DSL block.
8
+ #
9
+ # ## Features
10
+ # - Lazy loading: Sub-swarms are only loaded when first accessed
11
+ # - Caching: Loaded swarms are cached for reuse
12
+ # - Hierarchical IDs: Sub-swarms get IDs based on parent + registration name
13
+ # - Context control: keep_context determines if swarm state persists
14
+ # - Lifecycle management: Cleanup cascades through all sub-swarms
15
+ #
16
+ # ## Example
17
+ #
18
+ # registry = SwarmRegistry.new(parent_swarm_id: "main_app")
19
+ # registry.register("code_review", file: "./swarms/code_review.rb", keep_context: true)
20
+ #
21
+ # # Lazy load on first access
22
+ # swarm = registry.load_swarm("code_review")
23
+ # # => Swarm with swarm_id = "main_app/code_review"
24
+ #
25
+ # # Reset if keep_context: false
26
+ # registry.reset_if_needed("code_review")
27
+ #
28
+ class SwarmRegistry
29
+ # Initialize a new swarm registry
30
+ #
31
+ # @param parent_swarm_id [String] ID of the parent swarm
32
+ def initialize(parent_swarm_id:)
33
+ @parent_swarm_id = parent_swarm_id
34
+ @registered_swarms = {}
35
+ # Format: { "code_review" => { file: "...", keep_context: true, instance: nil } }
36
+ end
37
+
38
+ # Register a sub-swarm for lazy loading
39
+ #
40
+ # @param name [String] Registration name for the swarm
41
+ # @param source [Hash] Source specification with :type and :value
42
+ # - { type: :file, value: "./path/to/swarm.rb" }
43
+ # - { type: :yaml, value: "version: 2\n..." }
44
+ # - { type: :block, value: Proc }
45
+ # @param keep_context [Boolean] Whether to preserve conversation state between calls (default: true)
46
+ # @return [void]
47
+ # @raise [ArgumentError] If swarm with same name already registered
48
+ def register(name, source:, keep_context: true)
49
+ raise ArgumentError, "Swarm '#{name}' already registered" if @registered_swarms.key?(name)
50
+
51
+ @registered_swarms[name] = {
52
+ source: source,
53
+ keep_context: keep_context,
54
+ instance: nil, # Lazy load
55
+ }
56
+ end
57
+
58
+ # Check if a swarm is registered
59
+ #
60
+ # @param name [String] Swarm registration name
61
+ # @return [Boolean] True if swarm is registered
62
+ def registered?(name)
63
+ @registered_swarms.key?(name)
64
+ end
65
+
66
+ # Load a registered swarm (lazy load + cache)
67
+ #
68
+ # Loads the swarm from its source (file, yaml, or block) on first access, then caches it.
69
+ # Sets hierarchical swarm_id based on parent_swarm_id + registration name.
70
+ #
71
+ # @param name [String] Swarm registration name
72
+ # @return [Swarm] Loaded swarm instance
73
+ # @raise [ConfigurationError] If swarm not registered
74
+ def load_swarm(name)
75
+ entry = @registered_swarms[name]
76
+ raise ConfigurationError, "Swarm '#{name}' not registered" unless entry
77
+
78
+ # Return cached instance if exists
79
+ return entry[:instance] if entry[:instance]
80
+
81
+ # Load from appropriate source
82
+ swarm_id = "#{@parent_swarm_id}/#{name}" # Hierarchical
83
+ source = entry[:source]
84
+
85
+ swarm = case source[:type]
86
+ when :file
87
+ SwarmLoader.load_from_file(
88
+ source[:value],
89
+ swarm_id: swarm_id,
90
+ parent_swarm_id: @parent_swarm_id,
91
+ )
92
+ when :yaml
93
+ SwarmLoader.load_from_yaml_string(
94
+ source[:value],
95
+ swarm_id: swarm_id,
96
+ parent_swarm_id: @parent_swarm_id,
97
+ )
98
+ when :block
99
+ SwarmLoader.load_from_block(
100
+ source[:value],
101
+ swarm_id: swarm_id,
102
+ parent_swarm_id: @parent_swarm_id,
103
+ )
104
+ else
105
+ raise ConfigurationError, "Unknown source type: #{source[:type]}"
106
+ end
107
+
108
+ entry[:instance] = swarm
109
+ swarm
110
+ end
111
+
112
+ # Reset swarm context if keep_context: false
113
+ #
114
+ # @param name [String] Swarm registration name
115
+ # @return [void]
116
+ def reset_if_needed(name)
117
+ entry = @registered_swarms[name]
118
+ return if entry[:keep_context]
119
+
120
+ entry[:instance]&.reset_context!
121
+ end
122
+
123
+ # Cleanup all registered swarms
124
+ #
125
+ # Stops all loaded swarm instances and clears the registry.
126
+ # Should be called when parent swarm is done.
127
+ #
128
+ # @return [void]
129
+ def shutdown_all
130
+ @registered_swarms.each_value do |entry|
131
+ entry[:instance]&.cleanup
132
+ end
133
+ @registered_swarms.clear
134
+ end
135
+ end
136
+ end
@@ -14,10 +14,12 @@ module SwarmSDK
14
14
  #
15
15
  # @param delegate_name [String] Name of the delegate agent (e.g., "backend")
16
16
  # @param delegate_description [String] Description of the delegate agent
17
- # @param delegate_chat [AgentChat] The chat instance for the delegate agent
17
+ # @param delegate_chat [AgentChat, nil] The chat instance for the delegate agent (nil if delegating to swarm)
18
18
  # @param agent_name [Symbol, String] Name of the agent using this tool
19
19
  # @param swarm [Swarm] The swarm instance
20
20
  # @param hook_registry [Hooks::Registry] Registry for callbacks
21
+ # @param call_stack [Array] Delegation call stack for circular dependency detection
22
+ # @param swarm_registry [SwarmRegistry, nil] Registry for sub-swarms (nil if not using composable swarms)
21
23
  # @param delegating_chat [Agent::Chat, nil] The chat instance of the agent doing the delegating (for accessing hooks)
22
24
  def initialize(
23
25
  delegate_name:,
@@ -26,6 +28,8 @@ module SwarmSDK
26
28
  agent_name:,
27
29
  swarm:,
28
30
  hook_registry:,
31
+ call_stack:,
32
+ swarm_registry: nil,
29
33
  delegating_chat: nil
30
34
  )
31
35
  super()
@@ -36,6 +40,8 @@ module SwarmSDK
36
40
  @agent_name = agent_name
37
41
  @swarm = swarm
38
42
  @hook_registry = hook_registry
43
+ @call_stack = call_stack
44
+ @swarm_registry = swarm_registry
39
45
  @delegating_chat = delegating_chat
40
46
 
41
47
  # Generate tool name in the expected format: DelegateTaskTo[AgentName]
@@ -63,6 +69,13 @@ module SwarmSDK
63
69
  # @param task [String] Task to delegate
64
70
  # @return [String] Result from delegate agent or error message
65
71
  def execute(task:)
72
+ # Check for circular dependency
73
+ if @call_stack.include?(@delegate_target)
74
+ emit_circular_warning
75
+ return "Error: Circular delegation detected: #{@call_stack.join(" -> ")} -> #{@delegate_target}. " \
76
+ "Please restructure your delegation to avoid infinite loops."
77
+ end
78
+
66
79
  # Get agent-specific hooks from the delegating chat instance
67
80
  agent_hooks = if @delegating_chat&.respond_to?(:hook_agent_hooks)
68
81
  @delegating_chat.hook_agent_hooks || {}
@@ -94,9 +107,16 @@ module SwarmSDK
94
107
  return result.value
95
108
  end
96
109
 
97
- # Proceed with delegation
98
- response = @delegate_chat.ask(task)
99
- delegation_result = response.content
110
+ # Determine delegation type and proceed
111
+ delegation_result = if @delegate_chat
112
+ # Delegate to agent
113
+ delegate_to_agent(task)
114
+ elsif @swarm_registry&.registered?(@delegate_target)
115
+ # Delegate to registered swarm
116
+ delegate_to_swarm(task)
117
+ else
118
+ raise ConfigurationError, "Unknown delegation target: #{@delegate_target}"
119
+ end
100
120
 
101
121
  # Trigger post_delegation callback
102
122
  post_context = Hooks::Context.new(
@@ -127,10 +147,12 @@ module SwarmSDK
127
147
  LogStream.emit(
128
148
  type: "delegation_error",
129
149
  agent: @agent_name,
150
+ swarm_id: @swarm.swarm_id,
151
+ parent_swarm_id: @swarm.parent_swarm_id,
130
152
  delegate_to: @tool_name,
131
153
  error_class: e.class.name,
132
154
  error_message: "Request timed out",
133
- backtrace: e.backtrace&.first(5) || [],
155
+ error_backtrace: e.backtrace&.first(5) || [],
134
156
  )
135
157
  "Error: Request to #{@tool_name} timed out. The agent may be overloaded or the LLM service is not responding. Please try again or simplify the task."
136
158
  rescue Faraday::Error => e
@@ -138,10 +160,12 @@ module SwarmSDK
138
160
  LogStream.emit(
139
161
  type: "delegation_error",
140
162
  agent: @agent_name,
163
+ swarm_id: @swarm.swarm_id,
164
+ parent_swarm_id: @swarm.parent_swarm_id,
141
165
  delegate_to: @tool_name,
142
166
  error_class: e.class.name,
143
167
  error_message: e.message,
144
- backtrace: e.backtrace&.first(5) || [],
168
+ error_backtrace: e.backtrace&.first(5) || [],
145
169
  )
146
170
  "Error: Network error communicating with #{@tool_name}: #{e.class.name}. Please check connectivity and try again."
147
171
  rescue StandardError => e
@@ -150,15 +174,76 @@ module SwarmSDK
150
174
  LogStream.emit(
151
175
  type: "delegation_error",
152
176
  agent: @agent_name,
177
+ swarm_id: @swarm.swarm_id,
178
+ parent_swarm_id: @swarm.parent_swarm_id,
153
179
  delegate_to: @tool_name,
154
180
  error_class: e.class.name,
155
181
  error_message: e.message,
156
- backtrace: backtrace_array,
182
+ error_backtrace: backtrace_array,
157
183
  )
158
184
  # Return error string for LLM
159
185
  backtrace_str = backtrace_array.join("\n ")
160
186
  "Error: #{@tool_name} encountered an error: #{e.class.name}: #{e.message}\nBacktrace:\n #{backtrace_str}"
161
187
  end
188
+
189
+ private
190
+
191
+ # Delegate to an agent
192
+ #
193
+ # @param task [String] Task to delegate
194
+ # @return [String] Result from agent
195
+ def delegate_to_agent(task)
196
+ # Push delegate target onto call stack to track delegation chain
197
+ @call_stack.push(@delegate_target)
198
+ begin
199
+ response = @delegate_chat.ask(task)
200
+ response.content
201
+ ensure
202
+ # Always pop from stack, even if delegation fails
203
+ @call_stack.pop
204
+ end
205
+ end
206
+
207
+ # Delegate to a registered swarm
208
+ #
209
+ # @param task [String] Task to delegate
210
+ # @return [String] Result from swarm's lead agent
211
+ def delegate_to_swarm(task)
212
+ # Load sub-swarm (lazy load + cache)
213
+ subswarm = @swarm_registry.load_swarm(@delegate_target)
214
+
215
+ # Push delegate target onto call stack to track delegation chain
216
+ @call_stack.push(@delegate_target)
217
+ begin
218
+ # Execute sub-swarm's lead agent
219
+ lead_agent = subswarm.agents[subswarm.lead_agent]
220
+ response = lead_agent.ask(task)
221
+ result = response.content
222
+
223
+ # Reset if keep_context: false
224
+ @swarm_registry.reset_if_needed(@delegate_target)
225
+
226
+ result
227
+ ensure
228
+ # Always pop from stack, even if delegation fails
229
+ @call_stack.pop
230
+ end
231
+ end
232
+
233
+ # Emit circular dependency warning event
234
+ #
235
+ # @return [void]
236
+ def emit_circular_warning
237
+ LogStream.emit(
238
+ type: "delegation_circular_dependency",
239
+ agent: @agent_name,
240
+ swarm_id: @swarm.swarm_id,
241
+ parent_swarm_id: @swarm.parent_swarm_id,
242
+ target: @delegate_target,
243
+ call_stack: @call_stack,
244
+ timestamp: Time.now.utc.iso8601,
245
+ )
246
+ end
162
247
  end
163
248
  end
164
249
  end
@@ -92,25 +92,37 @@ module SwarmSDK
92
92
  return validation_error("Path is a directory, not a file. Use Bash with ls to read directories.")
93
93
  end
94
94
 
95
- # Register this read in the tracker (use resolved path)
96
- Stores::ReadTracker.register_read(@agent_name, resolved_path)
97
-
98
95
  # Check if it's a document and try to convert it
99
96
  converter = find_converter_for_file(resolved_path)
100
97
  if converter
101
98
  result = converter.new.convert(resolved_path)
99
+ # For document files, register the converted text content
100
+ # Extract text from result (may be wrapped in system-reminder tags)
101
+ if result.is_a?(String)
102
+ # Remove system-reminder wrapper if present to get clean text for digest
103
+ text_content = result.gsub(%r{<system-reminder>.*?</system-reminder>}m, "").strip
104
+ Stores::ReadTracker.register_read(@agent_name, resolved_path, text_content)
105
+ end
102
106
  return result
103
107
  end
104
108
 
105
109
  # Try to read as text, handle binary files separately
106
110
  content = read_file_content(resolved_path)
107
111
 
108
- # If content is a Content object (binary file), return it directly
109
- return content if content.is_a?(RubyLLM::Content)
112
+ # If content is a Content object (binary file), track with binary digest and return
113
+ if content.is_a?(RubyLLM::Content)
114
+ # For binary files, read raw bytes for digest
115
+ binary_content = File.binread(resolved_path)
116
+ Stores::ReadTracker.register_read(@agent_name, resolved_path, binary_content)
117
+ return content
118
+ end
110
119
 
111
120
  # Return early if we got an error message or system reminder
112
121
  return content if content.is_a?(String) && (content.start_with?("Error:") || content.start_with?("<system-reminder>"))
113
122
 
123
+ # At this point, we have valid text content - register the read with digest
124
+ Stores::ReadTracker.register_read(@agent_name, resolved_path, content)
125
+
114
126
  # Check if file is empty
115
127
  if content.empty?
116
128
  return format_with_reminder(
@@ -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