claude_swarm 1.0.9 → 1.0.11

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/{CHANGELOG.md → CHANGELOG.claude-swarm.md} +10 -0
  3. data/CLAUDE.md +346 -191
  4. data/decisions/2025-11-22-001-global-agent-registry.md +172 -0
  5. data/docs/v2/CHANGELOG.swarm_cli.md +20 -0
  6. data/docs/v2/CHANGELOG.swarm_memory.md +146 -1
  7. data/docs/v2/CHANGELOG.swarm_sdk.md +433 -10
  8. data/docs/v2/README.md +20 -5
  9. data/docs/v2/guides/complete-tutorial.md +95 -9
  10. data/docs/v2/guides/getting-started.md +10 -8
  11. data/docs/v2/guides/memory-adapters.md +41 -0
  12. data/docs/v2/guides/migrating-to-2.x.md +746 -0
  13. data/docs/v2/guides/plugins.md +52 -5
  14. data/docs/v2/guides/rails-integration.md +6 -0
  15. data/docs/v2/guides/snapshots.md +14 -14
  16. data/docs/v2/guides/swarm-memory.md +2 -13
  17. data/docs/v2/reference/architecture-flow.md +3 -3
  18. data/docs/v2/reference/cli.md +0 -1
  19. data/docs/v2/reference/configuration_reference.md +300 -0
  20. data/docs/v2/reference/event_payload_structures.md +27 -5
  21. data/docs/v2/reference/ruby-dsl.md +614 -18
  22. data/docs/v2/reference/swarm_memory_technical_details.md +7 -29
  23. data/docs/v2/reference/yaml.md +172 -54
  24. data/examples/snapshot_demo.rb +2 -2
  25. data/lib/claude_swarm/mcp_generator.rb +8 -21
  26. data/lib/claude_swarm/orchestrator.rb +8 -1
  27. data/lib/claude_swarm/version.rb +1 -1
  28. data/lib/swarm_cli/commands/run.rb +2 -2
  29. data/lib/swarm_cli/config_loader.rb +11 -11
  30. data/lib/swarm_cli/formatters/human_formatter.rb +0 -33
  31. data/lib/swarm_cli/interactive_repl.rb +2 -2
  32. data/lib/swarm_cli/ui/icons.rb +0 -23
  33. data/lib/swarm_cli/version.rb +1 -1
  34. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  35. data/lib/swarm_memory/core/semantic_index.rb +10 -2
  36. data/lib/swarm_memory/core/storage.rb +7 -2
  37. data/lib/swarm_memory/dsl/memory_config.rb +37 -0
  38. data/lib/swarm_memory/integration/sdk_plugin.rb +201 -28
  39. data/lib/swarm_memory/optimization/defragmenter.rb +1 -1
  40. data/lib/swarm_memory/prompts/memory_researcher.md.erb +0 -1
  41. data/lib/swarm_memory/tools/load_skill.rb +0 -1
  42. data/lib/swarm_memory/tools/memory_edit.rb +2 -1
  43. data/lib/swarm_memory/tools/memory_read.rb +1 -1
  44. data/lib/swarm_memory/version.rb +1 -1
  45. data/lib/swarm_memory.rb +8 -6
  46. data/lib/swarm_sdk/agent/builder.rb +58 -0
  47. data/lib/swarm_sdk/agent/chat.rb +527 -1061
  48. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +13 -88
  49. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  50. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +108 -46
  51. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  52. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +267 -0
  53. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +3 -3
  54. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  55. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +11 -13
  56. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  57. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +146 -0
  58. data/lib/swarm_sdk/agent/context.rb +1 -2
  59. data/lib/swarm_sdk/agent/definition.rb +66 -154
  60. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  61. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  62. data/lib/swarm_sdk/agent_registry.rb +146 -0
  63. data/lib/swarm_sdk/builders/base_builder.rb +488 -0
  64. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  65. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  66. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  67. data/lib/swarm_sdk/config.rb +302 -0
  68. data/lib/swarm_sdk/configuration/parser.rb +373 -0
  69. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  70. data/lib/swarm_sdk/configuration.rb +77 -546
  71. data/lib/swarm_sdk/context_compactor/token_counter.rb +2 -6
  72. data/lib/swarm_sdk/context_compactor.rb +6 -11
  73. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  74. data/lib/swarm_sdk/context_management/context.rb +328 -0
  75. data/lib/swarm_sdk/custom_tool_registry.rb +226 -0
  76. data/lib/swarm_sdk/defaults.rb +196 -0
  77. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  78. data/lib/swarm_sdk/hooks/adapter.rb +3 -3
  79. data/lib/swarm_sdk/hooks/shell_executor.rb +4 -2
  80. data/lib/swarm_sdk/log_collector.rb +179 -29
  81. data/lib/swarm_sdk/log_stream.rb +29 -0
  82. data/lib/swarm_sdk/models.json +4333 -1
  83. data/lib/swarm_sdk/models.rb +43 -2
  84. data/lib/swarm_sdk/node_context.rb +1 -1
  85. data/lib/swarm_sdk/observer/builder.rb +81 -0
  86. data/lib/swarm_sdk/observer/config.rb +45 -0
  87. data/lib/swarm_sdk/observer/manager.rb +236 -0
  88. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  89. data/lib/swarm_sdk/plugin.rb +95 -5
  90. data/lib/swarm_sdk/result.rb +52 -0
  91. data/lib/swarm_sdk/snapshot.rb +6 -6
  92. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  93. data/lib/swarm_sdk/state_restorer.rb +136 -151
  94. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  95. data/lib/swarm_sdk/swarm/agent_initializer.rb +181 -137
  96. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  97. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  98. data/lib/swarm_sdk/swarm/hook_triggers.rb +151 -0
  99. data/lib/swarm_sdk/swarm/logging_callbacks.rb +341 -0
  100. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  101. data/lib/swarm_sdk/swarm/tool_configurator.rb +58 -140
  102. data/lib/swarm_sdk/swarm.rb +203 -683
  103. data/lib/swarm_sdk/tools/bash.rb +14 -8
  104. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  105. data/lib/swarm_sdk/tools/edit.rb +8 -13
  106. data/lib/swarm_sdk/tools/glob.rb +12 -4
  107. data/lib/swarm_sdk/tools/grep.rb +7 -0
  108. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  109. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  110. data/lib/swarm_sdk/tools/read.rb +16 -18
  111. data/lib/swarm_sdk/tools/registry.rb +122 -10
  112. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +9 -5
  113. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  114. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  115. data/lib/swarm_sdk/tools/web_fetch.rb +20 -17
  116. data/lib/swarm_sdk/tools/write.rb +8 -13
  117. data/lib/swarm_sdk/version.rb +1 -1
  118. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  119. data/lib/swarm_sdk/workflow/builder.rb +192 -0
  120. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  121. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +7 -5
  122. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +5 -3
  123. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  124. data/lib/swarm_sdk.rb +294 -108
  125. data/rubocop/cop/security/no_reflection_methods.rb +1 -1
  126. data/swarm_cli.gemspec +1 -1
  127. data/swarm_memory.gemspec +8 -3
  128. data/swarm_sdk.gemspec +6 -4
  129. data/team_full.yml +124 -320
  130. metadata +42 -14
  131. data/lib/swarm_memory/chat_extension.rb +0 -34
  132. data/lib/swarm_memory/tools/memory_multi_edit.rb +0 -281
  133. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
  134. /data/lib/swarm_memory/{errors.rb → error.rb} +0 -0
