swarm_sdk 2.1.2 → 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 (49) 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 +15 -22
  10. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  11. data/lib/swarm_sdk/configuration.rb +420 -103
  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/prompts/base_system_prompt.md.erb +0 -126
  21. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  22. data/lib/swarm_sdk/restore_result.rb +65 -0
  23. data/lib/swarm_sdk/snapshot.rb +156 -0
  24. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  25. data/lib/swarm_sdk/state_restorer.rb +491 -0
  26. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  27. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  28. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  29. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  30. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  31. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  32. data/lib/swarm_sdk/swarm.rb +367 -90
  33. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  34. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  35. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  36. data/lib/swarm_sdk/tools/read.rb +17 -5
  37. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  38. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  39. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  40. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  41. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  42. data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
  43. data/lib/swarm_sdk/tools/think.rb +4 -1
  44. data/lib/swarm_sdk/tools/todo_write.rb +20 -8
  45. data/lib/swarm_sdk/utils.rb +18 -0
  46. data/lib/swarm_sdk/validation_result.rb +33 -0
  47. data/lib/swarm_sdk/version.rb +1 -1
  48. data/lib/swarm_sdk.rb +362 -21
  49. metadata +17 -5
@@ -4,25 +4,10 @@ module SwarmSDK
4
4
  # Swarm orchestrates multiple AI agents with shared rate limiting and coordination.
5
5
  #
6
6
  # This is the main user-facing API for SwarmSDK. Users create swarms using:
7
- # - Direct API: Create Agent::Definition objects and add to swarm
8
- # - Ruby DSL: Use Swarm::Builder for fluent configuration
9
- # - YAML: Load from configuration files
10
- #
11
- # ## Direct API
12
- #
13
- # swarm = Swarm.new(name: "Development Team")
14
- #
15
- # backend_agent = Agent::Definition.new(:backend, {
16
- # description: "Backend developer",
17
- # model: "gpt-5",
18
- # system_prompt: "You build APIs and databases...",
19
- # tools: [:Read, :Edit, :Bash],
20
- # delegates_to: [:database]
21
- # })
22
- # swarm.add_agent(backend_agent)
23
- #
24
- # swarm.lead = :backend
25
- # result = swarm.execute("Build authentication")
7
+ # - Ruby DSL: SwarmSDK.build { ... } (Recommended)
8
+ # - YAML String: SwarmSDK.load(yaml, base_dir:)
9
+ # - YAML File: SwarmSDK.load_file(path)
10
+ # - Direct API: Swarm.new + add_agent (Advanced)
26
11
  #
27
12
  # ## Ruby DSL (Recommended)
28
13
  #
@@ -39,14 +24,36 @@ module SwarmSDK
39
24
  # end
40
25
  # result = swarm.execute("Build authentication")
41
26
  #
42
- # ## YAML API
27
+ # ## YAML String API
28
+ #
29
+ # yaml = File.read("swarm.yml")
30
+ # swarm = SwarmSDK.load(yaml, base_dir: "/path/to/project")
31
+ # result = swarm.execute("Build authentication")
32
+ #
33
+ # ## YAML File API (Convenience)
43
34
  #
44
- # swarm = Swarm.load("swarm.yml")
35
+ # swarm = SwarmSDK.load_file("swarm.yml")
36
+ # result = swarm.execute("Build authentication")
37
+ #
38
+ # ## Direct API (Advanced)
39
+ #
40
+ # swarm = Swarm.new(name: "Development Team")
41
+ #
42
+ # backend_agent = Agent::Definition.new(:backend, {
43
+ # description: "Backend developer",
44
+ # model: "gpt-5",
45
+ # system_prompt: "You build APIs and databases...",
46
+ # tools: [:Read, :Edit, :Bash],
47
+ # delegates_to: [:database]
48
+ # })
49
+ # swarm.add_agent(backend_agent)
50
+ #
51
+ # swarm.lead = :backend
45
52
  # result = swarm.execute("Build authentication")
46
53
  #
47
54
  # ## Architecture
48
55
  #
49
- # All three APIs converge on Agent::Definition for validation.
56
+ # All APIs converge on Agent::Definition for validation.
50
57
  # Swarm delegates to specialized concerns:
