swarm_sdk 2.1.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +33 -0
  3. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  4. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  5. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  6. data/lib/swarm_sdk/agent/chat.rb +198 -51
  7. data/lib/swarm_sdk/agent/context.rb +6 -2
  8. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  9. data/lib/swarm_sdk/agent/definition.rb +14 -2
  10. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  11. data/lib/swarm_sdk/configuration.rb +387 -94
  12. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  13. data/lib/swarm_sdk/log_collector.rb +31 -5
  14. data/lib/swarm_sdk/log_stream.rb +37 -8
  15. data/lib/swarm_sdk/model_aliases.json +4 -1
  16. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  17. data/lib/swarm_sdk/node/builder.rb +39 -18
  18. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  19. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  20. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  21. data/lib/swarm_sdk/restore_result.rb +65 -0
  22. data/lib/swarm_sdk/snapshot.rb +156 -0
  23. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  24. data/lib/swarm_sdk/state_restorer.rb +491 -0
  25. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  26. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  27. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  28. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  29. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  30. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  31. data/lib/swarm_sdk/swarm.rb +337 -42
  32. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  33. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  34. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  35. data/lib/swarm_sdk/tools/read.rb +17 -5
  36. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  37. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  38. data/lib/swarm_sdk/utils.rb +18 -0
  39. data/lib/swarm_sdk/validation_result.rb +33 -0
  40. data/lib/swarm_sdk/version.rb +1 -1
  41. data/lib/swarm_sdk.rb +40 -8
  42. metadata +17 -6
  43. data/lib/swarm_sdk/mcp.rb +0 -16
@@ -4,7 +4,7 @@ module SwarmSDK
4
4
  class Configuration
5
5
  ENV_VAR_WITH_DEFAULT_PATTERN = /\$\{([^:}]+)(:=([^}]*))?\}/
6
6
 
7
- attr_reader :swarm_name, :lead_agent, :agents, :all_agents_config, :swarm_hooks, :all_agents_hooks, :scratchpad_enabled
7
+ attr_reader :swarm_name, :swarm_id, :lead_agent, :agents, :all_agents_config, :swarm_hooks, :all_agents_hooks, :scratchpad_enabled, :nodes, :start_node, :external_swarms
8
8
 
9
9
  class << self
10
10
  # Load configuration from YAML file
@@ -41,10 +41,14 @@ module SwarmSDK
41
41
 
42
42
  @yaml_content = yaml_content
43
43
  @base_dir = Pathname.new(base_dir).expand_path
44
- @agents = {}
44
+ @swarm_id = nil # Optional swarm ID from YAML
45
+ @agents = {} # Parsed agent configs (hashes, not Definitions)
45
46
  @all_agents_config = {} # Settings applied to all agents
46
47
  @swarm_hooks = {} # Swarm-level hooks (swarm_start, swarm_stop)
47
48
  @all_agents_hooks = {} # Hooks applied to all agents
49
+ @external_swarms = {} # External swarms for composable swarms
50
+ @nodes = {} # Parsed node configs (hashes)
51
+ @start_node = nil # Starting node for workflows
48
52
  end
49
53
 
50
54
  def load_and_validate
@@ -61,6 +65,7 @@ module SwarmSDK
61
65
  load_hooks_config
62
66
  validate_swarm
63
67
  load_agents
68
+ load_nodes
64
69
  detect_circular_dependencies
65
70
  self
66
71
  rescue Psych::SyntaxError => e
@@ -72,36 +77,84 @@ module SwarmSDK
72
77
  end
73
78
 
74
79
  def connections_for(agent_name)
75
- @agents[agent_name]&.delegates_to || []
80
+ agent_config = @agents[agent_name]
81
+ return [] unless agent_config
82
+
83
+ # Extract delegates_to from hash and convert to symbols
84
+ delegates = agent_config[:delegates_to] || []
85
+ Array(delegates).map(&:to_sym)
76
86
  end
77
87
 
