swarm_memory 2.1.2 → 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 (118) 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 +30 -19
  5. data/lib/claude_swarm/mcp_generator.rb +5 -10
  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/mcp_serve.rb +2 -2
  11. data/lib/swarm_cli/commands/run.rb +2 -2
  12. data/lib/swarm_cli/config_loader.rb +14 -14
  13. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  14. data/lib/swarm_cli/interactive_repl.rb +11 -5
  15. data/lib/swarm_cli/ui/icons.rb +0 -23
  16. data/lib/swarm_cli/version.rb +1 -1
  17. data/lib/swarm_memory/adapters/base.rb +4 -4
  18. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  19. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  20. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  21. data/lib/swarm_memory/integration/sdk_plugin.rb +98 -12
  22. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  23. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  24. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  25. data/lib/swarm_memory/version.rb +1 -1
  26. data/lib/swarm_memory.rb +6 -1
  27. data/lib/swarm_sdk/agent/builder.rb +91 -0
  28. data/lib/swarm_sdk/agent/chat.rb +540 -925
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
  30. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  31. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
  32. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  33. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  34. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  35. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  36. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
  37. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  38. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  39. data/lib/swarm_sdk/agent/context.rb +8 -4
  40. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  41. data/lib/swarm_sdk/agent/definition.rb +79 -174
  42. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
  43. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  44. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  45. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  46. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  47. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  48. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  49. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  50. data/lib/swarm_sdk/configuration.rb +100 -261
  51. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  52. data/lib/swarm_sdk/context_compactor.rb +6 -11
  53. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  54. data/lib/swarm_sdk/context_management/context.rb +328 -0
  55. data/lib/swarm_sdk/defaults.rb +196 -0
  56. data/lib/swarm_sdk/events_to_messages.rb +199 -0
  57. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  58. data/lib/swarm_sdk/log_collector.rb +192 -16
  59. data/lib/swarm_sdk/log_stream.rb +66 -8
  60. data/lib/swarm_sdk/model_aliases.json +4 -1
  61. data/lib/swarm_sdk/node_context.rb +1 -1
  62. data/lib/swarm_sdk/observer/builder.rb +81 -0
  63. data/lib/swarm_sdk/observer/config.rb +45 -0
  64. data/lib/swarm_sdk/observer/manager.rb +236 -0
  65. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  66. data/lib/swarm_sdk/plugin.rb +93 -3
  67. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  68. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  69. data/lib/swarm_sdk/restore_result.rb +65 -0
  70. data/lib/swarm_sdk/snapshot.rb +156 -0
  71. data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
  72. data/lib/swarm_sdk/state_restorer.rb +476 -0
  73. data/lib/swarm_sdk/state_snapshot.rb +334 -0
  74. data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
  75. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  76. data/lib/swarm_sdk/swarm/builder.rb +69 -407
  77. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  78. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  79. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  80. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  81. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  82. data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
  83. data/lib/swarm_sdk/swarm.rb +366 -631
  84. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  85. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  86. data/lib/swarm_sdk/tools/bash.rb +11 -3
  87. data/lib/swarm_sdk/tools/delegate.rb +127 -24
  88. data/lib/swarm_sdk/tools/edit.rb +8 -13
  89. data/lib/swarm_sdk/tools/glob.rb +9 -1
  90. data/lib/swarm_sdk/tools/grep.rb +7 -0
  91. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  92. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  93. data/lib/swarm_sdk/tools/read.rb +28 -18
  94. data/lib/swarm_sdk/tools/registry.rb +122 -10
  95. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  96. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  97. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  98. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  99. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
  100. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  101. data/lib/swarm_sdk/tools/think.rb +4 -1
  102. data/lib/swarm_sdk/tools/todo_write.rb +27 -8
  103. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  104. data/lib/swarm_sdk/tools/write.rb +8 -13
  105. data/lib/swarm_sdk/utils.rb +18 -0
  106. data/lib/swarm_sdk/validation_result.rb +33 -0
  107. data/lib/swarm_sdk/version.rb +1 -1
  108. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
  109. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  110. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  111. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
  112. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  113. data/lib/swarm_sdk/workflow.rb +554 -0
  114. data/lib/swarm_sdk.rb +393 -22
  115. metadata +51 -16
  116. data/lib/swarm_memory/chat_extension.rb +0 -34
  117. data/lib/swarm_sdk/node_orchestrator.rb +0 -591
  118. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
@@ -4,25 +4,10 @@ module SwarmSDK
4
4
  # Swarm orchestrates multiple AI agents with shared rate limiting and coordination.
5
5
  #
6
6
  # This is the main user-facing API for SwarmSDK. Users create swarms using:
