swarm_sdk 2.5.5 → 2.6.1

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: 6a4f3f78d9207417839e82926b9960b56f6217cf030db8c741f8ef01111c7866
4
- data.tar.gz: 49b9bb1af8c79090dcb061dc519e9c9c286829f695feb02385be1a44082d8f24
3
+ metadata.gz: ce2f1c2ddb88cfb9a4fe7505fc0417494c2baf22a41678cbe7eb9085d96f136f
4
+ data.tar.gz: 5d5c8174d6f96d52f6855636d5fc7c485db6a0470e11d18ee2b5866fcd91431a
5
5
  SHA512:
6
- metadata.gz: 4760b9d3c722515a641c28da4a0575b8695074fcf8de8ccea0f71a14f23e1f53bfab6eec846132318fe159f253f7d70c166d36aaf06eb13c437043eb1d3bf97d
7
- data.tar.gz: 60b24194187ab0a14e532e44b06c47e562fbf6edea5e35f369823bbf697558f8dbd4705ed3c91aea8d061573eb21d45651c6d665a46e9c3121109e7cd1728f30
6
+ metadata.gz: 5e503eeda171b1840cabc10fccc5f98d1c4bcd75eba1782f885fd19cc09219b39850662c901793ff9e9d0cbfbdfcc9759071c782476bb793ab87f9ddd94c3ae9
7
+ data.tar.gz: 054e3a4db5ead9c96e2d332a1a85e9102bdaabe8e50c1e3163776789709289b5fbc73fce5f73b4af856c6e1ca0a3e6f5b3cbf4918f10f6a946c9ef6223384b12
@@ -49,7 +49,8 @@ module SwarmSDK
49
49
  @directory = "."
50
50
  @parameters = {}
51
51
  @headers = {}
52
- @timeout = nil
52
+ @request_timeout = nil
53
+ @turn_timeout = nil
53
54
  @mcp_servers = []
54
55
  @disable_default_tools = nil # nil = include all default tools
55
56
  @bypass_permissions = false
@@ -112,11 +113,18 @@ module SwarmSDK
112
113
  @headers = header_hash
113
114
  end
114
115
 
115
- # Set/get timeout
116
- def timeout(seconds = :__not_provided__)
117
- return @timeout if seconds == :__not_provided__
116
+ # Set/get request timeout
117
+ def request_timeout(seconds = :__not_provided__)
118
+ return @request_timeout if seconds == :__not_provided__
118
119
 
119
- @timeout = seconds
120
+ @request_timeout = seconds
121
+ end
122
+
123
+ # Set/get turn timeout
124
+ def turn_timeout(seconds = :__not_provided__)
125
+ return @turn_timeout if seconds == :__not_provided__
126
+
127
+ @turn_timeout = seconds
120
128
  end
121
129
 
122
130
  # Add an MCP server configuration
@@ -242,8 +250,39 @@ module SwarmSDK
242
250
  end
243
251
 
244
252
  # Set delegation targets
245
- def delegates_to(*agent_names)
246
- @delegates_to.concat(agent_names)
253
+ #
254
+ # Supports multiple formats for flexibility:
255
+ #
256
+ # @example Simple array (backwards compatible)
257
+ # delegates_to :frontend, :backend, :qa
258
+ #
259
+ # @example Hash with custom tool names
260
+ # delegates_to frontend: "AskFrontend",
261
+ # backend: "GetBackendHelp",
262
+ # qa: "RequestReview"
263
+ #
264
+ # @example Mixed - some auto, some custom
265
+ # delegates_to :frontend,
266
+ # backend: "GetBackendHelp",
267
+ # :qa
268
+ #
269
+ # @param agent_names_and_options [Array<Symbol, Hash>] Agent names and/or hash with custom tool names
270
+ # @return [void]
271
+ def delegates_to(*agent_names_and_options)
272
+ agent_names_and_options.each do |item|
273
+ case item
274
+ when Symbol, String
275
+ # Simple format: :frontend
276
+ @delegates_to << { agent: item.to_sym, tool_name: nil }
277
+ when Hash
278
+ # Hash format: { frontend: "AskFrontend", backend: nil }
279
+ item.each do |agent, tool_name|
280
+ @delegates_to << { agent: agent.to_sym, tool_name: tool_name }
281
+ end
282
+ else
283
+ raise ConfigurationError, "delegates_to accepts Symbols or Hashes, got #{item.class}"
284
+ end
285
+ end
247
286
  end