78
- # Convert configuration to Swarm instance using Ruby API
88
+ # Convert configuration to Swarm or NodeOrchestrator using DSL
79
89
  #
80
- # This method bridges YAML configuration to the Ruby API, making YAML
81
- # a thin convenience layer over the programmatic interface.
90
+ # This method translates YAML configuration to Ruby DSL calls.
91
+ # The DSL (Swarm::Builder) handles all validation, merging, and construction.
82
92
  #
83
- # @return [Swarm] Configured swarm instance
84
- def to_swarm
85
- swarm = Swarm.new(
86
- name: @swarm_name,
87
- global_concurrency: Swarm::DEFAULT_GLOBAL_CONCURRENCY,
88
- default_local_concurrency: Swarm::DEFAULT_LOCAL_CONCURRENCY,
89
- scratchpad_enabled: @scratchpad_enabled,
90
- )
91
-
92
- # Add all agents - pass definitions directly
93
- @agents.each do |_name, agent_def|
94
- swarm.add_agent(agent_def)
93
+ # @param allow_filesystem_tools [Boolean, nil] Whether to allow filesystem tools (nil uses global setting)
94
+ # @return [Swarm, NodeOrchestrator] Configured swarm or orchestrator
95
+ def to_swarm(allow_filesystem_tools: nil)
96
+ builder = Swarm::Builder.new(allow_filesystem_tools: allow_filesystem_tools)
97
+
98
+ # Translate basic swarm config to DSL
99
+ builder.id(@swarm_id) if @swarm_id
100
+ builder.name(@swarm_name)
101
+ builder.lead(@lead_agent)
102
+ builder.scratchpad(@scratchpad_mode)
103
+
104
+ # Translate external swarms
105
+ if @external_swarms&.any?
106
+ builder.swarms do
107
+ @external_swarms.each do |name, config|
108
+ source = config[:source]
109
+ case source[:type]
110
+ when :file
111
+ register(name, file: source[:value], keep_context: config[:keep_context])
112
+ when :yaml
113
+ register(name, yaml: source[:value], keep_context: config[:keep_context])
114
+ else
115
+ raise ConfigurationError, "Unknown source type: #{source[:type]}"
116
+ end
117
+ end
118
+ end
95
119
  end
96
120
 
97
- # Set lead agent
98
- swarm.lead = @lead_agent
121
+ # Translate all_agents config to DSL (if present)
122
+ translate_all_agents(builder) if @all_agents_config.any?
123
+
124
+ # Translate agents to DSL
125
+ translate_agents(builder)
126
+
127
+ # Translate swarm-level hooks to DSL (if present)
128
+ translate_swarm_hooks(builder) if @swarm_hooks.any?
129
+
130
+ # Translate nodes to DSL (if present)
131
+ if @nodes.any?
132
+ translate_nodes(builder)
133
+ builder.start_node(@start_node)
134
+ end
99
135
 
100
- swarm
136
+ # Build the swarm or orchestrator (DSL decides based on presence of nodes)
137
+ builder.build_swarm
101
138
  end
102
139
 
103
140
  private
104
141
 
142
+ def parse_scratchpad_mode(value)
143
+ return :disabled if value.nil? # Default
144
+
145
+ # Convert strings from YAML to symbols
146
+ value = value.to_sym if value.is_a?(String)
147
+
148
+ # Validate symbols
149
+ case value
150
+ when :enabled, :disabled, :per_node
151
+ value
152
+ else
153
+ raise ConfigurationError,
154
+ "Invalid scratchpad mode: #{value.inspect}. Use :enabled, :per_node, or :disabled"
155
+ end
156
+ end
157
+
105
158
  def interpolate_env_vars!(obj)
106
159
  case obj
107
160
  when String
@@ -170,8 +223,46 @@ module SwarmSDK
170
223
  raise ConfigurationError, "No agents defined" if swarm[:agents].empty?
171
224
 
172
225
  @swarm_name = swarm[:name]
