swarm_sdk 2.2.0 → 2.4.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +58 -0
  3. data/lib/swarm_sdk/agent/chat.rb +527 -1059
  4. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
  5. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  6. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
  7. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  8. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +262 -0
  9. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  10. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  11. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +11 -13
  12. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  13. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  14. data/lib/swarm_sdk/agent/context.rb +1 -2
  15. data/lib/swarm_sdk/agent/definition.rb +66 -154
  16. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  17. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  18. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  19. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  20. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  21. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  22. data/lib/swarm_sdk/config.rb +301 -0
  23. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  24. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  25. data/lib/swarm_sdk/configuration.rb +65 -543
  26. data/lib/swarm_sdk/context_compactor/token_counter.rb +2 -6
  27. data/lib/swarm_sdk/context_compactor.rb +6 -11
  28. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  29. data/lib/swarm_sdk/context_management/context.rb +328 -0
  30. data/lib/swarm_sdk/defaults.rb +196 -0
  31. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  32. data/lib/swarm_sdk/hooks/adapter.rb +3 -3
  33. data/lib/swarm_sdk/hooks/shell_executor.rb +4 -2
  34. data/lib/swarm_sdk/log_collector.rb +179 -29
  35. data/lib/swarm_sdk/log_stream.rb +29 -0
  36. data/lib/swarm_sdk/models.json +4333 -1
  37. data/lib/swarm_sdk/node_context.rb +1 -1
  38. data/lib/swarm_sdk/observer/builder.rb +81 -0
  39. data/lib/swarm_sdk/observer/config.rb +45 -0
  40. data/lib/swarm_sdk/observer/manager.rb +236 -0
  41. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  42. data/lib/swarm_sdk/plugin.rb +93 -3
  43. data/lib/swarm_sdk/snapshot.rb +6 -6
  44. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  45. data/lib/swarm_sdk/state_restorer.rb +136 -151
  46. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  47. data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
  48. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  49. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  50. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  51. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  52. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  53. data/lib/swarm_sdk/swarm/tool_configurator.rb +44 -140
  54. data/lib/swarm_sdk/swarm.rb +146 -689
  55. data/lib/swarm_sdk/tools/bash.rb +14 -8
  56. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  57. data/lib/swarm_sdk/tools/edit.rb +8 -13
  58. data/lib/swarm_sdk/tools/glob.rb +12 -4
  59. data/lib/swarm_sdk/tools/grep.rb +7 -0
  60. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  61. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  62. data/lib/swarm_sdk/tools/read.rb +16 -18
  63. data/lib/swarm_sdk/tools/registry.rb +122 -10
  64. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +9 -5
  65. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  66. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  67. data/lib/swarm_sdk/tools/web_fetch.rb +20 -17
  68. data/lib/swarm_sdk/tools/write.rb +8 -13
  69. data/lib/swarm_sdk/version.rb +1 -1
  70. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  71. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  72. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  73. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +7 -5
  74. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +5 -3
  75. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  76. data/lib/swarm_sdk.rb +64 -104
  77. metadata +68 -15
  78. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Centralized configuration for SwarmSDK