7
- # - Direct API: Create Agent::Definition objects and add to swarm
8
- # - Ruby DSL: Use Swarm::Builder for fluent configuration
9
- # - YAML: Load from configuration files
10
- #
11
- # ## Direct API
12
- #
13
- # swarm = Swarm.new(name: "Development Team")
14
- #
15
- # backend_agent = Agent::Definition.new(:backend, {
16
- # description: "Backend developer",
17
- # model: "gpt-5",
18
- # system_prompt: "You build APIs and databases...",
19
- # tools: [:Read, :Edit, :Bash],
20
- # delegates_to: [:database]
21
- # })
22
- # swarm.add_agent(backend_agent)
23
- #
24
- # swarm.lead = :backend
25
- # result = swarm.execute("Build authentication")
7
+ # - Ruby DSL: SwarmSDK.build { ... } (Recommended)
8
+ # - YAML String: SwarmSDK.load(yaml, base_dir:)
9
+ # - YAML File: SwarmSDK.load_file(path)
10
+ # - Direct API: Swarm.new + add_agent (Advanced)
26
11
  #
27
12
  # ## Ruby DSL (Recommended)
28
13
  #
@@ -39,14 +24,36 @@ module SwarmSDK
39
24
  # end
40
25
  # result = swarm.execute("Build authentication")
41
26
  #
42
- # ## YAML API
27
+ # ## YAML String API
28
+ #
29
+ # yaml = File.read("swarm.yml")
30
+ # swarm = SwarmSDK.load(yaml, base_dir: "/path/to/project")
31
+ # result = swarm.execute("Build authentication")
32
+ #
33
+ # ## YAML File API (Convenience)
34
+ #
35
+ # swarm = SwarmSDK.load_file("swarm.yml")
36
+ # result = swarm.execute("Build authentication")
37
+ #
38
+ # ## Direct API (Advanced)
39
+ #
40
+ # swarm = Swarm.new(name: "Development Team")
41
+ #
42
+ # backend_agent = Agent::Definition.new(:backend, {
43
+ # description: "Backend developer",
44
+ # model: "gpt-5",
45
+ # system_prompt: "You build APIs and databases...",
46
+ # tools: [:Read, :Edit, :Bash],
47
+ # delegates_to: [:database]
48
+ # })
49
+ # swarm.add_agent(backend_agent)
43
50
  #
44
- # swarm = Swarm.load("swarm.yml")
51
+ # swarm.lead = :backend
45
52
  # result = swarm.execute("Build authentication")
46
53
  #
47
54
  # ## Architecture
48
55
  #
49
- # All three APIs converge on Agent::Definition for validation.
56
+ # All APIs converge on Agent::Definition for validation.
50
57
  # Swarm delegates to specialized concerns:
51
58
  # - Agent::Definition: Validates configuration, builds system prompts
52
59
  # - AgentInitializer: Complex 5-pass agent setup
@@ -54,23 +61,42 @@ module SwarmSDK
54
61
  # - McpConfigurator: MCP client management (via AgentInitializer)
55
62
  #
56
63
  class Swarm
57
- DEFAULT_GLOBAL_CONCURRENCY = 50
58
- DEFAULT_LOCAL_CONCURRENCY = 10
59
- 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
60
72
 
61
73
  # Default tools available to all agents
62
74
  DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
63
75
 
64
- 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
65
78
 
66
79
  # Check if scratchpad tools are enabled
67
80
  #
68
81
  # @return [Boolean]
69
82
  def scratchpad_enabled?
70
- @scratchpad_enabled
83
+ @scratchpad_mode == :enabled
71
84
  end
72
85
  attr_writer :config_for_hooks
73
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
+
74
100
  # Class-level MCP log level configuration
75
101
  @mcp_log_level = DEFAULT_MCP_LOG_LEVEL
76
102
  @mcp_logging_configured = false
@@ -102,54 +128,54 @@ module SwarmSDK
102
128
 
103
129
  @mcp_logging_configured = true
104
130
  end
105
-
106
- # Load swarm from YAML configuration file
107
- #
108
- # @param config_path [String] Path to YAML configuration file
109
- # @return [Swarm] Configured swarm instance
110
- def load(config_path)
111
- config = Configuration.load(config_path)
112
- swarm = config.to_swarm
113
-
114
- # Apply hooks if any are configured (YAML-only feature)
115
- if hooks_configured?(config)
116
- Hooks::Adapter.apply_hooks(swarm, config)
117
- end
118
-
119
- # Store config reference for agent hooks (applied during initialize_agents)
120
- swarm.config_for_hooks = config
121
-
122
- swarm
123
- end
124
-
125
- private
126
-
127
- def hooks_configured?(config)
128
- config.swarm_hooks.any? ||
129
- config.all_agents_hooks.any? ||
130
- config.agents.any? { |_, agent_def| agent_def.hooks&.any? }
131
- end
132
131
  end