51
58
  # - Agent::Definition: Validates configuration, builds system prompts
52
59
  # - AgentInitializer: Complex 5-pass agent setup
@@ -61,16 +68,30 @@ module SwarmSDK
61
68
  # Default tools available to all agents
62
69
  DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
63
70
 
64
- 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
65
73
 
66
74
  # Check if scratchpad tools are enabled
67
75
  #
68
76
  # @return [Boolean]
69
77
  def scratchpad_enabled?
70
- @scratchpad_enabled
78
+ @scratchpad_mode == :enabled
71
79
  end
72
80
  attr_writer :config_for_hooks
73
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
+
74
95
  # Class-level MCP log level configuration
75
96
  @mcp_log_level = DEFAULT_MCP_LOG_LEVEL
76
97
  @mcp_logging_configured = false
@@ -102,54 +123,54 @@ module SwarmSDK
102
123
 
103
124
  @mcp_logging_configured = true
104
125
  end
105
-
106
- # Load swarm from YAML configuration file
107
- #
108
- # @param config_path [String] Path to YAML configuration file
109
- # @return [Swarm] Configured swarm instance
110
- def load(config_path)
111
- config = Configuration.load(config_path)
112
- swarm = config.to_swarm
113
-
114
- # Apply hooks if any are configured (YAML-only feature)
115
- if hooks_configured?(config)
116
- Hooks::Adapter.apply_hooks(swarm, config)
117
- end
118
-
119
- # Store config reference for agent hooks (applied during initialize_agents)
120
- swarm.config_for_hooks = config
121
-
122
- swarm
123
- end
124
-
125
- private
126
-
127
- def hooks_configured?(config)
128
- config.swarm_hooks.any? ||
129
- config.all_agents_hooks.any? ||
130
- config.agents.any? { |_, agent_def| agent_def.hooks&.any? }
131
- end
132
126
  end
133
127
 
134
128
  # Initialize a new Swarm
135
129
  #
136
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)
137
133
  # @param global_concurrency [Integer] Max concurrent LLM calls across entire swarm
138
134
  # @param default_local_concurrency [Integer] Default max concurrent tool calls per agent
139
- # @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing)
140
- # @param scratchpad_enabled [Boolean] Whether to enable scratchpad tools (default: true)
141
- 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)
142
139
  @name = name
140
+ @swarm_id = swarm_id || generate_swarm_id(name)
141
+ @parent_swarm_id = parent_swarm_id
143
142
  @global_concurrency = global_concurrency
144
143
  @default_local_concurrency = default_local_concurrency
145
- @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 = []
146
163
 
147
164
  # Shared semaphore for all agents
148
165
  @global_semaphore = Async::Semaphore.new(@global_concurrency)
149
166
 
150
167
  # Shared scratchpad storage for all agents (volatile)
151
- # Use provided scratchpad storage (for testing) or create volatile one
152
- @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
153
174
 
154
175
  # Per-agent plugin storages (persistent)
155
176
  # Format: { plugin_name => { agent_name => storage } }
@@ -165,6 +186,7 @@ module SwarmSDK
165
186
  # Agent definitions and instances
166
187
  @agent_definitions = {}
167
188
  @agents = {}
189
+ @delegation_instances = {} # { "delegate@delegator" => Agent::Chat }
168
190
  @agents_initialized = false
169
191
  @agent_contexts = {}
170
192
 
@@ -175,6 +197,10 @@ module SwarmSDK
175
197
 
176
198
  # Track if first message has been sent
177
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
178
204
  end
179
205
 
180
206
  # Add an agent to the swarm
@@ -240,8 +266,25 @@ module SwarmSDK
240
266
  logs = []
241
267
  current_prompt = prompt
242
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
+
243
283
  # Setup logging FIRST if block given (so swarm_start event can be emitted)
244
284
  if block_given?
285
+ # Force fresh callback array for this execution
286
+ Fiber[:log_callbacks] = []
287
+
245
288
  # Register callback to collect logs and forward to user's block
246
289
  LogCollector.on_log do |entry|
247
290
  logs << entry
