swarm_sdk 2.1.3 → 2.2.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +33 -0
  3. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  4. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  5. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  6. data/lib/swarm_sdk/agent/chat.rb +198 -51
  7. data/lib/swarm_sdk/agent/context.rb +6 -2
  8. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  9. data/lib/swarm_sdk/agent/definition.rb +14 -2
  10. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  11. data/lib/swarm_sdk/configuration.rb +387 -94
  12. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  13. data/lib/swarm_sdk/log_collector.rb +31 -5
  14. data/lib/swarm_sdk/log_stream.rb +37 -8
  15. data/lib/swarm_sdk/model_aliases.json +4 -1
  16. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  17. data/lib/swarm_sdk/node/builder.rb +39 -18
  18. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  19. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  20. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  21. data/lib/swarm_sdk/restore_result.rb +65 -0
  22. data/lib/swarm_sdk/snapshot.rb +156 -0
  23. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  24. data/lib/swarm_sdk/state_restorer.rb +491 -0
  25. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  26. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  27. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  28. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  29. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  30. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  31. data/lib/swarm_sdk/swarm.rb +337 -42
  32. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  33. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  34. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  35. data/lib/swarm_sdk/tools/read.rb +17 -5
  36. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  37. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  38. data/lib/swarm_sdk/utils.rb +18 -0
  39. data/lib/swarm_sdk/validation_result.rb +33 -0
  40. data/lib/swarm_sdk/version.rb +1 -1
  41. data/lib/swarm_sdk.rb +40 -8
  42. metadata +17 -6
  43. data/lib/swarm_sdk/mcp.rb +0 -16
