swarm_sdk 2.7.12 → 2.7.14

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.
@@ -72,8 +72,7 @@ module SwarmSDK
72
72
  # Default tools available to all agents
73
73
  DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
74
74
 
75
- attr_reader :name, :agents, :lead_agent, :mcp_clients, :delegation_instances, :agent_definitions, :swarm_id, :parent_swarm_id, :swarm_registry, :scratchpad_storage, :allow_filesystem_tools, :hook_registry, :global_semaphore, :plugin_storages, :config_for_hooks, :observer_configs, :execution_timeout
76
- attr_accessor :delegation_call_stack
75
+ attr_reader :name, :agents, :lead_agent, :mcp_clients, :delegation_instances, :agent_definitions, :swarm_id, :parent_swarm_id, :swarm_registry, :scratchpad_storage, :allow_filesystem_tools, :hook_registry, :global_semaphore, :plugin_storages, :config_for_hooks, :observer_configs, :execution_timeout, :stop_signal_read
77
76
 
78
77
  # Check if scratchpad tools are enabled
79
78
  #
@@ -174,9 +173,6 @@ module SwarmSDK
174
173
  # Swarm registry for managing sub-swarms (initialized later if needed)
175
174
  @swarm_registry = nil
176
175
 
177
- # Delegation call stack for circular dependency detection
178
- @delegation_call_stack = []
179
-
180
176
  # Shared semaphore for all agents
181
177
  @global_semaphore = Async::Semaphore.new(@global_concurrency)
182
178
 
@@ -221,6 +217,13 @@ module SwarmSDK
221
217
  # Observer agent configurations
222
218
  @observer_configs = []
223
219
  @observer_manager = nil
220
+
221
+ # Stop mechanism state
222
+ @stop_requested = false
223
+ @execution_barrier = nil
224
+ @stop_signal_read = nil
225
+ @stop_signal_write = nil
226
+ @active_agent_chats = {}
224
227
  end
225
228
 
226
229
  # Add an agent to the swarm
@@ -475,12 +478,17 @@ module SwarmSDK
475
478
 
476
479
  # Wait for all observer tasks to complete
477
480
  #
481
+ # If a stop was requested, stops observer tasks immediately instead of waiting.
478
482
  # Called by Executor to wait for observer agents before cleanup.
479
483
  # Safe to call even if no observers are configured.
480
484
  #
481
485
  # @return [void]
482
486
  def wait_for_observers
483
- @observer_manager&.wait_for_completion
487
+ if @stop_requested
488
+ stop_observers
489
+ else
490
+ @observer_manager&.wait_for_completion
491
+ end
484
492
  end
485
493
 
486
494
  # Cleanup observer subscriptions
@@ -494,6 +502,124 @@ module SwarmSDK
494
502
  @observer_manager = nil
495
503
  end
496
504
 
