swarm_sdk 2.7.11 → 2.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bcabd24d5d88debcf61b86803b61996a60a8d56dac5ca56c1952478df8725079
4
- data.tar.gz: 1c661fd7c839822ce4cddcfbb499ed8700b3311b45d6f791e94e3b4fb9965dd3
3
+ metadata.gz: 427d736e32c386d76df7481b50b95e272045b88eb93290353528fd48c2525d91
4
+ data.tar.gz: fc3cf69eaa253871f0db9f6332dd62669af2941082f622e266f65e035f6b2b21
5
5
  SHA512:
6
- metadata.gz: eec76f06fd85bbfe4bfc124473760ef9a3a84dd69aae3abb3de018b18b817882b5b1d23e1708cd9dce7f04abff433bec60eb436d7e5939a8925ef84f44a88005
7
- data.tar.gz: 5b71b6839d87d0010525a5dcf708424a8a585ed59472a384af0d13b5e2992e760f9ee15e9707c63ce0023e3aef3e19c373e333fa1d6dfd3bcb87dc59b9dd76c3
6
+ metadata.gz: d08958fe437094ce91bdae63e6e20290158417270d1d39446fbb9cc45b2ea02bfbbae99dccfb36097fb053a2190f4cee1fd101c876eda1a388c19ba3ba4cdf4c
7
+ data.tar.gz: cf06e09fe354ca95035d27e5ee4d710be461757829c1448c5d178b9f8a9b9372419e27bc036101d9ad0add625459784fa766413695f3f030efcba8fa97ee8467
@@ -62,6 +62,7 @@ module SwarmSDK
62
62
  @memory_config = nil
63
63
  @shared_across_delegations = nil # nil = not set (will default to false in Definition)
64
64
  @streaming = nil # nil = not set (will use global config default)
65
+ @thinking = nil # nil = not set (extended thinking disabled)
65
66
  @context_management_config = nil # Context management DSL hooks
66
67
  end
67
68
 
@@ -372,6 +373,38 @@ module SwarmSDK
372
373
  !@streaming.nil?
373
374
  end
374
375
 
376
+ # Configure extended thinking for this agent
377
+ #
378
+ # Extended thinking allows models to reason through complex problems before responding.
379
+ # For Anthropic models, specify a budget (token count). For OpenAI models, specify effort.
380
+ # Both can be specified for cross-provider compatibility.
381
+ #
382
+ # @param effort [Symbol, String, nil] Reasoning effort level (:low, :medium, :high) — used by OpenAI
383
+ # @param budget [Integer, nil] Token budget for thinking — used by Anthropic
384
+ # @return [self] Returns self for method chaining
385
+ #
386
+ # @example Anthropic thinking with budget
387
+ # thinking budget: 10_000
388
+ #
389
+ # @example OpenAI reasoning effort
390
+ # thinking effort: :high
391
+ #
392
+ # @example Cross-provider (both)
393
+ # thinking effort: :high, budget: 10_000
394
+ def thinking(effort: nil, budget: nil)
395
+ raise ArgumentError, "thinking requires :effort or :budget" if effort.nil? && budget.nil?
396
+
397
+ @thinking = { effort: effort, budget: budget }.compact
398
+ self
399
+ end
400
+
401
+ # Check if thinking has been explicitly set
402
+ #
403
+ # @return [Boolean] true if thinking was explicitly configured
404
+ def thinking_set?
405
+ !@thinking.nil?
406
+ end
407
+
375
408
  # Configure context management handlers
376
409
  #
377
410
  # Define custom handlers for context warning thresholds (60%, 80%, 90%).
@@ -552,6 +585,7 @@ module SwarmSDK
552
585
  agent_config[:memory] = @memory_config if @memory_config
553
586
  agent_config[:shared_across_delegations] = @shared_across_delegations unless @shared_across_delegations.nil?
554
587
  agent_config[:streaming] = @streaming unless @streaming.nil?
588
+ agent_config[:thinking] = @thinking if @thinking
555
589
 
556
590
  # Convert DSL hooks to HookDefinition format
