swarm_sdk 2.1.3 → 2.3.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +91 -0
  3. data/lib/swarm_sdk/agent/chat.rb +540 -925
  4. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
  5. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  6. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
  7. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  8. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  9. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  10. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  11. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
  12. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  13. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  14. data/lib/swarm_sdk/agent/context.rb +8 -4
  15. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  16. data/lib/swarm_sdk/agent/definition.rb +79 -155
  17. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
  18. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  19. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  20. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  21. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  22. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  23. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  24. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  25. data/lib/swarm_sdk/configuration.rb +72 -257
  26. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  27. data/lib/swarm_sdk/context_compactor.rb +6 -11
  28. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  29. data/lib/swarm_sdk/context_management/context.rb +328 -0
  30. data/lib/swarm_sdk/defaults.rb +196 -0
  31. data/lib/swarm_sdk/events_to_messages.rb +199 -0
  32. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  33. data/lib/swarm_sdk/log_collector.rb +192 -16
  34. data/lib/swarm_sdk/log_stream.rb +66 -8
  35. data/lib/swarm_sdk/model_aliases.json +4 -1
  36. data/lib/swarm_sdk/node_context.rb +1 -1
  37. data/lib/swarm_sdk/observer/builder.rb +81 -0
  38. data/lib/swarm_sdk/observer/config.rb +45 -0
  39. data/lib/swarm_sdk/observer/manager.rb +236 -0
  40. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  41. data/lib/swarm_sdk/plugin.rb +93 -3
  42. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  43. data/lib/swarm_sdk/restore_result.rb +65 -0
  44. data/lib/swarm_sdk/snapshot.rb +156 -0
  45. data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
  46. data/lib/swarm_sdk/state_restorer.rb +476 -0
  47. data/lib/swarm_sdk/state_snapshot.rb +334 -0
  48. data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
  49. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  50. data/lib/swarm_sdk/swarm/builder.rb +69 -407
  51. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  52. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  53. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  54. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  55. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  56. data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
  57. data/lib/swarm_sdk/swarm.rb +337 -584
  58. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  59. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  60. data/lib/swarm_sdk/tools/bash.rb +11 -3
  61. data/lib/swarm_sdk/tools/delegate.rb +127 -24
  62. data/lib/swarm_sdk/tools/edit.rb +8 -13
  63. data/lib/swarm_sdk/tools/glob.rb +9 -1
  64. data/lib/swarm_sdk/tools/grep.rb +7 -0
  65. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  66. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  67. data/lib/swarm_sdk/tools/read.rb +28 -18
  68. data/lib/swarm_sdk/tools/registry.rb +122 -10
  69. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  70. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
  71. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  72. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  73. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  74. data/lib/swarm_sdk/tools/write.rb +8 -13
  75. data/lib/swarm_sdk/utils.rb +18 -0
  76. data/lib/swarm_sdk/validation_result.rb +33 -0
  77. data/lib/swarm_sdk/version.rb +1 -1
  78. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
  79. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  80. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  81. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
  82. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  83. data/lib/swarm_sdk/workflow.rb +554 -0
  84. data/lib/swarm_sdk.rb +73 -11
  85. metadata +79 -16
  86. data/lib/swarm_sdk/mcp.rb +0 -16
  87. data/lib/swarm_sdk/node_orchestrator.rb +0 -591
  88. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
@@ -61,23 +61,42 @@ module SwarmSDK
61
61
  # - McpConfigurator: MCP client management (via AgentInitializer)
62
62
  #
63
63
  class Swarm
64
- DEFAULT_GLOBAL_CONCURRENCY = 50
65
- DEFAULT_LOCAL_CONCURRENCY = 10
66
- DEFAULT_MCP_LOG_LEVEL = Logger::WARN
64
+ include Concerns::Snapshotable
65
+ include Concerns::Validatable
66
+ include Concerns::Cleanupable
67
+ include LoggingCallbacks
68
+ include HookTriggers
69
+
70
+ # Backward compatibility aliases - use Defaults module for new code
71
+ DEFAULT_MCP_LOG_LEVEL = Defaults::Logging::MCP_LOG_LEVEL
67
72
 
68
73
  # Default tools available to all agents
69
74
  DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
70
75
 
71
- attr_reader :name, :agents, :lead_agent, :mcp_clients
76
+ attr_reader :name, :agents, :lead_agent, :mcp_clients, :delegation_instances, :agent_definitions, :swarm_id, :parent_swarm_id, :swarm_registry, :scratchpad_storage, :allow_filesystem_tools, :hook_registry, :global_semaphore, :plugin_storages, :config_for_hooks, :observer_configs
77
+ attr_accessor :delegation_call_stack
72
78
 
73
79
  # Check if scratchpad tools are enabled
74
80
  #
75
81
  # @return [Boolean]
76
82
  def scratchpad_enabled?
77
- @scratchpad_enabled
83
+ @scratchpad_mode == :enabled
78
84
  end
79
85
  attr_writer :config_for_hooks
80
86
 
87
+ # Check if first message has been sent (for system reminder injection)
88
+ #
89
+ # @return [Boolean]
90
+ def first_message_sent?
91
+ @first_message_sent
92
+ end
93
+
94
+ # Set first message sent flag (used by snapshot/restore)
95
+ #
96
+ # @param value [Boolean] New value
97
+ # @return [void]
98
+ attr_writer :first_message_sent
99
+
81
100
  # Class-level MCP log level configuration
82
101
  @mcp_log_level = DEFAULT_MCP_LOG_LEVEL
83
102
  @mcp_logging_configured = false
@@ -103,8 +122,6 @@ module SwarmSDK
103
122
  def apply_mcp_logging_configuration
104
123
  return if @mcp_logging_configured
105
124
 
106
- SwarmSDK::MCP.lazy_load
107
-
108
125
  RubyLLM::MCP.configure do |config|