226
+ @swarm_id = swarm[:id] # Optional - will auto-generate if missing
173
227
  @lead_agent = swarm[:lead].to_sym # Convert to symbol for consistency
174
- @scratchpad_enabled = swarm[:use_scratchpad].nil? ? true : swarm[:use_scratchpad] # Default: enabled
228
+ @scratchpad_mode = parse_scratchpad_mode(swarm[:scratchpad])
229
+
230
+ # Load external swarms for composable swarms
231
+ load_external_swarms(swarm[:swarms]) if swarm[:swarms]
232
+ end
233
+
234
+ def load_external_swarms(swarms_config)
235
+ @external_swarms = {}
236
+ swarms_config.each do |name, config|
237
+ # Determine source type: file, yaml string, or inline swarm definition
238
+ source = if config[:file]
239
+ # File path - resolve relative to base_dir
240
+ file_path = if config[:file].start_with?("/")
241
+ config[:file]
242
+ else
243
+ (@base_dir / config[:file]).to_s
244
+ end
245
+ { type: :file, value: file_path }
246
+ elsif config[:yaml]
247
+ # YAML string provided directly
248
+ { type: :yaml, value: config[:yaml] }
249
+ elsif config[:swarm]
250
+ # Inline swarm definition - convert to YAML string
251
+ inline_config = {
252
+ version: 2,
253
+ swarm: config[:swarm],
254
+ }
255
+ yaml_string = Utils.hash_to_yaml(inline_config)
256
+ { type: :yaml, value: yaml_string }
257
+ else
258
+ raise ConfigurationError, "Swarm '#{name}' must specify either 'file:', 'yaml:', or 'swarm:' (inline definition)"
259
+ end
260
+
261
+ @external_swarms[name.to_sym] = {
262
+ source: source,
263
+ keep_context: config.fetch(:keep_context, true),
264
+ }
265
+ end
175
266
  end
176
267
 
177
268
  def load_agents
@@ -182,28 +273,30 @@ module SwarmSDK
182
273
  # 1. String: assistant: "agents/assistant.md" (file path)
183
274
  # 2. Hash with agent_file: assistant: { agent_file: "..." }
184
275
  # 3. Hash with inline definition: assistant: { description: "...", model: "..." }
276
+ # 4. nil: Invalid (will be caught when building swarm)
185
277
 
186
- if agent_config.is_a?(String)
278
+ parsed_config = if agent_config.nil?
279
+ # Null config - store empty hash, will fail during swarm building
280
+ {}
281
+ elsif agent_config.is_a?(String)
187
282
  # Format 1: Direct file path as string
188
- file_path = agent_config
189
- merged_config = merge_all_agents_config({})
190
- @agents[name] = load_agent_from_file(name, file_path, merged_config)
283
+ { agent_file: agent_config }
284
+ elsif agent_config.is_a?(Hash) && agent_config[:agent_file]
285
+ # Format 2: Hash with agent_file key
286
+ agent_config
191
287
  else
192
- # Format 2 or 3: Hash configuration
193
- agent_config ||= {}
194
-
195
- # Merge all_agents_config into agent config
196
- # Agent-specific config overrides all_agents config
197
- merged_config = merge_all_agents_config(agent_config)
288
+ # Format 3: Inline definition
289
+ agent_config || {}
290
+ end
198
291
 
199
- @agents[name] = if agent_config[:agent_file]
200
- # Format 2: Hash with agent_file key
201
- load_agent_from_file(name, agent_config[:agent_file], merged_config)
202
- else
203
- # Format 3: Inline definition
204
- Agent::Definition.new(name, merged_config)
205
- end
292
+ # Validate required fields for inline definitions (strict validation for YAML)
293
+ # File-based agents are validated when loaded
294
+ if parsed_config[:agent_file].nil? && parsed_config[:description].nil?
295
+ raise ConfigurationError,
296
+ "Agent '#{name}' missing required 'description' field"
206
297
  end
