swarm_memory 2.1.3 → 2.1.4

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/claude_mcp_server.rb +1 -0
  3. data/lib/claude_swarm/cli.rb +5 -18
  4. data/lib/claude_swarm/configuration.rb +2 -15
  5. data/lib/claude_swarm/mcp_generator.rb +1 -0
  6. data/lib/claude_swarm/openai/chat_completion.rb +4 -12
  7. data/lib/claude_swarm/openai/executor.rb +3 -1
  8. data/lib/claude_swarm/openai/responses.rb +13 -32
  9. data/lib/claude_swarm/version.rb +1 -1
  10. data/lib/swarm_cli/commands/run.rb +2 -2
  11. data/lib/swarm_cli/config_loader.rb +11 -11
  12. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  13. data/lib/swarm_cli/interactive_repl.rb +11 -5
  14. data/lib/swarm_cli/ui/icons.rb +0 -23
  15. data/lib/swarm_cli/version.rb +1 -1
  16. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  17. data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
  18. data/lib/swarm_memory/version.rb +1 -1
  19. data/lib/swarm_memory.rb +1 -1
  20. data/lib/swarm_sdk/agent/builder.rb +58 -0
  21. data/lib/swarm_sdk/agent/chat.rb +527 -1059
  22. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
  23. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  24. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
  25. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  26. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  27. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  28. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +12 -12
  30. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  31. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  32. data/lib/swarm_sdk/agent/context.rb +2 -2
  33. data/lib/swarm_sdk/agent/definition.rb +66 -154
  34. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  35. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  36. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  37. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  38. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  39. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  40. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  41. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  42. data/lib/swarm_sdk/configuration.rb +65 -543
  43. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  44. data/lib/swarm_sdk/context_compactor.rb +6 -11
  45. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  46. data/lib/swarm_sdk/context_management/context.rb +328 -0
  47. data/lib/swarm_sdk/defaults.rb +196 -0
  48. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  49. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  50. data/lib/swarm_sdk/log_collector.rb +179 -29
  51. data/lib/swarm_sdk/log_stream.rb +29 -0
  52. data/lib/swarm_sdk/node_context.rb +1 -1
  53. data/lib/swarm_sdk/observer/builder.rb +81 -0
  54. data/lib/swarm_sdk/observer/config.rb +45 -0
  55. data/lib/swarm_sdk/observer/manager.rb +236 -0
  56. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  57. data/lib/swarm_sdk/plugin.rb +93 -3
  58. data/lib/swarm_sdk/snapshot.rb +6 -6
  59. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  60. data/lib/swarm_sdk/state_restorer.rb +136 -151
  61. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  62. data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
  63. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  64. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  65. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  66. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  67. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  68. data/lib/swarm_sdk/swarm/tool_configurator.rb +42 -138
  69. data/lib/swarm_sdk/swarm.rb +137 -679
  70. data/lib/swarm_sdk/tools/bash.rb +11 -3
  71. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  72. data/lib/swarm_sdk/tools/edit.rb +8 -13
  73. data/lib/swarm_sdk/tools/glob.rb +9 -1
  74. data/lib/swarm_sdk/tools/grep.rb +7 -0
  75. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  76. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  77. data/lib/swarm_sdk/tools/read.rb +11 -13
  78. data/lib/swarm_sdk/tools/registry.rb +122 -10
  79. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
  80. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  81. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  82. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  83. data/lib/swarm_sdk/tools/write.rb +8 -13
  84. data/lib/swarm_sdk/version.rb +1 -1
  85. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  86. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  87. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  88. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
  89. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  90. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  91. data/lib/swarm_sdk.rb +33 -3
  92. metadata +37 -14
  93. data/lib/swarm_memory/chat_extension.rb +0 -34
  94. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
@@ -61,14 +61,19 @@ 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, :delegation_instances, :agent_definitions, :swarm_id, :parent_swarm_id, :swarm_registry, :scratchpad_storage, :allow_filesystem_tools
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
72
77
  attr_accessor :delegation_call_stack
73
78
 
74
79
  # Check if scratchpad tools are enabled
@@ -135,7 +140,7 @@ module SwarmSDK
135
140
  # @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing/internal use)
136
141
  # @param scratchpad_mode [Symbol, String] Scratchpad mode (:enabled or :disabled). :per_node not allowed for non-node swarms.