109
126
  config.log_level = @mcp_log_level
110
127
  end
@@ -116,22 +133,49 @@ module SwarmSDK
116
133
  # Initialize a new Swarm
117
134
  #
118
135
  # @param name [String] Human-readable swarm name
136
+ # @param swarm_id [String, nil] Optional swarm ID (auto-generated if not provided)
137
+ # @param parent_swarm_id [String, nil] Optional parent swarm ID (nil for root swarms)
119
138
  # @param global_concurrency [Integer] Max concurrent LLM calls across entire swarm
120
139
  # @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)
140
+ # @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing/internal use)
141
+ # @param scratchpad_mode [Symbol, String] Scratchpad mode (:enabled or :disabled). :per_node not allowed for non-node swarms.
142
+ # @param allow_filesystem_tools [Boolean, nil] Whether to allow filesystem tools (nil uses global setting)
143
+ def initialize(name:, swarm_id: nil, parent_swarm_id: nil, global_concurrency: Defaults::Concurrency::GLOBAL_LIMIT, default_local_concurrency: Defaults::Concurrency::LOCAL_LIMIT, scratchpad: nil, scratchpad_mode: :enabled, allow_filesystem_tools: nil)
124
144
  @name = name
145
+ @swarm_id = swarm_id || generate_swarm_id(name)
146
+ @parent_swarm_id = parent_swarm_id
125
147
  @global_concurrency = global_concurrency
126
148
  @default_local_concurrency = default_local_concurrency
127
- @scratchpad_enabled = scratchpad_enabled
149
+
150
+ # Handle scratchpad_mode parameter
151
+ # For Swarm: :enabled or :disabled (not :per_node - that's for nodes)
152
+ @scratchpad_mode = validate_swarm_scratchpad_mode(scratchpad_mode)
153
+
154
+ # Resolve allow_filesystem_tools with priority:
155
+ # 1. Explicit parameter (if not nil)
156
+ # 2. Global settings
157
+ @allow_filesystem_tools = if allow_filesystem_tools.nil?
158
+ SwarmSDK.settings.allow_filesystem_tools
159
+ else
160
+ allow_filesystem_tools
161
+ end
162
+
163
+ # Swarm registry for managing sub-swarms (initialized later if needed)
164
+ @swarm_registry = nil
165
+
166
+ # Delegation call stack for circular dependency detection
167
+ @delegation_call_stack = []
128
168
 
129
169
  # Shared semaphore for all agents
130
170
  @global_semaphore = Async::Semaphore.new(@global_concurrency)
131
171
 
132
172
  # 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
173
+ # Use provided scratchpad storage (for testing) or create volatile one based on mode
174
+ @scratchpad_storage = if scratchpad
175
+ scratchpad # Testing/internal use - explicit instance provided
176
+ elsif @scratchpad_mode == :enabled
177
+ Tools::Stores::ScratchpadStorage.new
178
+ end
135
179
 
136
180
  # Per-agent plugin storages (persistent)
137
181
  # Format: { plugin_name => { agent_name => storage } }
@@ -147,6 +191,7 @@ module SwarmSDK
147
191
  # Agent definitions and instances
148
192
  @agent_definitions = {}
149
193
  @agents = {}
194
+ @delegation_instances = {} # { "delegate@delegator" => Agent::Chat }
150
195
  @agents_initialized = false
151
196
  @agent_contexts = {}
152
197
 
@@ -157,6 +202,14 @@ module SwarmSDK
157
202
 
158
203
  # Track if first message has been sent
159
204
  @first_message_sent = false
205
+
206
+ # Track if agent_start events have been emitted
207
+ # This prevents duplicate emissions and ensures events are emitted when logging is ready
208
+ @agent_start_events_emitted = false
209
+
210
+ # Observer agent configurations
211
+ @observer_configs = []
212
+ @observer_manager = nil
160
213
  end
161
214
 
162
215
  # Add an agent to the swarm
@@ -212,36 +265,53 @@ module SwarmSDK
212
265
  # and the entire swarm coordinates with shared rate limiting.
213
266
  # Supports reprompting via swarm_stop hooks.
214
267
  #
268
+ # By default, this method blocks until execution completes. Set wait: false
269
+ # to return an Async::Task immediately, enabling cancellation via task.stop.
270
+ #
215
271
  # @param prompt [String] Task to execute
272
+ # @param wait [Boolean] If true (default), blocks until execution completes.
273
+ # If false, returns Async::Task immediately for non-blocking execution.
216
274
  # @yield [Hash] Log entry if block given (for streaming)
217
- # @return [Result] Execution result
218
- def execute(prompt, &block)
275
+ # @return [Result, Async::Task] Result if wait: true, Async::Task if wait: false
276
+ #
277
+ # @example Blocking execution (default)
278
+ # result = swarm.execute("Build auth")
279
+ # puts result.content
280
+ #
281
+ # @example Non-blocking execution with cancellation
282
+ # task = swarm.execute("Build auth", wait: false) { |event| puts event }
283
+ # # ... do other work ...
284
+ # task.stop # Cancel anytime
285
+ # result = task.wait # Returns nil for cancelled tasks
286
+ def execute(prompt, wait: true, &block)
219
287
  raise ConfigurationError, "No lead agent set. Set lead= first." unless @lead_agent
220
288
 
221
- start_time = Time.now
222
289
  logs = []
223
290
  current_prompt = prompt
291
+ has_logging = block_given?
292
+
293
+ # Save original Fiber storage for restoration (preserves parent context for nested swarms)
294
+ original_fiber_storage = {
295
+ execution_id: Fiber[:execution_id],
296
+ swarm_id: Fiber[:swarm_id],
297
+ parent_swarm_id: Fiber[:parent_swarm_id],
298
+ }
299
+
300
+ # Set fiber-local execution context
301
+ # Use ||= to inherit parent's execution_id if one exists (for mini-swarms)
302
+ Fiber[:execution_id] ||= generate_execution_id
303
+ Fiber[:swarm_id] = @swarm_id
304
+ Fiber[:parent_swarm_id] = @parent_swarm_id
224
305
 
