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
@@ -68,16 +68,30 @@ module SwarmSDK
68
68
  # Default tools available to all agents
69
69
  DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
70
70
 
71
- attr_reader :name, :agents, :lead_agent, :mcp_clients
71
+ attr_reader :name, :agents, :lead_agent, :mcp_clients, :delegation_instances, :agent_definitions, :swarm_id, :parent_swarm_id, :swarm_registry, :scratchpad_storage, :allow_filesystem_tools
72
+ attr_accessor :delegation_call_stack
72
73
 
73
74
  # Check if scratchpad tools are enabled
74
75
  #
75
76
  # @return [Boolean]
76
77
  def scratchpad_enabled?
77
- @scratchpad_enabled
78
+ @scratchpad_mode == :enabled
78
79
  end
79
80
  attr_writer :config_for_hooks
80
81
 
82
+ # Check if first message has been sent (for system reminder injection)
83
+ #
84
+ # @return [Boolean]
85
+ def first_message_sent?
86
+ @first_message_sent
87
+ end
88
+
89
+ # Set first message sent flag (used by snapshot/restore)
90
+ #
91
+ # @param value [Boolean] New value
92
+ # @return [void]
93
+ attr_writer :first_message_sent
94
+
81
95
  # Class-level MCP log level configuration
82
96
  @mcp_log_level = DEFAULT_MCP_LOG_LEVEL
83
97
  @mcp_logging_configured = false
@@ -103,8 +117,6 @@ module SwarmSDK
103
117
  def apply_mcp_logging_configuration
104
118
  return if @mcp_logging_configured
105
119
 
106
- SwarmSDK::MCP.lazy_load
107
-
108
120
  RubyLLM::MCP.configure do |config|
109
121
  config.log_level = @mcp_log_level
110
122
  end
@@ -116,22 +128,49 @@ module SwarmSDK
116
128
  # Initialize a new Swarm
117
129
  #
118
130
  # @param name [String] Human-readable swarm name
131
+ # @param swarm_id [String, nil] Optional swarm ID (auto-generated if not provided)
132
+ # @param parent_swarm_id [String, nil] Optional parent swarm ID (nil for root swarms)
119
133
  # @param global_concurrency [Integer] Max concurrent LLM calls across entire swarm
120
134
  # @param default_local_concurrency [Integer] Default max concurrent tool calls per agent
121
- # @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing)
122
- # @param scratchpad_enabled [Boolean] Whether to enable scratchpad tools (default: true)
123
- def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY, scratchpad: nil, scratchpad_enabled: true)
135
+ # @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing/internal use)
136
+ # @param scratchpad_mode [Symbol, String] Scratchpad mode (:enabled or :disabled). :per_node not allowed for non-node swarms.
137
+ # @param allow_filesystem_tools [Boolean, nil] Whether to allow filesystem tools (nil uses global setting)
138
+ def initialize(name:, swarm_id: nil, parent_swarm_id: nil, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY, scratchpad: nil, scratchpad_mode: :enabled, allow_filesystem_tools: nil)
124
139
  @name = name
140
+ @swarm_id = swarm_id || generate_swarm_id(name)
141
+ @parent_swarm_id = parent_swarm_id
125
142
  @global_concurrency = global_concurrency
126
143
  @default_local_concurrency = default_local_concurrency
127
- @scratchpad_enabled = scratchpad_enabled
144
+
145
+ # Handle scratchpad_mode parameter
146
+ # For Swarm: :enabled or :disabled (not :per_node - that's for nodes)
147
+ @scratchpad_mode = validate_swarm_scratchpad_mode(scratchpad_mode)
148
+
149
+ # Resolve allow_filesystem_tools with priority:
150
+ # 1. Explicit parameter (if not nil)
151
+ # 2. Global settings
152
+ @allow_filesystem_tools = if allow_filesystem_tools.nil?
153
+ SwarmSDK.settings.allow_filesystem_tools
154
+ else
155
+ allow_filesystem_tools
156
+ end
157
+
158
+ # Swarm registry for managing sub-swarms (initialized later if needed)
159
+ @swarm_registry = nil
160
+
161
+ # Delegation call stack for circular dependency detection
162
+ @delegation_call_stack = []
128
163
 
