swarm_sdk 2.1.3 → 2.2.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 +33 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +198 -51
- data/lib/swarm_sdk/agent/context.rb +6 -2
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +14 -2
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +387 -94
- data/lib/swarm_sdk/events_to_messages.rb +181 -0
- data/lib/swarm_sdk/log_collector.rb +31 -5
- data/lib/swarm_sdk/log_stream.rb +37 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node/agent_config.rb +33 -8
- data/lib/swarm_sdk/node/builder.rb +39 -18
- data/lib/swarm_sdk/node_orchestrator.rb +293 -26
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
- data/lib/swarm_sdk/state_restorer.rb +491 -0
- data/lib/swarm_sdk/state_snapshot.rb +369 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +208 -12
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
- data/lib/swarm_sdk/swarm.rb +337 -42
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/delegate.rb +92 -7
- data/lib/swarm_sdk/tools/read.rb +17 -5
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
- data/lib/swarm_sdk/utils.rb +18 -0
- data/lib/swarm_sdk/validation_result.rb +33 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +40 -8
- metadata +17 -6
- data/lib/swarm_sdk/mcp.rb +0 -16
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
# Registry for managing sub-swarms in composable swarms
|
|
5
|
+
#
|
|
6
|
+
# SwarmRegistry handles lazy loading, caching, and lifecycle management
|
|
7
|
+
# of child swarms registered via the `swarms` DSL block.
|
|
8
|
+
#
|
|
9
|
+
# ## Features
|
|
10
|
+
# - Lazy loading: Sub-swarms are only loaded when first accessed
|
|
11
|
+
# - Caching: Loaded swarms are cached for reuse
|
|
12
|
+
# - Hierarchical IDs: Sub-swarms get IDs based on parent + registration name
|
|
13
|
+
# - Context control: keep_context determines if swarm state persists
|
|
14
|
+
# - Lifecycle management: Cleanup cascades through all sub-swarms
|
|
15
|
+
#
|
|
16
|
+
# ## Example
|
|
17
|
+
#
|
|
18
|
+
# registry = SwarmRegistry.new(parent_swarm_id: "main_app")
|
|
19
|
+
# registry.register("code_review", file: "./swarms/code_review.rb", keep_context: true)
|
|
20
|
+
#
|
|
21
|
+
# # Lazy load on first access
|
|
22
|
+
# swarm = registry.load_swarm("code_review")
|
|
23
|
+
# # => Swarm with swarm_id = "main_app/code_review"
|
|
24
|
+
#
|
|
25
|
+
# # Reset if keep_context: false
|
|
26
|
+
# registry.reset_if_needed("code_review")
|
|
27
|
+
#
|
|
28
|
+
class SwarmRegistry
|
|
29
|
+
# Initialize a new swarm registry
|
|
30
|
+
#
|
|
31
|
+
# @param parent_swarm_id [String] ID of the parent swarm
|
|
32
|
+
def initialize(parent_swarm_id:)
|
|
33
|
+
@parent_swarm_id = parent_swarm_id
|
|
34
|
+
@registered_swarms = {}
|
|
35
|
+
# Format: { "code_review" => { file: "...", keep_context: true, instance: nil } }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Register a sub-swarm for lazy loading
|
|
39
|
+
#
|
|
40
|
+
# @param name [String] Registration name for the swarm
|
|
41
|
+
# @param source [Hash] Source specification with :type and :value
|
|
42
|
+
# - { type: :file, value: "./path/to/swarm.rb" }
|
|
43
|
+
# - { type: :yaml, value: "version: 2\n..." }
|
|
44
|
+
# - { type: :block, value: Proc }
|
|
45
|
+
# @param keep_context [Boolean] Whether to preserve conversation state between calls (default: true)
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @raise [ArgumentError] If swarm with same name already registered
|
|
48
|
+
def register(name, source:, keep_context: true)
|
|
49
|
+
raise ArgumentError, "Swarm '#{name}' already registered" if @registered_swarms.key?(name)
|
|
50
|
+
|
|
51
|
+
@registered_swarms[name] = {
|
|
52
|
+
source: source,
|
|
53
|
+
keep_context: keep_context,
|
|
54
|
+
instance: nil, # Lazy load
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if a swarm is registered
|
|
59
|
+
#
|
|
60
|
+
# @param name [String] Swarm registration name
|
|
61
|
+
# @return [Boolean] True if swarm is registered
|
|
62
|
+
def registered?(name)
|
|
63
|
+
@registered_swarms.key?(name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Load a registered swarm (lazy load + cache)
|
|
67
|
+
#
|
|
68
|
+
# Loads the swarm from its source (file, yaml, or block) on first access, then caches it.
|
|
69
|
+
# Sets hierarchical swarm_id based on parent_swarm_id + registration name.
|
|
70
|
+
#
|
|
71
|
+
# @param name [String] Swarm registration name
|
|
72
|
+
# @return [Swarm] Loaded swarm instance
|
|
73
|
+
# @raise [ConfigurationError] If swarm not registered
|
|
74
|
+
def load_swarm(name)
|
|
75
|
+
entry = @registered_swarms[name]
|
|
76
|
+
raise ConfigurationError, "Swarm '#{name}' not registered" unless entry
|
|
77
|
+
|
|
78
|
+
# Return cached instance if exists
|
|
79
|
+
return entry[:instance] if entry[:instance]
|
|
80
|
+
|
|
81
|
+
# Load from appropriate source
|
|
82
|
+
swarm_id = "#{@parent_swarm_id}/#{name}" # Hierarchical
|
|
83
|
+
source = entry[:source]
|
|
84
|
+
|
|
85
|
+
swarm = case source[:type]
|
|
86
|
+
when :file
|
|
87
|
+
SwarmLoader.load_from_file(
|
|
88
|
+
source[:value],
|
|
89
|
+
swarm_id: swarm_id,
|
|
90
|
+
parent_swarm_id: @parent_swarm_id,
|
|
91
|
+
)
|
|
92
|
+
when :yaml
|
|
93
|
+
SwarmLoader.load_from_yaml_string(
|
|
94
|
+
source[:value],
|
|
95
|
+
swarm_id: swarm_id,
|
|
96
|
+
parent_swarm_id: @parent_swarm_id,
|
|
97
|
+
)
|
|
98
|
+
when :block
|
|
99
|
+
SwarmLoader.load_from_block(
|
|
100
|
+
source[:value],
|
|
101
|
+
swarm_id: swarm_id,
|
|
102
|
+
parent_swarm_id: @parent_swarm_id,
|
|
103
|
+
)
|
|
104
|
+
else
|
|
105
|
+
raise ConfigurationError, "Unknown source type: #{source[:type]}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
entry[:instance] = swarm
|
|
109
|
+
swarm
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Reset swarm context if keep_context: false
|
|
113
|
+
#
|
|
114
|
+
# @param name [String] Swarm registration name
|
|
115
|
+
# @return [void]
|
|
116
|
+
def reset_if_needed(name)
|
|
117
|
+
entry = @registered_swarms[name]
|
|
118
|
+
return if entry[:keep_context]
|
|
119
|
+
|
|
120
|
+
entry[:instance]&.reset_context!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Cleanup all registered swarms
|
|
124
|
+
#
|
|
125
|
+
# Stops all loaded swarm instances and clears the registry.
|
|
126
|
+
# Should be called when parent swarm is done.
|
|
127
|
+
#
|
|
128
|
+
# @return [void]
|
|
129
|
+
def shutdown_all
|
|
130
|
+
@registered_swarms.each_value do |entry|
|
|
131
|
+
entry[:instance]&.cleanup
|
|
132
|
+
end
|
|
133
|
+
@registered_swarms.clear
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -14,10 +14,12 @@ module SwarmSDK
|
|
|
14
14
|
#
|
|
15
15
|
# @param delegate_name [String] Name of the delegate agent (e.g., "backend")
|
|
16
16
|
# @param delegate_description [String] Description of the delegate agent
|
|
17
|
-
# @param delegate_chat [AgentChat] The chat instance for the delegate agent
|
|
17
|
+
# @param delegate_chat [AgentChat, nil] The chat instance for the delegate agent (nil if delegating to swarm)
|
|
18
18
|
# @param agent_name [Symbol, String] Name of the agent using this tool
|
|
19
19
|
# @param swarm [Swarm] The swarm instance
|
|
20
20
|
# @param hook_registry [Hooks::Registry] Registry for callbacks
|
|
21
|
+
# @param call_stack [Array] Delegation call stack for circular dependency detection
|
|
22
|
+
# @param swarm_registry [SwarmRegistry, nil] Registry for sub-swarms (nil if not using composable swarms)
|
|
21
23
|
# @param delegating_chat [Agent::Chat, nil] The chat instance of the agent doing the delegating (for accessing hooks)
|
|
22
24
|
def initialize(
|
|
23
25
|
delegate_name:,
|
|
@@ -26,6 +28,8 @@ module SwarmSDK
|
|
|
26
28
|
agent_name:,
|
|
27
29
|
swarm:,
|
|
28
30
|
hook_registry:,
|
|
31
|
+
call_stack:,
|
|
32
|
+
swarm_registry: nil,
|
|
29
33
|
delegating_chat: nil
|
|
30
34
|
)
|
|
31
35
|
super()
|
|
@@ -36,6 +40,8 @@ module SwarmSDK
|
|
|
36
40
|
@agent_name = agent_name
|
|
37
41
|
@swarm = swarm
|
|
38
42
|
@hook_registry = hook_registry
|
|
43
|
+
@call_stack = call_stack
|
|
44
|
+
@swarm_registry = swarm_registry
|
|
39
45
|
@delegating_chat = delegating_chat
|
|
40
46
|
|
|
41
47
|
# Generate tool name in the expected format: DelegateTaskTo[AgentName]
|
|
@@ -63,6 +69,13 @@ module SwarmSDK
|
|
|
63
69
|
# @param task [String] Task to delegate
|
|
64
70
|
# @return [String] Result from delegate agent or error message
|
|
65
71
|
def execute(task:)
|
|
72
|
+
# Check for circular dependency
|
|
73
|
+
if @call_stack.include?(@delegate_target)
|
|
74
|
+
emit_circular_warning
|
|
75
|
+
return "Error: Circular delegation detected: #{@call_stack.join(" -> ")} -> #{@delegate_target}. " \
|
|
76
|
+
"Please restructure your delegation to avoid infinite loops."
|
|
77
|
+
end
|
|
78
|
+
|
|
66
79
|
# Get agent-specific hooks from the delegating chat instance
|
|
67
80
|
agent_hooks = if @delegating_chat&.respond_to?(:hook_agent_hooks)
|
|
68
81
|
@delegating_chat.hook_agent_hooks || {}
|
|
@@ -94,9 +107,16 @@ module SwarmSDK
|
|
|
94
107
|
return result.value
|
|
95
108
|
end
|
|
96
109
|
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
# Determine delegation type and proceed
|
|
111
|
+
delegation_result = if @delegate_chat
|
|
112
|
+
# Delegate to agent
|
|
113
|
+
delegate_to_agent(task)
|
|
114
|
+
elsif @swarm_registry&.registered?(@delegate_target)
|
|
115
|
+
# Delegate to registered swarm
|
|
116
|
+
delegate_to_swarm(task)
|
|
117
|
+
else
|
|
118
|
+
raise ConfigurationError, "Unknown delegation target: #{@delegate_target}"
|
|
119
|
+
end
|
|
100
120
|
|
|
101
121
|
# Trigger post_delegation callback
|
|
102
122
|
post_context = Hooks::Context.new(
|
|
@@ -127,10 +147,12 @@ module SwarmSDK
|
|
|
127
147
|
LogStream.emit(
|
|
128
148
|
type: "delegation_error",
|
|
129
149
|
agent: @agent_name,
|
|
150
|
+
swarm_id: @swarm.swarm_id,
|
|
151
|
+
parent_swarm_id: @swarm.parent_swarm_id,
|
|
130
152
|
delegate_to: @tool_name,
|
|
131
153
|
error_class: e.class.name,
|
|
132
154
|
error_message: "Request timed out",
|
|
133
|
-
|
|
155
|
+
error_backtrace: e.backtrace&.first(5) || [],
|
|
134
156
|
)
|
|
135
157
|
"Error: Request to #{@tool_name} timed out. The agent may be overloaded or the LLM service is not responding. Please try again or simplify the task."
|
|
136
158
|
rescue Faraday::Error => e
|
|
@@ -138,10 +160,12 @@ module SwarmSDK
|
|
|
138
160
|
LogStream.emit(
|
|
139
161
|
type: "delegation_error",
|
|
140
162
|
agent: @agent_name,
|
|
163
|
+
swarm_id: @swarm.swarm_id,
|
|
164
|
+
parent_swarm_id: @swarm.parent_swarm_id,
|
|
141
165
|
delegate_to: @tool_name,
|
|
142
166
|
error_class: e.class.name,
|
|
143
167
|
error_message: e.message,
|
|
144
|
-
|
|
168
|
+
error_backtrace: e.backtrace&.first(5) || [],
|
|
145
169
|
)
|
|
146
170
|
"Error: Network error communicating with #{@tool_name}: #{e.class.name}. Please check connectivity and try again."
|
|
147
171
|
rescue StandardError => e
|
|
@@ -150,15 +174,76 @@ module SwarmSDK
|
|
|
150
174
|
LogStream.emit(
|
|
151
175
|
type: "delegation_error",
|
|
152
176
|
agent: @agent_name,
|
|
177
|
+
swarm_id: @swarm.swarm_id,
|
|
178
|
+
parent_swarm_id: @swarm.parent_swarm_id,
|
|
153
179
|
delegate_to: @tool_name,
|
|
154
180
|
error_class: e.class.name,
|
|
155
181
|
error_message: e.message,
|
|
156
|
-
|
|
182
|
+
error_backtrace: backtrace_array,
|
|
157
183
|
)
|
|
158
184
|
# Return error string for LLM
|
|
159
185
|
backtrace_str = backtrace_array.join("\n ")
|
|
160
186
|
"Error: #{@tool_name} encountered an error: #{e.class.name}: #{e.message}\nBacktrace:\n #{backtrace_str}"
|
|
161
187
|
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
# Delegate to an agent
|
|
192
|
+
#
|
|
193
|
+
# @param task [String] Task to delegate
|
|
194
|
+
# @return [String] Result from agent
|
|
195
|
+
def delegate_to_agent(task)
|
|
196
|
+
# Push delegate target onto call stack to track delegation chain
|
|
197
|
+
@call_stack.push(@delegate_target)
|
|
198
|
+
begin
|
|
199
|
+
response = @delegate_chat.ask(task)
|
|
200
|
+
response.content
|
|
201
|
+
ensure
|
|
202
|
+
# Always pop from stack, even if delegation fails
|
|
203
|
+
@call_stack.pop
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Delegate to a registered swarm
|
|
208
|
+
#
|
|
209
|
+
# @param task [String] Task to delegate
|
|
210
|
+
# @return [String] Result from swarm's lead agent
|
|
211
|
+
def delegate_to_swarm(task)
|
|
212
|
+
# Load sub-swarm (lazy load + cache)
|
|
213
|
+
subswarm = @swarm_registry.load_swarm(@delegate_target)
|
|
214
|
+
|
|
215
|
+
# Push delegate target onto call stack to track delegation chain
|
|
216
|
+
@call_stack.push(@delegate_target)
|
|
217
|
+
begin
|
|
218
|
+
# Execute sub-swarm's lead agent
|
|
219
|
+
lead_agent = subswarm.agents[subswarm.lead_agent]
|
|
220
|
+
response = lead_agent.ask(task)
|
|
221
|
+
result = response.content
|
|
222
|
+
|
|
223
|
+
# Reset if keep_context: false
|
|
224
|
+
@swarm_registry.reset_if_needed(@delegate_target)
|
|
225
|
+
|
|
226
|
+
result
|
|
227
|
+
ensure
|
|
228
|
+
# Always pop from stack, even if delegation fails
|
|
229
|
+
@call_stack.pop
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Emit circular dependency warning event
|
|
234
|
+
#
|
|
235
|
+
# @return [void]
|
|
236
|
+
def emit_circular_warning
|
|
237
|
+
LogStream.emit(
|
|
238
|
+
type: "delegation_circular_dependency",
|
|
239
|
+
agent: @agent_name,
|
|
240
|
+
swarm_id: @swarm.swarm_id,
|
|
241
|
+
parent_swarm_id: @swarm.parent_swarm_id,
|
|
242
|
+
target: @delegate_target,
|
|
243
|
+
call_stack: @call_stack,
|
|
244
|
+
timestamp: Time.now.utc.iso8601,
|
|
245
|
+
)
|
|
246
|
+
end
|
|
162
247
|
end
|
|
163
248
|
end
|
|
164
249
|
end
|
data/lib/swarm_sdk/tools/read.rb
CHANGED
|
@@ -92,25 +92,37 @@ module SwarmSDK
|
|
|
92
92
|
return validation_error("Path is a directory, not a file. Use Bash with ls to read directories.")
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
-
# Register this read in the tracker (use resolved path)
|
|
96
|
-
Stores::ReadTracker.register_read(@agent_name, resolved_path)
|
|
97
|
-
|
|
98
95
|
# Check if it's a document and try to convert it
|
|
99
96
|
converter = find_converter_for_file(resolved_path)
|
|
100
97
|
if converter
|
|
101
98
|
result = converter.new.convert(resolved_path)
|
|
99
|
+
# For document files, register the converted text content
|
|
100
|
+
# Extract text from result (may be wrapped in system-reminder tags)
|
|
101
|
+
if result.is_a?(String)
|
|
102
|
+
# Remove system-reminder wrapper if present to get clean text for digest
|
|
103
|
+
text_content = result.gsub(%r{<system-reminder>.*?</system-reminder>}m, "").strip
|
|
104
|
+
Stores::ReadTracker.register_read(@agent_name, resolved_path, text_content)
|
|
105
|
+
end
|
|
102
106
|
return result
|
|
103
107
|
end
|
|
104
108
|
|
|
105
109
|
# Try to read as text, handle binary files separately
|
|
106
110
|
content = read_file_content(resolved_path)
|
|
107
111
|
|
|
108
|
-
# If content is a Content object (binary file),
|
|
109
|
-
|
|
112
|
+
# If content is a Content object (binary file), track with binary digest and return
|
|
113
|
+
if content.is_a?(RubyLLM::Content)
|
|
114
|
+
# For binary files, read raw bytes for digest
|
|
115
|
+
binary_content = File.binread(resolved_path)
|
|
116
|
+
Stores::ReadTracker.register_read(@agent_name, resolved_path, binary_content)
|
|
117
|
+
return content
|
|
118
|
+
end
|
|
110
119
|
|
|
111
120
|
# Return early if we got an error message or system reminder
|
|
112
121
|
return content if content.is_a?(String) && (content.start_with?("Error:") || content.start_with?("<system-reminder>"))
|
|
113
122
|
|
|
123
|
+
# At this point, we have valid text content - register the read with digest
|
|
124
|
+
Stores::ReadTracker.register_read(@agent_name, resolved_path, content)
|
|
125
|
+
|
|
114
126
|
# Check if file is empty
|
|
115
127
|
if content.empty?
|
|
116
128
|
return format_with_reminder(
|
|
@@ -3,39 +3,74 @@
|
|
|
3
3
|
module SwarmSDK
|
|
4
4
|
module Tools
|
|
5
5
|
module Stores
|
|
6
|
-
# ReadTracker manages read-file tracking for all agents
|
|
6
|
+
# ReadTracker manages read-file tracking for all agents with content digest verification
|
|
7
7
|
#
|
|
8
8
|
# This module maintains a global registry of which files each agent has read
|
|
9
|
-
# during their conversation
|
|
10
|
-
# and "read-before-edit" rules that ensure
|
|
9
|
+
# during their conversation along with SHA256 digests of the content. This enables
|
|
10
|
+
# enforcement of the "read-before-write" and "read-before-edit" rules that ensure
|
|
11
|
+
# agents have context before modifying files, AND prevents editing files that have
|
|
12
|
+
# changed externally since being read.
|
|
11
13
|
#
|
|
12
|
-
# Each agent maintains an independent
|
|
14
|
+
# Each agent maintains an independent map of read files to content digests.
|
|
13
15
|
module ReadTracker
|
|
14
|
-
@read_files = {}
|
|
16
|
+
@read_files = {} # { agent_id => { file_path => sha256_digest } }
|
|
15
17
|
@mutex = Mutex.new
|
|
16
18
|
|
|
17
19
|
class << self
|
|
18
|
-
# Register that an agent has read a file
|
|
20
|
+
# Register that an agent has read a file with content digest
|
|
19
21
|
#
|
|
20
22
|
# @param agent_id [Symbol] The agent identifier
|
|
21
23
|
# @param file_path [String] The absolute path to the file
|
|
22
|
-
|
|
24
|
+
# @param content [String] File content (for digest calculation)
|
|
25
|
+
# @return [String] The calculated SHA256 digest
|
|
26
|
+
def register_read(agent_id, file_path, content)
|
|
23
27
|
@mutex.synchronize do
|
|
24
|
-
@read_files[agent_id] ||=
|
|
25
|
-
|
|
28
|
+
@read_files[agent_id] ||= {}
|
|
29
|
+
digest = Digest::SHA256.hexdigest(content)
|
|
30
|
+
@read_files[agent_id][File.expand_path(file_path)] = digest
|
|
31
|
+
digest
|
|
26
32
|
end
|
|
27
33
|
end
|
|
28
34
|
|
|
29
|
-
# Check if an agent has read a file
|
|
35
|
+
# Check if an agent has read a file AND content hasn't changed
|
|
30
36
|
#
|
|
31
37
|
# @param agent_id [Symbol] The agent identifier
|
|
32
38
|
# @param file_path [String] The absolute path to the file
|
|
33
|
-
# @return [Boolean] true if
|
|
39
|
+
# @return [Boolean] true if agent read file and content matches
|
|
34
40
|
def file_read?(agent_id, file_path)
|
|
35
41
|
@mutex.synchronize do
|
|
36
42
|
return false unless @read_files[agent_id]
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
expanded_path = File.expand_path(file_path)
|
|
45
|
+
stored_digest = @read_files[agent_id][expanded_path]
|
|
46
|
+
return false unless stored_digest
|
|
47
|
+
|
|
48
|
+
# Check if file still exists and matches stored digest
|
|
49
|
+
return false unless File.exist?(expanded_path)
|
|
50
|
+
|
|
51
|
+
current_digest = Digest::SHA256.hexdigest(File.read(expanded_path))
|
|
52
|
+
current_digest == stored_digest
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get all read files with digests for snapshot
|
|
57
|
+
#
|
|
58
|
+
# @param agent_id [Symbol] The agent identifier
|
|
59
|
+
# @return [Hash] { file_path => digest }
|
|
60
|
+
def get_read_files(agent_id)
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
@read_files[agent_id]&.dup || {}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Restore read files with digests from snapshot
|
|
67
|
+
#
|
|
68
|
+
# @param agent_id [Symbol] The agent identifier
|
|
69
|
+
# @param files_with_digests [Hash] { file_path => digest }
|
|
70
|
+
# @return [void]
|
|
71
|
+
def restore_read_files(agent_id, files_with_digests)
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@read_files[agent_id] = files_with_digests.dup
|
|
39
74
|
end
|
|
40
75
|
end
|
|
41
76
|
|
|
@@ -218,6 +218,51 @@ module SwarmSDK
|
|
|
218
218
|
def size
|
|
219
219
|
@entries.size
|
|
220
220
|
end
|
|
221
|
+
|
|
222
|
+
# Get all entries with content for snapshot
|
|
223
|
+
#
|
|
224
|
+
# Thread-safe method that returns a copy of all entries.
|
|
225
|
+
# Used by snapshot/restore functionality.
|
|
226
|
+
#
|
|
227
|
+
# @return [Hash] { path => Entry }
|
|
228
|
+
def all_entries
|
|
229
|
+
@mutex.synchronize do
|
|
230
|
+
@entries.dup
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Restore entries from snapshot
|
|
235
|
+
#
|
|
236
|
+
# Restores entries directly without using write() to preserve timestamps.
|
|
237
|
+
# This ensures entry ordering and metadata accuracy after restore.
|
|
238
|
+
#
|
|
239
|
+
# @param entries_data [Hash] { path => { content:, title:, updated_at:, size: } }
|
|
240
|
+
# @return [void]
|
|
241
|
+
def restore_entries(entries_data)
|
|
242
|
+
@mutex.synchronize do
|
|
243
|
+
entries_data.each do |path, data|
|
|
244
|
+
# Handle both symbol and string keys from JSON
|
|
245
|
+
content = data[:content] || data["content"]
|
|
246
|
+
title = data[:title] || data["title"]
|
|
247
|
+
updated_at_str = data[:updated_at] || data["updated_at"]
|
|
248
|
+
|
|
249
|
+
# Parse timestamp from ISO8601 string
|
|
250
|
+
updated_at = Time.parse(updated_at_str)
|
|
251
|
+
|
|
252
|
+
# Create entry with preserved timestamp
|
|
253
|
+
entry = Entry.new(
|
|
254
|
+
content: content,
|
|
255
|
+
title: title,
|
|
256
|
+
updated_at: updated_at,
|
|
257
|
+
size: content.bytesize,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Update storage
|
|
261
|
+
@entries[path] = entry
|
|
262
|
+
@total_size += entry.size
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
221
266
|
end
|
|
222
267
|
end
|
|
223
268
|
end
|
data/lib/swarm_sdk/utils.rb
CHANGED
|
@@ -45,6 +45,24 @@ module SwarmSDK
|
|
|
45
45
|
obj
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
|
+
|
|
49
|
+
# Convert hash to YAML string
|
|
50
|
+
#
|
|
51
|
+
# Converts a Ruby hash to a YAML string. Useful for creating inline
|
|
52
|
+
# swarm definitions from hash configurations.
|
|
53
|
+
#
|
|
54
|
+
# @param hash [Hash] Hash to convert
|
|
55
|
+
# @return [String] YAML string representation
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# config = { version: 2, swarm: { name: "Test" } }
|
|
59
|
+
# Utils.hash_to_yaml(config)
|
|
60
|
+
# # => "---\nversion: 2\nswarm:\n name: Test\n"
|
|
61
|
+
def hash_to_yaml(hash)
|
|
62
|
+
# Convert symbols to strings for valid YAML
|
|
63
|
+
stringified = stringify_keys(hash)
|
|
64
|
+
stringified.to_yaml
|
|
65
|
+
end
|
|
48
66
|
end
|
|
49
67
|
end
|
|
50
68
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
# Internal result object for validation phase during snapshot restore
|
|
5
|
+
#
|
|
6
|
+
# Used during restore to track which agents can be restored and which
|
|
7
|
+
# need to be skipped due to configuration mismatches.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class ValidationResult
|
|
11
|
+
attr_reader :warnings,
|
|
12
|
+
:skipped_agents,
|
|
13
|
+
:restorable_agents,
|
|
14
|
+
:skipped_delegations,
|
|
15
|
+
:restorable_delegations
|
|
16
|
+
|
|
17
|
+
# Initialize validation result
|
|
18
|
+
#
|
|
19
|
+
# @param warnings [Array<Hash>] Warning messages with details
|
|
20
|
+
# @param skipped_agents [Array<Symbol>] Names of agents that can't be restored
|
|
21
|
+
# @param restorable_agents [Array<Symbol>] Names of agents that can be restored
|
|
22
|
+
# @param skipped_delegations [Array<String>] Names of delegations that can't be restored
|
|
23
|
+
# @param restorable_delegations [Array<String>] Names of delegations that can be restored
|
|
24
|
+
def initialize(warnings:, skipped_agents:, restorable_agents:,
|
|
25
|
+
skipped_delegations:, restorable_delegations:)
|
|
26
|
+
@warnings = warnings
|
|
27
|
+
@skipped_agents = skipped_agents
|
|
28
|
+
@restorable_agents = restorable_agents
|
|
29
|
+
@skipped_delegations = skipped_delegations
|
|
30
|
+
@restorable_delegations = restorable_delegations
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/swarm_sdk/version.rb
CHANGED
data/lib/swarm_sdk.rb
CHANGED
|
@@ -15,6 +15,7 @@ require "yaml"
|
|
|
15
15
|
require "async"
|
|
16
16
|
require "async/semaphore"
|
|
17
17
|
require "ruby_llm"
|
|
18
|
+
require "ruby_llm/mcp"
|
|
18
19
|
|
|
19
20
|
require_relative "swarm_sdk/version"
|
|
20
21
|
|
|
@@ -25,6 +26,8 @@ loader.push_dir("#{__dir__}/swarm_sdk", namespace: SwarmSDK)
|
|
|
25
26
|
loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
|
|
26
27
|
loader.inflector.inflect(
|
|
27
28
|
"cli" => "CLI",
|
|
29
|
+
"llm_instrumentation_middleware" => "LLMInstrumentationMiddleware",
|
|
30
|
+
"mcp" => "MCP",
|
|
28
31
|
"openai_with_responses" => "OpenAIWithResponses",
|
|
29
32
|
)
|
|
30
33
|
loader.setup
|
|
@@ -43,8 +46,8 @@ module SwarmSDK
|
|
|
43
46
|
attr_accessor :settings
|
|
44
47
|
|
|
45
48
|
# Main entry point for DSL
|
|
46
|
-
def build(&block)
|
|
47
|
-
Swarm::Builder.build(&block)
|
|
49
|
+
def build(allow_filesystem_tools: nil, &block)
|
|
50
|
+
Swarm::Builder.build(allow_filesystem_tools: allow_filesystem_tools, &block)
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
# Validate YAML configuration without creating a swarm
|
|
@@ -84,6 +87,10 @@ module SwarmSDK
|
|
|
84
87
|
begin
|
|
85
88
|
config = Configuration.new(yaml_content, base_dir: base_dir)
|
|
86
89
|
config.load_and_validate
|
|
90
|
+
|
|
91
|
+
# Build swarm to trigger DSL validation
|
|
92
|
+
# This catches errors from Agent::Definition, Builder, etc.
|
|
93
|
+
config.to_swarm
|
|
87
94
|
rescue ConfigurationError, CircularDependencyError => e
|
|
88
95
|
errors << parse_configuration_error(e)
|
|
89
96
|
rescue StandardError => e
|
|
@@ -164,10 +171,10 @@ module SwarmSDK
|
|
|
164
171
|
# @example Load with default base_dir (Dir.pwd)
|
|
165
172
|
# yaml = File.read("config.yml")
|
|
166
173
|
# swarm = SwarmSDK.load(yaml) # base_dir defaults to Dir.pwd
|
|
167
|
-
def load(yaml_content, base_dir: Dir.pwd)
|
|
174
|
+
def load(yaml_content, base_dir: Dir.pwd, allow_filesystem_tools: nil)
|
|
168
175
|
config = Configuration.new(yaml_content, base_dir: base_dir)
|
|
169
176
|
config.load_and_validate
|
|
170
|
-
swarm = config.to_swarm
|
|
177
|
+
swarm = config.to_swarm(allow_filesystem_tools: allow_filesystem_tools)
|
|
171
178
|
|
|
172
179
|
# Apply hooks if any are configured (YAML-only feature)
|
|
173
180
|
if hooks_configured?(config)
|
|
@@ -196,9 +203,9 @@ module SwarmSDK
|
|
|
196
203
|
#
|
|
197
204
|
# @example With absolute path
|
|
198
205
|
# swarm = SwarmSDK.load_file("/absolute/path/config.yml")
|
|
199
|
-
def load_file(path)
|
|
206
|
+
def load_file(path, allow_filesystem_tools: nil)
|
|
200
207
|
config = Configuration.load_file(path)
|
|
201
|
-
swarm = config.to_swarm
|
|
208
|
+
swarm = config.to_swarm(allow_filesystem_tools: allow_filesystem_tools)
|
|
202
209
|
|
|
203
210
|
# Apply hooks if any are configured (YAML-only feature)
|
|
204
211
|
if hooks_configured?(config)
|
|
@@ -235,7 +242,7 @@ module SwarmSDK
|
|
|
235
242
|
def hooks_configured?(config)
|
|
236
243
|
config.swarm_hooks.any? ||
|
|
237
244
|
config.all_agents_hooks.any? ||
|
|
238
|
-
config.agents.any? { |_,
|
|
245
|
+
config.agents.any? { |_, agent_config| agent_config[:hooks]&.any? }
|
|
239
246
|
end
|
|
240
247
|
|
|
241
248
|
# Parse configuration error and extract structured information
|
|
@@ -323,7 +330,7 @@ module SwarmSDK
|
|
|
323
330
|
field: "swarm.lead",
|
|
324
331
|
)
|
|
325
332
|
|
|
326
|
-
# Unknown agent in connections
|
|
333
|
+
# Unknown agent in connections (old format)
|
|
327
334
|
when /Agent '([^']+)' has connection to unknown agent '([^']+)'/i
|
|
328
335
|
agent_name = Regexp.last_match(1)
|
|
329
336
|
error_hash.merge!(
|
|
@@ -332,6 +339,15 @@ module SwarmSDK
|
|
|
332
339
|
agent: agent_name,
|
|
333
340
|
)
|
|
334
341
|
|
|
342
|
+
# Unknown agent in connections (new format with composable swarms)
|
|
343
|
+
when /Agent '([^']+)' delegates to unknown target '([^']+)'/i
|
|
344
|
+
agent_name = Regexp.last_match(1)
|
|
345
|
+
error_hash.merge!(
|
|
346
|
+
type: :invalid_reference,
|
|
347
|
+
field: "swarm.agents.#{agent_name}.delegates_to",
|
|
348
|
+
agent: agent_name,
|
|
349
|
+
)
|
|
350
|
+
|
|
335
351
|
# Circular dependency
|
|
336
352
|
when /Circular dependency detected/i
|
|
337
353
|
error_hash.merge!(
|
|
@@ -397,17 +413,33 @@ module SwarmSDK
|
|
|
397
413
|
# WebFetch tool LLM processing configuration
|
|
398
414
|
attr_accessor :webfetch_provider, :webfetch_model, :webfetch_base_url, :webfetch_max_tokens
|
|
399
415
|
|
|
416
|
+
# Filesystem tools control
|
|
417
|
+
attr_accessor :allow_filesystem_tools
|
|
418
|
+
|
|
400
419
|
def initialize
|
|
401
420
|
@webfetch_provider = nil
|
|
402
421
|
@webfetch_model = nil
|
|
403
422
|
@webfetch_base_url = nil
|
|
404
423
|
@webfetch_max_tokens = 4096
|
|
424
|
+
@allow_filesystem_tools = parse_env_bool("SWARM_SDK_ALLOW_FILESYSTEM_TOOLS", default: true)
|
|
405
425
|
end
|
|
406
426
|
|
|
407
427
|
# Check if WebFetch LLM processing is enabled
|
|
408
428
|
def webfetch_llm_enabled?
|
|
409
429
|
!@webfetch_provider.nil? && !@webfetch_model.nil?
|
|
410
430
|
end
|
|
431
|
+
|
|
432
|
+
private
|
|
433
|
+
|
|
434
|
+
def parse_env_bool(key, default:)
|
|
435
|
+
return default unless ENV.key?(key)
|
|
436
|
+
|
|
437
|
+
value = ENV[key].to_s.downcase
|
|
438
|
+
return true if ["true", "yes", "1", "on", "enabled"].include?(value)
|
|
439
|
+
return false if ["false", "no", "0", "off", "disabled"].include?(value)
|
|
440
|
+
|
|
441
|
+
default
|
|
442
|
+
end
|
|
411
443
|
end
|
|
412
444
|
|
|
413
445
|
# Initialize default settings
|