137
142
  # @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)
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)
139
144
  @name = name
140
145
  @swarm_id = swarm_id || generate_swarm_id(name)
141
146
  @parent_swarm_id = parent_swarm_id
@@ -201,6 +206,10 @@ module SwarmSDK
201
206
  # Track if agent_start events have been emitted
202
207
  # This prevents duplicate emissions and ensures events are emitted when logging is ready
203
208
  @agent_start_events_emitted = false
209
+
210
+ # Observer agent configurations
211
+ @observer_configs = []
212
+ @observer_manager = nil
204
213
  end
205
214
 
206
215
  # Add an agent to the swarm
@@ -256,53 +265,53 @@ module SwarmSDK
256
265
  # and the entire swarm coordinates with shared rate limiting.
257
266
  # Supports reprompting via swarm_stop hooks.
258
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
+ #
259
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.
260
274
  # @yield [Hash] Log entry if block given (for streaming)
261
- # @return [Result] Execution result
262
- 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)
263
287
  raise ConfigurationError, "No lead agent set. Set lead= first." unless @lead_agent
264
288
 
265
- start_time = Time.now
266
289
  logs = []
267
290
  current_prompt = prompt
291
+ has_logging = block_given?
268
292
 
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
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
+ }
275
299
 
276
300
  # Set fiber-local execution context
277
301
  # Use ||= to inherit parent's execution_id if one exists (for mini-swarms)
278
- # Child fibers (tools, delegations) inherit automatically
279
302
  Fiber[:execution_id] ||= generate_execution_id
280
303
  Fiber[:swarm_id] = @swarm_id
281
304
  Fiber[:parent_swarm_id] = @parent_swarm_id
282
305
 
283
306
  # Setup logging FIRST if block given (so swarm_start event can be emitted)
284
- if block_given?
285
- # Force fresh callback array for this execution
286
- Fiber[:log_callbacks] = []
287
-
288
- # Register callback to collect logs and forward to user's block
289
- LogCollector.on_log do |entry|
290
- logs << entry
291
- block.call(entry)
292
- end
307
+ setup_logging(logs, &block) if has_logging
293
308
 
294
- # Set LogStream to use LogCollector as emitter
295
- LogStream.emitter = LogCollector
296
- 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?
297
312
 
298
313
  # Trigger swarm_start hooks (before any execution)
299
- # Hook can append stdout to prompt (exit code 0)
300
- # Default callback emits swarm_start event to LogStream
301
- swarm_start_result = trigger_swarm_start(current_prompt)
302
- if swarm_start_result&.replace?
303
- # Hook provided stdout to append to prompt
304
- current_prompt = "#{current_prompt}\n\n<hook-context>\n#{swarm_start_result.value}\n</hook-context>"
305
- end
314
+ current_prompt = apply_swarm_start_hooks(current_prompt)
306
315
 
307
316
  # Trigger first_message hooks on first execution
308
317
  unless @first_message_sent
@@ -313,147 +322,18 @@ module SwarmSDK
313
322
  # Lazy initialization of agents (with optional logging)
314
323
  initialize_agents unless @agents_initialized
315
324
 
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
-
327
- # Execution loop (supports reprompting)
328
- result = nil
329
- swarm_stop_triggered = false
330
-
331
- loop do
332
- # Execute within Async reactor to enable fiber scheduler for parallel execution
333
- # This sets Fiber.scheduler, making Faraday fiber-aware so HTTP requests yield during I/O
334
- # Use finished: false to suppress warnings for expected task failures
335
- lead = @agents[@lead_agent]
336
- response = Async(finished: false) do
337
- lead.ask(current_prompt)
338
- end.wait
339
-
340
- # Check if swarm was finished by a hook (finish_swarm)
341
- if response.is_a?(Hash) && response[:__finish_swarm__]
342
- result = Result.new(
343
- content: response[:message],
344
- agent: @lead_agent.to_s,
345
- logs: logs,
346
- duration: Time.now - start_time,
347
- )
348
-
349
- # Trigger swarm_stop hooks for event emission
350
- trigger_swarm_stop(result)
351
- swarm_stop_triggered = true
352
-
353
- # Break immediately - don't allow reprompting when swarm is finished by hook
354
- break
355
- end
356
-
357
- result = Result.new(
358
- content: response.content,
359
- agent: @lead_agent.to_s,
360
- logs: logs,
361
- duration: Time.now - start_time,
362
- )
363
-
364
- # Trigger swarm_stop hooks (for reprompt check and event emission)
365
- hook_result = trigger_swarm_stop(result)
366
- swarm_stop_triggered = true
367
-
368
- # Check if hook requests reprompting
369
- if hook_result&.reprompt?
370
- current_prompt = hook_result.value
371
- swarm_stop_triggered = false # Will trigger again in next iteration
372
- # Continue loop with new prompt
373
- else
374
- # Exit loop - execution complete
375
- break
376
- end
377
- end
325
+ # Emit agent_start events if agents were initialized before logging was set up
326
+ emit_retroactive_agent_start_events if has_logging
378
327
 