133
132
 
134
133
  # Initialize a new Swarm
135
134
  #
136
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)
137
138
  # @param global_concurrency [Integer] Max concurrent LLM calls across entire swarm
138
139
  # @param default_local_concurrency [Integer] Default max concurrent tool calls per agent
139
- # @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing)
140
- # @param scratchpad_enabled [Boolean] Whether to enable scratchpad tools (default: true)
141
- def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY, scratchpad: nil, scratchpad_enabled: true)
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)
142
144
  @name = name
145
+ @swarm_id = swarm_id || generate_swarm_id(name)
146
+ @parent_swarm_id = parent_swarm_id
143
147
  @global_concurrency = global_concurrency
144
148
  @default_local_concurrency = default_local_concurrency
145
- @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 = []
146
168
 
147
169
  # Shared semaphore for all agents
148
170
  @global_semaphore = Async::Semaphore.new(@global_concurrency)
149
171
 
150
172
  # Shared scratchpad storage for all agents (volatile)
151
- # Use provided scratchpad storage (for testing) or create volatile one
152
- @scratchpad_storage = scratchpad || Tools::Stores::ScratchpadStorage.new
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
153
179
 
154
180
  # Per-agent plugin storages (persistent)
155
181
  # Format: { plugin_name => { agent_name => storage } }
@@ -165,6 +191,7 @@ module SwarmSDK
165
191
  # Agent definitions and instances
166
192
  @agent_definitions = {}
167
193
  @agents = {}
194
+ @delegation_instances = {} # { "delegate@delegator" => Agent::Chat }
168
195
  @agents_initialized = false
169
196
  @agent_contexts = {}
170
197
 
@@ -175,6 +202,14 @@ module SwarmSDK
175
202
 
176
203
  # Track if first message has been sent
177
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
178
213
  end
179
214
 
180
215
  # Add an agent to the swarm
@@ -230,36 +265,53 @@ module SwarmSDK
230
265
  # and the entire swarm coordinates with shared rate limiting.
231
266
  # Supports reprompting via swarm_stop hooks.
232
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
+ #
233
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.
234
274
  # @yield [Hash] Log entry if block given (for streaming)
235
- # @return [Result] Execution result
236
- 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)
237
287
  raise ConfigurationError, "No lead agent set. Set lead= first." unless @lead_agent
238
288
 
239
- start_time = Time.now
240
289
  logs = []
241
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
242
305
 
243
306
  # Setup logging FIRST if block given (so swarm_start event can be emitted)
244
- if block_given?
245
- # Register callback to collect logs and forward to user's block
246
- LogCollector.on_log do |entry|
247
- logs << entry
248
- block.call(entry)
249
- end
307
+ setup_logging(logs, &block) if has_logging
250
308
 
251
- # Set LogStream to use LogCollector as emitter
252
- LogStream.emitter = LogCollector
253
- 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?
254
312
 
255
313
  # Trigger swarm_start hooks (before any execution)
256
- # Hook can append stdout to prompt (exit code 0)
257
- # Default callback emits swarm_start event to LogStream
258
- swarm_start_result = trigger_swarm_start(current_prompt)
259
- if swarm_start_result&.replace?
260
- # Hook provided stdout to append to prompt
261
- current_prompt = "#{current_prompt}\n\n<hook-context>\n#{swarm_start_result.value}\n</hook-context>"
262
- end
314
+ current_prompt = apply_swarm_start_hooks(current_prompt)
263
315
 
264
316
  # Trigger first_message hooks on first execution
265
317
  unless @first_message_sent
@@ -270,128 +322,18 @@ module SwarmSDK
270
322
  # Lazy initialization of agents (with optional logging)
271
323
  initialize_agents unless @agents_initialized
272
324
 
273
- # Execution loop (supports reprompting)
274
- result = nil
275
- swarm_stop_triggered = false
276
-
277
- loop do
278
- # Execute within Async reactor to enable fiber scheduler for parallel execution
279
- # This sets Fiber.scheduler, making Faraday fiber-aware so HTTP requests yield during I/O
280
- # Use finished: false to suppress warnings for expected task failures
281
- lead = @agents[@lead_agent]
282
- response = Async(finished: false) do
283
- lead.ask(current_prompt)
284
- end.wait
285
-
286
- # Check if swarm was finished by a hook (finish_swarm)
287
- if response.is_a?(Hash) && response[:__finish_swarm__]
288
- result = Result.new(
289
- content: response[:message],
290
- agent: @lead_agent.to_s,
291
- logs: logs,
292
- duration: Time.now - start_time,
293
- )
294
-
295
- # Trigger swarm_stop hooks for event emission
296
- trigger_swarm_stop(result)
297
- swarm_stop_triggered = true
298
-
299
- # Break immediately - don't allow reprompting when swarm is finished by hook
300
- break
301
- end
325
+ # Emit agent_start events if agents were initialized before logging was set up
326
+ emit_retroactive_agent_start_events if has_logging
302
327
 
