swarm_memory 2.1.1 → 2.1.3
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/claude_swarm/cli.rb +9 -11
- data/lib/claude_swarm/commands/ps.rb +1 -2
- data/lib/claude_swarm/configuration.rb +30 -7
- data/lib/claude_swarm/mcp_generator.rb +4 -10
- data/lib/claude_swarm/orchestrator.rb +43 -44
- data/lib/claude_swarm/system_utils.rb +4 -4
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm.rb +5 -9
- data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
- data/lib/swarm_cli/commands/mcp_tools.rb +3 -3
- data/lib/swarm_cli/config_loader.rb +14 -13
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_cli.rb +2 -0
- data/lib/swarm_memory/adapters/base.rb +4 -4
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +0 -12
- data/lib/swarm_memory/core/storage.rb +66 -6
- data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
- data/lib/swarm_memory/integration/cli_registration.rb +3 -2
- data/lib/swarm_memory/integration/sdk_plugin.rb +24 -4
- data/lib/swarm_memory/optimization/defragmenter.rb +4 -0
- data/lib/swarm_memory/tools/memory_edit.rb +3 -2
- data/lib/swarm_memory/tools/memory_glob.rb +24 -1
- data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_read.rb +3 -3
- data/lib/swarm_memory/tools/memory_write.rb +2 -2
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +7 -0
- 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 +199 -52
- 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 +32 -23
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +420 -103
- 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 +39 -9
- data/lib/swarm_sdk/node/builder.rb +158 -42
- data/lib/swarm_sdk/node_context.rb +75 -0
- data/lib/swarm_sdk/node_orchestrator.rb +492 -18
- data/lib/swarm_sdk/plugin.rb +73 -1
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
- data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/result.rb +32 -6
- 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 -11
- 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 +367 -90
- 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 +94 -9
- data/lib/swarm_sdk/tools/read.rb +17 -5
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
- 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/tools/stores/storage.rb +4 -4
- data/lib/swarm_sdk/tools/think.rb +4 -1
- data/lib/swarm_sdk/tools/todo_write.rb +20 -8
- 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 +365 -28
- metadata +17 -5
|
@@ -18,18 +18,120 @@ module SwarmSDK
|
|
|
18
18
|
# )
|
|
19
19
|
# result = orchestrator.execute("Build auth system")
|
|
20
20
|
class NodeOrchestrator
|
|
21
|
-
attr_reader :swarm_name, :nodes, :start_node
|
|
21
|
+
attr_reader :swarm_name, :nodes, :start_node, :agent_definitions, :agent_instance_cache, :scratchpad
|
|
22
|
+
attr_writer :swarm_id, :config_for_hooks
|
|
23
|
+
attr_accessor :swarm_registry_config
|
|
22
24
|
|
|
23
|
-
def initialize(swarm_name:, agent_definitions:, nodes:, start_node:)
|
|
25
|
+
def initialize(swarm_name:, agent_definitions:, nodes:, start_node:, swarm_id: nil, scratchpad: :enabled, allow_filesystem_tools: nil)
|
|
24
26
|
@swarm_name = swarm_name
|
|
27
|
+
@swarm_id = swarm_id
|
|
25
28
|
@agent_definitions = agent_definitions
|
|
26
29
|
@nodes = nodes
|
|
27
30
|
@start_node = start_node
|
|
31
|
+
@scratchpad = normalize_scratchpad_mode(scratchpad)
|
|
32
|
+
@allow_filesystem_tools = allow_filesystem_tools
|
|
33
|
+
@swarm_registry_config = [] # External swarms config (if using composable swarms)
|
|
34
|
+
@agent_instance_cache = {
|
|
35
|
+
primary: {}, # { agent_name => Agent::Chat }
|
|
36
|
+
delegations: {}, # { "delegate@delegator" => Agent::Chat }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Initialize scratchpad storage based on mode
|
|
40
|
+
case @scratchpad
|
|
41
|
+
when :enabled
|
|
42
|
+
# Enabled mode: single scratchpad shared across all nodes
|
|
43
|
+
@shared_scratchpad_storage = Tools::Stores::ScratchpadStorage.new
|
|
44
|
+
@node_scratchpads = nil
|
|
45
|
+
when :per_node
|
|
46
|
+
# Per-node mode: separate scratchpad per node (lazy initialized)
|
|
47
|
+
@shared_scratchpad_storage = nil
|
|
48
|
+
@node_scratchpads = {}
|
|
49
|
+
when :disabled
|
|
50
|
+
# Disabled: no storage at all
|
|
51
|
+
@shared_scratchpad_storage = nil
|
|
52
|
+
@node_scratchpads = nil
|
|
53
|
+
end
|
|
28
54
|
|
|
29
55
|
validate!
|
|
30
56
|
@execution_order = build_execution_order
|
|
31
57
|
end
|
|
32
58
|
|
|
59
|
+
# Alias for compatibility with Swarm interface
|
|
60
|
+
alias_method :name, :swarm_name
|
|
61
|
+
|
|
62
|
+
# Get scratchpad storage for a specific node
|
|
63
|
+
#
|
|
64
|
+
# Returns the appropriate scratchpad based on mode:
|
|
65
|
+
# - :enabled - returns the shared scratchpad (same for all nodes)
|
|
66
|
+
# - :per_node - returns node-specific scratchpad (lazy initialized)
|
|
67
|
+
# - :disabled - returns nil
|
|
68
|
+
#
|
|
69
|
+
# @param node_name [Symbol] Node name
|
|
70
|
+
# @return [Tools::Stores::ScratchpadStorage, nil] Scratchpad instance or nil if disabled
|
|
71
|
+
def scratchpad_for(node_name)
|
|
72
|
+
case @scratchpad
|
|
73
|
+
when :enabled
|
|
74
|
+
@shared_scratchpad_storage
|
|
75
|
+
when :per_node
|
|
76
|
+
# Lazy initialization per node
|
|
77
|
+
@node_scratchpads[node_name] ||= Tools::Stores::ScratchpadStorage.new
|
|
78
|
+
when :disabled
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get all scratchpad storages (for snapshot/restore)
|
|
84
|
+
#
|
|
85
|
+
# @return [Hash] { :shared => scratchpad } or { node_name => scratchpad }
|
|
86
|
+
def all_scratchpads
|
|
87
|
+
case @scratchpad
|
|
88
|
+
when :enabled
|
|
89
|
+
{ shared: @shared_scratchpad_storage }
|
|
90
|
+
when :per_node
|
|
91
|
+
@node_scratchpads.dup
|
|
92
|
+
when :disabled
|
|
93
|
+
{}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if scratchpad is enabled
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def scratchpad_enabled?
|
|
101
|
+
@scratchpad != :disabled
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if scratchpad is shared between nodes (enabled mode)
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def shared_scratchpad?
|
|
108
|
+
@scratchpad == :enabled
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check if scratchpad is per-node
|
|
112
|
+
#
|
|
113
|
+
# @return [Boolean]
|
|
114
|
+
def per_node_scratchpad?
|
|
115
|
+
@scratchpad == :per_node
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Backward compatibility accessor
|
|
119
|
+
#
|
|
120
|
+
# @return [Tools::Stores::ScratchpadStorage, nil]
|
|
121
|
+
def shared_scratchpad_storage
|
|
122
|
+
if @scratchpad == :per_node
|
|
123
|
+
RubyLLM.logger.warn("NodeOrchestrator: Accessing shared_scratchpad_storage in per-node mode. Use scratchpad_for(node_name) instead.")
|
|
124
|
+
end
|
|
125
|
+
@shared_scratchpad_storage
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Return the lead agent of the start node for CLI compatibility
|
|
129
|
+
#
|
|
130
|
+
# @return [Symbol] Lead agent of the start node
|
|
131
|
+
def lead_agent
|
|
132
|
+
@nodes[@start_node].lead_agent
|
|
133
|
+
end
|
|
134
|
+
|
|
33
135
|
# Execute the node workflow
|
|
34
136
|
#
|
|
35
137
|
# Executes nodes in topological order, passing output from each node
|
|
@@ -44,6 +146,9 @@ module SwarmSDK
|
|
|
44
146
|
results = {}
|
|
45
147
|
@original_prompt = prompt # Store original prompt for NodeContext
|
|
46
148
|
|
|
149
|
+
# Set fiber-local execution context for entire workflow
|
|
150
|
+
Fiber[:execution_id] = generate_execution_id
|
|
151
|
+
|
|
47
152
|
# Setup logging if block given
|
|
48
153
|
if block_given?
|
|
49
154
|
# Register callback to collect logs and forward to user's block
|
|
@@ -56,10 +161,21 @@ module SwarmSDK
|
|
|
56
161
|
LogStream.emitter = LogCollector
|
|
57
162
|
end
|
|
58
163
|
|
|
59
|
-
|
|
164
|
+
# Dynamic execution with support for goto_node
|
|
165
|
+
execution_index = 0
|
|
166
|
+
last_result = nil
|
|
167
|
+
|
|
168
|
+
while execution_index < @execution_order.size
|
|
169
|
+
node_name = @execution_order[execution_index]
|
|
60
170
|
node = @nodes[node_name]
|
|
61
171
|
node_start_time = Time.now
|
|
62
172
|
|
|
173
|
+
# Set node-specific swarm_id in fiber storage
|
|
174
|
+
# Mini-swarms will use ||= to inherit execution_id
|
|
175
|
+
node_swarm_id = @swarm_id ? "#{@swarm_id}/node:#{node_name}" : nil
|
|
176
|
+
Fiber[:swarm_id] = node_swarm_id
|
|
177
|
+
Fiber[:parent_swarm_id] = @swarm_id
|
|
178
|
+
|
|
63
179
|
# Emit node_start event
|
|
64
180
|
emit_node_start(node_name, node)
|
|
65
181
|
|
|
@@ -102,13 +218,26 @@ module SwarmSDK
|
|
|
102
218
|
# - Exit 2: Halt workflow with error from STDERR (STDOUT ignored)
|
|
103
219
|
transformed = node.transform_input(input_context, current_input: current_input)
|
|
104
220
|
|
|
105
|
-
# Check
|
|
106
|
-
|
|
107
|
-
|
|
221
|
+
# Check for control flow from transformer
|
|
222
|
+
control_result = handle_transformer_control_flow(
|
|
223
|
+
transformed: transformed,
|
|
224
|
+
node_name: node_name,
|
|
225
|
+
node: node,
|
|
226
|
+
node_start_time: node_start_time,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
case control_result[:action]
|
|
230
|
+
when :halt
|
|
231
|
+
return control_result[:result]
|
|
232
|
+
when :goto
|
|
233
|
+
execution_index = find_node_index(control_result[:target])
|
|
234
|
+
current_input = control_result[:content]
|
|
235
|
+
next
|
|
236
|
+
when :skip
|
|
108
237
|
skip_execution = true
|
|
109
|
-
skip_content =
|
|
110
|
-
|
|
111
|
-
current_input =
|
|
238
|
+
skip_content = control_result[:content]
|
|
239
|
+
when :continue
|
|
240
|
+
current_input = control_result[:content]
|
|
112
241
|
end
|
|
113
242
|
end
|
|
114
243
|
|
|
@@ -130,6 +259,9 @@ module SwarmSDK
|
|
|
130
259
|
mini_swarm = build_swarm_for_node(node)
|
|
131
260
|
result = mini_swarm.execute(current_input)
|
|
132
261
|
|
|
262
|
+
# Cache agent instances for context preservation
|
|
263
|
+
cache_agent_instances(mini_swarm, node)
|
|
264
|
+
|
|
133
265
|
# If result has error, log it with backtrace
|
|
134
266
|
if result.error
|
|
135
267
|
RubyLLM.logger.error("NodeOrchestrator: Node '#{node_name}' failed: #{result.error.message}")
|
|
@@ -138,6 +270,7 @@ module SwarmSDK
|
|
|
138
270
|
end
|
|
139
271
|
|
|
140
272
|
results[node_name] = result
|
|
273
|
+
last_result = result
|
|
141
274
|
|
|
142
275
|
# Transform output for next node using NodeContext
|
|
143
276
|
output_context = NodeContext.for_output(
|
|
@@ -146,7 +279,29 @@ module SwarmSDK
|
|
|
146
279
|
original_prompt: @original_prompt,
|
|
147
280
|
node_name: node_name,
|
|
148
281
|
)
|
|
149
|
-
|
|
282
|
+
transformed_output = node.transform_output(output_context)
|
|
283
|
+
|
|
284
|
+
# Check for control flow from output transformer
|
|
285
|
+
control_result = handle_output_transformer_control_flow(
|
|
286
|
+
transformed: transformed_output,
|
|
287
|
+
node_name: node_name,
|
|
288
|
+
node: node,
|
|
289
|
+
node_start_time: node_start_time,
|
|
290
|
+
skip_execution: skip_execution,
|
|
291
|
+
result: result,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
case control_result[:action]
|
|
295
|
+
when :halt
|
|
296
|
+
return control_result[:result]
|
|
297
|
+
when :goto
|
|
298
|
+
execution_index = find_node_index(control_result[:target])
|
|
299
|
+
current_input = control_result[:content]
|
|
300
|
+
emit_node_stop(node_name, node, result, Time.now - node_start_time, skip_execution)
|
|
301
|
+
next
|
|
302
|
+
when :continue
|
|
303
|
+
current_input = control_result[:content]
|
|
304
|
+
end
|
|
150
305
|
|
|
151
306
|
# For agent-less nodes, update the result with transformed content
|
|
152
307
|
# This ensures all_results contains the actual output, not the input
|
|
@@ -158,22 +313,104 @@ module SwarmSDK
|
|
|
158
313
|
duration: result.duration,
|
|
159
314
|
error: result.error,
|
|
160
315
|
)
|
|
316
|
+
last_result = results[node_name]
|
|
161
317
|
end
|
|
162
318
|
|
|
163
319
|
# Emit node_stop event
|
|
164
320
|
node_duration = Time.now - node_start_time
|
|
165
321
|
emit_node_stop(node_name, node, result, node_duration, skip_execution)
|
|
322
|
+
|
|
323
|
+
execution_index += 1
|
|
166
324
|
end
|
|
167
325
|
|
|
168
|
-
|
|
326
|
+
last_result
|
|
169
327
|
ensure
|
|
328
|
+
# NodeOrchestrator always clears (always sets up logging)
|
|
329
|
+
Fiber[:execution_id] = nil
|
|
330
|
+
Fiber[:swarm_id] = nil
|
|
331
|
+
Fiber[:parent_swarm_id] = nil
|
|
332
|
+
|
|
170
333
|
# Reset logging state for next execution
|
|
171
334
|
LogCollector.reset!
|
|
172
335
|
LogStream.reset!
|
|
173
336
|
end
|
|
174
337
|
|
|
338
|
+
# Create snapshot of current workflow state
|
|
339
|
+
#
|
|
340
|
+
# Returns a Snapshot object containing agent conversations, context state,
|
|
341
|
+
# and scratchpad data from all nodes that have been executed. The snapshot
|
|
342
|
+
# captures the state of agents in the agent_instance_cache (both primary and
|
|
343
|
+
# delegation instances), as well as scratchpad storage.
|
|
344
|
+
#
|
|
345
|
+
# Configuration (agent definitions, nodes, transformers) stays in your code
|
|
346
|
+
# and is NOT included in snapshots.
|
|
347
|
+
#
|
|
348
|
+
# Scratchpad behavior depends on scratchpad mode:
|
|
349
|
+
# - :enabled (default): single scratchpad shared across all nodes
|
|
350
|
+
# - :per_node: separate scratchpad per node
|
|
351
|
+
# - :disabled: no scratchpad data
|
|
352
|
+
#
|
|
353
|
+
# @return [Snapshot] Snapshot object with convenient serialization methods
|
|
354
|
+
#
|
|
355
|
+
# @example Save snapshot to JSON file
|
|
356
|
+
# orchestrator = NodeOrchestrator.new(...)
|
|
357
|
+
# orchestrator.execute("Build feature")
|
|
358
|
+
# snapshot = orchestrator.snapshot
|
|
359
|
+
# snapshot.write_to_file("workflow_session.json")
|
|
360
|
+
def snapshot
|
|
361
|
+
StateSnapshot.new(self).snapshot
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Restore workflow state from snapshot
|
|
365
|
+
#
|
|
366
|
+
# Accepts a Snapshot object, hash, or JSON string. Validates compatibility
|
|
367
|
+
# between snapshot and current orchestrator configuration. Restores agent
|
|
368
|
+
# conversations that exist in the agent_instance_cache.
|
|
369
|
+
#
|
|
370
|
+
# The orchestrator must be created with the SAME configuration (agent definitions,
|
|
371
|
+
# nodes) as when the snapshot was created. Only conversation state is restored.
|
|
372
|
+
#
|
|
373
|
+
# For agents with reset_context: false, restored conversations will be injected
|
|
374
|
+
# during node execution. Agents not in cache yet will be skipped (they haven't
|
|
375
|
+
# been used yet, so there's nothing to restore).
|
|
376
|
+
#
|
|
377
|
+
# @param snapshot [Snapshot, Hash, String] Snapshot object, hash, or JSON string
|
|
378
|
+
# @return [RestoreResult] Result with warnings about skipped agents
|
|
379
|
+
#
|
|
380
|
+
# @example Restore from Snapshot object
|
|
381
|
+
# orchestrator = NodeOrchestrator.new(...) # Same config as snapshot
|
|
382
|
+
# snapshot = Snapshot.from_file("workflow_session.json")
|
|
383
|
+
# result = orchestrator.restore(snapshot)
|
|
384
|
+
# if result.success?
|
|
385
|
+
# puts "All agents restored"
|
|
386
|
+
# else
|
|
387
|
+
# puts result.summary
|
|
388
|
+
# end
|
|
389
|
+
#
|
|
390
|
+
# Restore orchestrator state from snapshot
|
|
391
|
+
#
|
|
392
|
+
# By default, uses current system prompts from agent definitions (YAML + SDK defaults + plugin injections).
|
|
393
|
+
# Set preserve_system_prompts: true to use historical prompts from snapshot.
|
|
394
|
+
#
|
|
395
|
+
# @param snapshot [Snapshot, Hash, String] Snapshot object, hash, or JSON string
|
|
396
|
+
# @param preserve_system_prompts [Boolean] Use historical system prompts instead of current config (default: false)
|
|
397
|
+
# @return [RestoreResult] Result with warnings about partial restores
|
|
398
|
+
def restore(snapshot, preserve_system_prompts: false)
|
|
399
|
+
StateRestorer.new(self, snapshot, preserve_system_prompts: preserve_system_prompts).restore
|
|
400
|
+
end
|
|
401
|
+
|
|
175
402
|
private
|
|
176
403
|
|
|
404
|
+
# Generate a unique execution ID for workflow
|
|
405
|
+
#
|
|
406
|
+
# Creates an execution ID that uniquely identifies a single orchestrator.execute() call.
|
|
407
|
+
# Format: "exec_workflow_{random_hex}"
|
|
408
|
+
#
|
|
409
|
+
# @return [String] Generated execution ID (e.g., "exec_workflow_a3f2b1c8")
|
|
410
|
+
def generate_execution_id
|
|
411
|
+
"exec_workflow_#{SecureRandom.hex(8)}"
|
|
412
|
+
end
|
|
413
|
+
|
|
177
414
|
# Emit node_start event
|
|
178
415
|
#
|
|
179
416
|
# @param node_name [Symbol] Name of the node
|
|
@@ -284,21 +521,49 @@ module SwarmSDK
|
|
|
284
521
|
# Creates a new Swarm with only the agents specified in the node,
|
|
285
522
|
# configured with the node's delegation topology.
|
|
286
523
|
#
|
|
524
|
+
# For agents with reset_context: false, injects cached instances
|
|
525
|
+
# to preserve conversation history across nodes.
|
|
526
|
+
#
|
|
527
|
+
# Scratchpad behavior depends on mode:
|
|
528
|
+
# - :enabled - all nodes use the same scratchpad instance
|
|
529
|
+
# - :per_node - each node gets its own scratchpad instance
|
|
530
|
+
# - :disabled - no scratchpad
|
|
531
|
+
#
|
|
287
532
|
# @param node [Node::Builder] Node configuration
|
|
288
533
|
# @return [Swarm] Configured swarm instance
|
|
289
534
|
def build_swarm_for_node(node)
|
|
290
|
-
|
|
535
|
+
# Build hierarchical swarm_id if parent has one (nil auto-generates)
|
|
536
|
+
node_swarm_id = @swarm_id ? "#{@swarm_id}/node:#{node.name}" : nil
|
|
537
|
+
|
|
538
|
+
swarm = Swarm.new(
|
|
539
|
+
name: "#{@swarm_name}:#{node.name}",
|
|
540
|
+
swarm_id: node_swarm_id,
|
|
541
|
+
parent_swarm_id: @swarm_id,
|
|
542
|
+
scratchpad: scratchpad_for(node.name),
|
|
543
|
+
scratchpad_mode: :enabled, # Mini-swarms always use enabled (scratchpad instance passed in)
|
|
544
|
+
allow_filesystem_tools: @allow_filesystem_tools,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Setup swarm registry if external swarms are registered
|
|
548
|
+
if @swarm_registry_config&.any?
|
|
549
|
+
registry = SwarmRegistry.new(parent_swarm_id: node_swarm_id || swarm.swarm_id)
|
|
550
|
+
@swarm_registry_config.each do |reg|
|
|
551
|
+
registry.register(reg[:name], source: reg[:source], keep_context: reg[:keep_context])
|
|
552
|
+
end
|
|
553
|
+
swarm.swarm_registry = registry
|
|
554
|
+
end
|
|
291
555
|
|
|
292
556
|
# Add each agent specified in this node
|
|
293
557
|
node.agent_configs.each do |config|
|
|
294
558
|
agent_name = config[:agent]
|
|
295
559
|
delegates_to = config[:delegates_to]
|
|
560
|
+
tools_override = config[:tools]
|
|
296
561
|
|
|
297
562
|
# Get global agent definition
|
|
298
563
|
agent_def = @agent_definitions[agent_name]
|
|
299
564
|
|
|
300
|
-
# Clone definition with node-specific
|
|
301
|
-
node_specific_def =
|
|
565
|
+
# Clone definition with node-specific overrides
|
|
566
|
+
node_specific_def = clone_agent_for_node(agent_def, delegates_to, tools_override)
|
|
302
567
|
|
|
303
568
|
swarm.add_agent(node_specific_def)
|
|
304
569
|
end
|
|
@@ -306,17 +571,26 @@ module SwarmSDK
|
|
|
306
571
|
# Set lead agent
|
|
307
572
|
swarm.lead = node.lead_agent
|
|
308
573
|
|
|
574
|
+
# Inject cached agent instances for context preservation
|
|
575
|
+
inject_cached_agents(swarm, node)
|
|
576
|
+
|
|
309
577
|
swarm
|
|
310
578
|
end
|
|
311
579
|
|
|
312
|
-
# Clone an agent definition with
|
|
580
|
+
# Clone an agent definition with node-specific overrides
|
|
581
|
+
#
|
|
582
|
+
# Allows overriding delegation and tools per node. This enables:
|
|
583
|
+
# - Different delegation topology per node
|
|
584
|
+
# - Different tool sets per workflow stage
|
|
313
585
|
#
|
|
314
586
|
# @param agent_def [Agent::Definition] Original definition
|
|
315
587
|
# @param delegates_to [Array<Symbol>] New delegation targets
|
|
316
|
-
# @
|
|
317
|
-
|
|
588
|
+
# @param tools [Array<Symbol>, nil] Tool override (nil = use global agent definition)
|
|
589
|
+
# @return [Agent::Definition] Cloned definition with overrides
|
|
590
|
+
def clone_agent_for_node(agent_def, delegates_to, tools)
|
|
318
591
|
config = agent_def.to_h
|
|
319
592
|
config[:delegates_to] = delegates_to
|
|
593
|
+
config[:tools] = tools if tools # Only override if explicitly set
|
|
320
594
|
Agent::Definition.new(agent_def.name, config)
|
|
321
595
|
end
|
|
322
596
|
|
|
@@ -359,7 +633,8 @@ module SwarmSDK
|
|
|
359
633
|
if order.size < @nodes.size
|
|
360
634
|
unprocessed = @nodes.keys - order
|
|
361
635
|
raise CircularDependencyError,
|
|
362
|
-
"Circular dependency detected. Unprocessed nodes: #{unprocessed.join(", ")}"
|
|
636
|
+
"Circular dependency detected. Unprocessed nodes: #{unprocessed.join(", ")}. " \
|
|
637
|
+
"Use goto_node in transformers to create loops instead of circular depends_on."
|
|
363
638
|
end
|
|
364
639
|
|
|
365
640
|
# Verify start_node is in the execution order
|
|
@@ -380,5 +655,204 @@ module SwarmSDK
|
|
|
380
655
|
|
|
381
656
|
order
|
|
382
657
|
end
|
|
658
|
+
|
|
659
|
+
# Handle control flow from input transformer
|
|
660
|
+
#
|
|
661
|
+
# @param transformed [String, Hash] Result from transformer
|
|
662
|
+
# @param node_name [Symbol] Current node name
|
|
663
|
+
# @param node [Node::Builder] Node configuration
|
|
664
|
+
# @param node_start_time [Time] Node execution start time
|
|
665
|
+
# @return [Hash] Control result with :action and relevant data
|
|
666
|
+
def handle_transformer_control_flow(transformed:, node_name:, node:, node_start_time:)
|
|
667
|
+
return { action: :continue, content: transformed } unless transformed.is_a?(Hash)
|
|
668
|
+
|
|
669
|
+
if transformed[:halt_workflow]
|
|
670
|
+
# Halt entire workflow
|
|
671
|
+
halt_result = Result.new(
|
|
672
|
+
content: transformed[:content],
|
|
673
|
+
agent: "halted:#{node_name}",
|
|
674
|
+
logs: [],
|
|
675
|
+
duration: Time.now - node_start_time,
|
|
676
|
+
)
|
|
677
|
+
emit_node_stop(node_name, node, halt_result, Time.now - node_start_time, false)
|
|
678
|
+
{ action: :halt, result: halt_result }
|
|
679
|
+
elsif transformed[:goto_node]
|
|
680
|
+
# Jump to different node
|
|
681
|
+
{ action: :goto, target: transformed[:goto_node], content: transformed[:content] }
|
|
682
|
+
elsif transformed[:skip_execution]
|
|
683
|
+
# Skip node execution
|
|
684
|
+
{ action: :skip, content: transformed[:content] }
|
|
685
|
+
else
|
|
686
|
+
# No control flow - continue normally
|
|
687
|
+
{ action: :continue, content: transformed[:content] }
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Handle control flow from output transformer
|
|
692
|
+
#
|
|
693
|
+
# @param transformed [String, Hash] Result from transformer
|
|
694
|
+
# @param node_name [Symbol] Current node name
|
|
695
|
+
# @param node [Node::Builder] Node configuration
|
|
696
|
+
# @param node_start_time [Time] Node execution start time
|
|
697
|
+
# @param skip_execution [Boolean] Whether node execution was skipped
|
|
698
|
+
# @param result [Result] Node execution result
|
|
699
|
+
# @return [Hash] Control result with :action and relevant data
|
|
700
|
+
def handle_output_transformer_control_flow(transformed:, node_name:, node:, node_start_time:, skip_execution:, result:)
|
|
701
|
+
# If not a hash, it's just transformed content - continue normally
|
|
702
|
+
return { action: :continue, content: transformed } unless transformed.is_a?(Hash)
|
|
703
|
+
|
|
704
|
+
if transformed[:halt_workflow]
|
|
705
|
+
# Halt entire workflow
|
|
706
|
+
halt_result = Result.new(
|
|
707
|
+
content: transformed[:content],
|
|
708
|
+
agent: result.agent,
|
|
709
|
+
logs: result.logs,
|
|
710
|
+
duration: result.duration,
|
|
711
|
+
)
|
|
712
|
+
emit_node_stop(node_name, node, halt_result, Time.now - node_start_time, skip_execution)
|
|
713
|
+
{ action: :halt, result: halt_result }
|
|
714
|
+
elsif transformed[:goto_node]
|
|
715
|
+
# Jump to different node
|
|
716
|
+
{ action: :goto, target: transformed[:goto_node], content: transformed[:content] }
|
|
717
|
+
else
|
|
718
|
+
# Hash without control flow keys - treat as regular hash with :content key
|
|
719
|
+
# This handles the case where transformer returns a hash that's not for control flow
|
|
720
|
+
{ action: :continue, content: transformed[:content] || transformed }
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Find the index of a node in the execution order
|
|
725
|
+
#
|
|
726
|
+
# @param node_name [Symbol] Node name to find
|
|
727
|
+
# @return [Integer] Index in execution order
|
|
728
|
+
# @raise [ConfigurationError] If node not found
|
|
729
|
+
def find_node_index(node_name)
|
|
730
|
+
index = @execution_order.index(node_name)
|
|
731
|
+
unless index
|
|
732
|
+
raise ConfigurationError,
|
|
733
|
+
"goto_node target '#{node_name}' not found. Available nodes: #{@execution_order.join(", ")}"
|
|
734
|
+
end
|
|
735
|
+
index
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Cache agent instances from a swarm for potential reuse
|
|
739
|
+
#
|
|
740
|
+
# Only caches agents that have reset_context: false in this node.
|
|
741
|
+
# This allows preserving conversation history across nodes.
|
|
742
|
+
#
|
|
743
|
+
# @param swarm [Swarm] Swarm instance that just executed
|
|
744
|
+
# @param node [Node::Builder] Node configuration
|
|
745
|
+
# @return [void]
|
|
746
|
+
def cache_agent_instances(swarm, node)
|
|
747
|
+
return unless swarm.agents
|
|
748
|
+
|
|
749
|
+
node.agent_configs.each do |config|
|
|
750
|
+
agent_name = config[:agent]
|
|
751
|
+
reset_context = config[:reset_context]
|
|
752
|
+
|
|
753
|
+
# Only cache if reset_context: false
|
|
754
|
+
next if reset_context
|
|
755
|
+
|
|
756
|
+
# Cache primary agent
|
|
757
|
+
agent_instance = swarm.agents[agent_name]
|
|
758
|
+
@agent_instance_cache[:primary][agent_name] = agent_instance if agent_instance
|
|
759
|
+
|
|
760
|
+
# V7.0: Cache delegation instances atomically (together with primary)
|
|
761
|
+
agent_def = @agent_definitions[agent_name]
|
|
762
|
+
agent_def.delegates_to.each do |delegate_name|
|
|
763
|
+
delegation_key = "#{delegate_name}@#{agent_name}"
|
|
764
|
+
delegation_instance = swarm.delegation_instances[delegation_key]
|
|
765
|
+
|
|
766
|
+
if delegation_instance
|
|
767
|
+
@agent_instance_cache[:delegations][delegation_key] = delegation_instance
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# Inject cached agent instances into a swarm
|
|
774
|
+
#
|
|
775
|
+
# For agents with reset_context: false, reuses cached instances to preserve context.
|
|
776
|
+
# Forces agent initialization first (by accessing .agents), then swaps in cached instances.
|
|
777
|
+
#
|
|
778
|
+
# @param swarm [Swarm] Swarm instance to inject into
|
|
779
|
+
# @param node [Node::Builder] Node configuration
|
|
780
|
+
# @return [void]
|
|
781
|
+
def inject_cached_agents(swarm, node)
|
|
782
|
+
# Check if any agents need context preservation
|
|
783
|
+
has_preserved = node.agent_configs.any? do |c|
|
|
784
|
+
!c[:reset_context] && (
|
|
785
|
+
@agent_instance_cache[:primary][c[:agent]] ||
|
|
786
|
+
has_cached_delegations_for?(c[:agent])
|
|
787
|
+
)
|
|
788
|
+
end
|
|
789
|
+
return unless has_preserved
|
|
790
|
+
|
|
791
|
+
# V7.0 CRITICAL FIX: Force initialization FIRST
|
|
792
|
+
# Without this, @agents will be replaced by initialize_all, losing our injected instances
|
|
793
|
+
swarm.agent(node.agent_configs.first[:agent]) # Triggers lazy init
|
|
794
|
+
|
|
795
|
+
# Now safely inject cached instances
|
|
796
|
+
agents_hash = swarm.agents
|
|
797
|
+
delegation_hash = swarm.delegation_instances
|
|
798
|
+
|
|
799
|
+
# Inject cached PRIMARY agents
|
|
800
|
+
node.agent_configs.each do |config|
|
|
801
|
+
agent_name = config[:agent]
|
|
802
|
+
next if config[:reset_context]
|
|
803
|
+
|
|
804
|
+
cached_agent = @agent_instance_cache[:primary][agent_name]
|
|
805
|
+
next unless cached_agent
|
|
806
|
+
|
|
807
|
+
# Replace freshly initialized agent with cached instance
|
|
808
|
+
agents_hash[agent_name] = cached_agent
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# Inject cached DELEGATION instances (atomic with primary)
|
|
812
|
+
node.agent_configs.each do |config|
|
|
813
|
+
agent_name = config[:agent]
|
|
814
|
+
next if config[:reset_context]
|
|
815
|
+
|
|
816
|
+
agent_def = @agent_definitions[agent_name]
|
|
817
|
+
|
|
818
|
+
agent_def.delegates_to.each do |delegate_name|
|
|
819
|
+
delegation_key = "#{delegate_name}@#{agent_name}"
|
|
820
|
+
cached_delegation = @agent_instance_cache[:delegations][delegation_key]
|
|
821
|
+
next unless cached_delegation
|
|
822
|
+
|
|
823
|
+
# Replace freshly initialized delegation instance
|
|
824
|
+
# V7.0: Tool references intact - atomic caching preserves object graph
|
|
825
|
+
delegation_hash[delegation_key] = cached_delegation
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def has_cached_delegations_for?(agent_name)
|
|
831
|
+
agent_def = @agent_definitions[agent_name]
|
|
832
|
+
agent_def.delegates_to.any? do |delegate_name|
|
|
833
|
+
delegation_key = "#{delegate_name}@#{agent_name}"
|
|
834
|
+
@agent_instance_cache[:delegations][delegation_key]
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# Normalize scratchpad mode parameter
|
|
839
|
+
#
|
|
840
|
+
# Accepts symbols: :enabled, :per_node, or :disabled
|
|
841
|
+
#
|
|
842
|
+
# @param value [Symbol, String] Scratchpad mode (strings from YAML converted to symbols)
|
|
843
|
+
# @return [Symbol] Normalized mode (:enabled, :per_node, or :disabled)
|
|
844
|
+
# @raise [ArgumentError] If value is invalid
|
|
845
|
+
def normalize_scratchpad_mode(value)
|
|
846
|
+
# Convert strings from YAML to symbols
|
|
847
|
+
value = value.to_sym if value.is_a?(String)
|
|
848
|
+
|
|
849
|
+
case value
|
|
850
|
+
when :enabled, :per_node, :disabled
|
|
851
|
+
value
|
|
852
|
+
else
|
|
853
|
+
raise ArgumentError,
|
|
854
|
+
"Invalid scratchpad mode: #{value.inspect}. Use :enabled, :per_node, or :disabled"
|
|
855
|
+
end
|
|
856
|
+
end
|
|
383
857
|
end
|
|
384
858
|
end
|