225
306
  # Setup logging FIRST if block given (so swarm_start event can be emitted)
226
- if block_given?
227
- # Register callback to collect logs and forward to user's block
228
- LogCollector.on_log do |entry|
229
- logs << entry
230
- block.call(entry)
231
- end
307
+ setup_logging(logs, &block) if has_logging
232
308
 
233
- # Set LogStream to use LogCollector as emitter
234
- LogStream.emitter = LogCollector
235
- end
309
+ # Setup observer execution if any observers configured
310
+ # MUST happen AFTER setup_logging (which clears Fiber[:log_subscriptions])
311
+ setup_observer_execution if @observer_configs.any?
236
312
 
237
313
  # Trigger swarm_start hooks (before any execution)
238
- # Hook can append stdout to prompt (exit code 0)
239
- # Default callback emits swarm_start event to LogStream
240
- swarm_start_result = trigger_swarm_start(current_prompt)
241
- if swarm_start_result&.replace?
242
- # Hook provided stdout to append to prompt
243
- current_prompt = "#{current_prompt}\n\n<hook-context>\n#{swarm_start_result.value}\n</hook-context>"
244
- end
314
+ current_prompt = apply_swarm_start_hooks(current_prompt)
245
315
 
246
316
  # Trigger first_message hooks on first execution
247
317
  unless @first_message_sent
@@ -252,128 +322,18 @@ module SwarmSDK
252
322
  # Lazy initialization of agents (with optional logging)
253
323
  initialize_agents unless @agents_initialized
254
324
 
255
- # Execution loop (supports reprompting)
256
- result = nil
257
- swarm_stop_triggered = false
258
-
259
- loop do
260
- # Execute within Async reactor to enable fiber scheduler for parallel execution
261
- # This sets Fiber.scheduler, making Faraday fiber-aware so HTTP requests yield during I/O
262
- # Use finished: false to suppress warnings for expected task failures
263
- lead = @agents[@lead_agent]
264
- response = Async(finished: false) do
265
- lead.ask(current_prompt)
266
- end.wait
267
-
268
- # Check if swarm was finished by a hook (finish_swarm)
269
- if response.is_a?(Hash) && response[:__finish_swarm__]
270
- result = Result.new(
271
- content: response[:message],
272
- agent: @lead_agent.to_s,
273
- logs: logs,
274
- duration: Time.now - start_time,
275
- )
276
-
277
- # Trigger swarm_stop hooks for event emission
278
- trigger_swarm_stop(result)
279
- swarm_stop_triggered = true
280
-
281
- # Break immediately - don't allow reprompting when swarm is finished by hook
282
- break
283
- end
284
-
285
- result = Result.new(
286
- content: response.content,
287
- agent: @lead_agent.to_s,
288
- logs: logs,
289
- duration: Time.now - start_time,
290
- )
291
-
292
- # Trigger swarm_stop hooks (for reprompt check and event emission)
293
- hook_result = trigger_swarm_stop(result)
294
- swarm_stop_triggered = true
295
-
296
- # Check if hook requests reprompting
297
- if hook_result&.reprompt?
298
- current_prompt = hook_result.value
299
- swarm_stop_triggered = false # Will trigger again in next iteration
300
- # Continue loop with new prompt
301
- else
302
- # Exit loop - execution complete
303
- break
304
- end
305
- end
306
-
307
- result
308
- rescue ConfigurationError, AgentNotFoundError
309
- # Re-raise configuration errors - these should be fixed, not caught
310
- raise
311
- rescue TypeError => e
312
- # Catch the specific "String does not have #dig method" error
313
- if e.message.include?("does not have #dig method")
314
- agent_definition = @agent_definitions[@lead_agent]
315
- error_msg = if agent_definition.base_url
316
- "LLM API request failed: The proxy/server at '#{agent_definition.base_url}' returned an invalid response. " \
317
- "This usually means the proxy is unreachable, requires authentication, or returned an error in non-JSON format. " \
318
- "Original error: #{e.message}"
319
- else
320
- "LLM API request failed with unexpected response format. Original error: #{e.message}"
321
- end
325
+ # Emit agent_start events if agents were initialized before logging was set up
326
+ emit_retroactive_agent_start_events if has_logging
322
327
 