303
- result = Result.new(
304
- content: response.content,
305
- agent: @lead_agent.to_s,
306
- logs: logs,
307
- duration: Time.now - start_time,
308
- )
309
-
310
- # Trigger swarm_stop hooks (for reprompt check and event emission)
311
- hook_result = trigger_swarm_stop(result)
312
- swarm_stop_triggered = true
313
-
314
- # Check if hook requests reprompting
315
- if hook_result&.reprompt?
316
- current_prompt = hook_result.value
317
- swarm_stop_triggered = false # Will trigger again in next iteration
318
- # Continue loop with new prompt
319
- else
320
- # Exit loop - execution complete
321
- break
322
- end
323
- end
324
-
325
- result
326
- rescue ConfigurationError, AgentNotFoundError
327
- # Re-raise configuration errors - these should be fixed, not caught
328
- raise
329
- rescue TypeError => e
330
- # Catch the specific "String does not have #dig method" error
331
- if e.message.include?("does not have #dig method")
332
- agent_definition = @agent_definitions[@lead_agent]
333
- error_msg = if agent_definition.base_url
334
- "LLM API request failed: The proxy/server at '#{agent_definition.base_url}' returned an invalid response. " \
335
- "This usually means the proxy is unreachable, requires authentication, or returned an error in non-JSON format. " \
336
- "Original error: #{e.message}"
337
- else
338
- "LLM API request failed with unexpected response format. Original error: #{e.message}"
339
- end
340
-
341
- result = Result.new(
342
- content: nil,
343
- agent: @lead_agent.to_s,
344
- error: LLMError.new(error_msg),
345
- logs: logs,
346
- duration: Time.now - start_time,
347
- )
348
- else
349
- result = Result.new(
350
- content: nil,
351
- agent: @lead_agent.to_s,
352
- error: e,
353
- logs: logs,
354
- duration: Time.now - start_time,
355
- )
356
- end
357
- result
358
- rescue StandardError => e
359
- result = Result.new(
360
- content: nil,
361
- agent: @lead_agent&.to_s || "unknown",
362
- 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,
363
333
  logs: logs,
364
- duration: Time.now - start_time,
334
+ has_logging: has_logging,
335
+ original_fiber_storage: original_fiber_storage,
365
336
  )
366
- result
367
- ensure
368
- # Trigger swarm_stop if not already triggered (handles error cases)
369
- unless swarm_stop_triggered
370
- trigger_swarm_stop_final(result, start_time, logs)
371
- end
372
-
373
- # Cleanup MCP clients after execution
374
- cleanup
375
-
376
- # Reset logging state for next execution if we set it up
377
- #
378
- # IMPORTANT: Only reset if we set up logging (block_given? == true).
379
- # When this swarm is a mini-swarm within a NodeOrchestrator workflow,
380
- # the orchestrator manages LogCollector and we don't set up logging.
381
- #
382
- # Flow in NodeOrchestrator:
383
- # 1. NodeOrchestrator sets up LogCollector + LogStream (no block given to mini-swarms)
384
- # 2. Each mini-swarm executes without logging block (block_given? == false)
385
- # 3. Each mini-swarm skips reset (didn't set up logging)
386
- # 4. NodeOrchestrator resets once at the very end
387
- #
388
- # Flow in standalone swarm / interactive REPL:
389
- # 1. Swarm.execute sets up LogCollector + LogStream (block given)
390
- # 2. Swarm.execute resets in ensure block (cleanup for next call)
391
- if block_given?
392
- LogCollector.reset!
393
- LogStream.reset!
394
- end
395
337
  end
396
338
 
397
339
  # Get an agent chat instance by name
@@ -425,77 +367,17 @@ module SwarmSDK
425
367
  @agent_definitions.keys
426
368
  end
427
369
 
428
- # Validate swarm configuration and return warnings
429
- #
430
- # This performs lightweight validation checks without creating agents.
431
- # Useful for displaying configuration warnings before execution.
432
- #
433
- # @return [Array<Hash>] Array of warning hashes from all agent definitions
434
- #
435
- # @example
436
- # swarm = Swarm.load("config.yml")
437
- # warnings = swarm.validate
438
- # warnings.each do |warning|
439
- # puts "⚠️ #{warning[:agent]}: #{warning[:model]} not found"
440
- # end
441
- def validate
442
- @agent_definitions.flat_map { |_name, definition| definition.validate }
370
+ # Implement Snapshotable interface
371
+ def primary_agents
372
+ @agents
443
373
  end