298
+
299
+ @agents[name] = parsed_config
207
300
  end
208
301
 
209
302
  unless @agents.key?(@lead_agent)
@@ -211,76 +304,272 @@ module SwarmSDK
211
304
  end
212
305
  end
213
306
 
214
- # Merge all_agents config with agent-specific config
215
- # Agent config takes precedence over all_agents config
307
+ def load_nodes
308
+ return unless @config[:swarm][:nodes]
309
+
310
+ @nodes = Utils.symbolize_keys(@config[:swarm][:nodes])
311
+ @start_node = @config[:swarm][:start_node]&.to_sym
312
+
313
+ # Validate start_node is required if nodes defined
314
+ if @nodes.any? && !@start_node
315
+ raise ConfigurationError, "start_node required when nodes are defined"
316
+ end
317
+
318
+ # Validate start_node exists
319
+ if @start_node && !@nodes.key?(@start_node)
320
+ raise ConfigurationError, "start_node '#{@start_node}' not found in nodes"
321
+ end
322
+
323
+ # Basic node structure validation
324
+ @nodes.each do |node_name, node_config|
325
+ unless node_config.is_a?(Hash)
326
+ raise ConfigurationError, "Node '#{node_name}' must be a hash"
327
+ end
328
+
329
+ # Validate agents if present (optional for agent-less nodes)
330
+ if node_config[:agents]
331
+ unless node_config[:agents].is_a?(Array)
332
+ raise ConfigurationError, "Node '#{node_name}' agents must be an array"
333
+ end
334
+
335
+ # Validate each agent config
336
+ node_config[:agents].each do |agent_config|
337
+ unless agent_config.is_a?(Hash) && agent_config[:agent]
338
+ raise ConfigurationError,
339
+ "Node '#{node_name}' agents must be hashes with 'agent' key"
340
+ end
341
+
342
+ # Validate agent exists in swarm agents
343
+ agent_sym = agent_config[:agent].to_sym
344
+ unless @agents.key?(agent_sym)
345
+ raise ConfigurationError,
346
+ "Node '#{node_name}' references undefined agent '#{agent_config[:agent]}'"
347
+ end
348
+ end
349
+ end
350
+
351
+ # Validate dependencies if present
352
+ next unless node_config[:dependencies]
353
+ unless node_config[:dependencies].is_a?(Array)
354
+ raise ConfigurationError, "Node '#{node_name}' dependencies must be an array"
355
+ end
356
+
357
+ # Validate each dependency exists
358
+ node_config[:dependencies].each do |dep|
359
+ dep_sym = dep.to_sym
360
+ unless @nodes.key?(dep_sym)
361
+ raise ConfigurationError,
362
+ "Node '#{node_name}' depends on undefined node '#{dep}'"
363
+ end
364
+ end
365
+ end
366
+ end
367
+
368
+ # Translate all_agents configuration to DSL
216
369
  #