248
287
 
249
288
  # Add a hook (Ruby block OR shell command)
@@ -386,13 +425,22 @@ module SwarmSDK
386
425
  !@api_version.nil?
387
426
  end
388
427
 
389
- # Check if timeout has been explicitly set
428
+ # Check if request_timeout has been explicitly set
429
+ #
430
+ # Used by Swarm::Builder to determine if all_agents request_timeout should apply.
431
+ #
432
+ # @return [Boolean] true if request_timeout was explicitly set
433
+ def request_timeout_set?
434
+ !@request_timeout.nil?
435
+ end
436
+
437
+ # Check if turn_timeout has been explicitly set
390
438
  #
391
- # Used by Swarm::Builder to determine if all_agents timeout should apply.
439
+ # Used by Swarm::Builder to determine if all_agents turn_timeout should apply.
392
440
  #
393
- # @return [Boolean] true if timeout was explicitly set
394
- def timeout_set?
395
- !@timeout.nil?
441
+ # @return [Boolean] true if turn_timeout was explicitly set
442
+ def turn_timeout_set?
443
+ !@turn_timeout.nil?
396
444
  end
397
445
 
398
446
  # Check if coding_agent has been explicitly set
@@ -448,7 +496,8 @@ module SwarmSDK
448
496
  agent_config[:context_window] = @context_window if @context_window
449
497
  agent_config[:parameters] = @parameters if @parameters.any?
450
498
  agent_config[:headers] = @headers if @headers.any?
451
- agent_config[:timeout] = @timeout if @timeout
499
+ agent_config[:request_timeout] = @request_timeout if @request_timeout
500
+ agent_config[:turn_timeout] = @turn_timeout if @turn_timeout
452
501
  agent_config[:mcp_servers] = @mcp_servers if @mcp_servers.any?
453
502
  agent_config[:disable_default_tools] = @disable_default_tools unless @disable_default_tools.nil?
454
503
  agent_config[:bypass_permissions] = @bypass_permissions
@@ -122,7 +122,7 @@ module SwarmSDK
122
122
  max_concurrent_tools = definition[:max_concurrent_tools]
123
123
  base_url = definition[:base_url]
124
124
  api_version = definition[:api_version]
125
- timeout = definition[:timeout] || SwarmSDK.config.agent_request_timeout
125
+ request_timeout = definition[:request_timeout] || SwarmSDK.config.agent_request_timeout
126
126
  assume_model_exists = definition[:assume_model_exists]
127
127
  system_prompt = definition[:system_prompt]
128
128
  parameters = definition[:parameters]
@@ -131,6 +131,9 @@ module SwarmSDK
131
131
  # Agent identifier (for plugin callbacks)
132
132
  @agent_name = agent_name
133
133
 
134
+ # Turn timeout (external timeout for entire ask() call)
135
+ @turn_timeout = definition[:turn_timeout]
136
+
134
137
  # Context manager for ephemeral messages
135
138
  @context_manager = ContextManager.new
136
139
 
@@ -162,7 +165,7 @@ module SwarmSDK
162
165
  provider_name: provider_name,
163
166
  base_url: base_url,
164
167
  api_version: api_version,
165
- timeout: timeout,
168
+ timeout: request_timeout,
166
169
  assume_model_exists: assume_model_exists,
167
170
  max_concurrent_tools: max_concurrent_tools,
168
171
  )
@@ -461,48 +464,11 @@ module SwarmSDK
461
464
  # @return [RubyLLM::Message] LLM response
462
465
  def ask(prompt, **options)
463
466
  @ask_semaphore.acquire do
