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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/lib/swarm_sdk/agent/builder.rb +333 -0
  3. data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
  4. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  5. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
  6. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
  7. data/lib/swarm_sdk/agent/chat.rb +779 -0
  8. data/lib/swarm_sdk/agent/context.rb +108 -0
  9. data/lib/swarm_sdk/agent/definition.rb +335 -0
  10. data/lib/swarm_sdk/configuration.rb +251 -0
  11. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  12. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  13. data/lib/swarm_sdk/context_compactor.rb +340 -0
  14. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  15. data/lib/swarm_sdk/hooks/context.rb +163 -0
  16. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  17. data/lib/swarm_sdk/hooks/error.rb +29 -0
  18. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  19. data/lib/swarm_sdk/hooks/registry.rb +143 -0
  20. data/lib/swarm_sdk/hooks/result.rb +150 -0
  21. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  22. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  23. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  24. data/lib/swarm_sdk/log_collector.rb +83 -0
  25. data/lib/swarm_sdk/log_stream.rb +69 -0
  26. data/lib/swarm_sdk/markdown_parser.rb +46 -0
  27. data/lib/swarm_sdk/permissions/config.rb +239 -0
  28. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  29. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  30. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  31. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  32. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
  33. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  34. data/lib/swarm_sdk/result.rb +97 -0
  35. data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
  36. data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
  37. data/lib/swarm_sdk/swarm/builder.rb +240 -0
  38. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  39. data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
  40. data/lib/swarm_sdk/swarm.rb +837 -0
  41. data/lib/swarm_sdk/tools/bash.rb +274 -0
  42. data/lib/swarm_sdk/tools/delegate.rb +152 -0
  43. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  44. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  45. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  46. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  47. data/lib/swarm_sdk/tools/edit.rb +150 -0
  48. data/lib/swarm_sdk/tools/glob.rb +158 -0
  49. data/lib/swarm_sdk/tools/grep.rb +231 -0
  50. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  51. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  52. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  53. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  54. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  55. data/lib/swarm_sdk/tools/read.rb +251 -0
  56. data/lib/swarm_sdk/tools/registry.rb +73 -0
  57. data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
  58. data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
  59. data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
  60. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  61. data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
  62. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  63. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  64. data/lib/swarm_sdk/tools/write.rb +117 -0
  65. data/lib/swarm_sdk/utils.rb +50 -0
  66. data/lib/swarm_sdk/version.rb +5 -0
  67. data/lib/swarm_sdk.rb +69 -0
  68. 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