@@ -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(
@@ -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
 
@@ -218,6 +218,51 @@ module SwarmSDK
218
218
  def size
219
219
  @entries.size
220
220
  end
221
+
222
+ # Get all entries with content for snapshot
223
+ #
224
+ # Thread-safe method that returns a copy of all entries.
225
+ # Used by snapshot/restore functionality.
226
+ #
227
+ # @return [Hash] { path => Entry }
228
+ def all_entries
229
+ @mutex.synchronize do
230
+ @entries.dup
231
+ end
232
+ end
233
+
234
+ # Restore entries from snapshot
235
+ #
236
+ # Restores entries directly without using write() to preserve timestamps.
237
+ # This ensures entry ordering and metadata accuracy after restore.
238
+ #
239
+ # @param entries_data [Hash] { path => { content:, title:, updated_at:, size: } }
240
+ # @return [void]
241
+ def restore_entries(entries_data)
242
+ @mutex.synchronize do
243
+ entries_data.each do |path, data|
244
+ # Handle both symbol and string keys from JSON
245
+ content = data[:content] || data["content"]
246
+ title = data[:title] || data["title"]
247
+ updated_at_str = data[:updated_at] || data["updated_at"]
248
+
249
+ # Parse timestamp from ISO8601 string
250
+ updated_at = Time.parse(updated_at_str)
251
+
252
+ # Create entry with preserved timestamp
253
+ entry = Entry.new(
254
+ content: content,
255
+ title: title,
256
+ updated_at: updated_at,
257
+ size: content.bytesize,
258
+ )
259
+
260
+ # Update storage
261
+ @entries[path] = entry
262
+ @total_size += entry.size
263
+ end
264
+ end
265
+ end
221
266
  end
222
267
  end
223
268
  end
@@ -45,6 +45,24 @@ module SwarmSDK
45
45
  obj
46
46
  end
47
47
  end
48
+
49
+ # Convert hash to YAML string
50
+ #
51
+ # Converts a Ruby hash to a YAML string. Useful for creating inline
52
+ # swarm definitions from hash configurations.
53
+ #
54
+ # @param hash [Hash] Hash to convert
55
+ # @return [String] YAML string representation
56
+ #
57
+ # @example
58
+ # config = { version: 2, swarm: { name: "Test" } }
59
+ # Utils.hash_to_yaml(config)
60
+ # # => "---\nversion: 2\nswarm:\n name: Test\n"
61
+ def hash_to_yaml(hash)
62
+ # Convert symbols to strings for valid YAML
63
+ stringified = stringify_keys(hash)
64
+ stringified.to_yaml
65
+ end
48
66
  end
49
67
  end
50
68
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Internal result object for validation phase during snapshot restore
5
+ #
6
+ # Used during restore to track which agents can be restored and which
7
+ # need to be skipped due to configuration mismatches.
8
+ #
9
+ # @api private
10
+ class ValidationResult
11
+ attr_reader :warnings,
12
+ :skipped_agents,
13
+ :restorable_agents,
14
+ :skipped_delegations,
15
+ :restorable_delegations
16
+
17
+ # Initialize validation result
18
+ #
19
+ # @param warnings [Array<Hash>] Warning messages with details
20
+ # @param skipped_agents [Array<Symbol>] Names of agents that can't be restored
21
+ # @param restorable_agents [Array<Symbol>] Names of agents that can be restored
22
+ # @param skipped_delegations [Array<String>] Names of delegations that can't be restored
23
+ # @param restorable_delegations [Array<String>] Names of delegations that can be restored
24
+ def initialize(warnings:, skipped_agents:, restorable_agents:,
25
+ skipped_delegations:, restorable_delegations:)
26
+ @warnings = warnings
27
+ @skipped_agents = skipped_agents
28
+ @restorable_agents = restorable_agents
29
+ @skipped_delegations = skipped_delegations
30
+ @restorable_delegations = restorable_delegations
31
+ end
32
+ end
33
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
- VERSION = "2.1.3"
4
+ VERSION = "2.2.0"
5
5
  end
data/lib/swarm_sdk.rb CHANGED
@@ -15,6 +15,7 @@ require "yaml"
15
15
  require "async"
16
16
  require "async/semaphore"
17
17
  require "ruby_llm"
18
+ require "ruby_llm/mcp"
18
19
 
19
20
  require_relative "swarm_sdk/version"
20
21
 
@@ -25,6 +26,8 @@ loader.push_dir("#{__dir__}/swarm_sdk", namespace: SwarmSDK)
25
26
  loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
26
27
  loader.inflector.inflect(
27
28
  "cli" => "CLI",
29
+ "llm_instrumentation_middleware" => "LLMInstrumentationMiddleware",
30
+ "mcp" => "MCP",
28
31
  "openai_with_responses" => "OpenAIWithResponses",
29
32
  )
30
33
  loader.setup
@@ -43,8 +46,8 @@ module SwarmSDK
43
46
  attr_accessor :settings
44
47
 
45
48
  # Main entry point for DSL
46
- def build(&block)
47
- Swarm::Builder.build(&block)
49
+ def build(allow_filesystem_tools: nil, &block)
50
+ Swarm::Builder.build(allow_filesystem_tools: allow_filesystem_tools, &block)
48
51
  end
49
52
 
50
53
  # Validate YAML configuration without creating a swarm
@@ -84,6 +87,10 @@ module SwarmSDK
84
87
  begin
85
88
  config = Configuration.new(yaml_content, base_dir: base_dir)
86
89
  config.load_and_validate
90
+
91
+ # Build swarm to trigger DSL validation
92
+ # This catches errors from Agent::Definition, Builder, etc.
93
+ config.to_swarm
87
94
  rescue ConfigurationError, CircularDependencyError => e
88
95
  errors << parse_configuration_error(e)
89
96
  rescue StandardError => e
@@ -164,10 +171,10 @@ module SwarmSDK
164
171
  # @example Load with default base_dir (Dir.pwd)
165
172
  # yaml = File.read("config.yml")
166
173
  # swarm = SwarmSDK.load(yaml) # base_dir defaults to Dir.pwd
167
- def load(yaml_content, base_dir: Dir.pwd)
174
+ def load(yaml_content, base_dir: Dir.pwd, allow_filesystem_tools: nil)
168
175
  config = Configuration.new(yaml_content, base_dir: base_dir)
169
176
  config.load_and_validate
170
- swarm = config.to_swarm
177
+ swarm = config.to_swarm(allow_filesystem_tools: allow_filesystem_tools)
171
178
 
172
179
  # Apply hooks if any are configured (YAML-only feature)
173
180
  if hooks_configured?(config)
@@ -196,9 +203,9 @@ module SwarmSDK
196
203
  #
197
204
  # @example With absolute path
198
205
  # swarm = SwarmSDK.load_file("/absolute/path/config.yml")
199
- def load_file(path)
206
+ def load_file(path, allow_filesystem_tools: nil)
200
207
  config = Configuration.load_file(path)
201
- swarm = config.to_swarm
208
+ swarm = config.to_swarm(allow_filesystem_tools: allow_filesystem_tools)
202
209
 
203
210
  # Apply hooks if any are configured (YAML-only feature)
204
211
  if hooks_configured?(config)
@@ -235,7 +242,7 @@ module SwarmSDK
235
242
  def hooks_configured?(config)
236
243
  config.swarm_hooks.any? ||
237
244
  config.all_agents_hooks.any? ||
238
- config.agents.any? { |_, agent_def| agent_def.hooks&.any? }
245
+ config.agents.any? { |_, agent_config| agent_config[:hooks]&.any? }
239
246
  end
240
247
 
241
248
  # Parse configuration error and extract structured information
@@ -323,7 +330,7 @@ module SwarmSDK
323
330
  field: "swarm.lead",
324
331
  )