464
- is_first = first_message?
465
-
466
- # Collect system reminders to inject as ephemeral content
467
- reminders = collect_system_reminders(prompt, is_first)
468
-
469
- # Trigger user_prompt hook (with clean prompt, not reminders)
470
- source = options.delete(:source) || "user"
471
- final_prompt = prompt
472
- if @hook_executor
473
- hook_result = trigger_user_prompt(prompt, source: source)
474
-
475
- if hook_result[:halted]
476
- return RubyLLM::Message.new(
477
- role: :assistant,
478
- content: hook_result[:halt_message],
479
- model_id: model_id,
480
- )
481
- end
482
-
483
- final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
484
- end
485
-
486
- # Add CLEAN user message to history (no reminders embedded)
487
- @llm_chat.add_message(role: :user, content: final_prompt)
488
-
489
- # Track reminders as ephemeral content for this LLM call only
490
- # They'll be injected by around_llm_request hook but not stored
491
- reminders.each do |reminder|
492
- @context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
493
- end
494
-
495
- # Execute complete() which handles tool loop and ephemeral injection
496
- response = execute_with_global_semaphore do
497
- catch(:finish_agent) do
498
- catch(:finish_swarm) do
499
- @llm_chat.complete(**options)
500
- end
501
- end
467
+ if @turn_timeout
468
+ execute_with_turn_timeout(prompt, options)
469
+ else
470
+ execute_ask(prompt, options)
502
471
  end
503
-
504
- # Handle finish markers from hooks
505
- handle_finish_marker(response)
506
472
  end
507
473
  end
508
474
 
@@ -559,6 +525,103 @@ module SwarmSDK
559
525
 
560
526
  private
561
527
 
528
+ # Execute ask with turn timeout wrapper
529
+ def execute_with_turn_timeout(prompt, options)
530
+ task = Async::Task.current
531
+
532
+ # Use barrier to track child tasks spawned during this turn
533
+ # (includes RubyLLM's async tool execution when max_concurrent_tools is set)
534
+ barrier = Async::Barrier.new
535
+
536
+ begin
537
+ task.with_timeout(
538
+ @turn_timeout,
539
+ TurnTimeoutError,
540
+ "Agent turn timed out after #{@turn_timeout}s",
541
+ ) do
542
+ # Execute inside barrier to track child tasks
543
+ barrier.async do
544
+ execute_ask(prompt, options)
545
+ end.wait
546
+ end
547
+ rescue TurnTimeoutError
548
+ # Stop all child tasks
549
+ barrier.stop
550
+
551
+ emit_turn_timeout_event
552
+
553
+ # Return error message as response so caller can handle gracefully
554
+ # Format like other tool/delegation errors for natural flow
555
+ # This message goes to the swarm/caller, NOT added to agent's conversation history
556
+ RubyLLM::Message.new(
557
+ role: :assistant,
558
+ content: "Error: Request timed out after #{@turn_timeout}s. The agent did not complete its response within the time limit. Please try a simpler request or increase the turn timeout.",
559
+ model_id: model_id,
560
+ )
561
+ ensure
562
+ # Cleanup barrier if not already stopped
563
+ barrier.stop unless barrier.empty?
564
+ end
565
+ end
566
+
567
+ # Emit turn timeout event
568
+ def emit_turn_timeout_event
569
+ LogStream.emit(
570
+ type: "turn_timeout",
571
+ agent: @agent_name,
572
+ swarm_id: @agent_context&.swarm_id,
573
+ parent_swarm_id: @agent_context&.parent_swarm_id,
574
+ limit: @turn_timeout,
575
+ message: "Agent turn timed out after #{@turn_timeout}s",
576
+ )
577
+ end
578
+
579
+ # Execute ask without timeout (original ask implementation)
580
+ def execute_ask(prompt, options)
581
+ is_first = first_message?
582
+
583
+ # Collect system reminders to inject as ephemeral content
584
+ reminders = collect_system_reminders(prompt, is_first)
585
+
586
+ # Trigger user_prompt hook (with clean prompt, not reminders)
587
+ source = options.delete(:source) || "user"
588
+ final_prompt = prompt
589
+ if @hook_executor
590
+ hook_result = trigger_user_prompt(prompt, source: source)
591
+
592
+ if hook_result[:halted]
593
+ return RubyLLM::Message.new(
594
+ role: :assistant,
595
+ content: hook_result[:halt_message],
596
+ model_id: model_id,
597
+ )
598
+ end
599
+
600
+ final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
601
+ end
602
+
603
+ # Add CLEAN user message to history (no reminders embedded)
604
+ @llm_chat.add_message(role: :user, content: final_prompt)
605
+
606
+ # Track reminders as ephemeral content for this LLM call only
607
+ # They'll be injected by around_llm_request hook but not stored
608
+ reminders.each do |reminder|
609
+ @context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
610
+ end
611
+
612
+ # Execute complete() which handles tool loop and ephemeral injection
613
+ response = execute_with_global_semaphore do
614
+ catch(:finish_agent) do
615
+ catch(:finish_swarm) do
616
+ @llm_chat.complete(**options)
617
+ end
618
+ end
619
+ end
620
+
621
+ # Handle finish markers from hooks
622
+ handle_finish_marker(response)
623
+ end
624
+
562
625
  # --- Tool Execution Hook ---