379
- result
380
- rescue ConfigurationError, AgentNotFoundError
381
- # Re-raise configuration errors - these should be fixed, not caught
382
- raise
383
- rescue TypeError => e
384
- # Catch the specific "String does not have #dig method" error
385
- if e.message.include?("does not have #dig method")
386
- agent_definition = @agent_definitions[@lead_agent]
387
- error_msg = if agent_definition.base_url
388
- "LLM API request failed: The proxy/server at '#{agent_definition.base_url}' returned an invalid response. " \
389
- "This usually means the proxy is unreachable, requires authentication, or returned an error in non-JSON format. " \
390
- "Original error: #{e.message}"
391
- else
392
- "LLM API request failed with unexpected response format. Original error: #{e.message}"
393
- end
394
-
395
- result = Result.new(
396
- content: nil,
397
- agent: @lead_agent.to_s,
398
- error: LLMError.new(error_msg),
399
- logs: logs,
400
- duration: Time.now - start_time,
401
- )
402
- else
403
- result = Result.new(
404
- content: nil,
405
- agent: @lead_agent.to_s,
406
- error: e,
407
- logs: logs,
408
- duration: Time.now - start_time,
409
- )
410
- end
411
- result
412
- rescue StandardError => e
413
- result = Result.new(
414
- content: nil,
415
- agent: @lead_agent&.to_s || "unknown",
416
- 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,
417
333
  logs: logs,
418
- duration: Time.now - start_time,
334
+ has_logging: has_logging,
335
+ original_fiber_storage: original_fiber_storage,
419
336
  )
420
- result
421
- ensure
422
- # Trigger swarm_stop if not already triggered (handles error cases)
423
- unless swarm_stop_triggered
424
- trigger_swarm_stop_final(result, start_time, logs)
425
- end
426
-
427
- # Cleanup MCP clients after execution
428
- cleanup
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
-
438
- # Reset logging state for next execution if we set it up
439
- #
440
- # IMPORTANT: Only reset if we set up logging (block_given? == true).
441
- # When this swarm is a mini-swarm within a NodeOrchestrator workflow,
442
- # the orchestrator manages LogCollector and we don't set up logging.
443
- #
444
- # Flow in NodeOrchestrator:
445
- # 1. NodeOrchestrator sets up LogCollector + LogStream (no block given to mini-swarms)
446
- # 2. Each mini-swarm executes without logging block (block_given? == false)
447
- # 3. Each mini-swarm skips reset (didn't set up logging)
448
- # 4. NodeOrchestrator resets once at the very end
449
- #
450
- # Flow in standalone swarm / interactive REPL:
451
- # 1. Swarm.execute sets up LogCollector + LogStream (block given)
452
- # 2. Swarm.execute resets in ensure block (cleanup for next call)
453
- if block_given?
454
- LogCollector.reset!
455
- LogStream.reset!
456
- end
457
337
  end
458
338
 
459
339
  # Get an agent chat instance by name
@@ -487,86 +367,17 @@ module SwarmSDK
487
367
  @agent_definitions.keys
488
368
  end
489
369
 
490
- # Validate swarm configuration and return warnings
491
- #
492
- # This performs lightweight validation checks without creating agents.
493
- # Useful for displaying configuration warnings before execution.
494
- #
495
- # @return [Array<Hash>] Array of warning hashes from all agent definitions
496
- #
497
- # @example
498
- # swarm = SwarmSDK.load_file("config.yml")
499
- # warnings = swarm.validate
500
- # warnings.each do |warning|
501
- # puts "⚠️ #{warning[:agent]}: #{warning[:model]} not found"
502
- # end
503
- def validate
504
- @agent_definitions.flat_map { |_name, definition| definition.validate }
370
+ # Implement Snapshotable interface
371
+ def primary_agents
372
+ @agents
505
373
  end