505
+ # Stop all swarm execution immediately
506
+ #
507
+ # Thread-safe method that signals the execution to stop. Uses IO.pipe
508
+ # for cross-thread signaling, which wakes the Async scheduler from any
509
+ # thread. The stop listener task then calls barrier.stop within the
510
+ # reactor to cancel all executing tasks.
511
+ #
512
+ # Safe to call from event callbacks, other threads, or signal handlers.
513
+ # No-op if no execution is in progress or stop was already requested.
514
+ #
515
+ # @return [void]
516
+ #
517
+ # @example Stop from event callback
518
+ # swarm.execute("Build auth") do |event|
519
+ # swarm.stop if event[:type] == "tool_call" && event[:tool] == "Dangerous"
520
+ # end
521
+ #
522
+ # @example Stop from another thread
523
+ # Thread.new { swarm.execute("Build auth") }
524
+ # sleep 10
525
+ # swarm.stop
526
+ def stop
527
+ return if @stop_requested
528
+
529
+ @stop_requested = true
530
+ begin
531
+ @stop_signal_write&.write("x") unless @stop_signal_write&.closed?
532
+ @stop_signal_write&.close unless @stop_signal_write&.closed?
533
+ rescue IOError, Errno::EPIPE
534
+ # Pipe already closed - normal during cleanup
535
+ end
536
+ end
537
+
538
+ # Check if a stop has been requested
539
+ #
540
+ # @return [Boolean] true if stop was requested
541
+ def stop_requested?
542
+ @stop_requested
543
+ end
544
+
545
+ # Prepare stop signaling for a new execution
546
+ #
547
+ # Resets the stop flag and creates a new IO.pipe for signaling.
548
+ # Called by Executor at the start of each execution.
549
+ #
550
+ # @return [void]
551
+ def prepare_for_execution
552
+ @stop_requested = false
553
+ @stop_signal_read, @stop_signal_write = IO.pipe
554
+ @active_agent_chats = {}
555
+ end
556
+
557
+ # Close the stop signal pipe
558
+ #
559
+ # Called by Executor after execution completes.
560
+ #
561
+ # @return [void]
562
+ def cleanup_stop_signal
563
+ @stop_signal_read&.close unless @stop_signal_read&.closed?
564
+ @stop_signal_write&.close unless @stop_signal_write&.closed?
565
+ @stop_signal_read = nil
566
+ @stop_signal_write = nil
567
+ end
568
+
569
+ # Register the execution barrier for stop cancellation
570
+ #
571
+ # @param barrier [Async::Barrier] The barrier wrapping execution tasks
572
+ # @return [void]
573
+ def register_execution_barrier(barrier)
574
+ @execution_barrier = barrier
575
+ end
576
+
577
+ # Clear the execution barrier reference
578
+ #
579
+ # @return [void]
580
+ def clear_execution_barrier
581
+ @execution_barrier = nil
582
+ end
583
+
584
+ # Mark an agent as actively executing an LLM call
585
+ #
586
+ # Called by Agent::Chat#execute_ask to track which agents are mid-execution.
587
+ # Used during interruption to emit agent_stop events for active agents.
588
+ #
589
+ # @param name [Symbol] Agent name
590
+ # @param chat [Agent::Chat] Agent chat instance
591
+ # @return [void]
592
+ def mark_agent_active(name, chat)
593
+ @active_agent_chats[name] = chat
594
+ end
595
+
596
+ # Mark an agent as no longer actively executing
597
+ #
598
+ # @param name [Symbol] Agent name
599
+ # @return [void]
600
+ def mark_agent_inactive(name)
601
+ @active_agent_chats.delete(name)
602
+ end
603
+
604
+ # Get a snapshot of currently active agent chats
605
+ #
606
+ # Returns a copy to avoid concurrent modification issues.
607
+ #
608
+ # @return [Hash{Symbol => Agent::Chat}] Copy of active agent chats
609
+ def active_agent_chats
610
+ @active_agent_chats.dup
611
+ end
612
+
613
+ # Stop all observer tasks immediately
614
+ #
615
+ # Interrupts in-flight observer LLM calls.
616
+ # Called during swarm interruption instead of wait_for_completion.
617
+ #
618
+ # @return [void]
619
+ def stop_observers
620
+ @observer_manager&.stop
621
+ end
622
+
497
623
  # Create snapshot of current conversation state
498
624
  #
499
625
  # Returns a Snapshot object containing:
@@ -45,7 +45,7 @@ module SwarmSDK
45
45
  # @param delegate_description [String] Description of the delegate agent
46
46
  # @param delegate_chat [AgentChat, nil] The chat instance for the delegate agent (nil if delegating to swarm)
47
47
  # @param agent_name [Symbol, String] Name of the agent using this tool
48
- # @param swarm [Swarm] The swarm instance (provides hook_registry, delegation_call_stack, swarm_registry)
48
+ # @param swarm [Swarm] The swarm instance (provides hook_registry, swarm_registry)
49
49
  # @param delegating_chat [Agent::Chat, nil] The chat instance of the agent doing the delegating (for accessing hooks)
50
50
  # @param custom_tool_name [String, nil] Optional custom tool name (overrides auto-generated name)
51
51
  # @param preserve_context [Boolean] Whether to preserve conversation context between delegations (default: true)