557
591
  agent_config[:hooks] = convert_hooks_to_definitions if @hooks.any?
@@ -189,10 +189,11 @@ module SwarmSDK
189
189
  # Try to fetch real model info for accurate context tracking
190
190
  fetch_real_model_info(model_id)
191
191
 
192
- # Configure system prompt, parameters, and headers
192
+ # Configure system prompt, parameters, headers, and thinking
193
193
  configure_system_prompt(system_prompt) if system_prompt
194
194
  configure_parameters(parameters)
195
195
  configure_headers(custom_headers)
196
+ configure_thinking(definition[:thinking])
196
197
 
197
198
  # Setup around_tool_execution hook for SwarmSDK orchestration
198
199
  setup_tool_execution_hook
@@ -525,17 +526,24 @@ module SwarmSDK
525
526
  #
526
527
  # This method:
527
528
  # 1. Serializes concurrent asks via @ask_semaphore
528
- # 2. Adds CLEAN user message to history (no reminders)
529
- # 3. Injects system reminders as ephemeral content (sent to LLM but not stored)
530
- # 4. Triggers user_prompt hooks
531
- # 5. Acquires global semaphore for LLM call
532
- # 6. Delegates to RubyLLM::Chat for actual execution
529
+ # 2. Optionally clears conversation context (inside semaphore for safety)
530
+ # 3. Adds CLEAN user message to history (no reminders)
531
+ # 4. Injects system reminders as ephemeral content (sent to LLM but not stored)
532
+ # 5. Triggers user_prompt hooks
533
+ # 6. Acquires global semaphore for LLM call
534
+ # 7. Delegates to RubyLLM::Chat for actual execution
533
535
  #
534
536
  # @param prompt [String] User prompt
537
+ # @param clear_context [Boolean] When true, clears conversation history before
538
+ # processing. Clearing happens inside the ask_semaphore, making it safe for
539
+ # concurrent callers (e.g., parallel delegations to the same agent).
535
540
  # @param options [Hash] Additional options (source: for hooks)
536
541
  # @return [RubyLLM::Message] LLM response
537
- def ask(prompt, **options)
542
+ def ask(prompt, clear_context: false, **options)
538
543
  @ask_semaphore.acquire do
544
+ # Clear inside semaphore so concurrent callers don't corrupt each other's messages
545
+ clear_conversation if clear_context
546
+
539
547
  if @turn_timeout
540
548
  execute_with_turn_timeout(prompt, options)
541
549
  else
@@ -986,6 +994,12 @@ module SwarmSDK
986
994
  emit_non_retryable_error(e, "UnknownAPIError")
987
995
  return build_error_message(e)
988
996
 
997
+ # === CATEGORY A (CONTINUED): PROGRAMMING ERRORS ===
998
+ rescue ArgumentError, TypeError, NameError => e
999
+ # Programming errors (wrong keywords, type mismatches) - won't fix by retrying
1000
+ emit_non_retryable_error(e, e.class.name)
1001
+ return build_error_message(e)
1002
+
989
1003
  # === CATEGORY C: NETWORK/OTHER ERRORS ===
990
1004
  rescue StandardError => e
991
1005
  # Network errors, timeouts, unknown errors - retry with delays
@@ -224,6 +224,22 @@ module SwarmSDK
224
224
  RubyLLM.logger.debug("SwarmSDK: Enabled native Responses API support")
225
225
  end
226
226
 
227
+ # Configure extended thinking on the RubyLLM chat instance
228
+ #
229
+ # @param thinking_config [Hash, nil] Thinking configuration with :effort and/or :budget
230
+ # @return [self]
231
+ #
232
+ # @example
233
+ # configure_thinking(budget: 10_000)
234
+ # configure_thinking(effort: :high)
235
+ # configure_thinking(effort: :high, budget: 10_000)
236
+ def configure_thinking(thinking_config)
237
+ return self unless thinking_config
238
+
239
+ @llm_chat.with_thinking(**thinking_config)
240
+ self
241
+ end
242
+
227
243
  # Configure LLM parameters with proper temperature normalization