5
+ #
6
+ # Config provides a single entry point for all SwarmSDK configuration,
7
+ # including API keys (proxied to RubyLLM), defaults override, and
8
+ # WebFetch settings.
9
+ #
10
+ # ## Priority Order
11
+ #
12
+ # Configuration values are resolved in this order:
13
+ # 1. Explicit value (set via SwarmSDK.configure)
14
+ # 2. Environment variable
15
+ # 3. Module default (from SwarmSDK::Defaults)
16
+ #
17
+ # ## API Key Proxying
18
+ #
19
+ # API keys are automatically proxied to RubyLLM.config when set,
20
+ # ensuring RubyLLM always has the correct credentials.
21
+ #
22
+ # @example Basic configuration
23
+ # SwarmSDK.configure do |config|
24
+ # config.openai_api_key = "sk-..."
25
+ # config.default_model = "claude-sonnet-4"
26
+ # config.agent_request_timeout = 600
27
+ # end
28
+ #
29
+ # @example Testing setup
30
+ # def setup
31
+ # SwarmSDK.reset_config!
32
+ # end
33
+ class Config
34
+ # API keys that proxy to RubyLLM.config
35
+ # Maps SwarmSDK config key => [RubyLLM config key, ENV variable]
36
+ API_KEY_MAPPINGS = {
37
+ openai_api_key: [:openai_api_key, "OPENAI_API_KEY"],
38
+ openai_api_base: [:openai_api_base, "OPENAI_API_BASE"],
39
+ openai_organization_id: [:openai_organization_id, "OPENAI_ORG_ID"],
40
+ openai_project_id: [:openai_project_id, "OPENAI_PROJECT_ID"],
41
+ anthropic_api_key: [:anthropic_api_key, "ANTHROPIC_API_KEY"],
42
+ gemini_api_key: [:gemini_api_key, "GEMINI_API_KEY"],
43
+ gemini_api_base: [:gemini_api_base, "GEMINI_API_BASE"],
44
+ vertexai_project_id: [:vertexai_project_id, "GOOGLE_CLOUD_PROJECT"],
45
+ vertexai_location: [:vertexai_location, "GOOGLE_CLOUD_LOCATION"],
46
+ deepseek_api_key: [:deepseek_api_key, "DEEPSEEK_API_KEY"],
47
+ mistral_api_key: [:mistral_api_key, "MISTRAL_API_KEY"],
48
+ perplexity_api_key: [:perplexity_api_key, "PERPLEXITY_API_KEY"],
49
+ openrouter_api_key: [:openrouter_api_key, "OPENROUTER_API_KEY"],
50
+ bedrock_api_key: [:bedrock_api_key, "AWS_ACCESS_KEY_ID"],
51
+ bedrock_secret_key: [:bedrock_secret_key, "AWS_SECRET_ACCESS_KEY"],
52
+ bedrock_region: [:bedrock_region, "AWS_REGION"],
53
+ bedrock_session_token: [:bedrock_session_token, "AWS_SESSION_TOKEN"],
54
+ ollama_api_base: [:ollama_api_base, "OLLAMA_API_BASE"],
55
+ gpustack_api_base: [:gpustack_api_base, "GPUSTACK_API_BASE"],
56
+ gpustack_api_key: [:gpustack_api_key, "GPUSTACK_API_KEY"],
57
+ }.freeze
58
+
59
+ # SwarmSDK defaults that can be overridden
60
+ # Maps config key => [ENV variable, default proc]
61
+ DEFAULTS_MAPPINGS = {
62
+ default_model: ["SWARM_SDK_DEFAULT_MODEL", -> { Defaults::Agent::MODEL }],
63
+ default_provider: ["SWARM_SDK_DEFAULT_PROVIDER", -> { Defaults::Agent::PROVIDER }],
64
+ agent_request_timeout: ["SWARM_SDK_AGENT_REQUEST_TIMEOUT", -> { Defaults::Timeouts::AGENT_REQUEST_SECONDS }],
65
+ bash_command_timeout: ["SWARM_SDK_BASH_COMMAND_TIMEOUT", -> { Defaults::Timeouts::BASH_COMMAND_MS }],
66
+ bash_command_max_timeout: ["SWARM_SDK_BASH_COMMAND_MAX_TIMEOUT", -> { Defaults::Timeouts::BASH_COMMAND_MAX_MS }],
67
+ web_fetch_timeout: ["SWARM_SDK_WEB_FETCH_TIMEOUT", -> { Defaults::Timeouts::WEB_FETCH_SECONDS }],
68
+ hook_shell_timeout: ["SWARM_SDK_HOOK_SHELL_TIMEOUT", -> { Defaults::Timeouts::HOOK_SHELL_SECONDS }],
69
+ transformer_command_timeout: ["SWARM_SDK_TRANSFORMER_COMMAND_TIMEOUT", -> { Defaults::Timeouts::TRANSFORMER_COMMAND_SECONDS }],
70
+ global_concurrency_limit: ["SWARM_SDK_GLOBAL_CONCURRENCY_LIMIT", -> { Defaults::Concurrency::GLOBAL_LIMIT }],
71
+ local_concurrency_limit: ["SWARM_SDK_LOCAL_CONCURRENCY_LIMIT", -> { Defaults::Concurrency::LOCAL_LIMIT }],
72
+ output_character_limit: ["SWARM_SDK_OUTPUT_CHARACTER_LIMIT", -> { Defaults::Limits::OUTPUT_CHARACTERS }],
73
+ read_line_limit: ["SWARM_SDK_READ_LINE_LIMIT", -> { Defaults::Limits::READ_LINES }],
74
+ line_character_limit: ["SWARM_SDK_LINE_CHARACTER_LIMIT", -> { Defaults::Limits::LINE_CHARACTERS }],
75
+ web_fetch_character_limit: ["SWARM_SDK_WEB_FETCH_CHARACTER_LIMIT", -> { Defaults::Limits::WEB_FETCH_CHARACTERS }],
76
+ glob_result_limit: ["SWARM_SDK_GLOB_RESULT_LIMIT", -> { Defaults::Limits::GLOB_RESULTS }],
77
+ scratchpad_entry_size_limit: ["SWARM_SDK_SCRATCHPAD_ENTRY_SIZE_LIMIT", -> { Defaults::Storage::ENTRY_SIZE_BYTES }],
78
+ scratchpad_total_size_limit: ["SWARM_SDK_SCRATCHPAD_TOTAL_SIZE_LIMIT", -> { Defaults::Storage::TOTAL_SIZE_BYTES }],
79
+ context_compression_threshold: ["SWARM_SDK_CONTEXT_COMPRESSION_THRESHOLD", -> { Defaults::Context::COMPRESSION_THRESHOLD_PERCENT }],
80
+ todowrite_reminder_interval: ["SWARM_SDK_TODOWRITE_REMINDER_INTERVAL", -> { Defaults::Context::TODOWRITE_REMINDER_INTERVAL }],
81
+ chars_per_token_prose: ["SWARM_SDK_CHARS_PER_TOKEN_PROSE", -> { Defaults::TokenEstimation::CHARS_PER_TOKEN_PROSE }],
82
+ chars_per_token_code: ["SWARM_SDK_CHARS_PER_TOKEN_CODE", -> { Defaults::TokenEstimation::CHARS_PER_TOKEN_CODE }],
83
+ mcp_log_level: ["SWARM_SDK_MCP_LOG_LEVEL", -> { Defaults::Logging::MCP_LOG_LEVEL }],
84
+ }.freeze
85
+
86
+ # WebFetch and control settings
87
+ # Maps config key => [ENV variable, default value]
88
+ SETTINGS_MAPPINGS = {
89
+ webfetch_provider: ["SWARM_SDK_WEBFETCH_PROVIDER", nil],
90
+ webfetch_model: ["SWARM_SDK_WEBFETCH_MODEL", nil],
91
+ webfetch_base_url: ["SWARM_SDK_WEBFETCH_BASE_URL", nil],
92
+ webfetch_max_tokens: ["SWARM_SDK_WEBFETCH_MAX_TOKENS", 4096],
93
+ allow_filesystem_tools: ["SWARM_SDK_ALLOW_FILESYSTEM_TOOLS", true],
94
+ }.freeze
95
+
96
+ class << self
97
+ # Get the singleton Config instance
98
+ #
99
+ # @return [Config] The singleton instance
100
+ def instance
101
+ @instance ||= new
102
+ end
103
+
104
+ # Reset the Config instance
105
+ #
106
+ # Clears all configuration including explicit values and cached ENV values.
107
+ # Use in tests to ensure clean state.
108
+ #
109
+ # @return [void]
110
+ def reset!
111
+ @instance = nil
112
+ end
113
+ end
114
+
115
+ # Initialize a new Config instance
116
+ #
117
+ # @note Use Config.instance instead of new for the singleton pattern
118
+ def initialize
119
+ @explicit_values = {}
120
+ @env_values = {}
121
+ @env_loaded = false
122
+ @env_mutex = Mutex.new
123
+ end
124
+
125
+ # ========== API Key Accessors (with RubyLLM proxying) ==========
126
+
127
+ # @!method openai_api_key
128
+ # Get the OpenAI API key
129
+ # @return [String, nil] The API key
130
+ #
131
+ # @!method openai_api_key=(value)
132
+ # Set the OpenAI API key (proxied to RubyLLM)
133
+ # @param value [String] The API key
134
+
135
+ API_KEY_MAPPINGS.each_key do |config_key|
136
+ ruby_llm_key, _ = API_KEY_MAPPINGS[config_key]
137
+
138
+ # Getter
139
+ define_method(config_key) do
140
+ ensure_env_loaded!
141
+ @explicit_values[config_key] || @env_values[config_key]
142
+ end
143
+
144
+ # Setter with RubyLLM proxying
145
+ define_method("#{config_key}=") do |value|
146
+ @explicit_values[config_key] = value
147
+ RubyLLM.config.public_send("#{ruby_llm_key}=", value) if value
148
+ end
149
+ end
150
+
151
+ # ========== Defaults Accessors (with module constant fallback) ==========
152
+
153
+ # @!method default_model
154
+ # Get the default model
155
+ # @return [String] The default model (falls back to Defaults::Agent::MODEL)
156
+ #
157
+ # @!method default_model=(value)
158
+ # Set the default model
159
+ # @param value [String] The default model
160
+
161
+ DEFAULTS_MAPPINGS.each_key do |config_key|
162
+ _env_key, default_proc = DEFAULTS_MAPPINGS[config_key]
163
+
164
+ # Getter with default fallback
165
+ define_method(config_key) do
166
+ ensure_env_loaded!
167
+ @explicit_values[config_key] || @env_values[config_key] || default_proc.call
168
+ end
169
+
170
+ # Setter
171
+ define_method("#{config_key}=") do |value|
172
+ @explicit_values[config_key] = value
173
+ end
174
+ end
175
+
176
+ # ========== Settings Accessors (WebFetch and control) ==========
177
+
178
+ # @!method webfetch_provider
179
+ # Get the WebFetch LLM provider
180
+ # @return [String, nil] The provider
181
+ #
182
+ # @!method allow_filesystem_tools
183
+ # Get whether filesystem tools are allowed
184
+ # @return [Boolean] true if allowed
185
+
186
+ SETTINGS_MAPPINGS.each_key do |config_key|
187
+ _env_key, default_value = SETTINGS_MAPPINGS[config_key]
188
+
189
+ # Getter with default fallback
190
+ define_method(config_key) do
191
+ ensure_env_loaded!
192
+ if @explicit_values.key?(config_key)
193
+ @explicit_values[config_key]
194
+ elsif @env_values.key?(config_key)
195
+ @env_values[config_key]
196
+ else
197
+ default_value
198
+ end
199
+ end
200
+
201
+ # Setter
202
+ define_method("#{config_key}=") do |value|
203
+ @explicit_values[config_key] = value
204
+ end
205
+ end
206
+
207
+ # ========== Convenience Methods ==========
208
+
209
+ # Check if WebFetch LLM processing is enabled
210
+ #
211
+ # WebFetch uses LLM processing when both provider and model are configured.
212
+ #
213
+ # @return [Boolean] true if WebFetch LLM is configured
214
+ def webfetch_llm_enabled?
215
+ !webfetch_provider.nil? && !webfetch_model.nil?
216
+ end
217
+
218
+ private
219
+
220
+ # Ensure ENV values are loaded (lazy loading with double-check locking)
221
+ #
222
+ # Thread-safe lazy loading of ENV values. Only loads once per Config instance.
223
+ #
224
+ # @return [void]
225
+ def ensure_env_loaded!
226
+ return if @env_loaded
227
+
228
+ @env_mutex.synchronize do
229
+ return if @env_loaded
230
+
231
+ load_env_values!
232
+ @env_loaded = true
233
+ end
234
+ end
235
+
236
+ # Load environment variable values
237
+ #
238
+ # Loads API keys (with RubyLLM proxying), defaults, and settings from ENV.
239
+ # Only loads values that haven't been explicitly set.
240
+ #
241
+ # @return [void]
242
+ def load_env_values!
243
+ # Load API keys and proxy to RubyLLM
244
+ API_KEY_MAPPINGS.each do |config_key, (ruby_llm_key, env_key)|
245
+ next if @explicit_values.key?(config_key)
246
+ next unless ENV.key?(env_key)
247
+
248
+ value = ENV[env_key]
249
+ @env_values[config_key] = value
250
+
251
+ # Proxy to RubyLLM
252
+ RubyLLM.config.public_send("#{ruby_llm_key}=", value)
253
+ end
254
+
255
+ # Load defaults (no RubyLLM proxy)
256
+ DEFAULTS_MAPPINGS.each do |config_key, (env_key, _default_proc)|
257
+ next if @explicit_values.key?(config_key)
258
+ next unless ENV.key?(env_key)
259
+
260
+ @env_values[config_key] = parse_env_value(ENV[env_key], config_key)
261
+ end
262
+
263
+ # Load settings (no RubyLLM proxy)
264
+ SETTINGS_MAPPINGS.each do |config_key, (env_key, _default_value)|
265
+ next if @explicit_values.key?(config_key)
266
+ next unless ENV.key?(env_key)
267
+
268
+ @env_values[config_key] = parse_env_value(ENV[env_key], config_key)
269
+ end
270
+ end
271
+
272
+ # Parse environment variable value to appropriate type
273
+ #
274
+ # Converts string ENV values to integers, floats, or booleans based on
275
+ # the configuration key pattern.
276
+ #
277
+ # @param value [String] The ENV value string
278
+ # @param key [Symbol] The configuration key
279
+ # @return [Integer, Float, Boolean, String] The parsed value
280
+ def parse_env_value(value, key)
281
+ case key
282
+ when :allow_filesystem_tools
283
+ # Convert string to boolean
284
+ case value.to_s.downcase
285
+ when "true", "yes", "1", "on", "enabled"
286
+ true
287
+ when "false", "no", "0", "off", "disabled"
288
+ false
289
+ else
290
+ true # Default to true if unrecognized
291
+ end
292
+ when /_timeout$/, /_limit$/, /_interval$/, /_threshold$/, :mcp_log_level, :webfetch_max_tokens
293
+ value.to_i
294
+ when /^chars_per_token/
295
+ value.to_f
296
+ else
297
+ value
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ class Configuration
5
+ # Handles YAML parsing, validation, and normalization
6
+ #
7
+ # This class is responsible for:
8
+ # - Loading and parsing YAML content
9
+ # - Validating configuration structure
10
+ # - Normalizing data (symbolizing keys, env interpolation)
11
+ # - Detecting configuration type (swarm vs workflow)
12
+ # - Loading agents and nodes
13
+ # - Detecting circular dependencies
14
+ #
15
+ # After parsing, the parsed data can be translated to a Swarm/Workflow
16
+ # using the Translator class.
17
+ class Parser
18
+ ENV_VAR_WITH_DEFAULT_PATTERN = /\$\{([^:}]+)(:=([^}]*))?\}/
19
+
20
+ attr_reader :config_type,
21
+ :swarm_name,
22
+ :swarm_id,
23
+ :lead_agent,
24
+ :start_node,
25
+ :agents,
26
+ :all_agents_config,
27
+ :swarm_hooks,
28
+ :all_agents_hooks,
29
+ :scratchpad_mode,
30
+ :nodes,
31
+ :external_swarms
32
+
33
+ def initialize(yaml_content, base_dir:)
34
+ @yaml_content = yaml_content
35
+ @base_dir = Pathname.new(base_dir).expand_path
36
+ @config_type = nil
37
+ @swarm_id = nil
38
+ @swarm_name = nil
39
+ @lead_agent = nil
40
+ @start_node = nil
41
+ @agents = {}
42
+ @all_agents_config = {}
43
+ @swarm_hooks = {}
44
+ @all_agents_hooks = {}
45
+ @external_swarms = {}
46
+ @nodes = {}
47
+ @scratchpad_mode = :disabled
48
+ end
49
+
50
+ def parse
51
+ @config = YAML.safe_load(@yaml_content, permitted_classes: [Symbol], aliases: true)
52
+
53
+ unless @config.is_a?(Hash)
54
+ raise ConfigurationError, "Invalid YAML syntax: configuration must be a Hash"
55
+ end
56
+
57
+ @config = Utils.symbolize_keys(@config)
58
+ interpolate_env_vars!(@config)
59
+
60
+ validate_version
61
+ detect_and_validate_type
62
+ load_common_config
63
+ load_type_specific_config
64
+ load_agents
65
+ load_nodes if @config_type == :workflow
66
+ detect_circular_dependencies
67
+
68
+ self
69
+ rescue Psych::SyntaxError => e
70
+ raise ConfigurationError, "Invalid YAML syntax: #{e.message}"
71
+ end
72
+
73
+ def agent_names
74
+ @agents.keys
75
+ end
76
+
77
+ def connections_for(agent_name)
78
+ agent_config = @agents[agent_name]
79
+ return [] unless agent_config
80
+
81
+ delegates = agent_config[:delegates_to] || []
82
+ Array(delegates).map(&:to_sym)
83
+ end
84
+
85
+ attr_reader :base_dir
86
+
87
+ private
88
+
89
+ def validate_version
90
+ version = @config[:version]
91
+ raise ConfigurationError, "Missing 'version' field in configuration" unless version
92
+ raise ConfigurationError, "SwarmSDK requires version: 2 configuration. Got version: #{version}" unless version == 2
93
+ end
94
+
95
+ def detect_and_validate_type
96
+ has_swarm = @config.key?(:swarm)
97
+ has_workflow = @config.key?(:workflow)
98
+
99
+ if has_swarm && has_workflow
100
+ raise ConfigurationError, "Cannot have both 'swarm:' and 'workflow:' keys. Use one or the other."
101
+ end
102
+
103
+ unless has_swarm || has_workflow
104
+ raise ConfigurationError, "Missing 'swarm:' or 'workflow:' key in configuration"
105
+ end
106
+
107
+ @config_type = has_swarm ? :swarm : :workflow
108
+ @root_config = @config[@config_type]
109
+ end
110
+
111
+ def load_common_config
112
+ raise ConfigurationError, "Missing 'name' field in #{@config_type} configuration" unless @root_config[:name]
113
+
114
+ @swarm_name = @root_config[:name]
115
+ @swarm_id = @root_config[:id]
116
+ @scratchpad_mode = parse_scratchpad_mode(@root_config[:scratchpad])
117
+
118
+ load_all_agents_config
119
+ load_hooks_config
120
+ load_external_swarms(@root_config[:swarms]) if @root_config[:swarms]
121
+ end
122
+
123
+ def load_type_specific_config
124
+ if @config_type == :swarm
125
+ load_swarm_config
126
+ else
127
+ load_workflow_config
128
+ end
129
+ end
130
+
131
+ def load_swarm_config
132
+ raise ConfigurationError, "Missing 'lead' field in swarm configuration" unless @root_config[:lead]
133
+ raise ConfigurationError, "Missing 'agents' field in swarm configuration" unless @root_config[:agents]
134
+
135
+ @lead_agent = @root_config[:lead].to_sym
136
+
137
+ if @root_config[:nodes] || @root_config[:start_node]
138
+ raise ConfigurationError, "Swarm configuration cannot have 'nodes' or 'start_node'. Use 'workflow:' key instead."
139
+ end
140
+ end
141
+
142
+ def load_workflow_config
143
+ raise ConfigurationError, "Missing 'start_node' field in workflow configuration" unless @root_config[:start_node]
144
+ raise ConfigurationError, "Missing 'nodes' field in workflow configuration" unless @root_config[:nodes]
145
+ raise ConfigurationError, "Missing 'agents' field in workflow configuration" unless @root_config[:agents]
146
+
147
+ @start_node = @root_config[:start_node].to_sym
148
+
149
+ if @root_config[:lead]
150
+ raise ConfigurationError, "Workflow configuration cannot have 'lead'. Use 'start_node' instead."
151
+ end
152
+ end
153
+
154
+ def load_all_agents_config
155
+ @all_agents_config = @root_config[:all_agents] || {}
156
+
157
+ if @all_agents_config[:disable_default_tools].is_a?(Array)
158
+ @all_agents_config[:disable_default_tools] = @all_agents_config[:disable_default_tools].map(&:to_sym)
159
+ end
160
+ end
161
+
162
+ def load_hooks_config
163
+ @swarm_hooks = Utils.symbolize_keys(@root_config[:hooks] || {})
164
+
165
+ if @root_config[:all_agents]
166
+ @all_agents_hooks = Utils.symbolize_keys(@root_config[:all_agents][:hooks] || {})
167
+ end
168
+ end
169
+
170
+ def load_external_swarms(swarms_config)
171
+ @external_swarms = {}
172
+ swarms_config.each do |name, config|
173
+ source = if config[:file]
174
+ file_path = if config[:file].start_with?("/")
175
+ config[:file]
176
+ else
177
+ (@base_dir / config[:file]).to_s
178
+ end
179
+ { type: :file, value: file_path }
180
+ elsif config[:yaml]
181
+ { type: :yaml, value: config[:yaml] }
182
+ elsif config[:swarm]
183
+ inline_config = {
184
+ version: 2,
185
+ swarm: config[:swarm],
186
+ }
187
+ yaml_string = Utils.hash_to_yaml(inline_config)
188
+ { type: :yaml, value: yaml_string }
189
+ else
190
+ raise ConfigurationError, "Swarm '#{name}' must specify either 'file:', 'yaml:', or 'swarm:' (inline definition)"
191
+ end
192
+
193
+ @external_swarms[name.to_sym] = {
194
+ source: source,
195
+ keep_context: config.fetch(:keep_context, true),
196
+ }
197
+ end
198
+ end
199
+
200
+ def load_agents
201
+ swarm_agents = @root_config[:agents]
202
+ raise ConfigurationError, "No agents defined" if swarm_agents.empty?
203
+
204
+ swarm_agents.each do |name, agent_config|
205
+ parsed_config = if agent_config.nil?
206
+ {}
207
+ elsif agent_config.is_a?(String)
208
+ { agent_file: agent_config }
209
+ elsif agent_config.is_a?(Hash) && agent_config[:agent_file]
210
+ agent_config
211
+ else
212
+ agent_config || {}
213
+ end
214
+
215
+ if parsed_config[:agent_file].nil? && parsed_config[:description].nil?
216
+ raise ConfigurationError,
217
+ "Agent '#{name}' missing required 'description' field"
218
+ end
219
+
220
+ @agents[name] = parsed_config
221
+ end
222
+
223
+ if @config_type == :swarm
224
+ unless @agents.key?(@lead_agent)
225
+ raise ConfigurationError, "Lead agent '#{@lead_agent}' not found in agents"
226
+ end
227
+ end
228
+ end
229
+
230
+ def load_nodes
231
+ @nodes = Utils.symbolize_keys(@root_config[:nodes])
232
+
233
+ unless @nodes.key?(@start_node)
234
+ raise ConfigurationError, "start_node '#{@start_node}' not found in nodes"
235
+ end
236
+
237
+ @nodes.each do |node_name, node_config|
238
+ unless node_config.is_a?(Hash)
239
+ raise ConfigurationError, "Node '#{node_name}' must be a hash"
240
+ end
241
+
242
+ if node_config[:agents]
243
+ unless node_config[:agents].is_a?(Array)
244
+ raise ConfigurationError, "Node '#{node_name}' agents must be an array"
245
+ end
246
+
247
+ node_config[:agents].each do |agent_config|
248
+ unless agent_config.is_a?(Hash) && agent_config[:agent]
249
+ raise ConfigurationError,
250
+ "Node '#{node_name}' agents must be hashes with 'agent' key"
251
+ end
252
+
253
+ agent_sym = agent_config[:agent].to_sym
254
+ unless @agents.key?(agent_sym)
255
+ raise ConfigurationError,
256
+ "Node '#{node_name}' references undefined agent '#{agent_config[:agent]}'"
257
+ end
258
+ end
259
+ end
260
+
261
+ next unless node_config[:dependencies]
262
+ unless node_config[:dependencies].is_a?(Array)
263
+ raise ConfigurationError, "Node '#{node_name}' dependencies must be an array"
264
+ end
265
+
266
+ node_config[:dependencies].each do |dep|
267
+ dep_sym = dep.to_sym
268
+ unless @nodes.key?(dep_sym)
269
+ raise ConfigurationError,
270
+ "Node '#{node_name}' depends on undefined node '#{dep}'"
271
+ end
272
+ end
273
+ end
274
+ end
275
+
276
+ def parse_scratchpad_mode(value)
277
+ return :disabled if value.nil?
278
+
279
+ value = value.to_sym if value.is_a?(String)
280
+
281
+ case value
282
+ when :enabled, :disabled, :per_node
283
+ value
284
+ else
285
+ raise ConfigurationError,
286
+ "Invalid scratchpad mode: #{value.inspect}. Use :enabled, :per_node, or :disabled"
287
+ end
288
+ end
289
+
290
+ def interpolate_env_vars!(obj)
291
+ case obj
292
+ when String
293
+ interpolate_env_string(obj)
294
+ when Hash
295
+ obj.transform_values! { |v| interpolate_env_vars!(v) }
296
+ when Array
297
+ obj.map! { |v| interpolate_env_vars!(v) }
298
+ else
299
+ obj
300
+ end
301
+ end
302
+
303
+ def interpolate_env_string(str)
304
+ str.gsub(ENV_VAR_WITH_DEFAULT_PATTERN) do |_match|
305
+ env_var = Regexp.last_match(1)
306
+ has_default = Regexp.last_match(2)
307
+ default_value = Regexp.last_match(3)
308
+
309
+ if ENV.key?(env_var)
310
+ ENV[env_var]
311
+ elsif has_default
312
+ default_value || ""
313
+ else
314
+ raise ConfigurationError, "Environment variable '#{env_var}' is not set"
315
+ end
316
+ end
317
+ end
318
+
319
+ def detect_circular_dependencies
320
+ @agents.each_key do |agent_name|
321
+ visited = Set.new
322
+ path = []
323
+ detect_cycle_from(agent_name, visited, path)
324
+ end
325
+ end
326
+
327
+ def detect_cycle_from(agent_name, visited, path)
328
+ return if visited.include?(agent_name)
329
+
330
+ if path.include?(agent_name)
331
+ cycle_start = path.index(agent_name)
332
+ cycle = path[cycle_start..] + [agent_name]
333
+ raise CircularDependencyError, "Circular dependency detected: #{cycle.join(" -> ")}"
334
+ end
335
+
336
+ path.push(agent_name)
337
+ connections_for(agent_name).each do |connection|
338
+ connection_sym = connection.to_sym
339
+
340
+ next if @external_swarms.key?(connection_sym)
341
+
342
+ unless @agents.key?(connection_sym)
343
+ raise ConfigurationError, "Agent '#{agent_name}' delegates to unknown target '#{connection}' (not a local agent or registered swarm)"
344
+ end
345
+
346
+ detect_cycle_from(connection_sym, visited, path)
347
+ end
348
+ path.pop
349
+ visited.add(agent_name)
350
+ end
351
+ end
352
+ end
353
+ end