@@ -72,6 +72,16 @@ module SwarmSDK
72
72
  # Use custom tool name if provided, otherwise generate using canonical method
73
73
  @tool_name = custom_tool_name || self.class.tool_name_for(delegate_name)
74
74
  @delegate_target = delegate_name.to_s
75
+
76
+ # Track concurrent delegations to this target.
77
+ # When multiple parallel tool calls target the same delegate, only the first
78
+ # preserves context; subsequent concurrent calls always clear context to
79
+ # prevent cross-contamination between independent parallel work.
80
+ #
81
+ # No Mutex needed: Async Fibers run on a single thread and only switch at
82
+ # explicit yield points (IO, sleep, semaphore.acquire). Integer increment
83
+ # and decrement never yield, so they are inherently atomic.
84
+ @active_count = 0
75
85
  end
76
86
 
77
87
  # Override description to return dynamic string based on delegate
@@ -122,19 +132,32 @@ module SwarmSDK
122
132
 
123
133
  # Execute delegation with pre/post hooks
124
134
  #
135
+ # Uses Fiber-local path tracking for circular dependency detection.
136
+ # Each concurrent delegation runs in its own Fiber (via Async), so the path
137
+ # is isolated per execution path. This correctly distinguishes parallel fan-out
138
+ # (A→B, A→B) from true circular dependencies (A→B→A).
139
+ #
125
140
  # @param message [String] Message to send to the agent
126
141
  # @param reset_context [Boolean] Whether to reset the agent's conversation history before delegation
127
142
  # @return [String] Result from delegate agent or error message
128
143
  def execute(message:, reset_context: false)
144
+ # Save the current delegation path so we can restore it after execution.
145
+ # The extended path (with our target) is only needed during chat.ask() so
146
+ # child Fibers (nested delegations) inherit it. After delegation returns,
147
+ # this Fiber's path should be unchanged.
148
+ saved_delegation_path = Fiber[:delegation_path]
149
+
129
150
  # Access swarm infrastructure
130
- call_stack = @swarm.delegation_call_stack
131
151
  hook_registry = @swarm.hook_registry
132
152
  swarm_registry = @swarm.swarm_registry
133
153
 
134
- # Check for circular dependency
135
- if call_stack.include?(@delegate_target)
136
- emit_circular_warning(call_stack)
137
- return "Error: Circular delegation detected: #{call_stack.join(" -> ")} -> #{@delegate_target}. " \
154
+ # Check for circular dependency using Fiber-local path
155
+ # Each Fiber inherits the parent's path, so nested delegations
156
+ # accumulate the full chain while parallel siblings remain isolated
157
+ delegation_path = saved_delegation_path || []
158
+ if delegation_path.include?(@delegate_target)
159
+ emit_circular_warning(delegation_path)
160
+ return "Error: Circular delegation detected: #{delegation_path.join(" -> ")} -> #{@delegate_target}. " \
138
161
  "Please restructure your delegation to avoid infinite loops."
139
162
  end
140
163
 
@@ -172,10 +195,10 @@ module SwarmSDK
172
195
  # Determine delegation type and proceed
173
196
  delegation_result = if @delegate_chat
174
197
  # Delegate to agent
175
- delegate_to_agent(message, call_stack, reset_context: reset_context)
198
+ delegate_to_agent(message, reset_context: reset_context)
176
199
  elsif swarm_registry&.registered?(@delegate_target)
177
200
  # Delegate to registered swarm
178
- delegate_to_swarm(message, call_stack, swarm_registry, reset_context: reset_context)
201
+ delegate_to_swarm(message, swarm_registry, reset_context: reset_context)
179
202
  else
180
203
  raise ConfigurationError, "Unknown delegation target: #{@delegate_target}"
181
204
  end
@@ -246,6 +269,11 @@ module SwarmSDK
246
269
  # Return error string for LLM
247
270
  backtrace_str = backtrace_array.join("\n ")
248
271
  "Error: #{@tool_name} encountered an error: #{e.class.name}: #{e.message}\nBacktrace:\n #{backtrace_str}"