323
- result = Result.new(
324
- content: nil,
325
- agent: @lead_agent.to_s,
326
- error: LLMError.new(error_msg),
327
- logs: logs,
328
- duration: Time.now - start_time,
329
- )
330
- else
331
- result = Result.new(
332
- content: nil,
333
- agent: @lead_agent.to_s,
334
- error: e,
335
- logs: logs,
336
- duration: Time.now - start_time,
337
- )
338
- end
339
- result
340
- rescue StandardError => e
341
- result = Result.new(
342
- content: nil,
343
- agent: @lead_agent&.to_s || "unknown",
344
- error: e,
328
+ # Delegate to Executor for actual execution
329
+ executor = Executor.new(self)
330
+ @current_task = executor.run(
331
+ current_prompt,
332
+ wait: wait,
345
333
  logs: logs,
346
- duration: Time.now - start_time,
334
+ has_logging: has_logging,
335
+ original_fiber_storage: original_fiber_storage,
347
336
  )
348
- result
349
- ensure
350
- # Trigger swarm_stop if not already triggered (handles error cases)
351
- unless swarm_stop_triggered
352
- trigger_swarm_stop_final(result, start_time, logs)
353
- end
354
-
355
- # Cleanup MCP clients after execution
356
- cleanup
357
-
358
- # Reset logging state for next execution if we set it up
359
- #
360
- # IMPORTANT: Only reset if we set up logging (block_given? == true).
361
- # When this swarm is a mini-swarm within a NodeOrchestrator workflow,
362
- # the orchestrator manages LogCollector and we don't set up logging.
363
- #
364
- # Flow in NodeOrchestrator:
365
- # 1. NodeOrchestrator sets up LogCollector + LogStream (no block given to mini-swarms)
366
- # 2. Each mini-swarm executes without logging block (block_given? == false)
367
- # 3. Each mini-swarm skips reset (didn't set up logging)
368
- # 4. NodeOrchestrator resets once at the very end
369
- #
370
- # Flow in standalone swarm / interactive REPL:
371
- # 1. Swarm.execute sets up LogCollector + LogStream (block given)
372
- # 2. Swarm.execute resets in ensure block (cleanup for next call)
373
- if block_given?
374
- LogCollector.reset!
375
- LogStream.reset!
376
- end
377
337
  end
378
338
 
379
339
  # Get an agent chat instance by name
@@ -407,77 +367,17 @@ module SwarmSDK
407
367
  @agent_definitions.keys
408
368
  end
409
369
 
410
- # Validate swarm configuration and return warnings
411
- #
412
- # This performs lightweight validation checks without creating agents.
413
- # Useful for displaying configuration warnings before execution.
414
- #
415
- # @return [Array<Hash>] Array of warning hashes from all agent definitions
416
- #
417
- # @example
418
- # swarm = SwarmSDK.load_file("config.yml")
419
- # warnings = swarm.validate
420
- # warnings.each do |warning|
421
- # puts "⚠️ #{warning[:agent]}: #{warning[:model]} not found"
422
- # end
423
- def validate
424
- @agent_definitions.flat_map { |_name, definition| definition.validate }
370
+ # Implement Snapshotable interface
371
+ def primary_agents
372
+ @agents
425
373
  end
426
374
 
427
- # Emit validation warnings as log events
428
- #
429
- # This validates all agent definitions and emits any warnings as
430
- # model_lookup_warning events through LogStream. Useful for emitting
431
- # warnings before execution starts (e.g., in REPL after welcome screen).
432
- #
433
- # Requires LogStream.emitter to be set.
434
- #
435
- # @return [Array<Hash>] The validation warnings that were emitted
436
- #
437
- # @example
438
- # LogCollector.on_log { |event| puts event }
439
- # LogStream.emitter = LogCollector
440
- # swarm.emit_validation_warnings
441
- def emit_validation_warnings
442
- warnings = validate
443
-
444
- warnings.each do |warning|
445
- case warning[:type]
446
- when :model_not_found
447
- LogStream.emit(
448
- type: "model_lookup_warning",
449
- agent: warning[:agent],
450
- model: warning[:model],
451
- error_message: warning[:error_message],
452
- suggestions: warning[:suggestions],
453
- timestamp: Time.now.utc.iso8601,
454
- )
455
- end
456
- end
457
-
458
- warnings
375
+ def delegation_instances_hash
376
+ @delegation_instances
459
377
  end
460
378
 
461
- # Cleanup all MCP clients
462
- #
463
- # Stops all MCP client connections gracefully.
464
- # Should be called when the swarm is no longer needed.
465
- #
466
- # @return [void]
467
- def cleanup
468
- return if @mcp_clients.empty?
469
-
470
- @mcp_clients.each do |agent_name, clients|
471
- clients.each do |client|
472
- client.stop if client.alive?
473
- RubyLLM.logger.debug("SwarmSDK: Stopped MCP client '#{client.name}' for agent #{agent_name}")
474
- rescue StandardError => e
475
- RubyLLM.logger.error("SwarmSDK: Error stopping MCP client '#{client.name}' for agent #{agent_name}: #{e.message}")
476
- end
477
- end
478
-
479
- @mcp_clients.clear
480
- end
379
+ # NOTE: validate() and emit_validation_warnings() are provided by Concerns::Validatable
380
+ # Note: cleanup() is provided by Concerns::Cleanupable
481
381
 
482
382
  # Register a named hook that can be referenced in agent configurations
483
383
  #
@@ -497,28 +397,229 @@ module SwarmSDK
497
397
  self
498
398
  end
499
399
 
500
- # Add a swarm-level default hook that applies to all agents
400
+ # Reset context for all agents
501
401
  #
502
- # Default hooks are inherited by all agents unless overridden at agent level.
503
- # Useful for swarm-wide policies like logging, validation, or monitoring.
402
+ # Clears conversation history for all agents. This is used by composable swarms
403
+ # to reset sub-swarm context when keep_context: false is specified.
504
404
  #
505
- # @param event [Symbol] Event type (e.g., :pre_tool_use, :post_tool_use)
506
- # @param matcher [String, Regexp, nil] Optional regex pattern for tool names
507
- # @param priority [Integer] Execution priority (higher = earlier)
508
- # @param block [Proc] Hook implementation
509
- # @return [self]
405
+ # @return [void]
406
+ def reset_context!
407
+ @agents.each_value do |agent_chat|
408
+ agent_chat.clear_conversation if agent_chat.respond_to?(:clear_conversation)
409
+ end
410
+ end
411
+
412
+ # Add observer configuration
510
413
  #
511
- # @example Add logging for all tool calls
512
- # swarm.add_default_callback(:pre_tool_use) do |context|
513
- # puts "[#{context.agent_name}] Calling #{context.tool_call.name}"
414
+ # Called by Swarm::Builder to register observer agent configurations.
415
+ # Validates that the referenced agent exists.
416
+ #
417
+ # @param config [Observer::Config] Observer configuration
418
+ # @return [void]
419
+ def add_observer_config(config)
420
+ validate_observer_agent(config.agent_name)
421
+ @observer_configs << config
422
+ end
423
+
424
+ # Wait for all observer tasks to complete
425
+ #
426
+ # Called by Executor to wait for observer agents before cleanup.
427
+ # Safe to call even if no observers are configured.
428
+ #
429
+ # @return [void]
430
+ def wait_for_observers
431
+ @observer_manager&.wait_for_completion
432
+ end
433
+
434
+ # Cleanup observer subscriptions
435
+ #
436
+ # Called by Executor.cleanup_after_execution to unsubscribe observers.
437
+ # Matches the MCP cleanup pattern.
438
+ #
439
+ # @return [void]
440
+ def cleanup_observers
441
+ @observer_manager&.cleanup
442
+ @observer_manager = nil
443
+ end
444
+
445
+ # Create snapshot of current conversation state
446
+ #
447
+ # Returns a Snapshot object containing:
448
+ # - All agent conversations (@messages arrays)
449
+ # - Agent context state (warnings, compression, TodoWrite tracking, skills)
450
+ # - Delegation instance conversations
451
+ # - Scratchpad contents (volatile shared storage)
452
+ # - Read tracking state (which files each agent has read with digests)
453
+ # - Memory read tracking state (which memory entries each agent has read with digests)
454
+ #
455
+ # Configuration (agent definitions, tools, prompts) stays in your YAML/DSL
456
+ # and is NOT included in snapshots.
457
+ #
458
+ # @return [Snapshot] Snapshot object with convenient serialization methods
459
+ #
460
+ # @example Save snapshot to JSON file
461
+ # snapshot = swarm.snapshot
462
+ # snapshot.write_to_file("session.json")
463
+ #
464
+ # @example Convert to hash or JSON string
465
+ # snapshot = swarm.snapshot
466
+ # hash = snapshot.to_hash
467
+ # json_string = snapshot.to_json
468
+ def snapshot
469
+ StateSnapshot.new(self).snapshot
470
+ end
471
+
472
+ # Restore conversation state from snapshot
473
+ #
474
+ # Accepts a Snapshot object, hash, or JSON string. Validates compatibility
475
+ # between snapshot and current swarm configuration, restores agent conversations,
476
+ # context state, scratchpad, and read tracking. Returns RestoreResult with
477
+ # warnings about any agents that couldn't be restored due to configuration
478
+ # mismatches.
479
+ #
480
+ # The swarm must be created with the SAME configuration (agent definitions,
481
+ # tools, prompts) as when the snapshot was created. Only conversation state
482
+ # is restored from the snapshot.
483
+ #
484
+ # @param snapshot [Snapshot, Hash, String] Snapshot object, hash, or JSON string
485
+ # @return [RestoreResult] Result with warnings about skipped agents
486
+ #
487
+ # @example Restore from Snapshot object
488
+ # swarm = SwarmSDK.build { ... } # Same config as snapshot
489
+ # snapshot = Snapshot.from_file("session.json")
490
+ # result = swarm.restore(snapshot)
491
+ # if result.success?
492
+ # puts "All agents restored"
493
+ # else
494
+ # puts result.summary
495
+ # result.warnings.each { |w| puts " - #{w[:message]}" }
514
496
  # end
515
- def add_default_callback(event, matcher: nil, priority: 0, &block)
516
- @hook_registry.add_default(event, matcher: matcher, priority: priority, &block)
517
- self
497
+ #
498
+ # Restore swarm state from snapshot
499
+ #
500
+ # By default, uses current system prompts from agent definitions (YAML + SDK defaults + plugin injections).
501
+ # Set preserve_system_prompts: true to use historical prompts from snapshot.
502
+ #
503
+ # @param snapshot [Snapshot, Hash, String] Snapshot object, hash, or JSON string
504
+ # @param preserve_system_prompts [Boolean] Use historical system prompts instead of current config (default: false)
505
+ # @return [RestoreResult] Result with warnings about partial restores
506
+ def restore(snapshot, preserve_system_prompts: false)
507
+ StateRestorer.new(self, snapshot, preserve_system_prompts: preserve_system_prompts).restore
508
+ end
509
+
510
+ # Override swarm IDs for composable swarms
511
+ #
512
+ # Used by SwarmLoader to set hierarchical IDs when loading sub-swarms.
513
+ # This is called after the swarm is built to ensure proper parent/child relationships.
514
+ #
515
+ # @param swarm_id [String] New swarm ID
516
+ # @param parent_swarm_id [String] New parent swarm ID
517
+ # @return [void]
518
+ def override_swarm_ids(swarm_id:, parent_swarm_id:)
519
+ @swarm_id = swarm_id
520
+ @parent_swarm_id = parent_swarm_id
518
521
  end
519
522
 
523
+ # Set swarm registry for composable swarms
524
+ #
525
+ # Used by Builder to set the registry after swarm creation.
526
+ # This must be called before agent initialization to enable swarm delegation.
527
+ #
528
+ # @param registry [SwarmRegistry] Configured swarm registry
529
+ # @return [void]
530
+ attr_writer :swarm_registry
531
+
532
+ # --- Internal API (for Executor use only) ---
533
+ # Hook triggers for swarm lifecycle events are provided by HookTriggers module
534
+
520
535
  private
521
536
 
537
+ # Apply swarm_start hooks to prompt
538
+ #
539
+ # @param prompt [String] Original prompt
540
+ # @return [String] Modified prompt (possibly with hook context appended)
541
+ def apply_swarm_start_hooks(prompt)
542
+ swarm_start_result = trigger_swarm_start(prompt)
543
+ if swarm_start_result&.replace?
544
+ "#{prompt}\n\n<hook-context>\n#{swarm_start_result.value}\n</hook-context>"
545
+ else
546
+ prompt
547
+ end
548
+ end
549
+
550
+ # Validate that observer agent exists
551
+ #
552
+ # @param agent_name [Symbol] Name of the observer agent
553
+ # @raise [ConfigurationError] If agent not found
554
+ # @return [void]
555
+ def validate_observer_agent(agent_name)
556
+ return if @agent_definitions.key?(agent_name)
557
+
558
+ raise ConfigurationError,
559
+ "Observer agent '#{agent_name}' not found. " \
560
+ "Define the agent first with `agent :#{agent_name} do ... end`"
561
+ end
562
+
563
+ # Setup observer manager and subscriptions
564
+ #
565
+ # Creates Observer::Manager and registers event subscriptions.
566
+ # Must be called AFTER setup_logging (which clears Fiber[:log_subscriptions]).
567
+ #
568
+ # @return [void]
569
+ def setup_observer_execution
570
+ @observer_manager = Observer::Manager.new(self)
571
+ @observer_configs.each { |c| @observer_manager.add_config(c) }
572
+ @observer_manager.setup
573
+ end
574
+
575
+ # Validate and normalize scratchpad mode for Swarm
576
+ #
577
+ # Regular Swarms support :enabled or :disabled.
578
+ # Rejects :per_node since it only makes sense for Workflow with multiple nodes.
579
+ #
580
+ # @param value [Symbol, String] Scratchpad mode (strings from YAML converted to symbols)
581
+ # @return [Symbol] :enabled or :disabled
582
+ # @raise [ArgumentError] If :per_node used, or invalid value
583
+ def validate_swarm_scratchpad_mode(value)
584
+ # Convert strings from YAML to symbols
585
+ value = value.to_sym if value.is_a?(String)
586
+
587
+ case value
588
+ when :enabled, :disabled
589
+ value
590
+ when :per_node
591
+ raise ArgumentError,
592
+ "scratchpad: :per_node is only valid for Workflow with nodes. " \
593
+ "For regular Swarms, use :enabled or :disabled."
594
+ else
595
+ raise ArgumentError,
596
+ "Invalid scratchpad mode for Swarm: #{value.inspect}. " \
597
+ "Use :enabled or :disabled."
598
+ end
599
+ end
600
+
601
+ # Generate a unique swarm ID from name
602
+ #
603
+ # Creates a swarm ID by sanitizing the name and appending a random suffix.
604
+ # Used when swarm_id is not explicitly provided.
605
+ #
606
+ # @param name [String] Swarm name
607
+ # @return [String] Generated swarm ID (e.g., "dev_team_a3f2b1c8")
608
+ def generate_swarm_id(name)
609
+ sanitized = name.to_s.gsub(/[^a-z0-9_-]/i, "_").downcase
610
+ "#{sanitized}_#{SecureRandom.hex(4)}"
611
+ end
612
+
613
+ # Generate a unique execution ID
614
+ #
615
+ # Creates an execution ID that uniquely identifies a single swarm.execute() call.
616
+ # Format: "exec_{swarm_id}_{random_hex}"
617
+ #
618
+ # @return [String] Generated execution ID (e.g., "exec_main_a3f2b1c8")
619
+ def generate_execution_id
620
+ "exec_#{@swarm_id}_#{SecureRandom.hex(8)}"
621
+ end
622
+
522
623
  # Initialize all agents using AgentInitializer
523
624
  #
524
625
  # This is called automatically (lazy initialization) by execute() and agent().
@@ -528,58 +629,14 @@ module SwarmSDK
528
629
  def initialize_agents
529
630
  return if @agents_initialized
530
631
 
531
- initializer = AgentInitializer.new(
532
- self,
533
- @agent_definitions,
534
- @global_semaphore,
535
- @hook_registry,
536
- @scratchpad_storage,
537
- @plugin_storages,
538
- config_for_hooks: @config_for_hooks,
539
- )
632
+ initializer = AgentInitializer.new(self)
540
633
 
541
634
  @agents = initializer.initialize_all
542
635
  @agent_contexts = initializer.agent_contexts
543
636
  @agents_initialized = true
544
637
 
545
- # Emit agent_start events for all agents
546
- emit_agent_start_events
547
- end
548
-
549
- # Emit agent_start events for all initialized agents
550
- def emit_agent_start_events
551
- # Only emit if LogStream is enabled
552
- return unless LogStream.emitter
553
-
554
- @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
568
-
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
- )
582
- end
638
+ # NOTE: agent_start events are emitted in execute() when logging is set up
639
+ # This ensures events are never lost, even if agents are initialized early (e.g., by restore())
583
640
  end