228
244
  #
229
245
  # @param params [Hash] Parameter hash
@@ -42,7 +42,8 @@ module SwarmSDK
42
42
  :hooks,
43
43
  :plugin_configs,
44
44
  :shared_across_delegations,
45
- :streaming
45
+ :streaming,
46
+ :thinking
46
47
 
47
48
  attr_accessor :bypass_permissions, :max_concurrent_tools
48
49
 
@@ -114,6 +115,9 @@ module SwarmSDK
114
115
  # Streaming configuration (default: true from global config)
115
116
  @streaming = config.fetch(:streaming, SwarmSDK.config.streaming)
116
117
 
118
+ # Extended thinking configuration (nil = disabled)
119
+ @thinking = config[:thinking]
120
+
117
121
  # Build system prompt after directory and memory are set
118
122
  @system_prompt = build_full_system_prompt(config[:system_prompt])
119
123
 
@@ -82,6 +82,7 @@ module SwarmSDK
82
82
  def emit_request_event(env, timestamp)
83
83
  request_data = {
84
84
  provider: @provider_name,
85
+ url: env.url.to_s,
85
86
  body: parse_body(env.body),
86
87
  timestamp: timestamp.utc.iso8601,
87
88
  }
@@ -450,6 +450,10 @@ module SwarmSDK
450
450
  if !all_agents_hash[:streaming].nil? && !agent_builder.streaming_set?
451
451
  agent_builder.streaming(all_agents_hash[:streaming])
452
452
  end
453
+
454
+ if all_agents_hash[:thinking] && !agent_builder.thinking_set?
455
+ agent_builder.thinking(**all_agents_hash[:thinking])
456
+ end
453
457
  end
454
458
 
455
459
  # Validate all_agents filesystem tools
@@ -100,6 +100,7 @@ module SwarmSDK
100
100
  coding_agent(all_agents_cfg[:coding_agent]) unless all_agents_cfg[:coding_agent].nil?
101
101
  disable_default_tools(all_agents_cfg[:disable_default_tools]) unless all_agents_cfg[:disable_default_tools].nil?
102
102
  streaming(all_agents_cfg[:streaming]) unless all_agents_cfg[:streaming].nil?
103
+ thinking(**all_agents_cfg[:thinking]) if all_agents_cfg[:thinking]
103
104
 
104
105
  if all_agents_hks.any?
105
106
  all_agents_hks.each do |event, hook_specs|
@@ -164,6 +165,7 @@ module SwarmSDK
164
165
  disable_default_tools(config[:disable_default_tools]) unless config[:disable_default_tools].nil?
165
166
  shared_across_delegations(config[:shared_across_delegations]) unless config[:shared_across_delegations].nil?
166
167
  streaming(config[:streaming]) unless config[:streaming].nil?
168
+ thinking(**config[:thinking]) if config[:thinking]
167
169
 
168
170
  if config[:tools]&.any?
169
171
  tool_names = config[:tools].map { |t| t.is_a?(Hash) ? t[:name] : t }
@@ -207,17 +207,19 @@ module RubyLLM
207
207
 
208
208
  # Perform the actual LLM request
209
209
  def perform_llm_request(messages_to_send, &block)
210
- @provider.complete(
211
- messages_to_send,
210
+ kwargs = {
212
211
  tools: @tools,
213
212
  temperature: @temperature,
214
213
  model: @model,
215
214
  params: @params,
216
215
  headers: @headers,
217
216
  schema: @schema,
218
- thinking: @thinking,
219
- &wrap_streaming_block(&block)
220
- )
217
+ }
218
+ # Only pass thinking when explicitly configured via with_thinking
219
+ # to maintain compatibility with providers that don't support this keyword
220
+ kwargs[:thinking] = @thinking if @thinking
221
+
222
+ @provider.complete(messages_to_send, **kwargs, &wrap_streaming_block(&block))
221
223
  rescue ArgumentError => e
222
224
  raise ArgumentError,
223
225
  "#{e.message} — provider #{@provider.class.name} does not support this parameter " \