217
- # Merge strategy:
218
- # - Arrays (tools, delegates_to): Concatenate
219
- # - Hashes (parameters, headers): Merge (agent values override)
220
- # - Scalars (model, provider, base_url, timeout, coding_agent): Agent overrides
370
+ # @param builder [Swarm::Builder] DSL builder instance
371
+ # @return [void]
372
+ def translate_all_agents(builder)
373
+ # Capture instance variables for block scope
374
+ all_agents_cfg = @all_agents_config
375
+ all_agents_hks = @all_agents_hooks
376
+
377
+ builder.all_agents do
378
+ # Translate each all_agents field to DSL method calls
379
+ tools(*all_agents_cfg[:tools]) if all_agents_cfg[:tools]&.any?
380
+ model(all_agents_cfg[:model]) if all_agents_cfg[:model]
381
+ provider(all_agents_cfg[:provider]) if all_agents_cfg[:provider]
382
+ base_url(all_agents_cfg[:base_url]) if all_agents_cfg[:base_url]
383
+ api_version(all_agents_cfg[:api_version]) if all_agents_cfg[:api_version]
384
+ timeout(all_agents_cfg[:timeout]) if all_agents_cfg[:timeout]
385
+ parameters(all_agents_cfg[:parameters]) if all_agents_cfg[:parameters]
386
+ headers(all_agents_cfg[:headers]) if all_agents_cfg[:headers]
387
+ coding_agent(all_agents_cfg[:coding_agent]) unless all_agents_cfg[:coding_agent].nil?
388
+ disable_default_tools(all_agents_cfg[:disable_default_tools]) unless all_agents_cfg[:disable_default_tools].nil?
389
+
390
+ # Translate all_agents hooks
391
+ if all_agents_hks.any?
392
+ all_agents_hks.each do |event, hook_specs|
393
+ Array(hook_specs).each do |spec|
394
+ matcher = spec[:matcher]
395
+ hook(event, matcher: matcher, command: spec[:command], timeout: spec[:timeout]) if spec[:type] == "command"
396
+ end
397
+ end
398
+ end
399
+
400
+ # Permissions - set directly as hash (YAML doesn't use DSL block syntax)
401
+ self.permissions_hash = all_agents_cfg[:permissions] if all_agents_cfg[:permissions]
402
+ end
403
+ end
404
+
405
+ # Translate agents to DSL
221
406
  #
222
- # @param agent_config [Hash] Agent-specific configuration
223
- # @return [Hash] Merged configuration
224
- def merge_all_agents_config(agent_config)
225
- merged = @all_agents_config.dup
226
-
227
- # For arrays, concatenate
228
- # For hashes, merge (agent values override)
229
- # For scalars, agent value overrides
230
- agent_config.each do |key, value|
231
- case key
232
- when :tools
233
- # Concatenate tools: all_agents.tools + agent.tools
234
- merged[:tools] = Array(merged[:tools]) + Array(value)
235
- when :delegates_to
236
- # Concatenate delegates_to
237
- merged[:delegates_to] = Array(merged[:delegates_to]) + Array(value)
238
- when :parameters
239
- # Merge parameters: all_agents.parameters + agent.parameters
240
- # Agent values override all_agents values for same keys
241
- merged[:parameters] = (merged[:parameters] || {}).merge(value || {})
242
- when :headers
243
- # Merge headers: all_agents.headers + agent.headers
244
- # Agent values override all_agents values for same keys
245
- merged[:headers] = (merged[:headers] || {}).merge(value || {})
246
- when :disable_default_tools
247
- # Convert array elements to symbols if it's an array
248
- merged[key] = value.is_a?(Array) ? value.map(&:to_sym) : value
407
+ # @param builder [Swarm::Builder] DSL builder instance
408
+ # @return [void]
409
+ def translate_agents(builder)
410
+ @agents.each do |name, agent_config|
411
+ translate_agent(builder, name, agent_config)
412
+ rescue ConfigurationError => e
413
+ # Re-raise with agent context for better error messages
414
+ raise ConfigurationError, "Error in swarm.agents.#{name}: #{e.message}"
415
+ end
416
+ end
417
+
418
+ # Translate single agent to DSL
419
+ #
420
+ # @param builder [Swarm::Builder] DSL builder instance
421
+ # @param name [Symbol] Agent name
422
+ # @param config [Hash] Agent configuration
423
+ # @return [void]
424
+ def translate_agent(builder, name, config)
425
+ if config[:agent_file]
426
+ # Load from file
427
+ agent_file_path = resolve_agent_file_path(config[:agent_file])
428
+
429
+ unless File.exist?(agent_file_path)
430
+ raise ConfigurationError, "Agent file not found: #{agent_file_path}"
431
+ end
432
+
433
+ content = File.read(agent_file_path)
434
+
435
+ # Check if there are overrides besides agent_file
436
+ overrides = config.except(:agent_file)
437
+
438
+ if overrides.any?
439
+ # Load from markdown with DSL overrides
440
+ builder.agent(name, content, &create_agent_config_block(overrides))
249
441
  else
