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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +58 -0
- data/lib/swarm_sdk/agent/chat.rb +527 -1059
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +262 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +11 -13
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
- data/lib/swarm_sdk/agent/context.rb +1 -2
- data/lib/swarm_sdk/agent/definition.rb +66 -154
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/builders/base_builder.rb +409 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
- data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
- data/lib/swarm_sdk/concerns/validatable.rb +55 -0
- data/lib/swarm_sdk/config.rb +301 -0
- data/lib/swarm_sdk/configuration/parser.rb +353 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +65 -543
- data/lib/swarm_sdk/context_compactor/token_counter.rb +2 -6
- data/lib/swarm_sdk/context_compactor.rb +6 -11
- data/lib/swarm_sdk/context_management/builder.rb +128 -0
- data/lib/swarm_sdk/context_management/context.rb +328 -0
- data/lib/swarm_sdk/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +18 -0
- data/lib/swarm_sdk/hooks/adapter.rb +3 -3
- data/lib/swarm_sdk/hooks/shell_executor.rb +4 -2
- data/lib/swarm_sdk/log_collector.rb +179 -29
- data/lib/swarm_sdk/log_stream.rb +29 -0
- data/lib/swarm_sdk/models.json +4333 -1
- data/lib/swarm_sdk/node_context.rb +1 -1
- data/lib/swarm_sdk/observer/builder.rb +81 -0
- data/lib/swarm_sdk/observer/config.rb +45 -0
- data/lib/swarm_sdk/observer/manager.rb +236 -0
- data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
- data/lib/swarm_sdk/plugin.rb +93 -3
- data/lib/swarm_sdk/snapshot.rb +6 -6
- data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
- data/lib/swarm_sdk/state_restorer.rb +136 -151
- data/lib/swarm_sdk/state_snapshot.rb +65 -100
- data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
- data/lib/swarm_sdk/swarm/builder.rb +44 -578
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/tool_configurator.rb +44 -140
- data/lib/swarm_sdk/swarm.rb +146 -689
- data/lib/swarm_sdk/tools/bash.rb +14 -8
- data/lib/swarm_sdk/tools/delegate.rb +61 -43
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +12 -4
- data/lib/swarm_sdk/tools/grep.rb +7 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
- data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
- data/lib/swarm_sdk/tools/read.rb +16 -18
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +9 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/todo_write.rb +7 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +20 -17
- data/lib/swarm_sdk/tools/write.rb +8 -13
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
- data/lib/swarm_sdk/workflow/builder.rb +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +7 -5
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +5 -3
- data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
- data/lib/swarm_sdk.rb +64 -104
- metadata +68 -15
- 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
|