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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ # AgentContext encapsulates per-agent state and metadata
6
+ #
7
+ # Each agent has its own context that tracks:
8
+ # - Agent identity (name)
9
+ # - Delegation relationships (which tool calls are delegations)
10
+ # - Context window warnings (which thresholds have been hit)
11
+ # - Optional metadata
12
+ #
13
+ # This class replaces the per-agent hash maps that were previously
14
+ # stored in UnifiedLogger.
15
+ #
16
+ # @example
17
+ # context = Agent::Context.new(
18
+ # name: :backend,
19
+ # delegation_tools: ["DelegateToDatabase", "DelegateToAuth"],
20
+ # metadata: { role: "backend" }
21
+ # )
22
+ #
23
+ # # Track a delegation
24
+ # context.track_delegation(call_id: "call_123", target: "DelegateToDatabase")
25
+ #
26
+ # # Check if a tool call is a delegation
27
+ # context.delegation?(call_id: "call_123") # => true
28
+ class Context
29
+ # Thresholds for context limit warnings (in percentage)
30
+ CONTEXT_WARNING_THRESHOLDS = [80, 90].freeze
31
+
32
+ attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit
33
+
34
+ # Initialize a new agent context
35
+ #
36
+ # @param name [Symbol, String] Agent name
37
+ # @param delegation_tools [Array<String>] Names of tools that are delegations
38
+ # @param metadata [Hash] Optional metadata about the agent
39
+ def initialize(name:, delegation_tools: [], metadata: {})
40
+ @name = name.to_sym
41
+ @delegation_tools = Set.new(delegation_tools.map(&:to_s))
42
+ @metadata = metadata
43
+ @delegation_call_ids = Set.new
44
+ @delegation_targets = {}
45
+ @warning_thresholds_hit = Set.new
46
+ end
47
+
48
+ # Track a delegation tool call
49
+ #
50
+ # @param call_id [String] Tool call ID
51
+ # @param target [String] Target agent/tool name
52
+ # @return [void]
53
+ def track_delegation(call_id:, target:)
54
+ @delegation_call_ids.add(call_id)
55
+ @delegation_targets[call_id] = target
56
+ end
57
+
58
+ # Check if a tool call is a delegation
59
+ #
60
+ # @param call_id [String] Tool call ID
61
+ # @return [Boolean]
62
+ def delegation?(call_id:)
63
+ @delegation_call_ids.include?(call_id)
64
+ end
65
+
66
+ # Get the delegation target for a tool call
67
+ #
68
+ # @param call_id [String] Tool call ID
69
+ # @return [String, nil] Target agent/tool name, or nil if not a delegation
70
+ def delegation_target(call_id:)
71
+ @delegation_targets[call_id]
72
+ end
73
+
74
+ # Remove a delegation from tracking (after it completes)
75
+ #
76
+ # @param call_id [String] Tool call ID
77
+ # @return [void]
78
+ def clear_delegation(call_id:)
79
+ @delegation_targets.delete(call_id)
80
+ @delegation_call_ids.delete(call_id)
81
+ end
82
+
83
+ # Check if a tool name is a delegation tool
84
+ #
85
+ # @param tool_name [String] Tool name
86
+ # @return [Boolean]
87
+ def delegation_tool?(tool_name)
88
+ @delegation_tools.include?(tool_name.to_s)
89
+ end
90
+
91
+ # Record that a context warning threshold has been hit
92
+ #
93
+ # @param threshold [Integer] Threshold percentage (80, 90, etc)
94
+ # @return [Boolean] true if this is the first time hitting this threshold
95
+ def hit_warning_threshold?(threshold)
96
+ !@warning_thresholds_hit.add?(threshold).nil?
97
+ end
98
+
99
+ # Check if a warning threshold has been hit
100
+ #
101
+ # @param threshold [Integer] Threshold percentage
102
+ # @return [Boolean]
103
+ def warning_threshold_hit?(threshold)
104
+ @warning_thresholds_hit.include?(threshold)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ # Agent definition encapsulates agent configuration and builds system prompts
6
+ #
7
+ # This class is responsible for:
8
+ # - Parsing and validating agent configuration
9
+ # - Building the full system prompt (base + custom)
10
+ # - Handling tool permissions
11
+ # - Managing hooks (both DSL Ruby blocks and YAML shell commands)
12
+ #
13
+ # @example
14
+ # definition = Agent::Definition.new(:backend, {
15
+ # description: "Backend API developer",
16
+ # model: "gpt-5",
17
+ # tools: [:Read, :Write, :Bash],
18
+ # system_prompt: "You build APIs"
19
+ # })
20
+ class Definition
21
+ DEFAULT_MODEL = "gpt-5"
22
+ DEFAULT_PROVIDER = "openai"
23
+ DEFAULT_TIMEOUT = 300 # 5 minutes - reasoning models can take a while
24
+ BASE_SYSTEM_PROMPT_PATH = File.expand_path("../prompts/base_system_prompt.md.erb", __dir__)
25
+
26
+ attr_reader :name,
27
+ :description,
28
+ :model,
29
+ :context_window,
30
+ :directory,
31
+ :tools,
32
+ :delegates_to,
33
+ :system_prompt,
34
+ :provider,
35
+ :base_url,
36
+ :api_version,
37
+ :mcp_servers,
38
+ :parameters,
39
+ :headers,
40
+ :timeout,
41
+ :include_default_tools,
42
+ :skip_base_prompt,
43
+ :default_permissions,
44
+ :agent_permissions,
45
+ :assume_model_exists,
46
+ :hooks
47
+
48
+ attr_accessor :bypass_permissions, :max_concurrent_tools
49
+
50
+ def initialize(name, config = {})
51
+ @name = name.to_sym
52
+
53
+ # BREAKING CHANGE: Hard error for plural form
54
+ if config[:directories]
55
+ raise ConfigurationError,
56
+ "The 'directories' (plural) configuration is no longer supported in SwarmSDK 1.0+.\n\n" \
57
+ "Change 'directories:' to 'directory:' (singular).\n\n" \
58
+ "If you need access to multiple directories, use permissions:\n\n " \
59
+ "directory: 'backend/'\n " \
60
+ "permissions do\n " \
61
+ "tool(:Read).allow_paths('../shared/**')\n " \
62
+ "end"
63
+ end
64
+
65
+ @description = config[:description]
66
+ @model = config[:model] || DEFAULT_MODEL
67
+ @provider = config[:provider] || DEFAULT_PROVIDER
68
+ @base_url = config[:base_url]
69
+ @api_version = config[:api_version]
70
+ @context_window = config[:context_window] # Explicit context window override
71
+ @parameters = config[:parameters] || {}
72
+ @headers = Utils.stringify_keys(config[:headers] || {})
73
+ @timeout = config[:timeout] || DEFAULT_TIMEOUT
74
+ @bypass_permissions = config[:bypass_permissions] || false
75
+ @max_concurrent_tools = config[:max_concurrent_tools]
76
+ # Default to true when base_url is set, false otherwise (unless explicitly specified)
77
+ @assume_model_exists = if config.key?(:assume_model_exists)
78
+ config[:assume_model_exists]
79
+ else
80
+ (base_url ? true : false)
81
+ end
82
+
83
+ # include_default_tools defaults to true if not specified
84
+ @include_default_tools = config.key?(:include_default_tools) ? config[:include_default_tools] : true
85
+
86
+ # skip_base_prompt defaults to false if not specified
87
+ @skip_base_prompt = config.key?(:skip_base_prompt) ? config[:skip_base_prompt] : false
88
+
89
+ # Parse directory first so it can be used in system prompt rendering
90
+ @directory = parse_directory(config[:directory])
91
+
92
+ # Build system prompt after directory is set
93
+ @system_prompt = build_full_system_prompt(config[:system_prompt])
94
+
95
+ # Parse tools with permissions support
96
+ @default_permissions = config[:default_permissions] || {}
97
+ @agent_permissions = config[:permissions] || {}
98
+ @tools = parse_tools_with_permissions(
99
+ config[:tools],
100
+ @default_permissions,
101
+ @agent_permissions,
102
+ )
103
+
104
+ # Inject default write restrictions for security
105
+ @tools = inject_default_write_permissions(@tools)
106
+
107
+ @delegates_to = Array(config[:delegates_to] || []).map(&:to_sym)
108
+ @mcp_servers = Array(config[:mcp_servers] || [])
109
+
110
+ # Parse hooks configuration
111
+ # Handles both DSL (HookDefinition objects) and YAML (raw hash) formats
112
+ @hooks = parse_hooks(config[:hooks])
113
+
114
+ validate!
115
+ end
116
+
117
+ def to_h
118
+ {
119
+ name: @name,
120
+ description: @description,
121
+ model: @model,
122
+ directory: @directory,
123
+ tools: @tools,
124
+ delegates_to: @delegates_to,
125
+ system_prompt: @system_prompt,
126
+ provider: @provider,
127
+ base_url: @base_url,
128
+ api_version: @api_version,
129
+ mcp_servers: @mcp_servers,
130
+ parameters: @parameters,
131
+ headers: @headers,
132
+ timeout: @timeout,
133
+ bypass_permissions: @bypass_permissions,
134
+ include_default_tools: @include_default_tools,
135
+ skip_base_prompt: @skip_base_prompt,
136
+ assume_model_exists: @assume_model_exists,
137
+ max_concurrent_tools: @max_concurrent_tools,
138
+ hooks: @hooks,
139
+ }.compact
140
+ end
141
+
142
+ private
143
+
144
+ def build_full_system_prompt(custom_prompt)
145
+ # If skip_base_prompt is true, return only the custom prompt (or empty string if nil)
146
+ if @skip_base_prompt
147
+ return (custom_prompt || "").to_s
148
+ end
149
+
150
+ rendered_base = render_base_system_prompt
151
+
152
+ return rendered_base if custom_prompt.nil? || custom_prompt.strip.empty?
153
+
154
+ "#{rendered_base}\n\n#{custom_prompt}"
155
+ end
156
+
157
+ def render_base_system_prompt
158
+ cwd = @directory || Dir.pwd
159
+ platform = RUBY_PLATFORM
160
+ os_version = begin
161
+ %x(uname -sr 2>/dev/null).strip
162
+ rescue
163
+ RUBY_PLATFORM
164
+ end
165
+ date = Time.now.strftime("%Y-%m-%d")
166
+
167
+ template_content = File.read(BASE_SYSTEM_PROMPT_PATH)
168
+ ERB.new(template_content).result(binding)
169
+ end
170
+
171
+ def parse_directory(directory_config)
172
+ directory_config ||= "."
173
+ File.expand_path(directory_config.to_s)
174
+ end
175
+
176
+ # Parse tools configuration with permissions support
177
+ #
178
+ # Tools can be specified as:
179
+ # - Symbol: :Write (no permissions)
180
+ # - Hash: { Write: { allowed_paths: [...] } } (with permissions)
181
+ #
182
+ # Returns array of tool configs:
183
+ # [
184
+ # { name: :Read, permissions: nil },
185
+ # { name: :Write, permissions: { allowed_paths: [...] } }
186
+ # ]
187
+ def parse_tools_with_permissions(tools_config, default_permissions, agent_permissions)
188
+ tools_array = Array(tools_config || [])
189
+
190
+ tools_array.map do |tool_spec|
191
+ case tool_spec
192
+ when Symbol, String
193
+ # Simple tool: :Write or "Write"
194
+ tool_name = tool_spec.to_sym
195
+ permissions = resolve_permissions(tool_name, default_permissions, agent_permissions)
196
+
197
+ { name: tool_name, permissions: permissions }
198
+ when Hash
199
+ # Check if already in parsed format: { name: :Write, permissions: {...} }
200
+ if tool_spec.key?(:name)
201
+ # Already parsed - pass through as-is
202
+ tool_spec
203
+ else
204
+ # Tool with inline permissions: { Write: { allowed_paths: [...] } }
205
+ tool_name = tool_spec.keys.first.to_sym
206
+ inline_permissions = tool_spec.values.first
207
+
208
+ # Inline permissions override defaults
209
+ { name: tool_name, permissions: inline_permissions }
210
+ end
211
+ else
212
+ raise ConfigurationError, "Invalid tool specification: #{tool_spec.inspect}"
213
+ end
214
+ end
215
+ end
216
+
217
+ # Resolve permissions for a tool from defaults and agent-level overrides
218
+ def resolve_permissions(tool_name, default_permissions, agent_permissions)
219
+ # Agent-level permissions override defaults
220
+ agent_permissions[tool_name] || default_permissions[tool_name]
221
+ end
222
+
223
+ # Inject default write permissions for security
224
+ #
225
+ # Write, Edit, and MultiEdit tools without explicit permissions are automatically
226
+ # restricted to only write within the agent's directory. This prevents accidental
227
+ # writes outside the agent's working scope.
228
+ #
229
+ # Default permission: { allowed_paths: ["**/*"] }
230
+ # This is resolved relative to the agent's directory by the permissions system.
231
+ #
232
+ # Users can override by explicitly setting permissions for these tools.
233
+ def inject_default_write_permissions(tools)
234
+ write_tools = [:Write, :Edit, :MultiEdit]
235
+
236
+ tools.map do |tool_config|
237
+ tool_name = tool_config[:name]
238
+
239
+ # If it's a write tool and has no permissions, inject default
240
+ if write_tools.include?(tool_name) && tool_config[:permissions].nil?
241
+ tool_config.merge(permissions: { allowed_paths: ["**/*"] })
242
+ else
243
+ tool_config
244
+ end
245
+ end
246
+ end
247
+
248
+ # Parse hooks configuration
249
+ #
250
+ # Handles two input formats:
251
+ #
252
+ # 1. DSL format (from Agent::Builder): Pre-parsed HookDefinition objects
253
+ # { event_type: [HookDefinition, ...] }
254
+ # These are applied directly in pass_4_configure_hooks
255
+ #
256
+ # 2. YAML format: Raw hash with shell command specifications
257
+ # hooks:
258
+ # pre_tool_use:
259
+ # - matcher: "Write|Edit"
260
+ # type: command
261
+ # command: "validate.sh"
262
+ # These are kept raw and processed by Hooks::Adapter in pass_5
263
+ #
264
+ # Returns:
265
+ # - DSL: { event_type: [HookDefinition, ...] }
266
+ # - YAML: Raw hash (for Hooks::Adapter)
267
+ def parse_hooks(hooks_config)
268
+ return {} if hooks_config.nil? || hooks_config.empty?
269
+
270
+ # If already parsed from DSL (HookDefinition objects), return as-is
271
+ if hooks_config.is_a?(Hash) && hooks_config.values.all? { |v| v.is_a?(Array) && v.all? { |item| item.is_a?(Hooks::Definition) } }
272
+ return hooks_config
273
+ end
274
+
275
+ # For YAML hooks: validate structure but keep raw for Hooks::Adapter
276
+ validate_yaml_hooks(hooks_config)
277
+
278
+ # Return raw YAML - Hooks::Adapter will process in pass_5
279
+ hooks_config
280
+ end
281
+
282
+ # Validate YAML hooks structure
283
+ #
284
+ # @param hooks_config [Hash] YAML hooks configuration
285
+ # @return [void]
286
+ def validate_yaml_hooks(hooks_config)
287
+ hooks_config.each do |event_name, hook_specs|
288
+ event_sym = event_name.to_sym
289
+
290
+ # Validate event type
291
+ unless Hooks::Registry::VALID_EVENTS.include?(event_sym)
292
+ raise ConfigurationError,
293
+ "Invalid hook event '#{event_name}' for agent '#{@name}'. " \
294
+ "Valid events: #{Hooks::Registry::VALID_EVENTS.join(", ")}"
295
+ end
296
+
297
+ # Validate each hook spec structure
298
+ Array(hook_specs).each do |spec|
299
+ hook_type = spec[:type] || spec["type"]
300
+ command = spec[:command] || spec["command"]
301
+
302
+ raise ConfigurationError, "Hook missing 'type' field for event #{event_name}" unless hook_type
303
+ raise ConfigurationError, "Hook missing 'command' field for event #{event_name}" if hook_type.to_s == "command" && !command
304
+ end
305
+ end
306
+ end
307
+
308
+ def validate!
309
+ raise ConfigurationError, "Agent '#{@name}' missing required 'description' field" unless @description
310
+
311
+ # Validate api_version can only be set for OpenAI-compatible providers
312
+ if @api_version
313
+ openai_compatible = ["openai", "deepseek", "perplexity", "mistral", "openrouter"]
314
+ unless openai_compatible.include?(@provider.to_s)
315
+ raise ConfigurationError,
316
+ "Agent '#{@name}' has api_version set, but provider is '#{@provider}'. " \
317
+ "api_version can only be used with OpenAI-compatible providers: #{openai_compatible.join(", ")}"
318
+ end
319
+
320
+ # Validate api_version value
321
+ valid_versions = ["v1/chat/completions", "v1/responses"]
322
+ unless valid_versions.include?(@api_version)
323
+ raise ConfigurationError,
324
+ "Agent '#{@name}' has invalid api_version '#{@api_version}'. " \
325
+ "Valid values: #{valid_versions.join(", ")}"
326
+ end
327
+ end
328
+
329
+ unless File.directory?(@directory)
330
+ raise ConfigurationError, "Directory '#{@directory}' for agent '#{@name}' does not exist"
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ class Configuration
5
+ ENV_VAR_WITH_DEFAULT_PATTERN = /\$\{([^:}]+)(:=([^}]*))?\}/
6
+
7
+ attr_reader :config_path, :swarm_name, :lead_agent, :agents, :all_agents_config, :swarm_hooks, :all_agents_hooks
8
+
9
+ class << self
10
+ def load(path)
11
+ new(path).tap(&:load_and_validate)
12
+ end
13
+ end
14
+
15
+ def initialize(config_path)
16
+ @config_path = Pathname.new(config_path).expand_path
17
+ @config_dir = @config_path.dirname
18
+ @agents = {}
19
+ @all_agents_config = {} # Settings applied to all agents
20
+ @swarm_hooks = {} # Swarm-level hooks (swarm_start, swarm_stop)
21
+ @all_agents_hooks = {} # Hooks applied to all agents
22
+ end
23
+
24
+ def load_and_validate
25
+ @config = YAML.load_file(@config_path, aliases: true)
26
+
27
+ unless @config.is_a?(Hash)
28
+ raise ConfigurationError, "Invalid YAML syntax: configuration must be a Hash"
29
+ end
30
+
31
+ @config = Utils.symbolize_keys(@config)
32
+ interpolate_env_vars!(@config)
33
+ validate_version
34
+ load_all_agents_config
35
+ load_hooks_config
36
+ validate_swarm
37
+ load_agents
38
+ detect_circular_dependencies
39
+ self
40
+ rescue Errno::ENOENT
41
+ raise ConfigurationError, "Configuration file not found: #{@config_path}"
42
+ rescue Psych::SyntaxError => e
43
+ raise ConfigurationError, "Invalid YAML syntax: #{e.message}"
44
+ end
45
+
46
+ def agent_names
47
+ @agents.keys
48
+ end
49
+
50
+ def connections_for(agent_name)
51
+ @agents[agent_name]&.delegates_to || []
52
+ end
53
+
54
+ # Convert configuration to Swarm instance using Ruby API
55
+ #
56
+ # This method bridges YAML configuration to the Ruby API, making YAML
57
+ # a thin convenience layer over the programmatic interface.
58
+ #
59
+ # @return [Swarm] Configured swarm instance
60
+ def to_swarm
61
+ swarm = Swarm.new(
62
+ name: @swarm_name,
63
+ global_concurrency: Swarm::DEFAULT_GLOBAL_CONCURRENCY,
64
+ default_local_concurrency: Swarm::DEFAULT_LOCAL_CONCURRENCY,
65
+ )
66
+
67
+ # Add all agents - pass definitions directly
68
+ @agents.each do |_name, agent_def|
69
+ swarm.add_agent(agent_def)
70
+ end
71
+
72
+ # Set lead agent
73
+ swarm.lead = @lead_agent
74
+
75
+ swarm
76
+ end
77
+
78
+ private
79
+
80
+ def interpolate_env_vars!(obj)
81
+ case obj
82
+ when String
83
+ interpolate_env_string(obj)
84
+ when Hash
85
+ obj.transform_values! { |v| interpolate_env_vars!(v) }
86
+ when Array
87
+ obj.map! { |v| interpolate_env_vars!(v) }
88
+ else
89
+ obj
90
+ end
91
+ end
92
+
93
+ def interpolate_env_string(str)
94
+ str.gsub(ENV_VAR_WITH_DEFAULT_PATTERN) do |_match|
95
+ env_var = Regexp.last_match(1)
96
+ has_default = Regexp.last_match(2)
97
+ default_value = Regexp.last_match(3)
98
+
99
+ if ENV.key?(env_var)
100
+ ENV[env_var]
101
+ elsif has_default
102
+ default_value || ""
103
+ else
104
+ raise ConfigurationError, "Environment variable '#{env_var}' is not set"
105
+ end
106
+ end
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 load_all_agents_config
116
+ return unless @config[:swarm]
117
+
118
+ @all_agents_config = @config[:swarm][:all_agents] || {}
119
+ end
120
+
121
+ def load_hooks_config
122
+ return unless @config[:swarm]
123
+
124
+ # Load swarm-level hooks (only swarm_start, swarm_stop allowed)
125
+ @swarm_hooks = Utils.symbolize_keys(@config[:swarm][:hooks] || {})
126
+
127
+ # Load all_agents hooks (applied as swarm defaults)
128
+ if @config[:swarm][:all_agents]
129
+ @all_agents_hooks = Utils.symbolize_keys(@config[:swarm][:all_agents][:hooks] || {})
130
+ end
131
+ end
132
+
133
+ def validate_swarm
134
+ raise ConfigurationError, "Missing 'swarm' field in configuration" unless @config[:swarm]
135
+
136
+ swarm = @config[:swarm]
137
+ raise ConfigurationError, "Missing 'name' field in swarm configuration" unless swarm[:name]
138
+ raise ConfigurationError, "Missing 'agents' field in swarm configuration" unless swarm[:agents]
139
+ raise ConfigurationError, "Missing 'lead' field in swarm configuration" unless swarm[:lead]
140
+ raise ConfigurationError, "No agents defined" if swarm[:agents].empty?
141
+
142
+ @swarm_name = swarm[:name]
143
+ @lead_agent = swarm[:lead].to_sym # Convert to symbol for consistency
144
+ end
145
+
146
+ def load_agents
147
+ swarm_agents = @config[:swarm][:agents]
148
+
149
+ swarm_agents.each do |name, agent_config|
150
+ agent_config ||= {}
151
+
152
+ # Merge all_agents_config into agent config
153
+ # Agent-specific config overrides all_agents config
154
+ merged_config = merge_all_agents_config(agent_config)
155
+
156
+ @agents[name] = if agent_config[:agent_file]
157
+ load_agent_from_file(name, agent_config[:agent_file], merged_config)
158
+ else
159
+ Agent::Definition.new(name, merged_config)
160
+ end
161
+ end
162
+
163
+ unless @agents.key?(@lead_agent)
164
+ raise ConfigurationError, "Lead agent '#{@lead_agent}' not found in agents"
165
+ end
166
+ end
167
+
168
+ # Merge all_agents config with agent-specific config
169
+ # Agent config takes precedence over all_agents config
170
+ def merge_all_agents_config(agent_config)
171
+ merged = @all_agents_config.dup
172
+
173
+ # For arrays (like tools, delegates_to), concatenate instead of replace
174
+ # For scalars, agent value overrides
175
+ agent_config.each do |key, value|
176
+ case key
177
+ when :tools
178
+ # Concatenate tools: all_agents.tools + agent.tools
179
+ merged[:tools] = Array(merged[:tools]) + Array(value)
180
+ when :delegates_to
181
+ # Concatenate delegates_to
182
+ merged[:delegates_to] = Array(merged[:delegates_to]) + Array(value)
183
+ else
184
+ # For everything else, agent value overrides all_agents value
185
+ merged[key] = value
186
+ end
187
+ end
188
+
189
+ # Pass all_agents permissions as default_permissions for backward compat with AgentDefinition
190
+ if @all_agents_config[:permissions]
191
+ merged[:default_permissions] = @all_agents_config[:permissions]
192
+ end
193
+
194
+ merged
195
+ end
196
+
197
+ def load_agent_from_file(name, file_path, merged_config)
198
+ agent_file_path = resolve_agent_file_path(file_path)
199
+
200
+ unless File.exist?(agent_file_path)
201
+ raise ConfigurationError, "Agent file not found: #{agent_file_path}"
202
+ end
203
+
204
+ content = File.read(agent_file_path)
205
+ # Parse markdown and merge with YAML config
206
+ agent_def_from_file = MarkdownParser.parse(content, name)
207
+
208
+ # Merge: markdown file overrides merged_config for fields it defines
209
+ final_config = merged_config.merge(agent_def_from_file.to_h.compact)
210
+ Agent::Definition.new(name, final_config)
211
+ rescue StandardError => e
212
+ raise ConfigurationError, "Error loading agent '#{name}' from file '#{file_path}': #{e.message}"
213
+ end
214
+
215
+ def resolve_agent_file_path(file_path)
216
+ return file_path if Pathname.new(file_path).absolute?
217
+
218
+ @config_dir.join(file_path).to_s
219
+ end
220
+
221
+ def detect_circular_dependencies
222
+ @agents.each_key do |agent_name|
223
+ visited = Set.new
224
+ path = []
225
+ detect_cycle_from(agent_name, visited, path)
226
+ end
227
+ end
228
+
229
+ def detect_cycle_from(agent_name, visited, path)
230
+ return if visited.include?(agent_name)
231
+
232
+ if path.include?(agent_name)
233
+ cycle_start = path.index(agent_name)
234
+ cycle = path[cycle_start..] + [agent_name]
235
+ raise CircularDependencyError, "Circular dependency detected: #{cycle.join(" -> ")}"
236
+ end
237
+
238
+ path.push(agent_name)
239
+ connections_for(agent_name).each do |connection|
240
+ connection_sym = connection.to_sym # Convert to symbol for lookup
241
+ unless @agents.key?(connection_sym)
242
+ raise ConfigurationError, "Agent '#{agent_name}' has connection to unknown agent '#{connection}'"
243
+ end
244
+
245
+ detect_cycle_from(connection_sym, visited, path)
246
+ end
247
+ path.pop
248
+ visited.add(agent_name)
249
+ end
250
+ end
251
+ end