444
374
 
445
- # Emit validation warnings as log events
446
- #
447
- # This validates all agent definitions and emits any warnings as
448
- # model_lookup_warning events through LogStream. Useful for emitting
449
- # warnings before execution starts (e.g., in REPL after welcome screen).
450
- #
451
- # Requires LogStream.emitter to be set.
452
- #
453
- # @return [Array<Hash>] The validation warnings that were emitted
454
- #
455
- # @example
456
- # LogCollector.on_log { |event| puts event }
457
- # LogStream.emitter = LogCollector
458
- # swarm.emit_validation_warnings
459
- def emit_validation_warnings
460
- warnings = validate
461
-
462
- warnings.each do |warning|
463
- case warning[:type]
464
- when :model_not_found
465
- LogStream.emit(
466
- type: "model_lookup_warning",
467
- agent: warning[:agent],
468
- model: warning[:model],
469
- error_message: warning[:error_message],
470
- suggestions: warning[:suggestions],
471
- timestamp: Time.now.utc.iso8601,
472
- )
473
- end
474
- end
475
-
476
- warnings
375
+ def delegation_instances_hash
376
+ @delegation_instances
477
377
  end
478
378
 
479
- # Cleanup all MCP clients
480
- #
481
- # Stops all MCP client connections gracefully.
482
- # Should be called when the swarm is no longer needed.
483
- #
484
- # @return [void]
485
- def cleanup
486
- return if @mcp_clients.empty?
487
-
488
- @mcp_clients.each do |agent_name, clients|
489
- clients.each do |client|
490
- client.stop if client.alive?
491
- RubyLLM.logger.debug("SwarmSDK: Stopped MCP client '#{client.name}' for agent #{agent_name}")
492
- rescue StandardError => e
493
- RubyLLM.logger.error("SwarmSDK: Error stopping MCP client '#{client.name}' for agent #{agent_name}: #{e.message}")
494
- end
495
- end
496
-
497
- @mcp_clients.clear
498
- end
379
+ # NOTE: validate() and emit_validation_warnings() are provided by Concerns::Validatable
380
+ # Note: cleanup() is provided by Concerns::Cleanupable
499
381
 
500
382
  # Register a named hook that can be referenced in agent configurations
501
383
  #
@@ -515,28 +397,229 @@ module SwarmSDK
515
397
  self
516
398
  end
517
399
 
518
- # Add a swarm-level default hook that applies to all agents
400
+ # Reset context for all agents
519
401
  #
520
- # Default hooks are inherited by all agents unless overridden at agent level.
521
- # 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.
522
404
  #
523
- # @param event [Symbol] Event type (e.g., :pre_tool_use, :post_tool_use)
524
- # @param matcher [String, Regexp, nil] Optional regex pattern for tool names
525
- # @param priority [Integer] Execution priority (higher = earlier)
526
- # @param block [Proc] Hook implementation
527
- # @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
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.
528
428
  #
529
- # @example Add logging for all tool calls
530
- # swarm.add_default_callback(:pre_tool_use) do |context|
531
- # puts "[#{context.agent_name}] Calling #{context.tool_call.name}"
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]}" }
532
496
  # end
533
- def add_default_callback(event, matcher: nil, priority: 0, &block)
534
- @hook_registry.add_default(event, matcher: matcher, priority: priority, &block)
535
- 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
536
521
  end
537
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
+
538
535
  private
539
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
+
540
623
  # Initialize all agents using AgentInitializer
541
624
  #
542
625
  # This is called automatically (lazy initialization) by execute() and agent().
@@ -546,58 +629,14 @@ module SwarmSDK
546
629
  def initialize_agents
547
630
  return if @agents_initialized
548
631
 
549
- initializer = AgentInitializer.new(
550
- self,
551
- @agent_definitions,
552
- @global_semaphore,
553
- @hook_registry,
554
- @scratchpad_storage,
555
- @plugin_storages,
556
- config_for_hooks: @config_for_hooks,
557
- )
632
+ initializer = AgentInitializer.new(self)
558
633
 
559
634
  @agents = initializer.initialize_all
560
635
  @agent_contexts = initializer.agent_contexts
561
636
  @agents_initialized = true
562
637
 