325
332
 
326
- # Unknown agent in connections
333
+ # Unknown agent in connections (old format)
327
334
  when /Agent '([^']+)' has connection to unknown agent '([^']+)'/i
328
335
  agent_name = Regexp.last_match(1)
329
336
  error_hash.merge!(
@@ -332,6 +339,15 @@ module SwarmSDK
332
339
  agent: agent_name,
333
340
  )
334
341
 
342
+ # Unknown agent in connections (new format with composable swarms)
343
+ when /Agent '([^']+)' delegates to unknown target '([^']+)'/i
344
+ agent_name = Regexp.last_match(1)
345
+ error_hash.merge!(
346
+ type: :invalid_reference,
347
+ field: "swarm.agents.#{agent_name}.delegates_to",
348
+ agent: agent_name,
349
+ )
350
+
335
351
  # Circular dependency
336
352
  when /Circular dependency detected/i
337
353
  error_hash.merge!(
@@ -397,17 +413,33 @@ module SwarmSDK
397
413
  # WebFetch tool LLM processing configuration
398
414
  attr_accessor :webfetch_provider, :webfetch_model, :webfetch_base_url, :webfetch_max_tokens
399
415
 
416
+ # Filesystem tools control
417
+ attr_accessor :allow_filesystem_tools
418
+
400
419
  def initialize
401
420
  @webfetch_provider = nil
402
421
  @webfetch_model = nil
403
422
  @webfetch_base_url = nil
404
423
  @webfetch_max_tokens = 4096
424
+ @allow_filesystem_tools = parse_env_bool("SWARM_SDK_ALLOW_FILESYSTEM_TOOLS", default: true)
405
425
  end
406
426
 
407
427
  # Check if WebFetch LLM processing is enabled
408
428
  def webfetch_llm_enabled?
409
429
  !@webfetch_provider.nil? && !@webfetch_model.nil?
410
430
  end
431
+
432
+ private
433
+
434
+ def parse_env_bool(key, default:)
435
+ return default unless ENV.key?(key)
436
+
437
+ value = ENV[key].to_s.downcase
438
+ return true if ["true", "yes", "1", "on", "enabled"].include?(value)
439
+ return false if ["false", "no", "0", "off", "disabled"].include?(value)
440
+
441
+ default
442
+ end
411
443
  end
412
444
 
413
445
  # Initialize default settings