584
641
 
585
642
  # Normalize tools to internal format (kept for add_agent)
@@ -629,7 +686,7 @@ module SwarmSDK
629
686
 
630
687
  # Create delegation tool (delegates to AgentInitializer)
631
688
  def create_delegation_tool(name:, description:, delegate_chat:, agent_name:)
632
- AgentInitializer.new(self, @agent_definitions, @global_semaphore, @hook_registry, @scratchpad_storage, @plugin_storages)
689
+ AgentInitializer.new(self)
633
690
  .create_delegation_tool(name: name, description: description, delegate_chat: delegate_chat, agent_name: agent_name)
634
691
  end
635
692
 
@@ -656,309 +713,5 @@ module SwarmSDK
656
713
  # Unknown config type
657
714
  nil
658
715
  end
659
-
660
- # Register default logging hooks that emit LogStream events
661
- #
662
- # These hooks implement the standard SwarmSDK logging behavior.
663
- # Users can override or extend them by registering their own hooks.
664
- #
665
- # @return [void]
666
- def register_default_logging_callbacks
667
- # Log swarm start
668
- add_default_callback(:swarm_start, priority: -100) do |context|
669
- # Only log if LogStream emitter is set (logging enabled)
670
- next unless LogStream.emitter
671
-
672
- LogStream.emit(
673
- type: "swarm_start",
674
- agent: context.metadata[:lead_agent], # Include agent for consistency
675
- swarm_name: context.metadata[:swarm_name],
676
- lead_agent: context.metadata[:lead_agent],
677
- prompt: context.metadata[:prompt],
678
- timestamp: context.metadata[:timestamp],
679
- )
680
- end
681
-
682
- # Log swarm stop
683
- add_default_callback(:swarm_stop, priority: -100) do |context|
684
- # Only log if LogStream emitter is set (logging enabled)
685
- next unless LogStream.emitter
686
-
687
- LogStream.emit(
688
- type: "swarm_stop",
689
- swarm_name: context.metadata[:swarm_name],
690
- lead_agent: context.metadata[:lead_agent],
691
- last_agent: context.metadata[:last_agent], # Agent that produced final response
692
- content: context.metadata[:content], # Final response content
693
- success: context.metadata[:success],
694
- duration: context.metadata[:duration],
695
- total_cost: context.metadata[:total_cost],
696
- total_tokens: context.metadata[:total_tokens],
697
- agents_involved: context.metadata[:agents_involved],
698
- timestamp: context.metadata[:timestamp],
699
- )
700
- end
701
-
702
- # Log user requests
703
- add_default_callback(:user_prompt, priority: -100) do |context|
704
- # Only log if LogStream emitter is set (logging enabled)
705
- next unless LogStream.emitter
706
-
707
- LogStream.emit(
708
- type: "user_prompt",
709
- agent: context.agent_name,
710
- model: context.metadata[:model] || "unknown",
711
- provider: context.metadata[:provider] || "unknown",
712
- message_count: context.metadata[:message_count] || 0,
713
- tools: context.metadata[:tools] || [],
714
- delegates_to: context.metadata[:delegates_to] || [],
715
- metadata: context.metadata,
716
- )
717
- end
718
-
719
- # Log intermediate agent responses with tool calls
720
- add_default_callback(:agent_step, priority: -100) do |context|
721
- # Only log if LogStream emitter is set (logging enabled)
722
- next unless LogStream.emitter
723
-
724
- # Extract top-level fields and remove from metadata to avoid duplication
725
- metadata_without_duplicates = context.metadata.except(:model, :content, :tool_calls, :finish_reason, :usage, :tool_executions)
726
-
727
- LogStream.emit(
728
- type: "agent_step",
729
- agent: context.agent_name,
730
- model: context.metadata[:model],
731
- content: context.metadata[:content],
732
- tool_calls: context.metadata[:tool_calls],
733
- finish_reason: context.metadata[:finish_reason],
734
- usage: context.metadata[:usage],
735
- tool_executions: context.metadata[:tool_executions],
736
- metadata: metadata_without_duplicates,
737
- )
738
- end
739
-
740
- # Log final agent responses
741
- add_default_callback(:agent_stop, priority: -100) do |context|
742
- # Only log if LogStream emitter is set (logging enabled)
743
- next unless LogStream.emitter
744
-
745
- # Extract top-level fields and remove from metadata to avoid duplication
746
- metadata_without_duplicates = context.metadata.except(:model, :content, :tool_calls, :finish_reason, :usage, :tool_executions)
747
-
748
- LogStream.emit(
749
- type: "agent_stop",
750
- agent: context.agent_name,
751
- model: context.metadata[:model],
752
- content: context.metadata[:content],
753
- tool_calls: context.metadata[:tool_calls],
754
- finish_reason: context.metadata[:finish_reason],
755
- usage: context.metadata[:usage],
756
- tool_executions: context.metadata[:tool_executions],
757
- metadata: metadata_without_duplicates,
758
- )
759
- end
760
-
761
- # Log tool calls (pre_tool_use)
762
- add_default_callback(:pre_tool_use, priority: -100) do |context|
763
- # Only log if LogStream emitter is set (logging enabled)
764
- next unless LogStream.emitter
765
-
766
- # Delegation tracking is handled separately in AgentChat
767
- # Just log the tool call - delegation info will be in metadata if needed
768
- LogStream.emit(
769
- type: "tool_call",
770
- agent: context.agent_name,
771
- tool_call_id: context.tool_call.id,
772
- tool: context.tool_call.name,
773
- arguments: context.tool_call.parameters,
774
- metadata: context.metadata,
775
- )
776
- end
777
-
778
- # Log tool results (post_tool_use)
779
- add_default_callback(:post_tool_use, priority: -100) do |context|
780
- # Only log if LogStream emitter is set (logging enabled)
781
- next unless LogStream.emitter
782
-
783
- # Delegation tracking is handled separately in AgentChat
784
- # Usage tracking is handled in agent_step/agent_stop events
785
- LogStream.emit(
786
- type: "tool_result",
787
- agent: context.agent_name,
788
- tool_call_id: context.tool_result.tool_call_id,
789
- tool: context.tool_result.tool_name,
790
- result: context.tool_result.content,
791
- metadata: context.metadata,
792
- )
793
- end
794
-
795
- # Log context warnings
796
- add_default_callback(:context_warning, priority: -100) do |context|
797
- # Only log if LogStream emitter is set (logging enabled)
798
- next unless LogStream.emitter
799
-
800
- LogStream.emit(
801
- type: "context_limit_warning",
802
- agent: context.agent_name,
803
- model: context.metadata[:model] || "unknown",
804
- threshold: "#{context.metadata[:threshold]}%",
805
- current_usage: "#{context.metadata[:percentage]}%",
806
- tokens_used: context.metadata[:tokens_used],
807
- tokens_remaining: context.metadata[:tokens_remaining],
808
- context_limit: context.metadata[:context_limit],
809
- metadata: context.metadata,
810
- )
811
- end
812
- end
813
-
814
- # Trigger swarm_start hooks when swarm execution begins
815
- #
816
- # This is a swarm-level event that fires when Swarm.execute is called
817
- # (before first user message is sent). Hooks can halt execution or append stdout to prompt.
818
- # Default callback emits to LogStream for logging.
819
- #
820
- # @param prompt [String] The user's task prompt
821
- # @return [Hooks::Result, nil] Result with stdout to append (if exit 0) or nil
822
- # @raise [Hooks::Error] If hook halts execution
823
- def trigger_swarm_start(prompt)
824
- context = Hooks::Context.new(
825
- event: :swarm_start,
826
- agent_name: @lead_agent.to_s,
827
- swarm: self,
828
- metadata: {
829
- swarm_name: @name,
830
- lead_agent: @lead_agent,
831
- prompt: prompt,
832
- timestamp: Time.now.utc.iso8601,
833
- },
834
- )
835
-
836
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
837
- result = executor.execute_safe(event: :swarm_start, context: context, callbacks: [])
838
-
839
- # Halt execution if hook requests it
840
- raise Hooks::Error, "Swarm start halted by hook: #{result.value}" if result.halt?
841
-
842
- # Return result so caller can check for replace (stdout injection)
843
- result
844
- rescue StandardError => e
845
- RubyLLM.logger.error("SwarmSDK: Error in swarm_start hook: #{e.message}")
846
- raise
847
- end
848
-
849
- # Trigger swarm_stop for final event emission (called in ensure block)
850
- #
851
- # This ALWAYS emits the swarm_stop event, even if there was an error.
852
- # It does NOT check for reprompt (that's done in trigger_swarm_stop_for_reprompt_check).
853
- #
854
- # @param result [Result, nil] Execution result (may be nil if exception before result created)
855
- # @param start_time [Time] Execution start time
856
- # @param logs [Array] Collected logs
857
- # @return [void]
858
- def trigger_swarm_stop_final(result, start_time, logs)
859
- # Create a minimal result if one doesn't exist (exception before result created)
860
- result ||= Result.new(
861
- content: nil,
862
- agent: @lead_agent&.to_s || "unknown",
863
- logs: logs,
864
- duration: Time.now - start_time,
865
- error: StandardError.new("Unknown error"),
866
- )
867
-
868
- context = Hooks::Context.new(
869
- event: :swarm_stop,
870
- agent_name: @lead_agent.to_s,
871
- swarm: self,
872
- metadata: {
873
- swarm_name: @name,
874
- lead_agent: @lead_agent,
875
- last_agent: result.agent, # Agent that produced the final response
876
- content: result.content, # Final response content
877
- success: result.success?,
878
- duration: result.duration,
879
- total_cost: result.total_cost,
880
- total_tokens: result.total_tokens,
881
- agents_involved: result.agents_involved,
882
- result: result,
883
- timestamp: Time.now.utc.iso8601,
884
- },
885
- )
886
-
887
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
888
- executor.execute_safe(event: :swarm_stop, context: context, callbacks: [])
889
- rescue StandardError => e
890
- # Don't let swarm_stop errors break the ensure block
891
- RubyLLM.logger.error("SwarmSDK: Error in swarm_stop final emission: #{e.message}")
892
- end
893
-
894
- # Trigger swarm_stop hooks for reprompt check and event emission
895
- #
896
- # This is called in the normal execution flow to check if hooks request reprompting.
897
- # The default callback also emits the swarm_stop event to LogStream.
898
- #
899
- # @param result [Result] The execution result
900
- # @return [Hooks::Result, nil] Hook result (reprompt action if applicable)
901
- def trigger_swarm_stop(result)
902
- context = Hooks::Context.new(
903
- event: :swarm_stop,
904
- agent_name: @lead_agent.to_s,
905
- swarm: self,
906
- metadata: {
907
- swarm_name: @name,
908
- lead_agent: @lead_agent,
909
- last_agent: result.agent, # Agent that produced the final response
910
- content: result.content, # Final response content
911
- success: result.success?,
912
- duration: result.duration,
913
- total_cost: result.total_cost,
914
- total_tokens: result.total_tokens,
915
- agents_involved: result.agents_involved,
916
- result: result, # Include full result for hook access
917
- timestamp: Time.now.utc.iso8601,
918
- },
919
- )
920
-
921
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
922
- hook_result = executor.execute_safe(event: :swarm_stop, context: context, callbacks: [])
923
-
924
- # Return hook result so caller can handle reprompt
925
- hook_result
926
- rescue StandardError => e
927
- RubyLLM.logger.error("SwarmSDK: Error in swarm_stop hook: #{e.message}")
928
- nil
929
- end
930
-
931
- # Trigger first_message hooks when first user message is sent
932
- #
933
- # This is a swarm-level event that fires once on the first call to execute().
934
- # Hooks can halt execution before the first message is sent.
935
- #
936
- # @param prompt [String] The first user message
937
- # @return [void]
938
- # @raise [Hooks::Error] If hook halts execution
939
- def trigger_first_message(prompt)
940
- return if @hook_registry.get_defaults(:first_message).empty?
941
-
942
- context = Hooks::Context.new(
943
- event: :first_message,
944
- agent_name: @lead_agent.to_s,
945
- swarm: self,
946
- metadata: {
947
- swarm_name: @name,
948
- lead_agent: @lead_agent,
949
- prompt: prompt,
950
- timestamp: Time.now.utc.iso8601,
951
- },
952
- )
953
-
954
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
955
- result = executor.execute_safe(event: :first_message, context: context, callbacks: [])
956
-
957
- # Halt execution if hook requests it
958
- raise Hooks::Error, "First message halted by hook: #{result.value}" if result.halt?
959
- rescue StandardError => e
960
- RubyLLM.logger.error("SwarmSDK: Error in first_message hook: #{e.message}")
961
- raise
962
- end
963
716
  end
964
717
  end