272
+ ensure
273
+ # Restore the calling Fiber's delegation path.
274
+ # The extended path was only needed during chat.ask() so child Fibers
275
+ # (spawned for nested tool calls) could inherit it for circular detection.
276
+ Fiber[:delegation_path] = saved_delegation_path
249
277
  end
250
278
 
251
279
  private
@@ -254,28 +282,41 @@ module SwarmSDK
254
282
  #
255
283
  # Handles both eager Agent::Chat instances and lazy-loaded delegates.
256
284
  # LazyDelegateChat instances are initialized on first access.
285
+ # Sets Fiber-local delegation path so child Fibers (nested delegations)
286
+ # inherit the full chain for circular dependency detection.
287
+ #
288
+ # Tracks concurrent delegations to this target. When multiple parallel
289
+ # tool calls target the same delegate (fan-out), only the first call
290
+ # preserves context; subsequent concurrent calls always clear context
291
+ # to prevent cross-contamination between independent parallel work.
292
+ # Context clearing happens inside Agent::Chat's ask_semaphore for safety.
257
293
  #
258
294
  # @param message [String] Message to send to the agent
259
- # @param call_stack [Array] Delegation call stack for circular dependency detection
260
295
  # @param reset_context [Boolean] Whether to reset the agent's conversation history before delegation
261
296
  # @return [String] Result from agent
262
- def delegate_to_agent(message, call_stack, reset_context: false)
263
- # Push delegate target onto call stack to track delegation chain
264
- call_stack.push(@delegate_target)
265
- begin
266
- # Resolve the chat instance (handles lazy loading)
267
- chat = resolve_delegate_chat
268
-
269
- # Clear conversation if reset_context is true OR if preserve_context is false
270
- # reset_context takes precedence as it's an explicit request
271
- chat.clear_conversation if reset_context || !@preserve_context
272
-
273
- response = chat.ask(message, source: "delegation")
274
- response.content
275
- ensure
276
- # Always pop from stack, even if delegation fails
277
- call_stack.pop
278
- end
297
+ def delegate_to_agent(message, reset_context: false)
298
+ @active_count += 1
299
+ concurrent = @active_count > 1
300
+
301
+ # Set Fiber-local delegation path for this execution path
302
+ # Child Fibers (from nested delegations) inherit this path automatically
303
+ # We create a new array to avoid mutating the parent Fiber's reference
304
+ Fiber[:delegation_path] = (Fiber[:delegation_path] || []) + [@delegate_target]
305
+
306
+ # Resolve the chat instance (handles lazy loading)
307
+ chat = resolve_delegate_chat
308
+
309
+ # Determine if context should be cleared:
310
+ # - reset_context: explicit caller request
311
+ # - !preserve_context: agent configuration
312
+ # - concurrent: parallel fan-out to same delegate (always isolate)
313
+ # Clearing is done inside chat.ask's semaphore to avoid race conditions
314
+ should_clear = reset_context || !@preserve_context || concurrent
315
+
316
+ response = chat.ask(message, source: "delegation", clear_context: should_clear)
317
+ response.content
318
+ ensure
319
+ @active_count -= 1
279
320
  end
280
321
 
281
322
  # Resolve the delegate chat instance
@@ -294,48 +335,52 @@ module SwarmSDK
294
335
 
295
336
  # Delegate to a registered swarm
296
337
  #
338
+ # Sets Fiber-local delegation path so child Fibers (nested delegations)
339
+ # inherit the full chain for circular dependency detection.
340
+ # Tracks concurrent delegations the same way as delegate_to_agent.
341
+ #
297
342
  # @param message [String] Message to send to the swarm
298
- # @param call_stack [Array] Delegation call stack for circular dependency detection
299
343
  # @param swarm_registry [SwarmRegistry] Registry for sub-swarms
300
344
  # @param reset_context [Boolean] Whether to reset the swarm's conversation history before delegation
301
345
  # @return [String] Result from swarm's lead agent
