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.
- checksums.yaml +7 -0
- data/lib/swarm_sdk/agent/builder.rb +333 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
- data/lib/swarm_sdk/agent/chat.rb +779 -0
- data/lib/swarm_sdk/agent/context.rb +108 -0
- data/lib/swarm_sdk/agent/definition.rb +335 -0
- data/lib/swarm_sdk/configuration.rb +251 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +163 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +143 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +83 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +46 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
- data/lib/swarm_sdk/swarm/builder.rb +240 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
- data/lib/swarm_sdk/swarm.rb +837 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/delegate.rb +152 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +231 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +73 -0
- data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
- data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +69 -0
- 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
|