563
626
 
564
627
  # Setup around_tool_execution hook for SwarmSDK orchestration
@@ -24,7 +24,7 @@ module SwarmSDK
24
24
  :context_window,
25
25
  :directory,
26
26
  :tools,
27
- :delegates_to,
27
+ :delegation_configs, # Full delegation config with tool names
28
28
  :system_prompt,
29
29
  :provider,
30
30
  :base_url,
@@ -32,7 +32,8 @@ module SwarmSDK
32
32
  :mcp_servers,
33
33
  :parameters,
34
34
  :headers,
35
- :timeout,
35
+ :request_timeout,
36
+ :turn_timeout,
36
37
  :disable_default_tools,
37
38
  :coding_agent,
38
39
  :default_permissions,
@@ -74,9 +75,16 @@ module SwarmSDK
74
75
  @context_window = coerce_to_integer(config[:context_window]) # Explicit context window override
75
76
  @parameters = config[:parameters] || {}
76
77
  @headers = Utils.stringify_keys(config[:headers] || {})
77
- @timeout = config[:timeout] || SwarmSDK.config.agent_request_timeout
78
+ @request_timeout = config[:request_timeout] || SwarmSDK.config.agent_request_timeout
78
79
  @bypass_permissions = config[:bypass_permissions] || false
79
80
  @max_concurrent_tools = config[:max_concurrent_tools]
81
+
82
+ # Use default from config unless explicitly set (including nil to disable)
83
+ @turn_timeout = if config.key?(:turn_timeout)
84
+ config[:turn_timeout] # Could be a number OR nil (to disable)
85
+ else
86
+ SwarmSDK.config.default_turn_timeout
87
+ end
80
88
  # Always assume model exists - SwarmSDK validates models separately using models.json
81
89
  # This prevents RubyLLM from trying to validate models in its registry
82
90
  @assume_model_exists = true
@@ -117,7 +125,8 @@ module SwarmSDK
117
125
  # Inject default write restrictions for security
118
126
  @tools = inject_default_write_permissions(@tools)
119
127
 
120
- @delegates_to = Array(config[:delegates_to] || []).map(&:to_sym).uniq
128
+ # Parse delegation configuration (supports both simple arrays and custom tool names)
129
+ @delegation_configs = parse_delegation_config(config[:delegates_to])
121
130
  @mcp_servers = Array(config[:mcp_servers] || [])
122
131
 
123
132
  # Parse hooks configuration
@@ -127,6 +136,20 @@ module SwarmSDK
127
136
  validate!
128
137
  end
129
138
 
139
+ # Get agent names that this agent delegates to (backwards compatible)
140
+ #
141
+ # Returns an array of agent name symbols. This maintains backwards compatibility
142
+ # with existing code that expects delegates_to to be a simple array.
143
+ #
144
+ # @return [Array<Symbol>] Delegate agent names
145
+ #
146
+ # @example
147
+ # agent_definition.delegates_to
148
+ # # => [:frontend, :backend, :qa]
149
+ def delegates_to
150
+ @delegation_configs.map { |config| config[:agent] }
151
+ end
152
+
130
153
  # Get plugin-specific configuration
131
154
  #
132
155
  # Plugins store their configuration in the generic plugin_configs hash.
@@ -138,7 +161,7 @@ module SwarmSDK
138
161
  #
139
162
  # @example
140
163
  # agent_definition.plugin_config(:memory)
141
- # # => { directory: "tmp/memory", mode: :researcher }
164
+ # # => { directory: "tmp/memory", mode: :full_access }
142
165
  def plugin_config(plugin_name)
143
166
  @plugin_configs[plugin_name.to_sym] || @plugin_configs[plugin_name.to_s]
144
167
  end
@@ -152,7 +175,7 @@ module SwarmSDK
152
175
  context_window: @context_window,
153
176
  directory: @directory,