250
- # For everything else (model, provider, base_url, timeout, coding_agent, etc.),
251
- # agent value overrides all_agents value
252
- merged[key] = value
442
+ # Load from markdown only
443
+ builder.agent(name, content)
253
444
  end
445
+ else
446
+ # Inline definition - translate to DSL
447
+ builder.agent(name, &create_agent_config_block(config))
254
448
  end
449
+ rescue StandardError => e
450
+ raise ConfigurationError, "Error loading agent '#{name}': #{e.message}"
451
+ end
452
+
453
+ # Create a block that configures an agent builder with the given config
454
+ #
455
+ # Returns a proc that can be passed to builder.agent
456
+ #
457
+ # @param config [Hash] Agent configuration hash
458
+ # @return [Proc] Block that configures agent builder
459
+ def create_agent_config_block(config)
460
+ proc do
461
+ description(config[:description]) if config[:description]
462
+ model(config[:model]) if config[:model]
463
+ provider(config[:provider]) if config[:provider]
464
+ base_url(config[:base_url]) if config[:base_url]
465
+ api_version(config[:api_version]) if config[:api_version]
466
+ context_window(config[:context_window]) if config[:context_window]
467
+ system_prompt(config[:system_prompt]) if config[:system_prompt]
468
+ directory(config[:directory]) if config[:directory]
469
+ timeout(config[:timeout]) if config[:timeout]
470
+ parameters(config[:parameters]) if config[:parameters]
471
+ headers(config[:headers]) if config[:headers]
472
+ coding_agent(config[:coding_agent]) unless config[:coding_agent].nil?
473
+ bypass_permissions(config[:bypass_permissions]) if config[:bypass_permissions]
474
+ disable_default_tools(config[:disable_default_tools]) unless config[:disable_default_tools].nil?
475
+ shared_across_delegations(config[:shared_across_delegations]) unless config[:shared_across_delegations].nil?
476
+
477
+ # Tools
478
+ if config[:tools]&.any?
479
+ tool_names = config[:tools].map { |t| t.is_a?(Hash) ? t[:name] : t }
480
+ tools(*tool_names)
481
+ end
482
+
483
+ # Delegation
484
+ delegates_to(*config[:delegates_to]) if config[:delegates_to]&.any?
485
+
486
+ # MCP servers
487
+ config[:mcp_servers]&.each do |server|
488
+ mcp_server(server[:name], **server.except(:name))
489
+ end
490
+
491
+ # Hooks (YAML-style command hooks)
492
+ config[:hooks]&.each do |event, hook_specs|
493
+ Array(hook_specs).each do |spec|
494
+ matcher = spec[:matcher]
495
+ hook(event, matcher: matcher, command: spec[:command], timeout: spec[:timeout]) if spec[:type] == "command"
496
+ end
497
+ end
498
+
499
+ # Memory
500
+ if config[:memory]
501
+ memory do
502
+ directory(config[:memory][:directory]) if config[:memory][:directory]
503
+ adapter(config[:memory][:adapter]) if config[:memory][:adapter]
504
+ mode(config[:memory][:mode]) if config[:memory][:mode]
505
+ end
506
+ end
255
507
 
256
- # Pass all_agents permissions as default_permissions for backward compat with AgentDefinition
257
- if @all_agents_config[:permissions]
258
- merged[:default_permissions] = @all_agents_config[:permissions]
508
+ # Permissions - set directly as hash (YAML doesn't use DSL block syntax)
509
+ self.permissions_hash = config[:permissions] if config[:permissions]
259
510
  end
511
+ end
260
512
 
261
- merged
513
+ # Translate swarm-level hooks to DSL
514
+ #
515
+ # @param builder [Swarm::Builder] Swarm builder instance
516
+ # @return [void]
517
+ def translate_swarm_hooks(builder)
518
+ @swarm_hooks.each do |event, hook_specs|
519
+ Array(hook_specs).each do |spec|
520
+ if spec[:type] == "command"
521
+ builder.hook(event, command: spec[:command], timeout: spec[:timeout])
522
+ end
523
+ end
524
+ end
262
525
  end
