swarm_sdk 2.0.0.pre.2
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 +7 -0
- data/lib/swarm_sdk/agent/builder.rb +333 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
- data/lib/swarm_sdk/agent/chat.rb +779 -0
- data/lib/swarm_sdk/agent/context.rb +108 -0
- data/lib/swarm_sdk/agent/definition.rb +335 -0
- data/lib/swarm_sdk/configuration.rb +251 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +163 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +143 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +83 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +46 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
- data/lib/swarm_sdk/swarm/builder.rb +240 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
- data/lib/swarm_sdk/swarm.rb +837 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/delegate.rb +152 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +231 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +73 -0
- data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
- data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +69 -0
- metadata +169 -0
@@ -0,0 +1,837 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
# Swarm orchestrates multiple AI agents with shared rate limiting and coordination.
|
5
|
+
#
|
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")
|
26
|
+
#
|
27
|
+
# ## Ruby DSL (Recommended)
|
28
|
+
#
|
29
|
+
# swarm = SwarmSDK.build do
|
30
|
+
# name "Development Team"
|
31
|
+
# lead :backend
|
32
|
+
#
|
33
|
+
# agent :backend do
|
34
|
+
# model "gpt-5"
|
35
|
+
# description "Backend developer"
|
36
|
+
# prompt "You build APIs"
|
37
|
+
# tools :Read, :Edit, :Bash
|
38
|
+
# end
|
39
|
+
# end
|
40
|
+
# result = swarm.execute("Build authentication")
|
41
|
+
#
|
42
|
+
# ## YAML API
|
43
|
+
#
|
44
|
+
# swarm = Swarm.load("swarm.yml")
|
45
|
+
# result = swarm.execute("Build authentication")
|
46
|
+
#
|
47
|
+
# ## Architecture
|
48
|
+
#
|
49
|
+
# All three APIs converge on Agent::Definition for validation.
|
50
|
+
# Swarm delegates to specialized concerns:
|
51
|
+
# - Agent::Definition: Validates configuration, builds system prompts
|
52
|
+
# - AgentInitializer: Complex 5-pass agent setup
|
53
|
+
# - ToolConfigurator: Tool creation and permissions (via AgentInitializer)
|
54
|
+
# - McpConfigurator: MCP client management (via AgentInitializer)
|
55
|
+
#
|
56
|
+
class Swarm
|
57
|
+
DEFAULT_GLOBAL_CONCURRENCY = 50
|
58
|
+
DEFAULT_LOCAL_CONCURRENCY = 10
|
59
|
+
DEFAULT_MCP_LOG_LEVEL = Logger::WARN
|
60
|
+
|
61
|
+
# Default tools available to all agents
|
62
|
+
DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
|
63
|
+
|
64
|
+
attr_reader :name, :agents, :lead_agent, :mcp_clients
|
65
|
+
attr_writer :config_for_hooks
|
66
|
+
|
67
|
+
# Class-level MCP log level configuration
|
68
|
+
@mcp_log_level = DEFAULT_MCP_LOG_LEVEL
|
69
|
+
@mcp_logging_configured = false
|
70
|
+
|
71
|
+
class << self
|
72
|
+
attr_accessor :mcp_log_level
|
73
|
+
|
74
|
+
# Configure MCP client logging globally
|
75
|
+
#
|
76
|
+
# This should be called before creating any swarms that use MCP servers.
|
77
|
+
# The configuration is global and affects all MCP clients.
|
78
|
+
#
|
79
|
+
# @param level [Integer] Log level (Logger::DEBUG, Logger::INFO, Logger::WARN, Logger::ERROR, Logger::FATAL)
|
80
|
+
# @return [void]
|
81
|
+
def configure_mcp_logging(level = DEFAULT_MCP_LOG_LEVEL)
|
82
|
+
@mcp_log_level = level
|
83
|
+
apply_mcp_logging_configuration
|
84
|
+
end
|
85
|
+
|
86
|
+
# Apply MCP logging configuration to RubyLLM::MCP
|
87
|
+
#
|
88
|
+
# @return [void]
|
89
|
+
def apply_mcp_logging_configuration
|
90
|
+
return if @mcp_logging_configured
|
91
|
+
|
92
|
+
RubyLLM::MCP.configure do |config|
|
93
|
+
config.log_level = @mcp_log_level
|
94
|
+
end
|
95
|
+
|
96
|
+
@mcp_logging_configured = true
|
97
|
+
end
|
98
|
+
|
99
|
+
# Load swarm from YAML configuration file
|
100
|
+
#
|
101
|
+
# @param config_path [String] Path to YAML configuration file
|
102
|
+
# @return [Swarm] Configured swarm instance
|
103
|
+
def load(config_path)
|
104
|
+
config = Configuration.load(config_path)
|
105
|
+
swarm = config.to_swarm
|
106
|
+
|
107
|
+
# Apply hooks if any are configured (YAML-only feature)
|
108
|
+
if hooks_configured?(config)
|
109
|
+
Hooks::Adapter.apply_hooks(swarm, config)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Store config reference for agent hooks (applied during initialize_agents)
|
113
|
+
swarm.config_for_hooks = config
|
114
|
+
|
115
|
+
swarm
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def hooks_configured?(config)
|
121
|
+
config.swarm_hooks.any? ||
|
122
|
+
config.all_agents_hooks.any? ||
|
123
|
+
config.agents.any? { |_, agent_def| agent_def.hooks&.any? }
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Initialize a new Swarm
|
128
|
+
#
|
129
|
+
# @param name [String] Human-readable swarm name
|
130
|
+
# @param global_concurrency [Integer] Max concurrent LLM calls across entire swarm
|
131
|
+
# @param default_local_concurrency [Integer] Default max concurrent tool calls per agent
|
132
|
+
def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY)
|
133
|
+
@name = name
|
134
|
+
@global_concurrency = global_concurrency
|
135
|
+
@default_local_concurrency = default_local_concurrency
|
136
|
+
|
137
|
+
# Shared semaphore for all agents
|
138
|
+
@global_semaphore = Async::Semaphore.new(@global_concurrency)
|
139
|
+
|
140
|
+
# Shared scratchpad for all agents
|
141
|
+
@scratchpad = Tools::Stores::Scratchpad.new
|
142
|
+
|
143
|
+
# Hook registry for named hooks and swarm defaults
|
144
|
+
@hook_registry = Hooks::Registry.new
|
145
|
+
|
146
|
+
# Register default logging hooks
|
147
|
+
register_default_logging_callbacks
|
148
|
+
|
149
|
+
# Agent definitions and instances
|
150
|
+
@agent_definitions = {}
|
151
|
+
@agents = {}
|
152
|
+
@agents_initialized = false
|
153
|
+
@agent_contexts = {}
|
154
|
+
|
155
|
+
# MCP clients per agent (for cleanup)
|
156
|
+
@mcp_clients = Hash.new { |h, k| h[k] = [] }
|
157
|
+
|
158
|
+
@lead_agent = nil
|
159
|
+
|
160
|
+
# Track if first message has been sent
|
161
|
+
@first_message_sent = false
|
162
|
+
end
|
163
|
+
|
164
|
+
# Add an agent to the swarm
|
165
|
+
#
|
166
|
+
# Accepts only Agent::Definition objects. This ensures all validation
|
167
|
+
# happens in a single place (Agent::Definition) and keeps the API clean.
|
168
|
+
#
|
169
|
+
# If the definition doesn't specify max_concurrent_tools, the swarm's
|
170
|
+
# default_local_concurrency is applied.
|
171
|
+
#
|
172
|
+
# @param definition [Agent::Definition] Fully configured agent definition
|
173
|
+
# @return [self]
|
174
|
+
#
|
175
|
+
# @example
|
176
|
+
# definition = Agent::Definition.new(:backend, {
|
177
|
+
# description: "Backend developer",
|
178
|
+
# model: "gpt-5",
|
179
|
+
# system_prompt: "You build APIs"
|
180
|
+
# })
|
181
|
+
# swarm.add_agent(definition)
|
182
|
+
def add_agent(definition)
|
183
|
+
unless definition.is_a?(Agent::Definition)
|
184
|
+
raise ArgumentError, "Expected Agent::Definition, got #{definition.class}"
|
185
|
+
end
|
186
|
+
|
187
|
+
name = definition.name
|
188
|
+
raise ConfigurationError, "Agent '#{name}' already exists" if @agent_definitions.key?(name)
|
189
|
+
|
190
|
+
# Apply swarm's default_local_concurrency if max_concurrent_tools not set
|
191
|
+
definition.max_concurrent_tools = @default_local_concurrency if definition.max_concurrent_tools.nil?
|
192
|
+
|
193
|
+
@agent_definitions[name] = definition
|
194
|
+
self
|
195
|
+
end
|
196
|
+
|
197
|
+
# Set the lead agent (entry point for swarm execution)
|
198
|
+
#
|
199
|
+
# @param name [Symbol, String] Name of agent to make lead
|
200
|
+
# @return [self]
|
201
|
+
def lead=(name)
|
202
|
+
name = name.to_sym
|
203
|
+
|
204
|
+
unless @agent_definitions.key?(name)
|
205
|
+
raise ConfigurationError, "Cannot set lead: agent '#{name}' not found"
|
206
|
+
end
|
207
|
+
|
208
|
+
@lead_agent = name
|
209
|
+
end
|
210
|
+
|
211
|
+
# Execute a task using the lead agent
|
212
|
+
#
|
213
|
+
# The lead agent can delegate to other agents via tool calls,
|
214
|
+
# and the entire swarm coordinates with shared rate limiting.
|
215
|
+
# Supports reprompting via swarm_stop hooks.
|
216
|
+
#
|
217
|
+
# @param prompt [String] Task to execute
|
218
|
+
# @yield [Hash] Log entry if block given (for streaming)
|
219
|
+
# @return [Result] Execution result
|
220
|
+
def execute(prompt, &block)
|
221
|
+
raise ConfigurationError, "No lead agent set. Set lead= first." unless @lead_agent
|
222
|
+
|
223
|
+
start_time = Time.now
|
224
|
+
logs = []
|
225
|
+
current_prompt = prompt
|
226
|
+
|
227
|
+
# Setup logging FIRST if block given (so swarm_start event can be emitted)
|
228
|
+
if block_given?
|
229
|
+
# Register callback to collect logs and forward to user's block
|
230
|
+
LogCollector.on_log do |entry|
|
231
|
+
logs << entry
|
232
|
+
block.call(entry)
|
233
|
+
end
|
234
|
+
|
235
|
+
# Set LogStream to use LogCollector as emitter
|
236
|
+
LogStream.emitter = LogCollector
|
237
|
+
end
|
238
|
+
|
239
|
+
# Trigger swarm_start hooks (before any execution)
|
240
|
+
# Hook can append stdout to prompt (exit code 0)
|
241
|
+
# Default callback emits swarm_start event to LogStream
|
242
|
+
swarm_start_result = trigger_swarm_start(current_prompt)
|
243
|
+
if swarm_start_result&.replace?
|
244
|
+
# Hook provided stdout to append to prompt
|
245
|
+
current_prompt = "#{current_prompt}\n\n<hook-context>\n#{swarm_start_result.value}\n</hook-context>"
|
246
|
+
end
|
247
|
+
|
248
|
+
# Trigger first_message hooks on first execution
|
249
|
+
unless @first_message_sent
|
250
|
+
trigger_first_message(current_prompt)
|
251
|
+
@first_message_sent = true
|
252
|
+
end
|
253
|
+
|
254
|
+
# Lazy initialization of agents (with optional logging)
|
255
|
+
initialize_agents unless @agents_initialized
|
256
|
+
|
257
|
+
# Freeze log collector to make it fiber-safe before Async execution
|
258
|
+
LogCollector.freeze! if block_given?
|
259
|
+
|
260
|
+
# Execution loop (supports reprompting)
|
261
|
+
result = nil
|
262
|
+
swarm_stop_triggered = false
|
263
|
+
|
264
|
+
loop do
|
265
|
+
# Execute within Async reactor to enable fiber scheduler for parallel execution
|
266
|
+
# This sets Fiber.scheduler, making Faraday fiber-aware so HTTP requests yield during I/O
|
267
|
+
# Use finished: false to suppress warnings for expected task failures
|
268
|
+
lead = @agents[@lead_agent]
|
269
|
+
response = Async(finished: false) do
|
270
|
+
lead.ask(current_prompt)
|
271
|
+
end.wait
|
272
|
+
|
273
|
+
# Check if swarm was finished by a hook (finish_swarm)
|
274
|
+
if response.is_a?(Hash) && response[:__finish_swarm__]
|
275
|
+
result = Result.new(
|
276
|
+
content: response[:message],
|
277
|
+
agent: @lead_agent.to_s,
|
278
|
+
logs: logs,
|
279
|
+
duration: Time.now - start_time,
|
280
|
+
)
|
281
|
+
|
282
|
+
# Trigger swarm_stop hooks for event emission
|
283
|
+
trigger_swarm_stop(result)
|
284
|
+
swarm_stop_triggered = true
|
285
|
+
|
286
|
+
# Break immediately - don't allow reprompting when swarm is finished by hook
|
287
|
+
break
|
288
|
+
end
|
289
|
+
|
290
|
+
result = Result.new(
|
291
|
+
content: response.content,
|
292
|
+
agent: @lead_agent.to_s,
|
293
|
+
logs: logs,
|
294
|
+
duration: Time.now - start_time,
|
295
|
+
)
|
296
|
+
|
297
|
+
# Trigger swarm_stop hooks (for reprompt check and event emission)
|
298
|
+
hook_result = trigger_swarm_stop(result)
|
299
|
+
swarm_stop_triggered = true
|
300
|
+
|
301
|
+
# Check if hook requests reprompting
|
302
|
+
if hook_result&.reprompt?
|
303
|
+
current_prompt = hook_result.value
|
304
|
+
swarm_stop_triggered = false # Will trigger again in next iteration
|
305
|
+
# Continue loop with new prompt
|
306
|
+
else
|
307
|
+
# Exit loop - execution complete
|
308
|
+
break
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
result
|
313
|
+
rescue ConfigurationError, AgentNotFoundError
|
314
|
+
# Re-raise configuration errors - these should be fixed, not caught
|
315
|
+
raise
|
316
|
+
rescue TypeError => e
|
317
|
+
# Catch the specific "String does not have #dig method" error
|
318
|
+
if e.message.include?("does not have #dig method")
|
319
|
+
agent_definition = @agent_definitions[@lead_agent]
|
320
|
+
error_msg = if agent_definition.base_url
|
321
|
+
"LLM API request failed: The proxy/server at '#{agent_definition.base_url}' returned an invalid response. " \
|
322
|
+
"This usually means the proxy is unreachable, requires authentication, or returned an error in non-JSON format. " \
|
323
|
+
"Original error: #{e.message}"
|
324
|
+
else
|
325
|
+
"LLM API request failed with unexpected response format. Original error: #{e.message}"
|
326
|
+
end
|
327
|
+
|
328
|
+
result = Result.new(
|
329
|
+
content: nil,
|
330
|
+
agent: @lead_agent.to_s,
|
331
|
+
error: LLMError.new(error_msg),
|
332
|
+
logs: logs,
|
333
|
+
duration: Time.now - start_time,
|
334
|
+
)
|
335
|
+
else
|
336
|
+
result = Result.new(
|
337
|
+
content: nil,
|
338
|
+
agent: @lead_agent.to_s,
|
339
|
+
error: e,
|
340
|
+
logs: logs,
|
341
|
+
duration: Time.now - start_time,
|
342
|
+
)
|
343
|
+
end
|
344
|
+
result
|
345
|
+
rescue StandardError => e
|
346
|
+
result = Result.new(
|
347
|
+
content: nil,
|
348
|
+
agent: @lead_agent&.to_s || "unknown",
|
349
|
+
error: e,
|
350
|
+
logs: logs,
|
351
|
+
duration: Time.now - start_time,
|
352
|
+
)
|
353
|
+
result
|
354
|
+
ensure
|
355
|
+
# Trigger swarm_stop if not already triggered (handles error cases)
|
356
|
+
unless swarm_stop_triggered
|
357
|
+
trigger_swarm_stop_final(result, start_time, logs)
|
358
|
+
end
|
359
|
+
|
360
|
+
# Cleanup MCP clients after execution
|
361
|
+
cleanup
|
362
|
+
# Reset logging state for next execution
|
363
|
+
LogCollector.reset!
|
364
|
+
LogStream.reset!
|
365
|
+
end
|
366
|
+
|
367
|
+
# Get an agent chat instance by name
|
368
|
+
#
|
369
|
+
# @param name [Symbol, String] Agent name
|
370
|
+
# @return [AgentChat] Agent chat instance
|
371
|
+
def agent(name)
|
372
|
+
name = name.to_sym
|
373
|
+
initialize_agents unless @agents_initialized
|
374
|
+
|
375
|
+
@agents[name] || raise(AgentNotFoundError, "Agent '#{name}' not found")
|
376
|
+
end
|
377
|
+
|
378
|
+
# Get an agent definition by name
|
379
|
+
#
|
380
|
+
# Use this to access and modify agent configuration:
|
381
|
+
# swarm.agent_definition(:backend).bypass_permissions = true
|
382
|
+
#
|
383
|
+
# @param name [Symbol, String] Agent name
|
384
|
+
# @return [AgentDefinition] Agent definition object
|
385
|
+
def agent_definition(name)
|
386
|
+
name = name.to_sym
|
387
|
+
|
388
|
+
@agent_definitions[name] || raise(AgentNotFoundError, "Agent '#{name}' not found")
|
389
|
+
end
|
390
|
+
|
391
|
+
# Get all agent names
|
392
|
+
#
|
393
|
+
# @return [Array<Symbol>] Agent names
|
394
|
+
def agent_names
|
395
|
+
@agent_definitions.keys
|
396
|
+
end
|
397
|
+
|
398
|
+
# Cleanup all MCP clients
|
399
|
+
#
|
400
|
+
# Stops all MCP client connections gracefully.
|
401
|
+
# Should be called when the swarm is no longer needed.
|
402
|
+
#
|
403
|
+
# @return [void]
|
404
|
+
def cleanup
|
405
|
+
return if @mcp_clients.empty?
|
406
|
+
|
407
|
+
@mcp_clients.each do |agent_name, clients|
|
408
|
+
clients.each do |client|
|
409
|
+
client.stop if client.alive?
|
410
|
+
RubyLLM.logger.debug("SwarmSDK: Stopped MCP client '#{client.name}' for agent #{agent_name}")
|
411
|
+
rescue StandardError => e
|
412
|
+
RubyLLM.logger.error("SwarmSDK: Error stopping MCP client '#{client.name}' for agent #{agent_name}: #{e.message}")
|
413
|
+
end
|
414
|
+
end
|
415
|
+
|
416
|
+
@mcp_clients.clear
|
417
|
+
end
|
418
|
+
|
419
|
+
# Register a named hook that can be referenced in agent configurations
|
420
|
+
#
|
421
|
+
# Named hooks are stored in the registry and can be referenced by symbol
|
422
|
+
# in agent YAML configurations or programmatically.
|
423
|
+
#
|
424
|
+
# @param name [Symbol] Unique hook name
|
425
|
+
# @param block [Proc] Hook implementation
|
426
|
+
# @return [self]
|
427
|
+
#
|
428
|
+
# @example Register a validation hook
|
429
|
+
# swarm.register_hook(:validate_code) do |context|
|
430
|
+
# raise SwarmSDK::Hooks::Error, "Invalid" unless valid?(context.tool_call)
|
431
|
+
# end
|
432
|
+
def register_hook(name, &block)
|
433
|
+
@hook_registry.register(name, &block)
|
434
|
+
self
|
435
|
+
end
|
436
|
+
|
437
|
+
# Add a swarm-level default hook that applies to all agents
|
438
|
+
#
|
439
|
+
# Default hooks are inherited by all agents unless overridden at agent level.
|
440
|
+
# Useful for swarm-wide policies like logging, validation, or monitoring.
|
441
|
+
#
|
442
|
+
# @param event [Symbol] Event type (e.g., :pre_tool_use, :post_tool_use)
|
443
|
+
# @param matcher [String, Regexp, nil] Optional regex pattern for tool names
|
444
|
+
# @param priority [Integer] Execution priority (higher = earlier)
|
445
|
+
# @param block [Proc] Hook implementation
|
446
|
+
# @return [self]
|
447
|
+
#
|
448
|
+
# @example Add logging for all tool calls
|
449
|
+
# swarm.add_default_callback(:pre_tool_use) do |context|
|
450
|
+
# puts "[#{context.agent_name}] Calling #{context.tool_call.name}"
|
451
|
+
# end
|
452
|
+
def add_default_callback(event, matcher: nil, priority: 0, &block)
|
453
|
+
@hook_registry.add_default(event, matcher: matcher, priority: priority, &block)
|
454
|
+
self
|
455
|
+
end
|
456
|
+
|
457
|
+
private
|
458
|
+
|
459
|
+
# Initialize all agents using AgentInitializer
|
460
|
+
#
|
461
|
+
# This is called automatically (lazy initialization) by execute() and agent().
|
462
|
+
# Delegates to AgentInitializer which handles the complex 5-pass setup.
|
463
|
+
#
|
464
|
+
# @return [void]
|
465
|
+
def initialize_agents
|
466
|
+
return if @agents_initialized
|
467
|
+
|
468
|
+
initializer = AgentInitializer.new(
|
469
|
+
self,
|
470
|
+
@agent_definitions,
|
471
|
+
@global_semaphore,
|
472
|
+
@hook_registry,
|
473
|
+
@scratchpad,
|
474
|
+
config_for_hooks: @config_for_hooks,
|
475
|
+
)
|
476
|
+
|
477
|
+
@agents = initializer.initialize_all
|
478
|
+
@agent_contexts = initializer.agent_contexts
|
479
|
+
@agents_initialized = true
|
480
|
+
end
|
481
|
+
|
482
|
+
# Normalize tools to internal format (kept for add_agent)
|
483
|
+
#
|
484
|
+
# Handles both Ruby API (simple symbols) and YAML API (already parsed configs)
|
485
|
+
#
|
486
|
+
# @param tools [Array] Tool specifications
|
487
|
+
# @return [Array<Hash>] Normalized tool configs
|
488
|
+
def normalize_tools(tools)
|
489
|
+
Array(tools).map do |tool|
|
490
|
+
case tool
|
491
|
+
when Symbol, String
|
492
|
+
# Simple tool from Ruby API
|
493
|
+
{ name: tool.to_sym, permissions: nil }
|
494
|
+
when Hash
|
495
|
+
# Already in config format from YAML (has :name and :permissions keys)
|
496
|
+
if tool.key?(:name)
|
497
|
+
tool
|
498
|
+
else
|
499
|
+
# Inline permissions format: { Write: { allowed_paths: [...] } }
|
500
|
+
tool_name = tool.keys.first.to_sym
|
501
|
+
{ name: tool_name, permissions: tool[tool_name] }
|
502
|
+
end
|
503
|
+
else
|
504
|
+
raise ConfigurationError, "Invalid tool specification: #{tool.inspect}"
|
505
|
+
end
|
506
|
+
end
|
507
|
+
end
|
508
|
+
|
509
|
+
# Delegation methods for testing (delegate to concerns)
|
510
|
+
# These allow tests to verify behavior without depending on internal structure
|
511
|
+
|
512
|
+
# Create a tool instance (delegates to ToolConfigurator)
|
513
|
+
def create_tool_instance(tool_name, agent_name, directory)
|
514
|
+
ToolConfigurator.new(self, @scratchpad).create_tool_instance(tool_name, agent_name, directory)
|
515
|
+
end
|
516
|
+
|
517
|
+
# Wrap tool with permissions (delegates to ToolConfigurator)
|
518
|
+
def wrap_tool_with_permissions(tool_instance, permissions_config, agent_definition)
|
519
|
+
ToolConfigurator.new(self, @scratchpad).wrap_tool_with_permissions(tool_instance, permissions_config, agent_definition)
|
520
|
+
end
|
521
|
+
|
522
|
+
# Build MCP transport config (delegates to McpConfigurator)
|
523
|
+
def build_mcp_transport_config(transport_type, config)
|
524
|
+
McpConfigurator.new(self).build_transport_config(transport_type, config)
|
525
|
+
end
|
526
|
+
|
527
|
+
# Create delegation tool (delegates to AgentInitializer)
|
528
|
+
def create_delegation_tool(name:, description:, delegate_chat:, agent_name:)
|
529
|
+
AgentInitializer.new(self, @agent_definitions, @global_semaphore, @hook_registry, @scratchpad)
|
530
|
+
.create_delegation_tool(name: name, description: description, delegate_chat: delegate_chat, agent_name: agent_name)
|
531
|
+
end
|
532
|
+
|
533
|
+
# Register default logging hooks that emit LogStream events
|
534
|
+
#
|
535
|
+
# These hooks implement the standard SwarmSDK logging behavior.
|
536
|
+
# Users can override or extend them by registering their own hooks.
|
537
|
+
#
|
538
|
+
# @return [void]
|
539
|
+
def register_default_logging_callbacks
|
540
|
+
# Log swarm start
|
541
|
+
add_default_callback(:swarm_start, priority: -100) do |context|
|
542
|
+
# Only log if LogStream emitter is set (logging enabled)
|
543
|
+
next unless LogStream.emitter
|
544
|
+
|
545
|
+
LogStream.emit(
|
546
|
+
type: "swarm_start",
|
547
|
+
agent: context.metadata[:lead_agent], # Include agent for consistency
|
548
|
+
swarm_name: context.metadata[:swarm_name],
|
549
|
+
lead_agent: context.metadata[:lead_agent],
|
550
|
+
prompt: context.metadata[:prompt],
|
551
|
+
timestamp: context.metadata[:timestamp],
|
552
|
+
)
|
553
|
+
end
|
554
|
+
|
555
|
+
# Log swarm stop
|
556
|
+
add_default_callback(:swarm_stop, priority: -100) do |context|
|
557
|
+
# Only log if LogStream emitter is set (logging enabled)
|
558
|
+
next unless LogStream.emitter
|
559
|
+
|
560
|
+
LogStream.emit(
|
561
|
+
type: "swarm_stop",
|
562
|
+
swarm_name: context.metadata[:swarm_name],
|
563
|
+
lead_agent: context.metadata[:lead_agent],
|
564
|
+
last_agent: context.metadata[:last_agent], # Agent that produced final response
|
565
|
+
content: context.metadata[:content], # Final response content
|
566
|
+
success: context.metadata[:success],
|
567
|
+
duration: context.metadata[:duration],
|
568
|
+
total_cost: context.metadata[:total_cost],
|
569
|
+
total_tokens: context.metadata[:total_tokens],
|
570
|
+
agents_involved: context.metadata[:agents_involved],
|
571
|
+
timestamp: context.metadata[:timestamp],
|
572
|
+
)
|
573
|
+
end
|
574
|
+
|
575
|
+
# Log user requests
|
576
|
+
add_default_callback(:user_prompt, priority: -100) do |context|
|
577
|
+
# Only log if LogStream emitter is set (logging enabled)
|
578
|
+
next unless LogStream.emitter
|
579
|
+
|
580
|
+
LogStream.emit(
|
581
|
+
type: "user_prompt",
|
582
|
+
agent: context.agent_name,
|
583
|
+
model: context.metadata[:model] || "unknown",
|
584
|
+
provider: context.metadata[:provider] || "unknown",
|
585
|
+
message_count: context.metadata[:message_count] || 0,
|
586
|
+
tools: context.metadata[:tools] || [],
|
587
|
+
delegates_to: context.metadata[:delegates_to] || [],
|
588
|
+
metadata: context.metadata,
|
589
|
+
)
|
590
|
+
end
|
591
|
+
|
592
|
+
# Log intermediate agent responses with tool calls
|
593
|
+
add_default_callback(:agent_step, priority: -100) do |context|
|
594
|
+
# Only log if LogStream emitter is set (logging enabled)
|
595
|
+
next unless LogStream.emitter
|
596
|
+
|
597
|
+
# Extract top-level fields and remove from metadata to avoid duplication
|
598
|
+
metadata_without_duplicates = context.metadata.except(:model, :content, :tool_calls, :finish_reason, :usage, :tool_executions)
|
599
|
+
|
600
|
+
LogStream.emit(
|
601
|
+
type: "agent_step",
|
602
|
+
agent: context.agent_name,
|
603
|
+
model: context.metadata[:model],
|
604
|
+
content: context.metadata[:content],
|
605
|
+
tool_calls: context.metadata[:tool_calls],
|
606
|
+
finish_reason: context.metadata[:finish_reason],
|
607
|
+
usage: context.metadata[:usage],
|
608
|
+
tool_executions: context.metadata[:tool_executions],
|
609
|
+
metadata: metadata_without_duplicates,
|
610
|
+
)
|
611
|
+
end
|
612
|
+
|
613
|
+
# Log final agent responses
|
614
|
+
add_default_callback(:agent_stop, priority: -100) do |context|
|
615
|
+
# Only log if LogStream emitter is set (logging enabled)
|
616
|
+
next unless LogStream.emitter
|
617
|
+
|
618
|
+
# Extract top-level fields and remove from metadata to avoid duplication
|
619
|
+
metadata_without_duplicates = context.metadata.except(:model, :content, :tool_calls, :finish_reason, :usage, :tool_executions)
|
620
|
+
|
621
|
+
LogStream.emit(
|
622
|
+
type: "agent_stop",
|
623
|
+
agent: context.agent_name,
|
624
|
+
model: context.metadata[:model],
|
625
|
+
content: context.metadata[:content],
|
626
|
+
tool_calls: context.metadata[:tool_calls],
|
627
|
+
finish_reason: context.metadata[:finish_reason],
|
628
|
+
usage: context.metadata[:usage],
|
629
|
+
tool_executions: context.metadata[:tool_executions],
|
630
|
+
metadata: metadata_without_duplicates,
|
631
|
+
)
|
632
|
+
end
|
633
|
+
|
634
|
+
# Log tool calls (pre_tool_use)
|
635
|
+
add_default_callback(:pre_tool_use, priority: -100) do |context|
|
636
|
+
# Only log if LogStream emitter is set (logging enabled)
|
637
|
+
next unless LogStream.emitter
|
638
|
+
|
639
|
+
# Delegation tracking is handled separately in AgentChat
|
640
|
+
# Just log the tool call - delegation info will be in metadata if needed
|
641
|
+
LogStream.emit(
|
642
|
+
type: "tool_call",
|
643
|
+
agent: context.agent_name,
|
644
|
+
tool_call_id: context.tool_call.id,
|
645
|
+
tool: context.tool_call.name,
|
646
|
+
arguments: context.tool_call.parameters,
|
647
|
+
metadata: context.metadata,
|
648
|
+
)
|
649
|
+
end
|
650
|
+
|
651
|
+
# Log tool results (post_tool_use)
|
652
|
+
add_default_callback(:post_tool_use, priority: -100) do |context|
|
653
|
+
# Only log if LogStream emitter is set (logging enabled)
|
654
|
+
next unless LogStream.emitter
|
655
|
+
|
656
|
+
# Delegation tracking is handled separately in AgentChat
|
657
|
+
# Usage tracking is handled in agent_step/agent_stop events
|
658
|
+
LogStream.emit(
|
659
|
+
type: "tool_result",
|
660
|
+
agent: context.agent_name,
|
661
|
+
tool_call_id: context.tool_result.tool_call_id,
|
662
|
+
tool: context.tool_result.tool_name,
|
663
|
+
result: context.tool_result.content,
|
664
|
+
metadata: context.metadata,
|
665
|
+
)
|
666
|
+
end
|
667
|
+
|
668
|
+
# Log context warnings
|
669
|
+
add_default_callback(:context_warning, priority: -100) do |context|
|
670
|
+
# Only log if LogStream emitter is set (logging enabled)
|
671
|
+
next unless LogStream.emitter
|
672
|
+
|
673
|
+
LogStream.emit(
|
674
|
+
type: "context_limit_warning",
|
675
|
+
agent: context.agent_name,
|
676
|
+
model: context.metadata[:model] || "unknown",
|
677
|
+
threshold: "#{context.metadata[:threshold]}%",
|
678
|
+
current_usage: "#{context.metadata[:percentage]}%",
|
679
|
+
tokens_used: context.metadata[:tokens_used],
|
680
|
+
tokens_remaining: context.metadata[:tokens_remaining],
|
681
|
+
context_limit: context.metadata[:context_limit],
|
682
|
+
metadata: context.metadata,
|
683
|
+
)
|
684
|
+
end
|
685
|
+
end
|
686
|
+
|
687
|
+
# Trigger swarm_start hooks when swarm execution begins
|
688
|
+
#
|
689
|
+
# This is a swarm-level event that fires when Swarm.execute is called
|
690
|
+
# (before first user message is sent). Hooks can halt execution or append stdout to prompt.
|
691
|
+
# Default callback emits to LogStream for logging.
|
692
|
+
#
|
693
|
+
# @param prompt [String] The user's task prompt
|
694
|
+
# @return [Hooks::Result, nil] Result with stdout to append (if exit 0) or nil
|
695
|
+
# @raise [Hooks::Error] If hook halts execution
|
696
|
+
def trigger_swarm_start(prompt)
|
697
|
+
context = Hooks::Context.new(
|
698
|
+
event: :swarm_start,
|
699
|
+
agent_name: @lead_agent.to_s,
|
700
|
+
swarm: self,
|
701
|
+
metadata: {
|
702
|
+
swarm_name: @name,
|
703
|
+
lead_agent: @lead_agent,
|
704
|
+
prompt: prompt,
|
705
|
+
timestamp: Time.now.utc.iso8601,
|
706
|
+
},
|
707
|
+
)
|
708
|
+
|
709
|
+
executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
|
710
|
+
result = executor.execute_safe(event: :swarm_start, context: context, callbacks: [])
|
711
|
+
|
712
|
+
# Halt execution if hook requests it
|
713
|
+
raise Hooks::Error, "Swarm start halted by hook: #{result.value}" if result.halt?
|
714
|
+
|
715
|
+
# Return result so caller can check for replace (stdout injection)
|
716
|
+
result
|
717
|
+
rescue StandardError => e
|
718
|
+
RubyLLM.logger.error("SwarmSDK: Error in swarm_start hook: #{e.message}")
|
719
|
+
raise
|
720
|
+
end
|
721
|
+
|
722
|
+
# Trigger swarm_stop for final event emission (called in ensure block)
|
723
|
+
#
|
724
|
+
# This ALWAYS emits the swarm_stop event, even if there was an error.
|
725
|
+
# It does NOT check for reprompt (that's done in trigger_swarm_stop_for_reprompt_check).
|
726
|
+
#
|
727
|
+
# @param result [Result, nil] Execution result (may be nil if exception before result created)
|
728
|
+
# @param start_time [Time] Execution start time
|
729
|
+
# @param logs [Array] Collected logs
|
730
|
+
# @return [void]
|
731
|
+
def trigger_swarm_stop_final(result, start_time, logs)
|
732
|
+
# Create a minimal result if one doesn't exist (exception before result created)
|
733
|
+
result ||= Result.new(
|
734
|
+
content: nil,
|
735
|
+
agent: @lead_agent&.to_s || "unknown",
|
736
|
+
logs: logs,
|
737
|
+
duration: Time.now - start_time,
|
738
|
+
error: StandardError.new("Unknown error"),
|
739
|
+
)
|
740
|
+
|
741
|
+
context = Hooks::Context.new(
|
742
|
+
event: :swarm_stop,
|
743
|
+
agent_name: @lead_agent.to_s,
|
744
|
+
swarm: self,
|
745
|
+
metadata: {
|
746
|
+
swarm_name: @name,
|
747
|
+
lead_agent: @lead_agent,
|
748
|
+
last_agent: result.agent, # Agent that produced the final response
|
749
|
+
content: result.content, # Final response content
|
750
|
+
success: result.success?,
|
751
|
+
duration: result.duration,
|
752
|
+
total_cost: result.total_cost,
|
753
|
+
total_tokens: result.total_tokens,
|
754
|
+
agents_involved: result.agents_involved,
|
755
|
+
result: result,
|
756
|
+
timestamp: Time.now.utc.iso8601,
|
757
|
+
},
|
758
|
+
)
|
759
|
+
|
760
|
+
executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
|
761
|
+
executor.execute_safe(event: :swarm_stop, context: context, callbacks: [])
|
762
|
+
rescue StandardError => e
|
763
|
+
# Don't let swarm_stop errors break the ensure block
|
764
|
+
RubyLLM.logger.error("SwarmSDK: Error in swarm_stop final emission: #{e.message}")
|
765
|
+
end
|
766
|
+
|
767
|
+
# Trigger swarm_stop hooks for reprompt check and event emission
|
768
|
+
#
|
769
|
+
# This is called in the normal execution flow to check if hooks request reprompting.
|
770
|
+
# The default callback also emits the swarm_stop event to LogStream.
|
771
|
+
#
|
772
|
+
# @param result [Result] The execution result
|
773
|
+
# @return [Hooks::Result, nil] Hook result (reprompt action if applicable)
|
774
|
+
def trigger_swarm_stop(result)
|
775
|
+
context = Hooks::Context.new(
|
776
|
+
event: :swarm_stop,
|
777
|
+
agent_name: @lead_agent.to_s,
|
778
|
+
swarm: self,
|
779
|
+
metadata: {
|
780
|
+
swarm_name: @name,
|
781
|
+
lead_agent: @lead_agent,
|
782
|
+
last_agent: result.agent, # Agent that produced the final response
|
783
|
+
content: result.content, # Final response content
|
784
|
+
success: result.success?,
|
785
|
+
duration: result.duration,
|
786
|
+
total_cost: result.total_cost,
|
787
|
+
total_tokens: result.total_tokens,
|
788
|
+
agents_involved: result.agents_involved,
|
789
|
+
result: result, # Include full result for hook access
|
790
|
+
timestamp: Time.now.utc.iso8601,
|
791
|
+
},
|
792
|
+
)
|
793
|
+
|
794
|
+
executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
|
795
|
+
hook_result = executor.execute_safe(event: :swarm_stop, context: context, callbacks: [])
|
796
|
+
|
797
|
+
# Return hook result so caller can handle reprompt
|
798
|
+
hook_result
|
799
|
+
rescue StandardError => e
|
800
|
+
RubyLLM.logger.error("SwarmSDK: Error in swarm_stop hook: #{e.message}")
|
801
|
+
nil
|
802
|
+
end
|
803
|
+
|
804
|
+
# Trigger first_message hooks when first user message is sent
|
805
|
+
#
|
806
|
+
# This is a swarm-level event that fires once on the first call to execute().
|
807
|
+
# Hooks can halt execution before the first message is sent.
|
808
|
+
#
|
809
|
+
# @param prompt [String] The first user message
|
810
|
+
# @return [void]
|
811
|
+
# @raise [Hooks::Error] If hook halts execution
|
812
|
+
def trigger_first_message(prompt)
|
813
|
+
return if @hook_registry.get_defaults(:first_message).empty?
|
814
|
+
|
815
|
+
context = Hooks::Context.new(
|
816
|
+
event: :first_message,
|
817
|
+
agent_name: @lead_agent.to_s,
|
818
|
+
swarm: self,
|
819
|
+
metadata: {
|
820
|
+
swarm_name: @name,
|
821
|
+
lead_agent: @lead_agent,
|
822
|
+
prompt: prompt,
|
823
|
+
timestamp: Time.now.utc.iso8601,
|
824
|
+
},
|
825
|
+
)
|
826
|
+
|
827
|
+
executor = Hooks::Executor.new(@hook_registry, logger: RubyLLM.logger)
|
828
|
+
result = executor.execute_safe(event: :first_message, context: context, callbacks: [])
|
829
|
+
|
830
|
+
# Halt execution if hook requests it
|
831
|
+
raise Hooks::Error, "First message halted by hook: #{result.value}" if result.halt?
|
832
|
+
rescue StandardError => e
|
833
|
+
RubyLLM.logger.error("SwarmSDK: Error in first_message hook: #{e.message}")
|
834
|
+
raise
|
835
|
+
end
|
836
|
+
end
|
837
|
+
end
|