154
177
  tools: @tools,
155
- delegates_to: @delegates_to,
178
+ delegates_to: @delegation_configs, # Serialize full config
156
179
  system_prompt: @system_prompt,
157
180
  provider: @provider,
158
181
  base_url: @base_url,
@@ -160,7 +183,8 @@ module SwarmSDK
160
183
  mcp_servers: @mcp_servers,
161
184
  parameters: @parameters,
162
185
  headers: @headers,
163
- timeout: @timeout,
186
+ request_timeout: @request_timeout,
187
+ turn_timeout: @turn_timeout,
164
188
  bypass_permissions: @bypass_permissions,
165
189
  disable_default_tools: @disable_default_tools,
166
190
  coding_agent: @coding_agent,
@@ -276,6 +300,51 @@ module SwarmSDK
276
300
  File.expand_path(directory_config.to_s)
277
301
  end
278
302
 
303
+ # Parse delegation configuration
304
+ #
305
+ # Supports multiple formats for backwards compatibility and new features:
306
+ # 1. Simple array (backwards compatible): [:frontend, :backend]
307
+ # 2. Hash with custom tool names: { frontend: "AskFrontend", backend: nil }
308
+ # 3. Array of hashes: [{ agent: :frontend, tool_name: "AskFrontend" }]
309
+ #
310
+ # Returns normalized format: [{agent: :name, tool_name: "Custom" or nil}]
311
+ #
312
+ # @param delegation_config [nil, Array, Hash] Delegation configuration
313
+ # @return [Array<Hash>] Normalized delegation config
314
+ def parse_delegation_config(delegation_config)
315
+ return [] if delegation_config.nil?
316
+ return [] if delegation_config.respond_to?(:empty?) && delegation_config.empty?
317
+
318
+ # Handle array format (could be symbols or hashes)
319
+ if delegation_config.is_a?(Array)
320
+ delegation_config.flat_map do |item|
321
+ case item
322
+ when Symbol, String
323
+ # Simple format: :frontend → {agent: :frontend, tool_name: nil}
324
+ [{ agent: item.to_sym, tool_name: nil }]
325
+ when Hash
326
+ # Could be already normalized or hash format
327
+ if item.key?(:agent)
328
+ # Already normalized: {agent: :frontend, tool_name: "Custom"}
329
+ [item]
330
+ else
331
+ # Hash format in array: {frontend: "AskFrontend"}
332
+ item.map { |agent, tool_name| { agent: agent.to_sym, tool_name: tool_name } }
333
+ end
334
+ else
335
+ raise ConfigurationError, "Invalid delegation config format: #{item.inspect}"
336
+ end
337
+ end.uniq { |config| config[:agent] } # Remove duplicates by agent name
338
+ elsif delegation_config.is_a?(Hash)
339
+ # Hash format: {frontend: "AskFrontend", backend: nil}
340
+ delegation_config.map do |agent, tool_name|
341
+ { agent: agent.to_sym, tool_name: tool_name }
342
+ end
343
+ else
344
+ raise ConfigurationError, "delegates_to must be an Array or Hash, got #{delegation_config.class}"
345
+ end
346
+ end
347
+
279
348
  # Extract plugin-specific configuration keys from the config hash
280
349
  #
281
350
  # Standard SDK keys are filtered out, leaving only plugin-specific keys.
@@ -294,7 +363,8 @@ module SwarmSDK
294
363
  :context_window,
295
364
  :parameters,
296
365
  :headers,
297
- :timeout,
366
+ :request_timeout,
367
+ :turn_timeout,
298
368
  :bypass_permissions,
299
369
  :max_concurrent_tools,
300
370
  :assume_model_exists,
@@ -465,6 +535,11 @@ module SwarmSDK
465
535
  def validate!
466
536
  raise ConfigurationError, "Agent '#{@name}' missing required 'description' field" unless @description
467
537
 
538
+ # Validate turn_timeout is positive if set
539
+ if @turn_timeout && @turn_timeout <= 0
540
+ raise ConfigurationError, "Agent '#{@name}' turn_timeout must be positive (got #{@turn_timeout})"
541
+ end
542
+
468
543
  # Validate api_version can only be set for OpenAI-compatible providers
469
544
  if @api_version
470
545
  openai_compatible = ["openai", "deepseek", "perplexity", "mistral", "openrouter"]