506
374
 
507
- # Emit validation warnings as log events
508
- #
509
- # This validates all agent definitions and emits any warnings as
510
- # model_lookup_warning events through LogStream. Useful for emitting
511
- # warnings before execution starts (e.g., in REPL after welcome screen).
512
- #
513
- # Requires LogStream.emitter to be set.
514
- #
515
- # @return [Array<Hash>] The validation warnings that were emitted
516
- #
517
- # @example
518
- # LogCollector.on_log { |event| puts event }
519
- # LogStream.emitter = LogCollector
520
- # swarm.emit_validation_warnings
521
- def emit_validation_warnings
522
- warnings = validate
523
-
524
- warnings.each do |warning|
525
- case warning[:type]
526
- when :model_not_found
527
- LogStream.emit(
528
- type: "model_lookup_warning",
529
- agent: warning[:agent],
530
- swarm_id: @swarm_id,
531
- parent_swarm_id: @parent_swarm_id,
532
- model: warning[:model],
533
- error_message: warning[:error_message],
534
- suggestions: warning[:suggestions],
535
- timestamp: Time.now.utc.iso8601,
536
- )
537
- end
538
- end
539
-
540
- warnings
375
+ def delegation_instances_hash
376
+ @delegation_instances
541
377
  end
542
378
 
543
- # Cleanup all MCP clients
544
- #
545
- # Stops all MCP client connections gracefully.
546
- # Should be called when the swarm is no longer needed.
547
- #
548
- # @return [void]
549
- def cleanup
550
- # Check if there's anything to clean up
551
- return if @mcp_clients.empty? && (!@delegation_instances || @delegation_instances.empty?)
552
-
553
- # Stop MCP clients for all agents (primaries + delegations tracked by instance name)
554
- @mcp_clients.each do |agent_name, clients|
555
- clients.each do |client|
556
- # Always call stop - this sets @running = false and stops background threads
557
- client.stop
558
- RubyLLM.logger.debug("SwarmSDK: Stopped MCP client '#{client.name}' for agent #{agent_name}")
559
- rescue StandardError => e
560
- # Don't fail cleanup if stopping one client fails
561
- RubyLLM.logger.debug("SwarmSDK: Error stopping MCP client '#{client.name}': #{e.message}")
562
- end
563
- end
564
-
565
- @mcp_clients.clear
566
-
567
- # Clear delegation instances (V7.0: Added for completeness)
568
- @delegation_instances&.clear
569
- end
379
+ # NOTE: validate() and emit_validation_warnings() are provided by Concerns::Validatable
380
+ # Note: cleanup() is provided by Concerns::Cleanupable
570
381
 
571
382
  # Register a named hook that can be referenced in agent configurations
572
383
  #
@@ -586,26 +397,6 @@ module SwarmSDK
586
397
  self
587
398
  end
588
399
 
589
- # Add a swarm-level default hook that applies to all agents
590
- #
591
- # Default hooks are inherited by all agents unless overridden at agent level.
592
- # Useful for swarm-wide policies like logging, validation, or monitoring.
593
- #
594
- # @param event [Symbol] Event type (e.g., :pre_tool_use, :post_tool_use)
595
- # @param matcher [String, Regexp, nil] Optional regex pattern for tool names
596
- # @param priority [Integer] Execution priority (higher = earlier)
597
- # @param block [Proc] Hook implementation
598
- # @return [self]
599
- #
600
- # @example Add logging for all tool calls
601
- # swarm.add_default_callback(:pre_tool_use) do |context|
602
- # puts "[#{context.agent_name}] Calling #{context.tool_call.name}"
603
- # end
604
- def add_default_callback(event, matcher: nil, priority: 0, &block)
605
- @hook_registry.add_default(event, matcher: matcher, priority: priority, &block)
606
- self
607
- end
608
-
609
400
  # Reset context for all agents
610
401
  #
611
402
  # Clears conversation history for all agents. This is used by composable swarms
@@ -618,6 +409,39 @@ module SwarmSDK
618
409
  end