263
526
 
264
- def load_agent_from_file(name, file_path, merged_config)
265
- agent_file_path = resolve_agent_file_path(file_path)
527
+ # Translate nodes to DSL
528
+ #
529
+ # @param builder [Swarm::Builder] Swarm builder instance
530
+ # @return [void]
531
+ def translate_nodes(builder)
532
+ @nodes.each do |node_name, node_config|
533
+ builder.node(node_name) do
534
+ # Translate agents
535
+ node_config[:agents]&.each do |agent_config|
536
+ agent_name = agent_config[:agent].to_sym
537
+ delegates = agent_config[:delegates_to] || []
538
+ reset_ctx = agent_config.key?(:reset_context) ? agent_config[:reset_context] : true
539
+ tools_override = agent_config[:tools]
540
+
541
+ # Build agent config with fluent API
542
+ agent_cfg = agent(agent_name, reset_context: reset_ctx)
543
+
544
+ # Apply delegation if present
545
+ agent_cfg = agent_cfg.delegates_to(*delegates) if delegates.any?
546
+
547
+ # Apply tools override if present
548
+ agent_cfg.tools(*tools_override) if tools_override # Return config (finalize will be called automatically)
549
+ end
266
550
 
267
- unless File.exist?(agent_file_path)
268
- raise ConfigurationError, "Agent file not found: #{agent_file_path}"
269
- end
551
+ # Translate dependencies
552
+ depends_on(*node_config[:dependencies]) if node_config[:dependencies]&.any?
270
553
 
271
- content = File.read(agent_file_path)
272
- # Parse markdown and merge with YAML config
273
- agent_def_from_file = MarkdownParser.parse(content, name)
554
+ # Translate lead override
555
+ lead(node_config[:lead].to_sym) if node_config[:lead]
274
556
 
275
- # Merge: YAML config overrides markdown file (YAML takes precedence)
276
- # This allows YAML to override any settings from the markdown file
277
- final_config = agent_def_from_file.to_h.compact.merge(merged_config.compact)
557
+ # Translate transformers
558
+ if node_config[:input_command]
559
+ input_command(node_config[:input_command], timeout: node_config[:input_timeout] || 60)
560
+ end
278
561
 
279
- Agent::Definition.new(name, final_config)
280
- rescue StandardError => e
281
- raise ConfigurationError, "Error loading agent '#{name}' from file '#{file_path}': #{e.message}"
562
+ if node_config[:output_command]
563
+ output_command(node_config[:output_command], timeout: node_config[:output_timeout] || 60)
564
+ end
565
+ end
566
+ end
282
567
  end
283
568
 
569
+ # Resolve agent file path relative to base_dir
570
+ #
571
+ # @param file_path [String] Relative or absolute file path
572
+ # @return [String] Resolved absolute path
284
573
  def resolve_agent_file_path(file_path)
285
574
  return file_path if Pathname.new(file_path).absolute?
286
575
 
@@ -307,8 +596,12 @@ module SwarmSDK
307
596
  path.push(agent_name)
308
597
  connections_for(agent_name).each do |connection|
309
598
  connection_sym = connection.to_sym # Convert to symbol for lookup
599
+
600
+ # Skip external swarms - they are not local agents and don't have circular dependency issues
601
+ next if @external_swarms.key?(connection_sym)
602
+
310
603
  unless @agents.key?(connection_sym)
311
- raise ConfigurationError, "Agent '#{agent_name}' has connection to unknown agent '#{connection}'"
604
+ raise ConfigurationError, "Agent '#{agent_name}' delegates to unknown target '#{connection}' (not a local agent or registered swarm)"
312
605
  end
313
606
 
314
607
  detect_cycle_from(connection_sym, visited, path)