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