619
410
  end
620
411
 
412
+ # Add observer configuration
413
+ #
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
+
621
445
  # Create snapshot of current conversation state
622
446
  #
623
447
  # Returns a Snapshot object containing:
@@ -705,12 +529,53 @@ module SwarmSDK
705
529
  # @return [void]
706
530
  attr_writer :swarm_registry
707
531
 
532
+ # --- Internal API (for Executor use only) ---
533
+ # Hook triggers for swarm lifecycle events are provided by HookTriggers module
534
+
708
535
  private
709
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
+
710
575
  # Validate and normalize scratchpad mode for Swarm
711
576
  #
712
577
  # Regular Swarms support :enabled or :disabled.
713
- # Rejects :per_node since it only makes sense for NodeOrchestrator with multiple nodes.
578
+ # Rejects :per_node since it only makes sense for Workflow with multiple nodes.
714
579
  #
715
580
  # @param value [Symbol, String] Scratchpad mode (strings from YAML converted to symbols)
716
581
  # @return [Symbol] :enabled or :disabled
@@ -724,7 +589,7 @@ module SwarmSDK
724
589
  value
725
590
  when :per_node
726
591
  raise ArgumentError,
727
- "scratchpad: :per_node is only valid for NodeOrchestrator with nodes. " \
592
+ "scratchpad: :per_node is only valid for Workflow with nodes. " \
728
593
  "For regular Swarms, use :enabled or :disabled."
729
594
  else
730
595
  raise ArgumentError,
@@ -764,15 +629,7 @@ module SwarmSDK
764
629
  def initialize_agents
765
630
  return if @agents_initialized
766
631
 
767
- initializer = AgentInitializer.new(
768
- self,
769
- @agent_definitions,
770
- @global_semaphore,
771
- @hook_registry,
772
- @scratchpad_storage,
773
- @plugin_storages,
774
- config_for_hooks: @config_for_hooks,
775
- )
632
+ initializer = AgentInitializer.new(self)
776
633
 
777
634
  @agents = initializer.initialize_all
778
635
  @agent_contexts = initializer.agent_contexts
@@ -782,85 +639,6 @@ module SwarmSDK
782
639
  # This ensures events are never lost, even if agents are initialized early (e.g., by restore())
783
640
  end
784
641
 
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
803
- end
804
-
805
- # Emit agent_start events for all initialized agents
806
- def emit_agent_start_events
807
- return unless LogStream.emitter
808
-
809
- # Emit for PRIMARY agents
810
- @agents.each do |agent_name, chat|
811
- emit_agent_start_for(agent_name, chat, is_delegation: false)
812
- end
813
-
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
- }
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
862
- end
863
-
864
642
  # Normalize tools to internal format (kept for add_agent)
865
643
  #
866
644
  # Handles both Ruby API (simple symbols) and YAML API (already parsed configs)
@@ -908,7 +686,7 @@ module SwarmSDK
908
686
 
909
687
  # Create delegation tool (delegates to AgentInitializer)
910
688
  def create_delegation_tool(name:, description:, delegate_chat:, agent_name:)
911
- AgentInitializer.new(self, @agent_definitions, @global_semaphore, @hook_registry, @scratchpad_storage, @plugin_storages)
689
+ AgentInitializer.new(self)
912
690
  .create_delegation_tool(name: name, description: description, delegate_chat: delegate_chat, agent_name: agent_name)
913
691
  end
914
692
 
@@ -935,325 +713,5 @@ module SwarmSDK
935
713
  # Unknown config type
936
714
  nil
937
715
  end
