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 +4 -4
- data/lib/swarm_sdk/agent/builder.rb +34 -0
- data/lib/swarm_sdk/agent/chat.rb +21 -7
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +16 -0
- data/lib/swarm_sdk/agent/definition.rb +5 -1
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +1 -0
- data/lib/swarm_sdk/builders/base_builder.rb +4 -0
- data/lib/swarm_sdk/configuration/translator.rb +2 -0
- data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +7 -5
- data/lib/swarm_sdk/ruby_llm_patches/configuration_patch.rb +14 -1
- data/lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb +8 -4
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +12 -0
- data/lib/swarm_sdk/swarm.rb +0 -4
- data/lib/swarm_sdk/tools/delegate.rb +92 -47
- data/lib/swarm_sdk/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 427d736e32c386d76df7481b50b95e272045b88eb93290353528fd48c2525d91
|
|
4
|
+
data.tar.gz: fc3cf69eaa253871f0db9f6332dd62669af2941082f622e266f65e035f6b2b21
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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?
|
data/lib/swarm_sdk/agent/chat.rb
CHANGED
|
@@ -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
|
|
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.
|
|
529
|
-
# 3.
|
|
530
|
-
# 4.
|
|
531
|
-
# 5.
|
|
532
|
-
# 6.
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
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
|
data/lib/swarm_sdk/swarm.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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,
|
|
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
|
-
#
|
|
307
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
364
|
+
# Reset if keep_context: false (standard behavior)
|
|
365
|
+
swarm_registry.reset_if_needed(@delegate_target)
|
|
319
366
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
374
|
+
# @param delegation_path [Array<String>] Current Fiber-local delegation path
|
|
330
375
|
# @return [void]
|
|
331
|
-
def emit_circular_warning(
|
|
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
|
-
|
|
383
|
+
delegation_path: delegation_path,
|
|
339
384
|
timestamp: Time.now.utc.iso8601,
|
|
340
385
|
)
|
|
341
386
|
end
|
data/lib/swarm_sdk/version.rb
CHANGED