129
164
  # Shared semaphore for all agents
130
165
  @global_semaphore = Async::Semaphore.new(@global_concurrency)
131
166
 
132
167
  # Shared scratchpad storage for all agents (volatile)
133
- # Use provided scratchpad storage (for testing) or create volatile one
134
- @scratchpad_storage = scratchpad || Tools::Stores::ScratchpadStorage.new
168
+ # Use provided scratchpad storage (for testing) or create volatile one based on mode
169
+ @scratchpad_storage = if scratchpad
170
+ scratchpad # Testing/internal use - explicit instance provided
171
+ elsif @scratchpad_mode == :enabled
172
+ Tools::Stores::ScratchpadStorage.new
173
+ end
135
174
 
136
175
  # Per-agent plugin storages (persistent)
137
176
  # Format: { plugin_name => { agent_name => storage } }
@@ -147,6 +186,7 @@ module SwarmSDK
147
186
  # Agent definitions and instances
148
187
  @agent_definitions = {}
149
188
  @agents = {}
189
+ @delegation_instances = {} # { "delegate@delegator" => Agent::Chat }
150
190
  @agents_initialized = false
151
191
  @agent_contexts = {}
152
192
 
@@ -157,6 +197,10 @@ module SwarmSDK
157
197
 
158
198
  # Track if first message has been sent
159
199
  @first_message_sent = false
200
+
201
+ # Track if agent_start events have been emitted
202
+ # This prevents duplicate emissions and ensures events are emitted when logging is ready
203
+ @agent_start_events_emitted = false
160
204
  end
161
205
 
162
206
  # Add an agent to the swarm
@@ -222,8 +266,25 @@ module SwarmSDK
222
266
  logs = []
223
267
  current_prompt = prompt
224
268
 
269
+ # Force cleanup of any lingering scheduler from previous requests
270
+ # This ensures we always take the clean Path C in Async()
271
+ # See: Async expert analysis - prevents scheduler leak in Puma
272
+ if Fiber.scheduler && !Async::Task.current?
273
+ Fiber.set_scheduler(nil)
274
+ end
275
+
276
+ # Set fiber-local execution context
277
+ # Use ||= to inherit parent's execution_id if one exists (for mini-swarms)
278
+ # Child fibers (tools, delegations) inherit automatically
279
+ Fiber[:execution_id] ||= generate_execution_id
280
+ Fiber[:swarm_id] = @swarm_id
281
+ Fiber[:parent_swarm_id] = @parent_swarm_id
282
+
225
283
  # Setup logging FIRST if block given (so swarm_start event can be emitted)
226
284
  if block_given?
285
+ # Force fresh callback array for this execution
286
+ Fiber[:log_callbacks] = []
287
+
227
288
  # Register callback to collect logs and forward to user's block
228
289
  LogCollector.on_log do |entry|
229
290
  logs << entry
@@ -252,6 +313,17 @@ module SwarmSDK
252
313
  # Lazy initialization of agents (with optional logging)
253
314
  initialize_agents unless @agents_initialized
254
315
 
316
+ # If agents were initialized BEFORE logging was set up (e.g., via restore()),
317
+ # we need to retroactively set up logging callbacks and emit agent_start events
318
+ if block_given? && @agents_initialized && !@agent_start_events_emitted
319
+ # Setup logging callbacks for all agents (they were skipped during initialization)
320
+ setup_logging_for_all_agents
321
+
322
+ # Emit agent_start events now that logging is ready
323
+ emit_agent_start_events
324
+ @agent_start_events_emitted = true
325
+ end
326
+
255
327
  # Execution loop (supports reprompting)
256
328
  result = nil
257
329
  swarm_stop_triggered = false
@@ -355,6 +427,14 @@ module SwarmSDK
355
427
  # Cleanup MCP clients after execution
356
428
  cleanup
357
429
 