@@ -0,0 +1,302 @@
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
+ env_interpolation: ["SWARM_SDK_ENV_INTERPOLATION", true],
95
+ }.freeze
96
+
97
+ class << self
98
+ # Get the singleton Config instance
99
+ #
100
+ # @return [Config] The singleton instance
101
+ def instance
102
+ @instance ||= new
103
+ end
104
+
105
+ # Reset the Config instance
106
+ #
107
+ # Clears all configuration including explicit values and cached ENV values.
108
+ # Use in tests to ensure clean state.
109
+ #
110
+ # @return [void]
111
+ def reset!
112
+ @instance = nil
113
+ end
114
+ end
115
+
116
+ # Initialize a new Config instance
117
+ #
118
+ # @note Use Config.instance instead of new for the singleton pattern
119
+ def initialize
120
+ @explicit_values = {}
121
+ @env_values = {}
122
+ @env_loaded = false
123
+ @env_mutex = Mutex.new
124
+ end
125
+
126
+ # ========== API Key Accessors (with RubyLLM proxying) ==========
127
+
128
+ # @!method openai_api_key
129
+ # Get the OpenAI API key
130
+ # @return [String, nil] The API key
131
+ #
132
+ # @!method openai_api_key=(value)
133
+ # Set the OpenAI API key (proxied to RubyLLM)
134
+ # @param value [String] The API key
135
+
136
+ API_KEY_MAPPINGS.each_key do |config_key|
137
+ ruby_llm_key, _ = API_KEY_MAPPINGS[config_key]
138
+
139
+ # Getter
140
+ define_method(config_key) do
141
+ ensure_env_loaded!
142
+ @explicit_values[config_key] || @env_values[config_key]
143
+ end
144
+
145
+ # Setter with RubyLLM proxying
146
+ define_method("#{config_key}=") do |value|
147
+ @explicit_values[config_key] = value
148
+ RubyLLM.config.public_send("#{ruby_llm_key}=", value) if value
149
+ end
150
+ end
151
+
152
+ # ========== Defaults Accessors (with module constant fallback) ==========
153
+
154
+ # @!method default_model
155
+ # Get the default model
156
+ # @return [String] The default model (falls back to Defaults::Agent::MODEL)
157
+ #
158
+ # @!method default_model=(value)
159
+ # Set the default model
160
+ # @param value [String] The default model
161
+
162
+ DEFAULTS_MAPPINGS.each_key do |config_key|
163
+ _env_key, default_proc = DEFAULTS_MAPPINGS[config_key]
164
+
165
+ # Getter with default fallback
166
+ define_method(config_key) do
167
+ ensure_env_loaded!
168
+ @explicit_values[config_key] || @env_values[config_key] || default_proc.call
169
+ end
170
+
171
+ # Setter
172
+ define_method("#{config_key}=") do |value|
173
+ @explicit_values[config_key] = value
174
+ end
175
+ end
176
+
177
+ # ========== Settings Accessors (WebFetch and control) ==========
178
+
179
+ # @!method webfetch_provider
180
+ # Get the WebFetch LLM provider
181
+ # @return [String, nil] The provider
182
+ #
183
+ # @!method allow_filesystem_tools
184
+ # Get whether filesystem tools are allowed
185
+ # @return [Boolean] true if allowed
186
+
187
+ SETTINGS_MAPPINGS.each_key do |config_key|
188
+ _env_key, default_value = SETTINGS_MAPPINGS[config_key]
189
+
190
+ # Getter with default fallback
191
+ define_method(config_key) do
192
+ ensure_env_loaded!
193
+ if @explicit_values.key?(config_key)
194
+ @explicit_values[config_key]
195
+ elsif @env_values.key?(config_key)
196
+ @env_values[config_key]
197
+ else
198
+ default_value
199
+ end
200
+ end
201
+
202
+ # Setter
203
+ define_method("#{config_key}=") do |value|
204
+ @explicit_values[config_key] = value
205
+ end
206
+ end
207
+
208
+ # ========== Convenience Methods ==========
209
+
210
+ # Check if WebFetch LLM processing is enabled
211
+ #
212
+ # WebFetch uses LLM processing when both provider and model are configured.
213
+ #
214
+ # @return [Boolean] true if WebFetch LLM is configured
215
+ def webfetch_llm_enabled?
216
+ !webfetch_provider.nil? && !webfetch_model.nil?
217
+ end
218
+
219
+ private
220
+
221
+ # Ensure ENV values are loaded (lazy loading with double-check locking)
222
+ #
223
+ # Thread-safe lazy loading of ENV values. Only loads once per Config instance.
224
+ #
225
+ # @return [void]
226
+ def ensure_env_loaded!
227
+ return if @env_loaded
228
+
229
+ @env_mutex.synchronize do
230
+ return if @env_loaded
231
+
232
+ load_env_values!
233
+ @env_loaded = true
234
+ end
235
+ end
236
+
237
+ # Load environment variable values
238
+ #
239
+ # Loads API keys (with RubyLLM proxying), defaults, and settings from ENV.
240
+ # Only loads values that haven't been explicitly set.
241
+ #
242
+ # @return [void]
243
+ def load_env_values!
244
+ # Load API keys and proxy to RubyLLM
245
+ API_KEY_MAPPINGS.each do |config_key, (ruby_llm_key, env_key)|
246
+ next if @explicit_values.key?(config_key)
247
+ next unless ENV.key?(env_key)
248
+
249
+ value = ENV[env_key]
250
+ @env_values[config_key] = value
251
+
252
+ # Proxy to RubyLLM
253
+ RubyLLM.config.public_send("#{ruby_llm_key}=", value)
254
+ end
255
+
256
+ # Load defaults (no RubyLLM proxy)
257
+ DEFAULTS_MAPPINGS.each do |config_key, (env_key, _default_proc)|
258
+ next if @explicit_values.key?(config_key)
259
+ next unless ENV.key?(env_key)
260
+
261
+ @env_values[config_key] = parse_env_value(ENV[env_key], config_key)
262
+ end
263
+
264
+ # Load settings (no RubyLLM proxy)
265
+ SETTINGS_MAPPINGS.each do |config_key, (env_key, _default_value)|
266
+ next if @explicit_values.key?(config_key)
267
+ next unless ENV.key?(env_key)
268
+
269
+ @env_values[config_key] = parse_env_value(ENV[env_key], config_key)
270
+ end
271
+ end
272
+
273
+ # Parse environment variable value to appropriate type
274
+ #
275
+ # Converts string ENV values to integers, floats, or booleans based on
276
+ # the configuration key pattern.
277
+ #
278
+ # @param value [String] The ENV value string
279
+ # @param key [Symbol] The configuration key
280
+ # @return [Integer, Float, Boolean, String] The parsed value
281
+ def parse_env_value(value, key)
282
+ case key
283
+ when :allow_filesystem_tools, :env_interpolation
284
+ # Convert string to boolean
285
+ case value.to_s.downcase
286
+ when "true", "yes", "1", "on", "enabled"
287
+ true
288
+ when "false", "no", "0", "off", "disabled"
289
+ false
290
+ else
291
+ true # Default to true if unrecognized
292
+ end
293
+ when /_timeout$/, /_limit$/, /_interval$/, /_threshold$/, :mcp_log_level, :webfetch_max_tokens
294
+ value.to_i
295
+ when /^chars_per_token/
296
+ value.to_f
297
+ else
298
+ value
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,373 @@
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
+ # Initialize parser with YAML content and options
34
+ #
35
+ # @param yaml_content [String] YAML configuration content
36
+ # @param base_dir [String, Pathname] Base directory for resolving paths
37
+ # @param env_interpolation [Boolean, nil] Whether to interpolate environment variables.
38
+ # When nil, uses the global SwarmSDK.config.env_interpolation setting.
39
+ # When true, interpolates ${VAR} and ${VAR:=default} patterns.
40
+ # When false, skips interpolation entirely.
41
+ def initialize(yaml_content, base_dir:, env_interpolation: nil)
42
+ @yaml_content = yaml_content
43
+ @base_dir = Pathname.new(base_dir).expand_path
44
+ @env_interpolation = env_interpolation
45
+ @config_type = nil
46
+ @swarm_id = nil
47
+ @swarm_name = nil
48
+ @lead_agent = nil
49
+ @start_node = nil
50
+ @agents = {}
51
+ @all_agents_config = {}
52
+ @swarm_hooks = {}
53
+ @all_agents_hooks = {}
54
+ @external_swarms = {}
55
+ @nodes = {}
56
+ @scratchpad_mode = :disabled
57
+ end
58
+
59
+ def parse
60
+ @config = YAML.safe_load(@yaml_content, permitted_classes: [Symbol], aliases: true)
61
+
62
+ unless @config.is_a?(Hash)
63
+ raise ConfigurationError, "Invalid YAML syntax: configuration must be a Hash"
64
+ end
65
+
66
+ @config = Utils.symbolize_keys(@config)
67
+ interpolate_env_vars!(@config) if env_interpolation_enabled?
68
+
69
+ validate_version
70
+ detect_and_validate_type
71
+ load_common_config
72
+ load_type_specific_config
73
+ load_agents
74
+ load_nodes if @config_type == :workflow
75
+ detect_circular_dependencies
76
+
77
+ self
78
+ rescue Psych::SyntaxError => e
79
+ raise ConfigurationError, "Invalid YAML syntax: #{e.message}"
80
+ end
81
+
82
+ def agent_names
83
+ @agents.keys
84
+ end
85
+
86
+ def connections_for(agent_name)
87
+ agent_config = @agents[agent_name]
88
+ return [] unless agent_config
89
+
90
+ delegates = agent_config[:delegates_to] || []
91
+ Array(delegates).map(&:to_sym)
92
+ end
93
+
94
+ attr_reader :base_dir
95
+
96
+ private
97
+
98
+ # Check if environment variable interpolation is enabled
99
+ #
100
+ # Uses the local setting if explicitly set, otherwise falls back to global config.
101
+ #
102
+ # @return [Boolean] true if interpolation should be performed
103
+ def env_interpolation_enabled?
104
+ return @env_interpolation unless @env_interpolation.nil?
105
+
106
+ SwarmSDK.config.env_interpolation
107
+ end
108
+
109
+ def validate_version
110
+ version = @config[:version]
111
+ raise ConfigurationError, "Missing 'version' field in configuration" unless version
112
+ raise ConfigurationError, "SwarmSDK requires version: 2 configuration. Got version: #{version}" unless version == 2
113
+ end
114
+
115
+ def detect_and_validate_type
116
+ has_swarm = @config.key?(:swarm)
117
+ has_workflow = @config.key?(:workflow)
118
+
119
+ if has_swarm && has_workflow
120
+ raise ConfigurationError, "Cannot have both 'swarm:' and 'workflow:' keys. Use one or the other."
121
+ end
122
+
123
+ unless has_swarm || has_workflow
124
+ raise ConfigurationError, "Missing 'swarm:' or 'workflow:' key in configuration"
125
+ end
126
+
127
+ @config_type = has_swarm ? :swarm : :workflow
128
+ @root_config = @config[@config_type]
129
+ end
130
+
131
+ def load_common_config
132
+ raise ConfigurationError, "Missing 'name' field in #{@config_type} configuration" unless @root_config[:name]
133
+
134
+ @swarm_name = @root_config[:name]
135
+ @swarm_id = @root_config[:id]
136
+ @scratchpad_mode = parse_scratchpad_mode(@root_config[:scratchpad])
137
+
138
+ load_all_agents_config
139
+ load_hooks_config
140
+ load_external_swarms(@root_config[:swarms]) if @root_config[:swarms]
141
+ end
142
+
143
+ def load_type_specific_config
144
+ if @config_type == :swarm
145
+ load_swarm_config
146
+ else
147
+ load_workflow_config
148
+ end
149
+ end
150
+
151
+ def load_swarm_config
152
+ raise ConfigurationError, "Missing 'lead' field in swarm configuration" unless @root_config[:lead]
153
+ raise ConfigurationError, "Missing 'agents' field in swarm configuration" unless @root_config[:agents]
154
+
155
+ @lead_agent = @root_config[:lead].to_sym
156
+
157
+ if @root_config[:nodes] || @root_config[:start_node]
158
+ raise ConfigurationError, "Swarm configuration cannot have 'nodes' or 'start_node'. Use 'workflow:' key instead."
159
+ end
160
+ end
161
+
162
+ def load_workflow_config
163
+ raise ConfigurationError, "Missing 'start_node' field in workflow configuration" unless @root_config[:start_node]
164
+ raise ConfigurationError, "Missing 'nodes' field in workflow configuration" unless @root_config[:nodes]
165
+ raise ConfigurationError, "Missing 'agents' field in workflow configuration" unless @root_config[:agents]
166
+
167
+ @start_node = @root_config[:start_node].to_sym
168
+
169
+ if @root_config[:lead]
170
+ raise ConfigurationError, "Workflow configuration cannot have 'lead'. Use 'start_node' instead."
171
+ end
172
+ end
173
+
174
+ def load_all_agents_config
175
+ @all_agents_config = @root_config[:all_agents] || {}
176
+
177
+ if @all_agents_config[:disable_default_tools].is_a?(Array)
178
+ @all_agents_config[:disable_default_tools] = @all_agents_config[:disable_default_tools].map(&:to_sym)
179
+ end
180
+ end
181
+
182
+ def load_hooks_config
183
+ @swarm_hooks = Utils.symbolize_keys(@root_config[:hooks] || {})
184
+
185
+ if @root_config[:all_agents]
186
+ @all_agents_hooks = Utils.symbolize_keys(@root_config[:all_agents][:hooks] || {})
187
+ end
188
+ end
189
+
190
+ def load_external_swarms(swarms_config)
191
+ @external_swarms = {}
192
+ swarms_config.each do |name, config|
193
+ source = if config[:file]
194
+ file_path = if config[:file].start_with?("/")
195
+ config[:file]
196
+ else
197
+ (@base_dir / config[:file]).to_s
198
+ end
199
+ { type: :file, value: file_path }
200
+ elsif config[:yaml]
201
+ { type: :yaml, value: config[:yaml] }
202
+ elsif config[:swarm]
203
+ inline_config = {
204
+ version: 2,
205
+ swarm: config[:swarm],
206
+ }
207
+ yaml_string = Utils.hash_to_yaml(inline_config)
208
+ { type: :yaml, value: yaml_string }
209
+ else
210
+ raise ConfigurationError, "Swarm '#{name}' must specify either 'file:', 'yaml:', or 'swarm:' (inline definition)"
211
+ end
212
+
213
+ @external_swarms[name.to_sym] = {
214
+ source: source,
215
+ keep_context: config.fetch(:keep_context, true),
216
+ }
217
+ end
218
+ end
219
+
220
+ def load_agents
221
+ swarm_agents = @root_config[:agents]
222
+ raise ConfigurationError, "No agents defined" if swarm_agents.empty?
223
+
224
+ swarm_agents.each do |name, agent_config|
225
+ parsed_config = if agent_config.nil?
226
+ {}
227
+ elsif agent_config.is_a?(String)
228
+ { agent_file: agent_config }
229
+ elsif agent_config.is_a?(Hash) && agent_config[:agent_file]
230
+ agent_config
231
+ else
232
+ agent_config || {}
233
+ end
234
+
235
+ if parsed_config[:agent_file].nil? && parsed_config[:description].nil?
236
+ raise ConfigurationError,
237
+ "Agent '#{name}' missing required 'description' field"
238
+ end
239
+
240
+ @agents[name] = parsed_config
241
+ end
242
+
243
+ if @config_type == :swarm
244
+ unless @agents.key?(@lead_agent)
245
+ raise ConfigurationError, "Lead agent '#{@lead_agent}' not found in agents"
246
+ end
247
+ end
248
+ end
249
+
250
+ def load_nodes
251
+ @nodes = Utils.symbolize_keys(@root_config[:nodes])
252
+
253
+ unless @nodes.key?(@start_node)
254
+ raise ConfigurationError, "start_node '#{@start_node}' not found in nodes"
255
+ end
256
+
257
+ @nodes.each do |node_name, node_config|
258
+ unless node_config.is_a?(Hash)
259
+ raise ConfigurationError, "Node '#{node_name}' must be a hash"
260
+ end
261
+
262
+ if node_config[:agents]
263
+ unless node_config[:agents].is_a?(Array)
264
+ raise ConfigurationError, "Node '#{node_name}' agents must be an array"
265
+ end
266
+
267
+ node_config[:agents].each do |agent_config|
268
+ unless agent_config.is_a?(Hash) && agent_config[:agent]
269
+ raise ConfigurationError,
270
+ "Node '#{node_name}' agents must be hashes with 'agent' key"
271
+ end
272
+
273
+ agent_sym = agent_config[:agent].to_sym
274
+ unless @agents.key?(agent_sym)
275
+ raise ConfigurationError,
276
+ "Node '#{node_name}' references undefined agent '#{agent_config[:agent]}'"
277
+ end
278
+ end
279
+ end
280
+
281
+ next unless node_config[:dependencies]
282
+ unless node_config[:dependencies].is_a?(Array)
283
+ raise ConfigurationError, "Node '#{node_name}' dependencies must be an array"
284
+ end
285
+
286
+ node_config[:dependencies].each do |dep|
287
+ dep_sym = dep.to_sym
288
+ unless @nodes.key?(dep_sym)
289
+ raise ConfigurationError,
290
+ "Node '#{node_name}' depends on undefined node '#{dep}'"
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ def parse_scratchpad_mode(value)
297
+ return :disabled if value.nil?
298
+
299
+ value = value.to_sym if value.is_a?(String)
300
+
301
+ case value
302
+ when :enabled, :disabled, :per_node
303
+ value
304
+ else
305
+ raise ConfigurationError,
306
+ "Invalid scratchpad mode: #{value.inspect}. Use :enabled, :per_node, or :disabled"
307
+ end
308
+ end
309
+
310
+ def interpolate_env_vars!(obj)
311
+ case obj
312
+ when String
313
+ interpolate_env_string(obj)
314
+ when Hash
315
+ obj.transform_values! { |v| interpolate_env_vars!(v) }
316
+ when Array
317
+ obj.map! { |v| interpolate_env_vars!(v) }
318
+ else
319
+ obj
320
+ end
321
+ end
322
+
323
+ def interpolate_env_string(str)
324
+ str.gsub(ENV_VAR_WITH_DEFAULT_PATTERN) do |_match|
325
+ env_var = Regexp.last_match(1)
326
+ has_default = Regexp.last_match(2)
327
+ default_value = Regexp.last_match(3)
328
+
329
+ if ENV.key?(env_var)
330
+ ENV[env_var]
331
+ elsif has_default
332
+ default_value || ""
333
+ else
334
+ raise ConfigurationError, "Environment variable '#{env_var}' is not set"
335
+ end
336
+ end
337
+ end
338
+
339
+ def detect_circular_dependencies
340
+ @agents.each_key do |agent_name|
341
+ visited = Set.new
342
+ path = []
343
+ detect_cycle_from(agent_name, visited, path)
344
+ end
345
+ end
346
+
347
+ def detect_cycle_from(agent_name, visited, path)
348
+ return if visited.include?(agent_name)
349
+
350
+ if path.include?(agent_name)
351
+ cycle_start = path.index(agent_name)
352
+ cycle = path[cycle_start..] + [agent_name]
353
+ raise CircularDependencyError, "Circular dependency detected: #{cycle.join(" -> ")}"
354
+ end
355
+
356
+ path.push(agent_name)
357
+ connections_for(agent_name).each do |connection|
358
+ connection_sym = connection.to_sym
359
+
360
+ next if @external_swarms.key?(connection_sym)
361
+
362
+ unless @agents.key?(connection_sym)
363
+ raise ConfigurationError, "Agent '#{agent_name}' delegates to unknown target '#{connection}' (not a local agent or registered swarm)"
364
+ end
365
+
366
+ detect_cycle_from(connection_sym, visited, path)
367
+ end
368
+ path.pop
369
+ visited.add(agent_name)
370
+ end
371
+ end
372
+ end
373
+ end