@@ -260,7 +260,8 @@ module SwarmSDK
260
260
  builder.context_window(config[:context_window]) if config[:context_window]
261
261
  builder.system_prompt(config[:system_prompt]) if config[:system_prompt]
262
262
  builder.directory(config[:directory]) if config[:directory]
263
- builder.timeout(config[:timeout]) if config[:timeout]
263
+ builder.request_timeout(config[:request_timeout]) if config[:request_timeout]
264
+ builder.turn_timeout(config[:turn_timeout]) if config[:turn_timeout]
264
265
  builder.parameters(config[:parameters]) if config[:parameters]
265
266
  builder.headers(config[:headers]) if config[:headers]
266
267
  builder.coding_agent(config[:coding_agent]) unless config[:coding_agent].nil?
@@ -275,8 +276,17 @@ module SwarmSDK
275
276
  builder.tools(*tool_names)
276
277
  end
277
278
 
278
- # Add delegates_to
279
- builder.delegates_to(*config[:delegates_to]) if config[:delegates_to]&.any?
279
+ # Add delegates_to (handle both array and hash formats)
280
+ if config[:delegates_to]&.any?
281
+ delegation_config = config[:delegates_to]
282
+ if delegation_config.is_a?(Hash)
283
+ # Hash format: pass as single argument
284
+ builder.delegates_to(delegation_config)
285
+ elsif delegation_config.is_a?(Array)
286
+ # Array format: splat the array
287
+ builder.delegates_to(*delegation_config)
288
+ end
289
+ end
280
290
 
281
291
  # Add MCP servers
282
292
  config[:mcp_servers]&.each do |server|
@@ -332,11 +342,25 @@ module SwarmSDK
332
342
  when :tools
333
343
  merged[:tools] = Array(merged[:tools]) + Array(value)
334
344
  when :delegates_to
335
- merged[:delegates_to] = Array(merged[:delegates_to]) + Array(value)
345
+ # Handle merging delegation configs (can be array or hash)
346
+ existing = merged[:delegates_to] || []
347
+ new_value = value || []
348
+
349
+ # Convert both to array of delegation configs for merging
350
+ existing_array = normalize_delegation_array(existing)
351
+ new_array = normalize_delegation_array(new_value)
352
+
353
+ merged[:delegates_to] = existing_array + new_array
336
354
  when :parameters
337
355
  merged[:parameters] = (merged[:parameters] || {}).merge(value || {})
338
356
  when :headers
339
357
  merged[:headers] = (merged[:headers] || {}).merge(value || {})
358
+ when :turn_timeout
359
+ # Agent-specific turn_timeout overrides all_agents
360
+ merged[key] = value
361
+ when :request_timeout
362
+ # Agent-specific request_timeout overrides all_agents
363
+ merged[key] = value
340
364
  else
341
365
  merged[key] = value
342
366
  end
@@ -350,6 +374,35 @@ module SwarmSDK
350
374
  merged
351
375
  end
352
376
 
377
+ # Normalize delegation config to array of hashes format
378
+ #
379
+ # Converts various delegation formats to normalized array for merging:
380
+ # - Array of symbols: [:frontend, :backend] → [{agent: :frontend, tool_name: nil}, ...]
381
+ # - Hash: {frontend: "Custom"} → [{agent: :frontend, tool_name: "Custom"}, ...]
382
+ # - Array of hashes: [{agent: :frontend, tool_name: "Custom"}] → unchanged
383
+ #
384
+ # @param delegation_config [Array, Hash] Delegation configuration
385
+ # @return [Array<Hash>] Normalized array of {agent:, tool_name:} hashes
386
+ def normalize_delegation_array(delegation_config)
387
+ return [] if delegation_config.nil? || (delegation_config.respond_to?(:empty?) && delegation_config.empty?)
388
+
389
+ case delegation_config
390
+ when Array
391
+ delegation_config.map do |item|
392
+ case item
393
+ when Symbol, String
394
+ { agent: item.to_sym, tool_name: nil }
395
+ when Hash
396
+ item.key?(:agent) ? item : item.map { |agent, tool_name| { agent: agent.to_sym, tool_name: tool_name } }
397
+ end
398
+ end.flatten
399
+ when Hash
400
+ delegation_config.map { |agent, tool_name| { agent: agent.to_sym, tool_name: tool_name } }
401
+ else
402
+ []
403
+ end
404
+ end
405
+
353
406
  # Apply all_agents defaults to an agent builder