430
+ # Only clear Fiber storage if we set up logging (same pattern as LogCollector)
431
+ # Mini-swarms are called without block, so they don't clear
432
+ if block_given?
433
+ Fiber[:execution_id] = nil
434
+ Fiber[:swarm_id] = nil
435
+ Fiber[:parent_swarm_id] = nil
436
+ end
437
+
358
438
  # Reset logging state for next execution if we set it up
359
439
  #
360
440
  # IMPORTANT: Only reset if we set up logging (block_given? == true).
@@ -447,6 +527,8 @@ module SwarmSDK
447
527
  LogStream.emit(
448
528
  type: "model_lookup_warning",
449
529
  agent: warning[:agent],
530
+ swarm_id: @swarm_id,
531
+ parent_swarm_id: @parent_swarm_id,
450
532
  model: warning[:model],
451
533
  error_message: warning[:error_message],
452
534
  suggestions: warning[:suggestions],
@@ -465,18 +547,25 @@ module SwarmSDK
465
547
  #
466
548
  # @return [void]
467
549
  def cleanup
468
- return if @mcp_clients.empty?
550
+ # Check if there's anything to clean up
551
+ return if @mcp_clients.empty? && (!@delegation_instances || @delegation_instances.empty?)
469
552
 
553
+ # Stop MCP clients for all agents (primaries + delegations tracked by instance name)
470
554
  @mcp_clients.each do |agent_name, clients|
471
555
  clients.each do |client|
472
- client.stop if client.alive?
556
+ # Always call stop - this sets @running = false and stops background threads
557
+ client.stop
473
558
  RubyLLM.logger.debug("SwarmSDK: Stopped MCP client '#{client.name}' for agent #{agent_name}")
474
559
  rescue StandardError => e
475
- RubyLLM.logger.error("SwarmSDK: Error stopping MCP client '#{client.name}' for agent #{agent_name}: #{e.message}")
560
+ # Don't fail cleanup if stopping one client fails
561
+ RubyLLM.logger.debug("SwarmSDK: Error stopping MCP client '#{client.name}': #{e.message}")
476
562
  end
477
563
  end
478
564
 
479
565
  @mcp_clients.clear
566
+
567
+ # Clear delegation instances (V7.0: Added for completeness)
568
+ @delegation_instances&.clear
480
569
  end
481
570
 
482
571
  # Register a named hook that can be referenced in agent configurations
@@ -517,8 +606,155 @@ module SwarmSDK
517
606
  self
518
607
  end
519
608
 
609
+ # Reset context for all agents
610
+ #
611
+ # Clears conversation history for all agents. This is used by composable swarms
612
+ # to reset sub-swarm context when keep_context: false is specified.
613
+ #
614
+ # @return [void]
615
+ def reset_context!
616
+ @agents.each_value do |agent_chat|
617
+ agent_chat.clear_conversation if agent_chat.respond_to?(:clear_conversation)
618
+ end
619
+ end
620
+
621
+ # Create snapshot of current conversation state
622
+ #
623
+ # Returns a Snapshot object containing:
624
+ # - All agent conversations (@messages arrays)
625
+ # - Agent context state (warnings, compression, TodoWrite tracking, skills)
626
+ # - Delegation instance conversations
627
+ # - Scratchpad contents (volatile shared storage)
628
+ # - Read tracking state (which files each agent has read with digests)
629
+ # - Memory read tracking state (which memory entries each agent has read with digests)
630
+ #
631
+ # Configuration (agent definitions, tools, prompts) stays in your YAML/DSL
632
+ # and is NOT included in snapshots.
633
+ #
634
+ # @return [Snapshot] Snapshot object with convenient serialization methods
635
+ #
636
+ # @example Save snapshot to JSON file
637
+ # snapshot = swarm.snapshot
638
+ # snapshot.write_to_file("session.json")
639
+ #
640
+ # @example Convert to hash or JSON string
641
+ # snapshot = swarm.snapshot
642
+ # hash = snapshot.to_hash
643
+ # json_string = snapshot.to_json
644
+ def snapshot
645
+ StateSnapshot.new(self).snapshot
646
+ end
647
+
648
+ # Restore conversation state from snapshot
649
+ #
650
+ # Accepts a Snapshot object, hash, or JSON string. Validates compatibility
651
+ # between snapshot and current swarm configuration, restores agent conversations,
652
+ # context state, scratchpad, and read tracking. Returns RestoreResult with
653
+ # warnings about any agents that couldn't be restored due to configuration
654
+ # mismatches.
655
+ #
656
+ # The swarm must be created with the SAME configuration (agent definitions,
657
+ # tools, prompts) as when the snapshot was created. Only conversation state
658
+ # is restored from the snapshot.
659
+ #
660
+ # @param snapshot [Snapshot, Hash, String] Snapshot object, hash, or JSON string
661
+ # @return [RestoreResult] Result with warnings about skipped agents
662
+ #
663
+ # @example Restore from Snapshot object
664
+ # swarm = SwarmSDK.build { ... } # Same config as snapshot
665
+ # snapshot = Snapshot.from_file("session.json")
666
+ # result = swarm.restore(snapshot)
667
+ # if result.success?
668
+ # puts "All agents restored"
669
+ # else
670
+ # puts result.summary
671
+ # result.warnings.each { |w| puts " - #{w[:message]}" }
672
+ # end
673
+ #
674
+ # Restore swarm state from snapshot
675
+ #
676
+ # By default, uses current system prompts from agent definitions (YAML + SDK defaults + plugin injections).
677
+ # Set preserve_system_prompts: true to use historical prompts from snapshot.
678
+ #
679
+ # @param snapshot [Snapshot, Hash, String] Snapshot object, hash, or JSON string
680
+ # @param preserve_system_prompts [Boolean] Use historical system prompts instead of current config (default: false)
681
+ # @return [RestoreResult] Result with warnings about partial restores
682
+ def restore(snapshot, preserve_system_prompts: false)
683
+ StateRestorer.new(self, snapshot, preserve_system_prompts: preserve_system_prompts).restore
684
+ end
685
+
686
+ # Override swarm IDs for composable swarms
687
+ #
688
+ # Used by SwarmLoader to set hierarchical IDs when loading sub-swarms.
689
+ # This is called after the swarm is built to ensure proper parent/child relationships.
690
+ #
691
+ # @param swarm_id [String] New swarm ID
692
+ # @param parent_swarm_id [String] New parent swarm ID
693
+ # @return [void]
694
+ def override_swarm_ids(swarm_id:, parent_swarm_id:)
695
+ @swarm_id = swarm_id
696
+ @parent_swarm_id = parent_swarm_id
697
+ end
698
+
699
+ # Set swarm registry for composable swarms
700
+ #
701
+ # Used by Builder to set the registry after swarm creation.
702
+ # This must be called before agent initialization to enable swarm delegation.
703
+ #
704
+ # @param registry [SwarmRegistry] Configured swarm registry
705
+ # @return [void]
706
+ attr_writer :swarm_registry
707
+
520
708
  private
521
709
 
710
+ # Validate and normalize scratchpad mode for Swarm
711
+ #
712
+ # Regular Swarms support :enabled or :disabled.
713
+ # Rejects :per_node since it only makes sense for NodeOrchestrator with multiple nodes.
714
+ #
715
+ # @param value [Symbol, String] Scratchpad mode (strings from YAML converted to symbols)
716
+ # @return [Symbol] :enabled or :disabled
717
+ # @raise [ArgumentError] If :per_node used, or invalid value
718
+ def validate_swarm_scratchpad_mode(value)
719
+ # Convert strings from YAML to symbols
720
+ value = value.to_sym if value.is_a?(String)
721
+
722
+ case value
723
+ when :enabled, :disabled
724
+ value
725
+ when :per_node
726
+ raise ArgumentError,
727
+ "scratchpad: :per_node is only valid for NodeOrchestrator with nodes. " \
728
+ "For regular Swarms, use :enabled or :disabled."
729
+ else
730
+ raise ArgumentError,
731
+ "Invalid scratchpad mode for Swarm: #{value.inspect}. " \
732
+ "Use :enabled or :disabled."
733
+ end
734
+ end
735
+
736
+ # Generate a unique swarm ID from name
737
+ #
738
+ # Creates a swarm ID by sanitizing the name and appending a random suffix.
739
+ # Used when swarm_id is not explicitly provided.
740
+ #
741
+ # @param name [String] Swarm name
742
+ # @return [String] Generated swarm ID (e.g., "dev_team_a3f2b1c8")
743
+ def generate_swarm_id(name)
744
+ sanitized = name.to_s.gsub(/[^a-z0-9_-]/i, "_").downcase
745
+ "#{sanitized}_#{SecureRandom.hex(4)}"
746
+ end
747
+
748
+ # Generate a unique execution ID
749
+ #
750
+ # Creates an execution ID that uniquely identifies a single swarm.execute() call.
751
+ # Format: "exec_{swarm_id}_{random_hex}"
752
+ #
753
+ # @return [String] Generated execution ID (e.g., "exec_main_a3f2b1c8")
754
+ def generate_execution_id
755
+ "exec_#{@swarm_id}_#{SecureRandom.hex(8)}"
756
+ end
757
+
522
758
  # Initialize all agents using AgentInitializer
523
759
  #
524
760
  # This is called automatically (lazy initialization) by execute() and agent().
@@ -542,44 +778,87 @@ module SwarmSDK
542
778
  @agent_contexts = initializer.agent_contexts
543
779
  @agents_initialized = true
544
780
 
545
- # Emit agent_start events for all agents
546
- emit_agent_start_events
781
+ # NOTE: agent_start events are emitted in execute() when logging is set up
782
+ # This ensures events are never lost, even if agents are initialized early (e.g., by restore())
783
+ end
784
+
785
+ # Setup logging callbacks for all agents
786
+ #
787
+ # Called when agents were initialized before logging was set up (e.g., via restore()).
788
+ # Retroactively registers RubyLLM callbacks (on_tool_call, on_end_message, etc.)
789
+ # so events are properly emitted during execution.
790
+ # Safe to call multiple times - RubyLLM callbacks are replaced, not appended.
791
+ #
792
+ # @return [void]
793
+ def setup_logging_for_all_agents
794
+ # Setup for PRIMARY agents
795
+ @agents.each_value do |chat|
796
+ chat.setup_logging if chat.respond_to?(:setup_logging)
797
+ end
798
+
799
+ # Setup for DELEGATION instances
800
+ @delegation_instances.each_value do |chat|
801
+ chat.setup_logging if chat.respond_to?(:setup_logging)
802
+ end
547
803
  end
548
804
 
549
805
  # Emit agent_start events for all initialized agents
550
806
  def emit_agent_start_events
551
- # Only emit if LogStream is enabled
552
807
  return unless LogStream.emitter
553
808
 
809
+ # Emit for PRIMARY agents
554
810
  @agents.each do |agent_name, chat|
555
- agent_def = @agent_definitions[agent_name]
556
-
557
- # Build plugin storage info for logging
558
- plugin_storage_info = {}
559
- @plugin_storages.each do |plugin_name, agent_storages|
560
- next unless agent_storages.key?(agent_name)
561
-
562
- plugin_storage_info[plugin_name] = {
563
- enabled: true,
564
- # Get additional info from agent definition if available
565
- config: agent_def.respond_to?(plugin_name) ? extract_plugin_config_info(agent_def.public_send(plugin_name)) : nil,
566
- }
567
- end
811
+ emit_agent_start_for(agent_name, chat, is_delegation: false)
812
+ end
568
813
 
569
- LogStream.emit(
570
- type: "agent_start",
571
- agent: agent_name,
572
- swarm_name: @name,
573
- model: agent_def.model,
574
- provider: agent_def.provider || "openai",
575
- directory: agent_def.directory,
576
- system_prompt: agent_def.system_prompt,
577
- tools: chat.tools.keys,
578
- delegates_to: agent_def.delegates_to,
579
- plugin_storages: plugin_storage_info,
580
- timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
581
- )
814
+ # Emit for DELEGATION instances
815
+ @delegation_instances.each do |instance_name, chat|
816
+ base_name = extract_base_name(instance_name)
817
+ emit_agent_start_for(instance_name.to_sym, chat, is_delegation: true, base_name: base_name)
818
+ end
819
+
820
+ # Mark as emitted to prevent duplicate emissions
821
+ @agent_start_events_emitted = true
822
+ end
823
+
824
+ # Helper for emitting agent_start event
825
+ def emit_agent_start_for(agent_name, chat, is_delegation:, base_name: nil)
826
+ base_name ||= agent_name
827
+ agent_def = @agent_definitions[base_name]
828
+
829
+ # Build plugin storage info using base name
830
+ plugin_storage_info = {}
831
+ @plugin_storages.each do |plugin_name, agent_storages|
832
+ next unless agent_storages.key?(base_name)
833
+
834
+ plugin_storage_info[plugin_name] = {
835
+ enabled: true,
836
+ config: agent_def.respond_to?(plugin_name) ? extract_plugin_config_info(agent_def.public_send(plugin_name)) : nil,
837
+ }
582
838
  end
839
+
840
+ LogStream.emit(
841
+ type: "agent_start",
842
+ agent: agent_name,
843
+ swarm_id: @swarm_id,
844
+ parent_swarm_id: @parent_swarm_id,
845
+ swarm_name: @name,
846
+ model: agent_def.model,
847
+ provider: agent_def.provider || "openai",
848
+ directory: agent_def.directory,
849
+ system_prompt: agent_def.system_prompt,
850
+ tools: chat.tools.keys,
851
+ delegates_to: agent_def.delegates_to,
852
+ plugin_storages: plugin_storage_info,
853
+ is_delegation_instance: is_delegation,
854
+ base_agent: (base_name if is_delegation),
855
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
856
+ )
857
+ end
858
+
859
+ # Extract base name from instance name
860
+ def extract_base_name(instance_name)
861
+ instance_name.to_s.split("@").first.to_sym
583
862
  end
584
863
 
585
864
  # Normalize tools to internal format (kept for add_agent)
@@ -672,6 +951,8 @@ module SwarmSDK
672
951
  LogStream.emit(
673
952
  type: "swarm_start",
674
953
  agent: context.metadata[:lead_agent], # Include agent for consistency
954
+ swarm_id: @swarm_id,
955
+ parent_swarm_id: @parent_swarm_id,
675
956
  swarm_name: context.metadata[:swarm_name],
676
957
  lead_agent: context.metadata[:lead_agent],
677
958
  prompt: context.metadata[:prompt],
@@ -686,6 +967,8 @@ module SwarmSDK
686
967
 
687
968
  LogStream.emit(
688
969
  type: "swarm_stop",
970
+ swarm_id: @swarm_id,
971
+ parent_swarm_id: @parent_swarm_id,
689
972
  swarm_name: context.metadata[:swarm_name],
690
973
  lead_agent: context.metadata[:lead_agent],
691
974
  last_agent: context.metadata[:last_agent], # Agent that produced final response
@@ -707,6 +990,8 @@ module SwarmSDK
707
990
  LogStream.emit(
708
991
  type: "user_prompt",
709
992
  agent: context.agent_name,
993
+ swarm_id: @swarm_id,
994
+ parent_swarm_id: @parent_swarm_id,
710
995
  model: context.metadata[:model] || "unknown",
711
996
  provider: context.metadata[:provider] || "unknown",
712
997
  message_count: context.metadata[:message_count] || 0,
@@ -727,6 +1012,8 @@ module SwarmSDK
727
1012
  LogStream.emit(
728
1013
  type: "agent_step",
729
1014
  agent: context.agent_name,
1015
+ swarm_id: @swarm_id,
1016
+ parent_swarm_id: @parent_swarm_id,
730
1017
  model: context.metadata[:model],
731
1018
  content: context.metadata[:content],
732
1019
  tool_calls: context.metadata[:tool_calls],
@@ -748,6 +1035,8 @@ module SwarmSDK
748
1035
  LogStream.emit(
749
1036
  type: "agent_stop",
750
1037
  agent: context.agent_name,
1038
+ swarm_id: @swarm_id,
1039
+ parent_swarm_id: @parent_swarm_id,
751
1040
  model: context.metadata[:model],
752
1041
  content: context.metadata[:content],
753
1042
  tool_calls: context.metadata[:tool_calls],
@@ -768,6 +1057,8 @@ module SwarmSDK
768
1057
  LogStream.emit(
769
1058
  type: "tool_call",
770
1059
  agent: context.agent_name,
1060
+ swarm_id: @swarm_id,
1061
+ parent_swarm_id: @parent_swarm_id,
771
1062
  tool_call_id: context.tool_call.id,
772
1063
  tool: context.tool_call.name,
773
1064
  arguments: context.tool_call.parameters,
@@ -785,6 +1076,8 @@ module SwarmSDK
785
1076
  LogStream.emit(
786
1077
  type: "tool_result",
787
1078
  agent: context.agent_name,
1079
+ swarm_id: @swarm_id,
1080
+ parent_swarm_id: @parent_swarm_id,
788
1081
  tool_call_id: context.tool_result.tool_call_id,
789
1082
  tool: context.tool_result.tool_name,
790
1083
  result: context.tool_result.content,
@@ -800,6 +1093,8 @@ module SwarmSDK
800
1093
  LogStream.emit(
801
1094
  type: "context_limit_warning",
802
1095
  agent: context.agent_name,
1096
+ swarm_id: @swarm_id,
1097
+ parent_swarm_id: @parent_swarm_id,
803
1098
  model: context.metadata[:model] || "unknown",
804
1099
  threshold: "#{context.metadata[:threshold]}%",
805
1100
  current_usage: "#{context.metadata[:percentage]}%",
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Loader for creating swarm instances from multiple sources
5
+ #
6
+ # SwarmLoader loads swarm configurations from:
7
+ # - Files: .rb (DSL) or .yml (YAML)
8
+ # - YAML strings: Direct YAML content
9
+ # - DSL blocks: Inline Ruby blocks
10
+ #
11
+ # All loaded swarms get hierarchical swarm_id and parent_swarm_id.
12
+ #
13
+ # ## Features
14
+ # - Supports Ruby DSL (.rb files or blocks)
15
+ # - Supports YAML (.yml/.yaml files or strings)
16
+ # - Sets hierarchical swarm_id based on parent + registration name
17
+ # - Isolates loading in separate context
18
+ # - Proper error handling for missing/invalid sources
19
+ #
20
+ # ## Examples
21
+ #
22
+ # # From file
23
+ # swarm = SwarmLoader.load_from_file(
24
+ # "./swarms/code_review.rb",
25
+ # swarm_id: "main/code_review",
26
+ # parent_swarm_id: "main"
27
+ # )
28
+ #
29
+ # # From YAML string
30
+ # swarm = SwarmLoader.load_from_yaml_string(
31
+ # "version: 2\nswarm:\n name: Test\n...",
32
+ # swarm_id: "main/testing",
33
+ # parent_swarm_id: "main"
34
+ # )
35
+ #
36
+ # # From block
37
+ # swarm = SwarmLoader.load_from_block(
38
+ # proc { id "team"; name "Team"; agent :dev { ... } },
39
+ # swarm_id: "main/team",
40
+ # parent_swarm_id: "main"
41
+ # )
42
+ #
43
+ class SwarmLoader
44
+ class << self
45
+ # Load a swarm from a file (.rb or .yml)
46
+ #
47
+ # @param file_path [String] Path to swarm file
48
+ # @param swarm_id [String] Hierarchical swarm ID to assign
49
+ # @param parent_swarm_id [String] Parent swarm ID
50
+ # @return [Swarm] Loaded swarm instance with overridden IDs
51
+ # @raise [ConfigurationError] If file not found or unsupported type
52
+ def load_from_file(file_path, swarm_id:, parent_swarm_id:)
53
+ path = Pathname.new(file_path).expand_path
54
+
55
+ raise ConfigurationError, "Swarm file not found: #{path}" unless path.exist?
56
+
57
+ # Determine file type and load
58
+ case path.extname
59
+ when ".rb"
60
+ load_from_ruby_file(path, swarm_id, parent_swarm_id)
61
+ when ".yml", ".yaml"
62
+ load_from_yaml_file(path, swarm_id, parent_swarm_id)
63
+ else
64
+ raise ConfigurationError, "Unsupported swarm file type: #{path.extname}. Use .rb, .yml, or .yaml"
65
+ end
66
+ end
67
+
68
+ # Load a swarm from YAML string
69
+ #
70
+ # @param yaml_content [String] YAML configuration content
71
+ # @param swarm_id [String] Hierarchical swarm ID to assign
72
+ # @param parent_swarm_id [String] Parent swarm ID
73
+ # @return [Swarm] Loaded swarm instance with overridden IDs
74
+ # @raise [ConfigurationError] If YAML is invalid
75
+ def load_from_yaml_string(yaml_content, swarm_id:, parent_swarm_id:)
76
+ # Use Configuration to parse YAML string
77
+ config = Configuration.new(yaml_content, base_dir: Dir.pwd)
78
+ config.load_and_validate
79
+ swarm = config.to_swarm
80
+
81
+ # Override swarm_id and parent_swarm_id
82
+ swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
83
+
84
+ swarm
85
+ end
86
+
87
+ # Load a swarm from DSL block
88
+ #
89
+ # @param block [Proc] Block containing SwarmSDK DSL
90
+ # @param swarm_id [String] Hierarchical swarm ID to assign
91
+ # @param parent_swarm_id [String] Parent swarm ID
92
+ # @return [Swarm] Loaded swarm instance with overridden IDs
93
+ def load_from_block(block, swarm_id:, parent_swarm_id:)
94
+ # Execute block in Builder context
95
+ builder = Swarm::Builder.new
96
+ builder.instance_eval(&block)
97
+ swarm = builder.build_swarm
98
+
99
+ # Override swarm_id and parent_swarm_id
100
+ swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
101
+
102
+ swarm
103
+ end
104
+
105
+ private
106
+
107
+ # Load swarm from Ruby DSL file
108
+ #
109
+ # @param path [Pathname] Path to .rb file
110
+ # @param swarm_id [String] Swarm ID to assign
111
+ # @param parent_swarm_id [String] Parent swarm ID
112
+ # @return [Swarm] Loaded swarm with overridden IDs
113
+ def load_from_ruby_file(path, swarm_id, parent_swarm_id)
114
+ content = File.read(path)
115
+
116
+ # Execute DSL in isolated context
117
+ # The DSL should return a swarm via SwarmSDK.build { ... }
118
+ swarm = eval(content, binding, path.to_s) # rubocop:disable Security/Eval
119
+
120
+ # Override swarm_id and parent_swarm_id
121
+ # These must be set after build to ensure hierarchical structure
122
+ swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
123
+
124
+ swarm
125
+ end
126
+
127
+ # Load swarm from YAML file
128
+ #
129
+ # @param path [Pathname] Path to .yml file
130
+ # @param swarm_id [String] Swarm ID to assign
131
+ # @param parent_swarm_id [String] Parent swarm ID
132
+ # @return [Swarm] Loaded swarm with overridden IDs
133
+ def load_from_yaml_file(path, swarm_id, parent_swarm_id)
134
+ # Use Configuration to load and convert YAML to swarm
135
+ config = Configuration.load_file(path.to_s)
136
+ swarm = config.to_swarm
137
+
138
+ # Override swarm_id and parent_swarm_id
139
+ swarm.override_swarm_ids(swarm_id: swarm_id, parent_swarm_id: parent_swarm_id)
140
+
141
+ swarm
142
+ end
143
+ end
144
+ end
145
+ end