@@ -3,6 +3,7 @@
3
3
  # Extends RubyLLM::Configuration with additional options:
4
4
  # - anthropic_api_base: Configurable Anthropic API base URL
5
5
  # - read_timeout, open_timeout, write_timeout: Granular timeout configuration
6
+ # - Fixes Anthropic completion_url leading slash that breaks proxy base URLs
6
7
  #
7
8
  # Fork Reference: Commits da6144b, 3daa4fb
8
9
 
@@ -29,13 +30,25 @@ module RubyLLM
29
30
  end
30
31
  end
31
32
 
32
- # Patch Anthropic provider to use configurable base URL
33
+ # Patch Anthropic provider to use configurable base URL and fix completion_url
33
34
  module Providers
34
35
  class Anthropic
35
36
  # Override api_base to use configurable base URL
36
37
  def api_base
37
38
  @config.anthropic_api_base || "https://api.anthropic.com"
38
39
  end
40
+
41
+ # Fix completion_url to use relative path (no leading slash).
42
+ # The leading slash causes Faraday to discard the base URL path component,
43
+ # breaking proxy configurations where api_base includes a path segment
44
+ # (e.g., https://proxy.dev/apis/anthropic/v1/messages → https://proxy.dev/v1/messages).
45
+ # stream_url delegates to completion_url, so this fixes both sync and streaming.
46
+ # Can be removed once RubyLLM releases a version including upstream fix (commit da6144b).
47
+ module Chat
48
+ def completion_url
49
+ "v1/messages"
50
+ end
51
+ end
39
52
  end
40
53
  end
41
54
  end
@@ -88,11 +88,15 @@ module RubyLLM
88
88
  "Add `gem 'async'` to your Gemfile. Original error: #{e.message}"
89
89
  end
90
90
 
91
- def run_with_sync(&)
92
- if defined?(Sync)
93
- Sync(&)
91
+ def run_with_sync(&block)
92
+ if Async::Task.current?
93
+ # Already inside an async reactor (SwarmSDK always runs in one).
94
+ # Just yield — no Sync, no nested reactor, no Promise mutex issues.
95
+ yield
94
96
  else
95
- Async(&).wait
97
+ # Outside async context (e.g., standalone RubyLLM usage).
98
+ # Sync handles reactor creation and cleanup.
99
+ Sync(&block)
96
100
  end
97
101
  end
98
102
 
@@ -36,6 +36,7 @@ module SwarmSDK
36
36
  @coding_agent = nil
37
37
  @disable_default_tools = nil
38
38
  @streaming = nil
39
+ @thinking = nil
39
40
  end
40
41
 
41
42
  # Set model for all agents
@@ -99,6 +100,16 @@ module SwarmSDK
99
100
  @streaming = value
100
101
  end
101
102
 
103
+ # Configure extended thinking for all agents
104
+ #
105
+ # @param effort [Symbol, String, nil] Reasoning effort (:low, :medium, :high) — OpenAI
106
+ # @param budget [Integer, nil] Token budget for thinking — Anthropic
107
+ def thinking(effort: nil, budget: nil)
108
+ raise ArgumentError, "thinking requires :effort or :budget" if effort.nil? && budget.nil?
109
+
110
+ @thinking = { effort: effort, budget: budget }.compact
111
+ end
112
+
102
113
  # Add tools that all agents will have
103
114
  def tools(*tool_names)
104
115
  @tools_list.concat(tool_names)
@@ -174,6 +185,7 @@ module SwarmSDK
174
185
  coding_agent: @coding_agent,
175
186
  disable_default_tools: @disable_default_tools,
176
187
  streaming: @streaming,
188
+ thinking: @thinking,
177
189
  tools: @tools_list,
178
190
  permissions: @permissions_config,
179
191
  }.compact
@@ -73,7 +73,6 @@ module SwarmSDK
73
73
  DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
74
74
 
75
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
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
 
@@ -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.11"
4
+ VERSION = "2.7.13"
5
5
  end
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.11
4
+ version: 2.7.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda