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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +33 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +198 -51
- data/lib/swarm_sdk/agent/context.rb +6 -2
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +14 -2
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +387 -94
- data/lib/swarm_sdk/events_to_messages.rb +181 -0
- data/lib/swarm_sdk/log_collector.rb +31 -5
- data/lib/swarm_sdk/log_stream.rb +37 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node/agent_config.rb +33 -8
- data/lib/swarm_sdk/node/builder.rb +39 -18
- data/lib/swarm_sdk/node_orchestrator.rb +293 -26
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
- data/lib/swarm_sdk/state_restorer.rb +491 -0
- data/lib/swarm_sdk/state_snapshot.rb +369 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +208 -12
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
- data/lib/swarm_sdk/swarm.rb +337 -42
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/delegate.rb +92 -7
- data/lib/swarm_sdk/tools/read.rb +17 -5
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
- data/lib/swarm_sdk/utils.rb +18 -0
- data/lib/swarm_sdk/validation_result.rb +33 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +40 -8
- metadata +17 -6
- data/lib/swarm_sdk/mcp.rb +0 -16
data/lib/swarm_sdk/swarm.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|