563
- # Emit agent_start events for all agents
564
- emit_agent_start_events
565
- end
566
-
567
- # Emit agent_start events for all initialized agents
568
- def emit_agent_start_events
569
- # Only emit if LogStream is enabled
570
- return unless LogStream.emitter
571
-
572
- @agents.each do |agent_name, chat|
573
- agent_def = @agent_definitions[agent_name]
574
-
575
- # Build plugin storage info for logging
576
- plugin_storage_info = {}
577
- @plugin_storages.each do |plugin_name, agent_storages|
578
- next unless agent_storages.key?(agent_name)
579
-
580
- plugin_storage_info[plugin_name] = {
581
- enabled: true,
582
- # Get additional info from agent definition if available
583
- config: agent_def.respond_to?(plugin_name) ? extract_plugin_config_info(agent_def.public_send(plugin_name)) : nil,
584
- }
585
- end
586
-
587
- LogStream.emit(
588
- type: "agent_start",
589
- agent: agent_name,
590
- swarm_name: @name,
591
- model: agent_def.model,
592
- provider: agent_def.provider || "openai",
593
- directory: agent_def.directory,
594
- system_prompt: agent_def.system_prompt,
595
- tools: chat.tools.keys,
596
- delegates_to: agent_def.delegates_to,
597
- plugin_storages: plugin_storage_info,
598
- timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
599
- )
600
- 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())
601
640
  end
602
641
 
603
642
  # Normalize tools to internal format (kept for add_agent)
@@ -647,7 +686,7 @@ module SwarmSDK
647
686
 
648
687
  # Create delegation tool (delegates to AgentInitializer)
649
688
  def create_delegation_tool(name:, description:, delegate_chat:, agent_name:)
650
- AgentInitializer.new(self, @agent_definitions, @global_semaphore, @hook_registry, @scratchpad_storage, @plugin_storages)
689
+ AgentInitializer.new(self)
651
690
  .create_delegation_tool(name: name, description: description, delegate_chat: delegate_chat, agent_name: agent_name)
652
691
  end
653
692
 
@@ -674,309 +713,5 @@ module SwarmSDK
674
713
  # Unknown config type
675
714
  nil
676
715
  end