938
-
939
- # Register default logging hooks that emit LogStream events
940
- #
941
- # These hooks implement the standard SwarmSDK logging behavior.
942
- # Users can override or extend them by registering their own hooks.
943
- #
944
- # @return [void]
945
- def register_default_logging_callbacks
946
- # Log swarm start
947
- add_default_callback(:swarm_start, priority: -100) do |context|
948
- # Only log if LogStream emitter is set (logging enabled)
949
- next unless LogStream.emitter
950
-
951
- LogStream.emit(
952
- type: "swarm_start",
953
- agent: context.metadata[:lead_agent], # Include agent for consistency
954
- swarm_id: @swarm_id,
955
- parent_swarm_id: @parent_swarm_id,
956
- swarm_name: context.metadata[:swarm_name],
957
- lead_agent: context.metadata[:lead_agent],
958
- prompt: context.metadata[:prompt],
959
- timestamp: context.metadata[:timestamp],
960
- )
961
- end
962
-
963
- # Log swarm stop
964
- add_default_callback(:swarm_stop, priority: -100) do |context|
965
- # Only log if LogStream emitter is set (logging enabled)
966
- next unless LogStream.emitter
967
-
968
- LogStream.emit(
969
- type: "swarm_stop",
970
- swarm_id: @swarm_id,
971
- parent_swarm_id: @parent_swarm_id,
972
- swarm_name: context.metadata[:swarm_name],
973
- lead_agent: context.metadata[:lead_agent],
974
- last_agent: context.metadata[:last_agent], # Agent that produced final response
975
- content: context.metadata[:content], # Final response content
976
- success: context.metadata[:success],
977
- duration: context.metadata[:duration],
978
- total_cost: context.metadata[:total_cost],
979
- total_tokens: context.metadata[:total_tokens],
980
- agents_involved: context.metadata[:agents_involved],
981
- timestamp: context.metadata[:timestamp],
982
- )
983
- end
984
-
985
- # Log user requests
986
- add_default_callback(:user_prompt, priority: -100) do |context|
987
- # Only log if LogStream emitter is set (logging enabled)
988
- next unless LogStream.emitter
989
-
990
- LogStream.emit(
991
- type: "user_prompt",
992
- agent: context.agent_name,
993
- swarm_id: @swarm_id,
994
- parent_swarm_id: @parent_swarm_id,
995
- model: context.metadata[:model] || "unknown",
996
- provider: context.metadata[:provider] || "unknown",
997
- message_count: context.metadata[:message_count] || 0,
998
- tools: context.metadata[:tools] || [],
999
- delegates_to: context.metadata[:delegates_to] || [],
1000
- metadata: context.metadata,
1001
- )
1002
- end
1003
-
1004
- # Log intermediate agent responses with tool calls
1005
- add_default_callback(:agent_step, priority: -100) do |context|
1006
- # Only log if LogStream emitter is set (logging enabled)
1007
- next unless LogStream.emitter
1008
-
1009
- # Extract top-level fields and remove from metadata to avoid duplication
1010
- metadata_without_duplicates = context.metadata.except(:model, :content, :tool_calls, :finish_reason, :usage, :tool_executions)
1011
-
1012
- LogStream.emit(
1013
- type: "agent_step",
1014
- agent: context.agent_name,
1015
- swarm_id: @swarm_id,
1016
- parent_swarm_id: @parent_swarm_id,
1017
- model: context.metadata[:model],
1018
- content: context.metadata[:content],
1019
- tool_calls: context.metadata[:tool_calls],
1020
- finish_reason: context.metadata[:finish_reason],
1021
- usage: context.metadata[:usage],
1022
- tool_executions: context.metadata[:tool_executions],
1023
- metadata: metadata_without_duplicates,
1024
- )
1025
- end
1026
-
1027
- # Log final agent responses
1028
- add_default_callback(:agent_stop, priority: -100) do |context|
1029
- # Only log if LogStream emitter is set (logging enabled)
1030
- next unless LogStream.emitter
1031
-
1032
- # Extract top-level fields and remove from metadata to avoid duplication
1033
- metadata_without_duplicates = context.metadata.except(:model, :content, :tool_calls, :finish_reason, :usage, :tool_executions)
1034
-
1035
- LogStream.emit(
1036
- type: "agent_stop",
1037
- agent: context.agent_name,
1038
- swarm_id: @swarm_id,
1039
- parent_swarm_id: @parent_swarm_id,
1040
- model: context.metadata[:model],
1041
- content: context.metadata[:content],
1042
- tool_calls: context.metadata[:tool_calls],
1043
- finish_reason: context.metadata[:finish_reason],
1044
- usage: context.metadata[:usage],
1045
- tool_executions: context.metadata[:tool_executions],
1046
- metadata: metadata_without_duplicates,
1047
- )
1048
- end
1049
-
1050
- # Log tool calls (pre_tool_use)
1051
- add_default_callback(:pre_tool_use, priority: -100) do |context|
1052
- # Only log if LogStream emitter is set (logging enabled)
1053
- next unless LogStream.emitter
1054
-
1055
- # Delegation tracking is handled separately in AgentChat
1056
- # Just log the tool call - delegation info will be in metadata if needed
1057
- LogStream.emit(
1058
- type: "tool_call",
1059
- agent: context.agent_name,
1060
- swarm_id: @swarm_id,
1061
- parent_swarm_id: @parent_swarm_id,
1062
- tool_call_id: context.tool_call.id,
1063
- tool: context.tool_call.name,
1064
- arguments: context.tool_call.parameters,
1065
- metadata: context.metadata,
1066
- )
1067
- end
1068
-
1069
- # Log tool results (post_tool_use)
1070
- add_default_callback(:post_tool_use, priority: -100) do |context|
1071
- # Only log if LogStream emitter is set (logging enabled)
1072
- next unless LogStream.emitter
1073
-
1074
- # Delegation tracking is handled separately in AgentChat
1075
- # Usage tracking is handled in agent_step/agent_stop events
1076
- LogStream.emit(
1077
- type: "tool_result",
1078
- agent: context.agent_name,
1079
- swarm_id: @swarm_id,
1080
- parent_swarm_id: @parent_swarm_id,
1081
- tool_call_id: context.tool_result.tool_call_id,
1082
- tool: context.tool_result.tool_name,
1083
- result: context.tool_result.content,
1084
- metadata: context.metadata,
1085
- )
1086
- end
1087
-
1088
- # Log context warnings
1089
- add_default_callback(:context_warning, priority: -100) do |context|
1090
- # Only log if LogStream emitter is set (logging enabled)
1091
- next unless LogStream.emitter
1092
-
1093
- LogStream.emit(
1094
- type: "context_limit_warning",
1095
- agent: context.agent_name,
1096
- swarm_id: @swarm_id,
1097
- parent_swarm_id: @parent_swarm_id,
1098
- model: context.metadata[:model] || "unknown",
1099
- threshold: "#{context.metadata[:threshold]}%",
1100
- current_usage: "#{context.metadata[:percentage]}%",
1101
- tokens_used: context.metadata[:tokens_used],
1102
- tokens_remaining: context.metadata[:tokens_remaining],
1103
- context_limit: context.metadata[:context_limit],
1104
- metadata: context.metadata,
1105
- )
1106
- end
1107
- end
1108
-
1109
- # Trigger swarm_start hooks when swarm execution begins
1110
- #
1111
- # This is a swarm-level event that fires when Swarm.execute is called
1112
- # (before first user message is sent). Hooks can halt execution or append stdout to prompt.
1113
- # Default callback emits to LogStream for logging.
1114
- #
1115
- # @param prompt [String] The user's task prompt
1116
- # @return [Hooks::Result, nil] Result with stdout to append (if exit 0) or nil
1117
- # @raise [Hooks::Error] If hook halts execution
1118
- def trigger_swarm_start(prompt)
1119
- context = Hooks::Context.new(
1120
- event: :swarm_start,
1121
- agent_name: @lead_agent.to_s,
1122
- swarm: self,
1123
- metadata: {
1124
- swarm_name: @name,
1125
- lead_agent: @lead_agent,
1126
- prompt: prompt,
1127
- timestamp: Time.now.utc.iso8601,
1128
- },
1129
- )
1130
-
1131
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
1132
- result = executor.execute_safe(event: :swarm_start, context: context, callbacks: [])
1133
-
1134
- # Halt execution if hook requests it
1135
- raise Hooks::Error, "Swarm start halted by hook: #{result.value}" if result.halt?
1136
-
1137
- # Return result so caller can check for replace (stdout injection)
1138
- result
1139
- rescue StandardError => e
1140
- RubyLLM.logger.error("SwarmSDK: Error in swarm_start hook: #{e.message}")
1141
- raise
1142
- end
1143
-
1144
- # Trigger swarm_stop for final event emission (called in ensure block)
1145
- #
1146
- # This ALWAYS emits the swarm_stop event, even if there was an error.
1147
- # It does NOT check for reprompt (that's done in trigger_swarm_stop_for_reprompt_check).
1148
- #
1149
- # @param result [Result, nil] Execution result (may be nil if exception before result created)
1150
- # @param start_time [Time] Execution start time
1151
- # @param logs [Array] Collected logs
1152
- # @return [void]
1153
- def trigger_swarm_stop_final(result, start_time, logs)
1154
- # Create a minimal result if one doesn't exist (exception before result created)
1155
- result ||= Result.new(
1156
- content: nil,
1157
- agent: @lead_agent&.to_s || "unknown",
1158
- logs: logs,
1159
- duration: Time.now - start_time,
1160
- error: StandardError.new("Unknown error"),
1161
- )
1162
-
1163
- context = Hooks::Context.new(
1164
- event: :swarm_stop,
1165
- agent_name: @lead_agent.to_s,
1166
- swarm: self,
1167
- metadata: {
1168
- swarm_name: @name,
1169
- lead_agent: @lead_agent,
1170
- last_agent: result.agent, # Agent that produced the final response
1171
- content: result.content, # Final response content
1172
- success: result.success?,
1173
- duration: result.duration,
1174
- total_cost: result.total_cost,
1175
- total_tokens: result.total_tokens,
1176
- agents_involved: result.agents_involved,
1177
- result: result,
1178
- timestamp: Time.now.utc.iso8601,
1179
- },
1180
- )
1181
-
1182
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
1183
- executor.execute_safe(event: :swarm_stop, context: context, callbacks: [])
1184
- rescue StandardError => e
1185
- # Don't let swarm_stop errors break the ensure block
1186
- RubyLLM.logger.error("SwarmSDK: Error in swarm_stop final emission: #{e.message}")
1187
- end
1188
-
1189
- # Trigger swarm_stop hooks for reprompt check and event emission
1190
- #
1191
- # This is called in the normal execution flow to check if hooks request reprompting.
1192
- # The default callback also emits the swarm_stop event to LogStream.
1193
- #
1194
- # @param result [Result] The execution result
1195
- # @return [Hooks::Result, nil] Hook result (reprompt action if applicable)
1196
- def trigger_swarm_stop(result)
1197
- context = Hooks::Context.new(
1198
- event: :swarm_stop,
1199
- agent_name: @lead_agent.to_s,
1200
- swarm: self,
1201
- metadata: {
1202
- swarm_name: @name,
1203
- lead_agent: @lead_agent,
1204
- last_agent: result.agent, # Agent that produced the final response
1205
- content: result.content, # Final response content
1206
- success: result.success?,
1207
- duration: result.duration,
1208
- total_cost: result.total_cost,
1209
- total_tokens: result.total_tokens,
1210
- agents_involved: result.agents_involved,
1211
- result: result, # Include full result for hook access
1212
- timestamp: Time.now.utc.iso8601,
1213
- },
1214
- )
1215
-
1216
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
1217
- hook_result = executor.execute_safe(event: :swarm_stop, context: context, callbacks: [])
1218
-
1219
- # Return hook result so caller can handle reprompt
1220
- hook_result
1221
- rescue StandardError => e
1222
- RubyLLM.logger.error("SwarmSDK: Error in swarm_stop hook: #{e.message}")
1223
- nil
1224
- end
1225
-
1226
- # Trigger first_message hooks when first user message is sent
1227
- #
1228
- # This is a swarm-level event that fires once on the first call to execute().
1229
- # Hooks can halt execution before the first message is sent.
1230
- #
1231
- # @param prompt [String] The first user message
1232
- # @return [void]
1233
- # @raise [Hooks::Error] If hook halts execution
1234
- def trigger_first_message(prompt)
1235
- return if @hook_registry.get_defaults(:first_message).empty?
1236
-
1237
- context = Hooks::Context.new(
1238
- event: :first_message,
1239
- agent_name: @lead_agent.to_s,
1240
- swarm: self,
1241
- metadata: {
1242
- swarm_name: @name,
1243
- lead_agent: @lead_agent,
1244
- prompt: prompt,
1245
- timestamp: Time.now.utc.iso8601,
1246
- },
1247
- )
1248
-
1249
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
1250
- result = executor.execute_safe(event: :first_message, context: context, callbacks: [])
1251
-
1252
- # Halt execution if hook requests it
1253
- raise Hooks::Error, "First message halted by hook: #{result.value}" if result.halt?
1254
- rescue StandardError => e
1255
- RubyLLM.logger.error("SwarmSDK: Error in first_message hook: #{e.message}")
1256
- raise
1257
- end
1258
716
  end
1259
717
  end