@@ -270,6 +313,17 @@ module SwarmSDK
270
313
  # Lazy initialization of agents (with optional logging)
271
314
  initialize_agents unless @agents_initialized
272
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
+
273
327
  # Execution loop (supports reprompting)
274
328
  result = nil
275
329
  swarm_stop_triggered = false
@@ -373,6 +427,14 @@ module SwarmSDK
373
427
  # Cleanup MCP clients after execution
374
428
  cleanup
375
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
+
376
438
  # Reset logging state for next execution if we set it up
377
439
  #
378
440
  # IMPORTANT: Only reset if we set up logging (block_given? == true).
@@ -433,7 +495,7 @@ module SwarmSDK
433
495
  # @return [Array<Hash>] Array of warning hashes from all agent definitions
434
496
  #
435
497
  # @example
436
- # swarm = Swarm.load("config.yml")
498
+ # swarm = SwarmSDK.load_file("config.yml")
437
499
  # warnings = swarm.validate
438
500
  # warnings.each do |warning|
439
501
  # puts "⚠️ #{warning[:agent]}: #{warning[:model]} not found"
@@ -465,6 +527,8 @@ module SwarmSDK
465
527
  LogStream.emit(
466
528
  type: "model_lookup_warning",
467
529
  agent: warning[:agent],
530
+ swarm_id: @swarm_id,
531
+ parent_swarm_id: @parent_swarm_id,
468
532
  model: warning[:model],
469
533
  error_message: warning[:error_message],
470
534
  suggestions: warning[:suggestions],
@@ -483,18 +547,25 @@ module SwarmSDK
483
547
  #
484
548
  # @return [void]
485
549
  def cleanup
486
- 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?)
487
552
 
553
+ # Stop MCP clients for all agents (primaries + delegations tracked by instance name)
488
554
  @mcp_clients.each do |agent_name, clients|
489
555
  clients.each do |client|
490
- client.stop if client.alive?
556
+ # Always call stop - this sets @running = false and stops background threads
557
+ client.stop
491
558
  RubyLLM.logger.debug("SwarmSDK: Stopped MCP client '#{client.name}' for agent #{agent_name}")
492
559
  rescue StandardError => e
493
- 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}")
494
562
  end
495
563
  end
496
564
 
497
565
  @mcp_clients.clear
566
+
567
+ # Clear delegation instances (V7.0: Added for completeness)
568
+ @delegation_instances&.clear
498
569
  end
499
570
 
500
571
  # Register a named hook that can be referenced in agent configurations
@@ -535,8 +606,155 @@ module SwarmSDK
535
606
  self
536
607
  end
537
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
+
538
708
  private
539
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
+
540
758
  # Initialize all agents using AgentInitializer
541
759
  #
542
760
  # This is called automatically (lazy initialization) by execute() and agent().
@@ -560,44 +778,87 @@ module SwarmSDK
560
778
  @agent_contexts = initializer.agent_contexts
561
779
  @agents_initialized = true
562
780
 
563
- # Emit agent_start events for all agents
564
- 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
565
803
  end
566
804
 
567
805
  # Emit agent_start events for all initialized agents
568
806
  def emit_agent_start_events
569
- # Only emit if LogStream is enabled
570
807
  return unless LogStream.emitter
571
808
 
809
+ # Emit for PRIMARY agents
572
810
  @agents.each do |agent_name, chat|
573
- agent_def = @agent_definitions[agent_name]
574
-
575
- # Build plugin storage info for logging
576
- plugin_storage_info = {}
577
- @plugin_storages.each do |plugin_name, agent_storages|
578
- next unless agent_storages.key?(agent_name)
579
-
580
- plugin_storage_info[plugin_name] = {
581
- enabled: true,
582
- # Get additional info from agent definition if available
583
- config: agent_def.respond_to?(plugin_name) ? extract_plugin_config_info(agent_def.public_send(plugin_name)) : nil,
584
- }
585
- end
811
+ emit_agent_start_for(agent_name, chat, is_delegation: false)
812
+ end
586
813
 
587
- LogStream.emit(
588
- type: "agent_start",
589
- agent: agent_name,
590
- swarm_name: @name,
591
- model: agent_def.model,
592
- provider: agent_def.provider || "openai",
593
- directory: agent_def.directory,
594
- system_prompt: agent_def.system_prompt,
595
- tools: chat.tools.keys,
596
- delegates_to: agent_def.delegates_to,
597
- plugin_storages: plugin_storage_info,
598
- timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
599
- )
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)
600
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
+ }
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
601
862
  end
602
863
 
603
864
  # Normalize tools to internal format (kept for add_agent)
@@ -690,6 +951,8 @@ module SwarmSDK
690
951
  LogStream.emit(
691
952
  type: "swarm_start",
692
953
  agent: context.metadata[:lead_agent], # Include agent for consistency
954
+ swarm_id: @swarm_id,
955
+ parent_swarm_id: @parent_swarm_id,
693
956
  swarm_name: context.metadata[:swarm_name],
694
957
  lead_agent: context.metadata[:lead_agent],
695
958
  prompt: context.metadata[:prompt],
@@ -704,6 +967,8 @@ module SwarmSDK
704
967
 
705
968
  LogStream.emit(
706
969
  type: "swarm_stop",
970
+ swarm_id: @swarm_id,
971
+ parent_swarm_id: @parent_swarm_id,
707
972
  swarm_name: context.metadata[:swarm_name],
708
973
  lead_agent: context.metadata[:lead_agent],
709
974
  last_agent: context.metadata[:last_agent], # Agent that produced final response
@@ -725,6 +990,8 @@ module SwarmSDK
725
990
  LogStream.emit(
726
991
  type: "user_prompt",
727
992
  agent: context.agent_name,
993
+ swarm_id: @swarm_id,
994
+ parent_swarm_id: @parent_swarm_id,
728
995
  model: context.metadata[:model] || "unknown",
729
996
  provider: context.metadata[:provider] || "unknown",
730
997
  message_count: context.metadata[:message_count] || 0,
@@ -745,6 +1012,8 @@ module SwarmSDK
745
1012
  LogStream.emit(
746
1013
  type: "agent_step",
747
1014
  agent: context.agent_name,
1015
+ swarm_id: @swarm_id,
1016
+ parent_swarm_id: @parent_swarm_id,
748
1017
  model: context.metadata[:model],
749
1018
  content: context.metadata[:content],
750
1019
  tool_calls: context.metadata[:tool_calls],
@@ -766,6 +1035,8 @@ module SwarmSDK
766
1035
  LogStream.emit(
767
1036
  type: "agent_stop",
768
1037
  agent: context.agent_name,
1038
+ swarm_id: @swarm_id,
1039
+ parent_swarm_id: @parent_swarm_id,
769
1040
  model: context.metadata[:model],
770
1041
  content: context.metadata[:content],
771
1042
  tool_calls: context.metadata[:tool_calls],
@@ -786,6 +1057,8 @@ module SwarmSDK
786
1057
  LogStream.emit(
787
1058
  type: "tool_call",
788
1059
  agent: context.agent_name,
1060
+ swarm_id: @swarm_id,
1061
+ parent_swarm_id: @parent_swarm_id,
789
1062
  tool_call_id: context.tool_call.id,
790
1063
  tool: context.tool_call.name,
791
1064
  arguments: context.tool_call.parameters,
@@ -803,6 +1076,8 @@ module SwarmSDK
803
1076
  LogStream.emit(
804
1077
  type: "tool_result",
805
1078
  agent: context.agent_name,
1079
+ swarm_id: @swarm_id,
1080
+ parent_swarm_id: @parent_swarm_id,
806
1081
  tool_call_id: context.tool_result.tool_call_id,
807
1082
  tool: context.tool_result.tool_name,
808
1083
  result: context.tool_result.content,
@@ -818,6 +1093,8 @@ module SwarmSDK
818
1093
  LogStream.emit(
819
1094
  type: "context_limit_warning",
820
1095
  agent: context.agent_name,
1096
+ swarm_id: @swarm_id,
1097
+ parent_swarm_id: @parent_swarm_id,
821
1098
  model: context.metadata[:model] || "unknown",
822
1099
  threshold: "#{context.metadata[:threshold]}%",
823
1100
  current_usage: "#{context.metadata[:percentage]}%",