354
407
  #
355
408
  # @param agent_builder [Agent::Builder] The agent builder to configure
@@ -372,8 +425,12 @@ module SwarmSDK
372
425
  agent_builder.api_version(all_agents_hash[:api_version])
373
426
  end
374
427
 
375
- if all_agents_hash[:timeout] && !agent_builder.timeout_set?
376
- agent_builder.timeout(all_agents_hash[:timeout])
428
+ if all_agents_hash[:request_timeout] && !agent_builder.request_timeout_set?
429
+ agent_builder.request_timeout(all_agents_hash[:request_timeout])
430
+ end
431
+
432
+ if all_agents_hash[:turn_timeout] && !agent_builder.turn_timeout_set?
433
+ agent_builder.turn_timeout(all_agents_hash[:turn_timeout])
377
434
  end
378
435
 
379
436
  if all_agents_hash[:parameters]
@@ -81,6 +81,8 @@ module SwarmSDK
81
81
  chars_per_token_prose: ["SWARM_SDK_CHARS_PER_TOKEN_PROSE", -> { Defaults::TokenEstimation::CHARS_PER_TOKEN_PROSE }],
82
82
  chars_per_token_code: ["SWARM_SDK_CHARS_PER_TOKEN_CODE", -> { Defaults::TokenEstimation::CHARS_PER_TOKEN_CODE }],
83
83
  mcp_log_level: ["SWARM_SDK_MCP_LOG_LEVEL", -> { Defaults::Logging::MCP_LOG_LEVEL }],
84
+ default_execution_timeout: ["SWARM_SDK_DEFAULT_EXECUTION_TIMEOUT", -> { Defaults::Timeouts::EXECUTION_TIMEOUT_SECONDS }],
85
+ default_turn_timeout: ["SWARM_SDK_DEFAULT_TURN_TIMEOUT", -> { Defaults::Timeouts::TURN_TIMEOUT_SECONDS }],
84
86
  }.freeze
85
87
 
86
88
  # WebFetch and control settings
@@ -28,7 +28,8 @@ module SwarmSDK
28
28
  :all_agents_hooks,
29
29
  :scratchpad_mode,
30
30
  :nodes,
31
- :external_swarms
31
+ :external_swarms,
32
+ :execution_timeout
32
33
 
33
34
  # Initialize parser with YAML content and options
34
35
  #
@@ -54,6 +55,7 @@ module SwarmSDK
54
55
  @external_swarms = {}
55
56
  @nodes = {}
56
57
  @scratchpad_mode = :disabled
58
+ @execution_timeout = nil
57
59
  end
58
60
 
59
61
  def parse
@@ -88,7 +90,28 @@ module SwarmSDK
88
90
  return [] unless agent_config
89
91
 
90
92
  delegates = agent_config[:delegates_to] || []
91
- Array(delegates).map(&:to_sym)
93
+
94
+ # Handle both array and hash formats for delegates_to
95
+ case delegates
96
+ when Array
97
+ # Array of symbols: [:frontend, :backend]
98
+ # OR array of hashes: [{agent: :frontend, tool_name: "Custom"}]
99
+ delegates.map do |item|
100
+ case item
101
+ when Symbol, String
102
+ item.to_sym
103
+ when Hash
104
+ # Extract agent name from hash format
105
+ agent_name = item[:agent] || item["agent"]
106
+ agent_name&.to_sym
107
+ end
108
+ end.compact # Remove nils from malformed hashes
109
+ when Hash
110
+ # Hash format: {frontend: "Custom", backend: nil}
111
+ delegates.keys.map(&:to_sym)
112
+ else
113
+ []
114
+ end
92
115
  end
93
116
 
94
117
  attr_reader :base_dir
@@ -134,6 +157,7 @@ module SwarmSDK
134
157
  @swarm_name = @root_config[:name]
135
158
  @swarm_id = @root_config[:id]
136
159
  @scratchpad_mode = parse_scratchpad_mode(@root_config[:scratchpad])
160
+ @execution_timeout = @root_config[:execution_timeout]
137
161
 
138
162
  load_all_agents_config
139
163
  load_hooks_config