677
-
678
- # Register default logging hooks that emit LogStream events
679
- #
680
- # These hooks implement the standard SwarmSDK logging behavior.
681
- # Users can override or extend them by registering their own hooks.
682
- #
683
- # @return [void]
684
- def register_default_logging_callbacks
685
- # Log swarm start
686
- add_default_callback(:swarm_start, priority: -100) do |context|
687
- # Only log if LogStream emitter is set (logging enabled)
688
- next unless LogStream.emitter
689
-
690
- LogStream.emit(
691
- type: "swarm_start",
692
- agent: context.metadata[:lead_agent], # Include agent for consistency
693
- swarm_name: context.metadata[:swarm_name],
694
- lead_agent: context.metadata[:lead_agent],
695
- prompt: context.metadata[:prompt],
696
- timestamp: context.metadata[:timestamp],
697
- )
698
- end
699
-
700
- # Log swarm stop
701
- add_default_callback(:swarm_stop, priority: -100) do |context|
702
- # Only log if LogStream emitter is set (logging enabled)
703
- next unless LogStream.emitter
704
-
705
- LogStream.emit(
706
- type: "swarm_stop",
707
- swarm_name: context.metadata[:swarm_name],
708
- lead_agent: context.metadata[:lead_agent],
709
- last_agent: context.metadata[:last_agent], # Agent that produced final response
710
- content: context.metadata[:content], # Final response content
711
- success: context.metadata[:success],
712
- duration: context.metadata[:duration],
713
- total_cost: context.metadata[:total_cost],
714
- total_tokens: context.metadata[:total_tokens],
715
- agents_involved: context.metadata[:agents_involved],
716
- timestamp: context.metadata[:timestamp],
717
- )
718
- end
719
-
720
- # Log user requests
721
- add_default_callback(:user_prompt, priority: -100) do |context|
722
- # Only log if LogStream emitter is set (logging enabled)
723
- next unless LogStream.emitter
724
-
725
- LogStream.emit(
726
- type: "user_prompt",
727
- agent: context.agent_name,
728
- model: context.metadata[:model] || "unknown",
729
- provider: context.metadata[:provider] || "unknown",
730
- message_count: context.metadata[:message_count] || 0,
731
- tools: context.metadata[:tools] || [],
732
- delegates_to: context.metadata[:delegates_to] || [],
733
- metadata: context.metadata,
734
- )
735
- end
736
-
737
- # Log intermediate agent responses with tool calls
738
- add_default_callback(:agent_step, priority: -100) do |context|
739
- # Only log if LogStream emitter is set (logging enabled)
740
- next unless LogStream.emitter
741
-
742
- # Extract top-level fields and remove from metadata to avoid duplication
743
- metadata_without_duplicates = context.metadata.except(:model, :content, :tool_calls, :finish_reason, :usage, :tool_executions)
744
-
745
- LogStream.emit(
746
- type: "agent_step",
747
- agent: context.agent_name,
748
- model: context.metadata[:model],
749
- content: context.metadata[:content],
750
- tool_calls: context.metadata[:tool_calls],
751
- finish_reason: context.metadata[:finish_reason],
752
- usage: context.metadata[:usage],
753
- tool_executions: context.metadata[:tool_executions],
754
- metadata: metadata_without_duplicates,
755
- )
756
- end
757
-
758
- # Log final agent responses
759
- add_default_callback(:agent_stop, priority: -100) do |context|
760
- # Only log if LogStream emitter is set (logging enabled)
761
- next unless LogStream.emitter
762
-
763
- # Extract top-level fields and remove from metadata to avoid duplication
764
- metadata_without_duplicates = context.metadata.except(:model, :content, :tool_calls, :finish_reason, :usage, :tool_executions)
765
-
766
- LogStream.emit(
767
- type: "agent_stop",
768
- agent: context.agent_name,
769
- model: context.metadata[:model],
770
- content: context.metadata[:content],
771
- tool_calls: context.metadata[:tool_calls],
772
- finish_reason: context.metadata[:finish_reason],
773
- usage: context.metadata[:usage],
774
- tool_executions: context.metadata[:tool_executions],
775
- metadata: metadata_without_duplicates,
776
- )
777
- end
778
-
779
- # Log tool calls (pre_tool_use)
780
- add_default_callback(:pre_tool_use, priority: -100) do |context|
781
- # Only log if LogStream emitter is set (logging enabled)
782
- next unless LogStream.emitter
783
-
784
- # Delegation tracking is handled separately in AgentChat
785
- # Just log the tool call - delegation info will be in metadata if needed
786
- LogStream.emit(
787
- type: "tool_call",
788
- agent: context.agent_name,
789
- tool_call_id: context.tool_call.id,
790
- tool: context.tool_call.name,
791
- arguments: context.tool_call.parameters,
792
- metadata: context.metadata,
793
- )
794
- end
795
-
796
- # Log tool results (post_tool_use)
797
- add_default_callback(:post_tool_use, priority: -100) do |context|
798
- # Only log if LogStream emitter is set (logging enabled)
799
- next unless LogStream.emitter
800
-
801
- # Delegation tracking is handled separately in AgentChat
802
- # Usage tracking is handled in agent_step/agent_stop events
803
- LogStream.emit(
804
- type: "tool_result",
805
- agent: context.agent_name,
806
- tool_call_id: context.tool_result.tool_call_id,
807
- tool: context.tool_result.tool_name,
808
- result: context.tool_result.content,
809
- metadata: context.metadata,
810
- )
811
- end
812
-
813
- # Log context warnings
814
- add_default_callback(:context_warning, priority: -100) do |context|
815
- # Only log if LogStream emitter is set (logging enabled)
816
- next unless LogStream.emitter
817
-
818
- LogStream.emit(
819
- type: "context_limit_warning",
820
- agent: context.agent_name,
821
- model: context.metadata[:model] || "unknown",
822
- threshold: "#{context.metadata[:threshold]}%",
823
- current_usage: "#{context.metadata[:percentage]}%",
824
- tokens_used: context.metadata[:tokens_used],
825
- tokens_remaining: context.metadata[:tokens_remaining],
826
- context_limit: context.metadata[:context_limit],
827
- metadata: context.metadata,
828
- )
829
- end
830
- end
831
-
832
- # Trigger swarm_start hooks when swarm execution begins
833
- #
834
- # This is a swarm-level event that fires when Swarm.execute is called
835
- # (before first user message is sent). Hooks can halt execution or append stdout to prompt.
836
- # Default callback emits to LogStream for logging.
837
- #
838
- # @param prompt [String] The user's task prompt
839
- # @return [Hooks::Result, nil] Result with stdout to append (if exit 0) or nil
840
- # @raise [Hooks::Error] If hook halts execution
841
- def trigger_swarm_start(prompt)
842
- context = Hooks::Context.new(
843
- event: :swarm_start,
844
- agent_name: @lead_agent.to_s,
845
- swarm: self,
846
- metadata: {
847
- swarm_name: @name,
848
- lead_agent: @lead_agent,
849
- prompt: prompt,
850
- timestamp: Time.now.utc.iso8601,
851
- },
852
- )
853
-
854
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
855
- result = executor.execute_safe(event: :swarm_start, context: context, callbacks: [])
856
-
857
- # Halt execution if hook requests it
858
- raise Hooks::Error, "Swarm start halted by hook: #{result.value}" if result.halt?
859
-
860
- # Return result so caller can check for replace (stdout injection)
861
- result
862
- rescue StandardError => e
863
- RubyLLM.logger.error("SwarmSDK: Error in swarm_start hook: #{e.message}")
864
- raise
865
- end
866
-
867
- # Trigger swarm_stop for final event emission (called in ensure block)
868
- #
869
- # This ALWAYS emits the swarm_stop event, even if there was an error.
870
- # It does NOT check for reprompt (that's done in trigger_swarm_stop_for_reprompt_check).
871
- #
872
- # @param result [Result, nil] Execution result (may be nil if exception before result created)
873
- # @param start_time [Time] Execution start time
874
- # @param logs [Array] Collected logs
875
- # @return [void]
876
- def trigger_swarm_stop_final(result, start_time, logs)
877
- # Create a minimal result if one doesn't exist (exception before result created)
878
- result ||= Result.new(
879
- content: nil,
880
- agent: @lead_agent&.to_s || "unknown",
881
- logs: logs,
882
- duration: Time.now - start_time,
883
- error: StandardError.new("Unknown error"),
884
- )
885
-
886
- context = Hooks::Context.new(
887
- event: :swarm_stop,
888
- agent_name: @lead_agent.to_s,
889
- swarm: self,
890
- metadata: {
891
- swarm_name: @name,
892
- lead_agent: @lead_agent,
893
- last_agent: result.agent, # Agent that produced the final response
894
- content: result.content, # Final response content
895
- success: result.success?,
896
- duration: result.duration,
897
- total_cost: result.total_cost,
898
- total_tokens: result.total_tokens,
899
- agents_involved: result.agents_involved,
900
- result: result,
901
- timestamp: Time.now.utc.iso8601,
902
- },
903
- )
904
-
905
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
906
- executor.execute_safe(event: :swarm_stop, context: context, callbacks: [])
907
- rescue StandardError => e
908
- # Don't let swarm_stop errors break the ensure block
909
- RubyLLM.logger.error("SwarmSDK: Error in swarm_stop final emission: #{e.message}")
910
- end
911
-
912
- # Trigger swarm_stop hooks for reprompt check and event emission
913
- #
914
- # This is called in the normal execution flow to check if hooks request reprompting.
915
- # The default callback also emits the swarm_stop event to LogStream.
916
- #
917
- # @param result [Result] The execution result
918
- # @return [Hooks::Result, nil] Hook result (reprompt action if applicable)
919
- def trigger_swarm_stop(result)
920
- context = Hooks::Context.new(
921
- event: :swarm_stop,
922
- agent_name: @lead_agent.to_s,
923
- swarm: self,
924
- metadata: {
925
- swarm_name: @name,
926
- lead_agent: @lead_agent,
927
- last_agent: result.agent, # Agent that produced the final response
928
- content: result.content, # Final response content
929
- success: result.success?,
930
- duration: result.duration,
931
- total_cost: result.total_cost,
932
- total_tokens: result.total_tokens,
933
- agents_involved: result.agents_involved,
934
- result: result, # Include full result for hook access
935
- timestamp: Time.now.utc.iso8601,
936
- },
937
- )
938
-
939
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
940
- hook_result = executor.execute_safe(event: :swarm_stop, context: context, callbacks: [])
941
-
942
- # Return hook result so caller can handle reprompt
943
- hook_result
944
- rescue StandardError => e
945
- RubyLLM.logger.error("SwarmSDK: Error in swarm_stop hook: #{e.message}")
946
- nil
947
- end
948
-
949
- # Trigger first_message hooks when first user message is sent
950
- #
951
- # This is a swarm-level event that fires once on the first call to execute().
952
- # Hooks can halt execution before the first message is sent.
953
- #
954
- # @param prompt [String] The first user message
955
- # @return [void]
956
- # @raise [Hooks::Error] If hook halts execution
957
- def trigger_first_message(prompt)
958
- return if @hook_registry.get_defaults(:first_message).empty?
959
-
960
- context = Hooks::Context.new(
961
- event: :first_message,
962
- agent_name: @lead_agent.to_s,
963
- swarm: self,
964
- metadata: {
965
- swarm_name: @name,
966
- lead_agent: @lead_agent,
967
- prompt: prompt,
968
- timestamp: Time.now.utc.iso8601,
969
- },
970
- )
971
-
972
- executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
973
- result = executor.execute_safe(event: :first_message, context: context, callbacks: [])
974
-
975
- # Halt execution if hook requests it
976
- raise Hooks::Error, "First message halted by hook: #{result.value}" if result.halt?
977
- rescue StandardError => e
978
- RubyLLM.logger.error("SwarmSDK: Error in first_message hook: #{e.message}")
979
- raise
980
- end
981
716
  end
982
717
  end