302
- def delegate_to_swarm(message, call_stack, swarm_registry, reset_context: false)
346
+ def delegate_to_swarm(message, swarm_registry, reset_context: false)
347
+ @active_count += 1
348
+ concurrent = @active_count > 1
349
+
350
+ # Set Fiber-local delegation path for this execution path
351
+ Fiber[:delegation_path] = (Fiber[:delegation_path] || []) + [@delegate_target]
352
+
303
353
  # Load sub-swarm (lazy load + cache)
304
354
  subswarm = swarm_registry.load_swarm(@delegate_target)
305
355
 
306
- # Push delegate target onto call stack to track delegation chain
307
- call_stack.push(@delegate_target)
308
- begin
309
- # Reset swarm if reset_context is true
310
- swarm_registry.reset(@delegate_target) if reset_context
356
+ # Reset swarm context if explicitly requested or concurrent fan-out
357
+ swarm_registry.reset(@delegate_target) if reset_context || concurrent
311
358
 
312
- # Execute sub-swarm's lead agent (uses agent() to trigger lazy initialization)
313
- lead_agent = subswarm.agent(subswarm.lead_agent)
314
- response = lead_agent.ask(message, source: "delegation")
315
- result = response.content
359
+ # Execute sub-swarm's lead agent (uses agent() to trigger lazy initialization)
360
+ lead_agent = subswarm.agent(subswarm.lead_agent)
361
+ response = lead_agent.ask(message, source: "delegation")
362
+ result = response.content
316
363
 
317
- # Reset if keep_context: false (standard behavior)
318
- swarm_registry.reset_if_needed(@delegate_target)
364
+ # Reset if keep_context: false (standard behavior)
365
+ swarm_registry.reset_if_needed(@delegate_target)
319
366
 
320
- result
321
- ensure
322
- # Always pop from stack, even if delegation fails
323
- call_stack.pop
324
- end
367
+ result
368
+ ensure
369
+ @active_count -= 1
325
370
  end
326
371
 
327
372
  # Emit circular dependency warning event
328
373
  #
329
- # @param call_stack [Array] Current delegation call stack
374
+ # @param delegation_path [Array<String>] Current Fiber-local delegation path
330
375
  # @return [void]
331
- def emit_circular_warning(call_stack)
376
+ def emit_circular_warning(delegation_path)
332
377
  LogStream.emit(
333
378
  type: "delegation_circular_dependency",
334
379
  agent: @agent_name,
335
380
  swarm_id: @swarm.swarm_id,
336
381
  parent_swarm_id: @swarm.parent_swarm_id,
337
382
  target: @delegate_target,
338
- call_stack: call_stack,
383
+ delegation_path: delegation_path,
339
384
  timestamp: Time.now.utc.iso8601,
340
385
  )
341
386
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
- VERSION = "2.7.12"
4
+ VERSION = "2.7.14"
5
5
  end
data/lib/swarm_sdk.rb CHANGED
@@ -91,6 +91,9 @@ module SwarmSDK
91
91
  # Raised when agent turn exceeds turn_timeout
92
92
  class TurnTimeoutError < TimeoutError; end
93
93
 
94
+ # Raised when swarm execution is interrupted via swarm.stop
95
+ class InterruptedError < Error; end
96
+
94
97
  # Base class for MCP-related errors (provides context about server/tool)
95
98
  class MCPError < Error; end
96
99
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: swarm_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.12
4
+ version: 2.7.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda
@@ -181,6 +181,7 @@ files:
181
181
  - lib/swarm_sdk/ruby_llm_patches/connection_patch.rb
182
182
  - lib/swarm_sdk/ruby_llm_patches/init.rb
183
183
  - lib/swarm_sdk/ruby_llm_patches/io_endpoint_patch.rb
184
+ - lib/swarm_sdk/ruby_llm_patches/mcp_ssl_patch.rb
184
185
  - lib/swarm_sdk/ruby_llm_patches/message_management_patch.rb
185
186
  - lib/swarm_sdk/ruby_llm_patches/responses_api_patch.rb
186
187
  - lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb