swarm_sdk 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +58 -0
- data/lib/swarm_sdk/agent/chat.rb +527 -1059
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +12 -12
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
- data/lib/swarm_sdk/agent/context.rb +2 -2
- data/lib/swarm_sdk/agent/definition.rb +66 -154
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/builders/base_builder.rb +409 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
- data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
- data/lib/swarm_sdk/concerns/validatable.rb +55 -0
- data/lib/swarm_sdk/configuration/parser.rb +353 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +65 -543
- data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
- data/lib/swarm_sdk/context_compactor.rb +6 -11
- data/lib/swarm_sdk/context_management/builder.rb +128 -0
- data/lib/swarm_sdk/context_management/context.rb +328 -0
- data/lib/swarm_sdk/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +18 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +179 -29
- data/lib/swarm_sdk/log_stream.rb +29 -0
- data/lib/swarm_sdk/node_context.rb +1 -1
- data/lib/swarm_sdk/observer/builder.rb +81 -0
- data/lib/swarm_sdk/observer/config.rb +45 -0
- data/lib/swarm_sdk/observer/manager.rb +236 -0
- data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
- data/lib/swarm_sdk/plugin.rb +93 -3
- data/lib/swarm_sdk/snapshot.rb +6 -6
- data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
- data/lib/swarm_sdk/state_restorer.rb +136 -151
- data/lib/swarm_sdk/state_snapshot.rb +65 -100
- data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
- data/lib/swarm_sdk/swarm/builder.rb +44 -578
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/tool_configurator.rb +42 -138
- data/lib/swarm_sdk/swarm.rb +137 -679
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- data/lib/swarm_sdk/tools/delegate.rb +61 -43
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +9 -1
- data/lib/swarm_sdk/tools/grep.rb +7 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
- data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
- data/lib/swarm_sdk/tools/read.rb +11 -13
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/todo_write.rb +7 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
- data/lib/swarm_sdk/tools/write.rb +8 -13
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
- data/lib/swarm_sdk/workflow/builder.rb +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
- data/lib/swarm_sdk.rb +33 -3
- metadata +67 -15
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
|
@@ -1,40 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SwarmSDK
|
|
4
|
-
#
|
|
4
|
+
# Workflow executes a multi-node workflow
|
|
5
5
|
#
|
|
6
|
-
# Each node represents a mini-swarm execution stage. The
|
|
6
|
+
# Each node represents a mini-swarm execution stage. The workflow:
|
|
7
7
|
# - Builds execution order from node dependencies (topological sort)
|
|
8
8
|
# - Creates a separate swarm instance for each node
|
|
9
9
|
# - Passes output from one node as input to dependent nodes
|
|
10
10
|
# - Supports input/output transformers for data flow customization
|
|
11
11
|
#
|
|
12
12
|
# @example
|
|
13
|
-
#
|
|
13
|
+
# workflow = Workflow.new(
|
|
14
14
|
# swarm_name: "Dev Team",
|
|
15
15
|
# agent_definitions: { backend: def1, tester: def2 },
|
|
16
16
|
# nodes: { planning: node1, implementation: node2 },
|
|
17
17
|
# start_node: :planning
|
|
18
18
|
# )
|
|
19
|
-
# result =
|
|
20
|
-
class
|
|
21
|
-
attr_reader :swarm_name, :nodes, :start_node, :agent_definitions, :
|
|
19
|
+
# result = workflow.execute("Build auth system")
|
|
20
|
+
class Workflow
|
|
21
|
+
attr_reader :swarm_name, :nodes, :start_node, :agent_definitions, :scratchpad
|
|
22
|
+
attr_reader :agents, :delegation_instances, :swarm_id, :parent_swarm_id, :mcp_clients
|
|
23
|
+
attr_reader :execution_order
|
|
22
24
|
attr_writer :swarm_id, :config_for_hooks
|
|
23
|
-
attr_accessor :swarm_registry_config
|
|
25
|
+
attr_accessor :swarm_registry_config, :original_prompt
|
|
24
26
|
|
|
25
27
|
def initialize(swarm_name:, agent_definitions:, nodes:, start_node:, swarm_id: nil, scratchpad: :enabled, allow_filesystem_tools: nil)
|
|
26
28
|
@swarm_name = swarm_name
|
|
27
|
-
@swarm_id = swarm_id
|
|
29
|
+
@swarm_id = swarm_id || generate_swarm_id(swarm_name)
|
|
30
|
+
@parent_swarm_id = nil # Workflows don't have parent swarms
|
|
28
31
|
@agent_definitions = agent_definitions
|
|
29
32
|
@nodes = nodes
|
|
30
33
|
@start_node = start_node
|
|
31
34
|
@scratchpad = normalize_scratchpad_mode(scratchpad)
|
|
32
35
|
@allow_filesystem_tools = allow_filesystem_tools
|
|
33
36
|
@swarm_registry_config = [] # External swarms config (if using composable swarms)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
37
|
+
|
|
38
|
+
# Simplified structure (matches Swarm)
|
|
39
|
+
@agents = {} # Cached primary agents from nodes
|
|
40
|
+
@delegation_instances = {} # Cached delegation instances from nodes
|
|
41
|
+
|
|
42
|
+
# MCP clients per agent (for cleanup compatibility)
|
|
43
|
+
@mcp_clients = Hash.new { |h, k| h[k] = [] }
|
|
38
44
|
|
|
39
45
|
# Initialize scratchpad storage based on mode
|
|
40
46
|
case @scratchpad
|
|
@@ -56,8 +62,24 @@ module SwarmSDK
|
|
|
56
62
|
@execution_order = build_execution_order
|
|
57
63
|
end
|
|
58
64
|
|
|
59
|
-
#
|
|
60
|
-
|
|
65
|
+
# Provide name method for interface compatibility
|
|
66
|
+
def name
|
|
67
|
+
@swarm_name
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Implement Snapshotable interface
|
|
71
|
+
def primary_agents
|
|
72
|
+
@agents
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def delegation_instances_hash
|
|
76
|
+
@delegation_instances
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# No-op for Swarm compatibility (Workflow doesn't track first message)
|
|
80
|
+
def first_message_sent?
|
|
81
|
+
false
|
|
82
|
+
end
|
|
61
83
|
|
|
62
84
|
# Get scratchpad storage for a specific node
|
|
63
85
|
#
|
|
@@ -120,7 +142,7 @@ module SwarmSDK
|
|
|
120
142
|
# @return [Tools::Stores::ScratchpadStorage, nil]
|
|
121
143
|
def shared_scratchpad_storage
|
|
122
144
|
if @scratchpad == :per_node
|
|
123
|
-
RubyLLM.logger.warn("
|
|
145
|
+
RubyLLM.logger.warn("Workflow: Accessing shared_scratchpad_storage in per-node mode. Use scratchpad_for(node_name) instead.")
|
|
124
146
|
end
|
|
125
147
|
@shared_scratchpad_storage
|
|
126
148
|
end
|
|
@@ -138,201 +160,12 @@ module SwarmSDK
|
|
|
138
160
|
# to its dependents. Supports streaming logs if block given.
|
|
139
161
|
#
|
|
140
162
|
# @param prompt [String] Initial prompt for the workflow
|
|
163
|
+
# @param inherit_subscriptions [Boolean] Whether to inherit parent log subscriptions
|
|
164
|
+
# (default: true). Set to false to isolate child workflow from parent's event stream.
|
|
141
165
|
# @yield [Hash] Log entry if block given (for streaming)
|
|
142
166
|
# @return [Result] Final result from last node execution
|
|
143
|
-
def execute(prompt, &block)
|
|
144
|
-
|
|
145
|
-
current_input = prompt
|
|
146
|
-
results = {}
|
|
147
|
-
@original_prompt = prompt # Store original prompt for NodeContext
|
|
148
|
-
|
|
149
|
-
# Set fiber-local execution context for entire workflow
|
|
150
|
-
Fiber[:execution_id] = generate_execution_id
|
|
151
|
-
|
|
152
|
-
# Setup logging if block given
|
|
153
|
-
if block_given?
|
|
154
|
-
# Register callback to collect logs and forward to user's block
|
|
155
|
-
LogCollector.on_log do |entry|
|
|
156
|
-
logs << entry
|
|
157
|
-
block.call(entry)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Set LogStream to use LogCollector as emitter
|
|
161
|
-
LogStream.emitter = LogCollector
|
|
162
|
-
end
|
|
163
|
-
|
|
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]
|
|
170
|
-
node = @nodes[node_name]
|
|
171
|
-
node_start_time = Time.now
|
|
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
|
-
|
|
179
|
-
# Emit node_start event
|
|
180
|
-
emit_node_start(node_name, node)
|
|
181
|
-
|
|
182
|
-
# Transform input if node has transformer (Ruby block or bash command)
|
|
183
|
-
skip_execution = false
|
|
184
|
-
skip_content = nil
|
|
185
|
-
|
|
186
|
-
if node.has_input_transformer?
|
|
187
|
-
# Build NodeContext based on dependencies
|
|
188
|
-
#
|
|
189
|
-
# For single dependency: previous_result has original Result metadata,
|
|
190
|
-
# transformed_content has output from previous transformer
|
|
191
|
-
# For multiple dependencies: previous_result is hash of Results
|
|
192
|
-
# For no dependencies: previous_result is initial prompt string
|
|
193
|
-
previous_result = if node.dependencies.size > 1
|
|
194
|
-
# Multiple dependencies: pass hash of original results
|
|
195
|
-
node.dependencies.to_h { |dep| [dep, results[dep]] }
|
|
196
|
-
elsif node.dependencies.size == 1
|
|
197
|
-
# Single dependency: pass the original result
|
|
198
|
-
results[node.dependencies.first]
|
|
199
|
-
else
|
|
200
|
-
# No dependencies: initial prompt
|
|
201
|
-
current_input
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
# Create NodeContext for input transformer
|
|
205
|
-
input_context = NodeContext.for_input(
|
|
206
|
-
previous_result: previous_result,
|
|
207
|
-
all_results: results,
|
|
208
|
-
original_prompt: @original_prompt,
|
|
209
|
-
node_name: node_name,
|
|
210
|
-
dependencies: node.dependencies,
|
|
211
|
-
transformed_content: node.dependencies.size == 1 ? current_input : nil,
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
# Apply input transformer (passes current_input for bash command fallback)
|
|
215
|
-
# Bash transformer exit codes:
|
|
216
|
-
# - Exit 0: Use STDOUT as transformed content
|
|
217
|
-
# - Exit 1: Skip node execution, use current_input unchanged (STDOUT ignored)
|
|
218
|
-
# - Exit 2: Halt workflow with error from STDERR (STDOUT ignored)
|
|
219
|
-
transformed = node.transform_input(input_context, current_input: current_input)
|
|
220
|
-
|
|
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
|
|
237
|
-
skip_execution = true
|
|
238
|
-
skip_content = control_result[:content]
|
|
239
|
-
when :continue
|
|
240
|
-
current_input = control_result[:content]
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Execute node (or skip if requested)
|
|
245
|
-
if skip_execution
|
|
246
|
-
# Skip execution: return result immediately with provided content
|
|
247
|
-
result = Result.new(
|
|
248
|
-
content: skip_content,
|
|
249
|
-
agent: "skipped:#{node_name}",
|
|
250
|
-
logs: [],
|
|
251
|
-
duration: 0.0,
|
|
252
|
-
)
|
|
253
|
-
elsif node.agent_less?
|
|
254
|
-
# Agent-less node: run pure computation without LLM
|
|
255
|
-
result = execute_agent_less_node(node, current_input)
|
|
256
|
-
else
|
|
257
|
-
# Normal node: build mini-swarm and execute with LLM
|
|
258
|
-
# NOTE: Don't pass block to mini-swarm - LogCollector already captures all logs
|
|
259
|
-
mini_swarm = build_swarm_for_node(node)
|
|
260
|
-
result = mini_swarm.execute(current_input)
|
|
261
|
-
|
|
262
|
-
# Cache agent instances for context preservation
|
|
263
|
-
cache_agent_instances(mini_swarm, node)
|
|
264
|
-
|
|
265
|
-
# If result has error, log it with backtrace
|
|
266
|
-
if result.error
|
|
267
|
-
RubyLLM.logger.error("NodeOrchestrator: Node '#{node_name}' failed: #{result.error.message}")
|
|
268
|
-
RubyLLM.logger.error(" Backtrace: #{result.error.backtrace&.first(5)&.join("\n ")}")
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
results[node_name] = result
|
|
273
|
-
last_result = result
|
|
274
|
-
|
|
275
|
-
# Transform output for next node using NodeContext
|
|
276
|
-
output_context = NodeContext.for_output(
|
|
277
|
-
result: result,
|
|
278
|
-
all_results: results,
|
|
279
|
-
original_prompt: @original_prompt,
|
|
280
|
-
node_name: node_name,
|
|
281
|
-
)
|
|
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
|
|
305
|
-
|
|
306
|
-
# For agent-less nodes, update the result with transformed content
|
|
307
|
-
# This ensures all_results contains the actual output, not the input
|
|
308
|
-
if node.agent_less? && current_input != result.content
|
|
309
|
-
results[node_name] = Result.new(
|
|
310
|
-
content: current_input,
|
|
311
|
-
agent: result.agent,
|
|
312
|
-
logs: result.logs,
|
|
313
|
-
duration: result.duration,
|
|
314
|
-
error: result.error,
|
|
315
|
-
)
|
|
316
|
-
last_result = results[node_name]
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
# Emit node_stop event
|
|
320
|
-
node_duration = Time.now - node_start_time
|
|
321
|
-
emit_node_stop(node_name, node, result, node_duration, skip_execution)
|
|
322
|
-
|
|
323
|
-
execution_index += 1
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
last_result
|
|
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
|
-
|
|
333
|
-
# Reset logging state for next execution
|
|
334
|
-
LogCollector.reset!
|
|
335
|
-
LogStream.reset!
|
|
167
|
+
def execute(prompt, inherit_subscriptions: true, &block)
|
|
168
|
+
Executor.new(self).run(prompt, inherit_subscriptions: inherit_subscriptions, &block)
|
|
336
169
|
end
|
|
337
170
|
|
|
338
171
|
# Create snapshot of current workflow state
|
|
@@ -353,9 +186,9 @@ module SwarmSDK
|
|
|
353
186
|
# @return [Snapshot] Snapshot object with convenient serialization methods
|
|
354
187
|
#
|
|
355
188
|
# @example Save snapshot to JSON file
|
|
356
|
-
#
|
|
357
|
-
#
|
|
358
|
-
# snapshot =
|
|
189
|
+
# workflow = Workflow.new(...)
|
|
190
|
+
# workflow.execute("Build feature")
|
|
191
|
+
# snapshot = workflow.snapshot
|
|
359
192
|
# snapshot.write_to_file("workflow_session.json")
|
|
360
193
|
def snapshot
|
|
361
194
|
StateSnapshot.new(self).snapshot
|
|
@@ -364,10 +197,10 @@ module SwarmSDK
|
|
|
364
197
|
# Restore workflow state from snapshot
|
|
365
198
|
#
|
|
366
199
|
# Accepts a Snapshot object, hash, or JSON string. Validates compatibility
|
|
367
|
-
# between snapshot and current
|
|
368
|
-
# conversations that exist in the
|
|
200
|
+
# between snapshot and current workflow configuration. Restores agent
|
|
201
|
+
# conversations that exist in the cached agents.
|
|
369
202
|
#
|
|
370
|
-
# The
|
|
203
|
+
# The workflow must be created with the SAME configuration (agent definitions,
|
|
371
204
|
# nodes) as when the snapshot was created. Only conversation state is restored.
|
|
372
205
|
#
|
|
373
206
|
# For agents with reset_context: false, restored conversations will be injected
|
|
@@ -378,16 +211,16 @@ module SwarmSDK
|
|
|
378
211
|
# @return [RestoreResult] Result with warnings about skipped agents
|
|
379
212
|
#
|
|
380
213
|
# @example Restore from Snapshot object
|
|
381
|
-
#
|
|
214
|
+
# workflow = Workflow.new(...) # Same config as snapshot
|
|
382
215
|
# snapshot = Snapshot.from_file("workflow_session.json")
|
|
383
|
-
# result =
|
|
216
|
+
# result = workflow.restore(snapshot)
|
|
384
217
|
# if result.success?
|
|
385
218
|
# puts "All agents restored"
|
|
386
219
|
# else
|
|
387
220
|
# puts result.summary
|
|
388
221
|
# end
|
|
389
222
|
#
|
|
390
|
-
# Restore
|
|
223
|
+
# Restore workflow state from snapshot
|
|
391
224
|
#
|
|
392
225
|
# By default, uses current system prompts from agent definitions (YAML + SDK defaults + plugin injections).
|
|
393
226
|
# Set preserve_system_prompts: true to use historical prompts from snapshot.
|
|
@@ -399,123 +232,6 @@ module SwarmSDK
|
|
|
399
232
|
StateRestorer.new(self, snapshot, preserve_system_prompts: preserve_system_prompts).restore
|
|
400
233
|
end
|
|
401
234
|
|
|
402
|
-
private
|
|
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
|
-
|
|
414
|
-
# Emit node_start event
|
|
415
|
-
#
|
|
416
|
-
# @param node_name [Symbol] Name of the node
|
|
417
|
-
# @param node [Node::Builder] Node configuration
|
|
418
|
-
# @return [void]
|
|
419
|
-
def emit_node_start(node_name, node)
|
|
420
|
-
return unless LogStream.emitter
|
|
421
|
-
|
|
422
|
-
LogStream.emit(
|
|
423
|
-
type: "node_start",
|
|
424
|
-
node: node_name.to_s,
|
|
425
|
-
agent_less: node.agent_less?,
|
|
426
|
-
agents: node.agent_configs.map { |ac| ac[:agent].to_s },
|
|
427
|
-
dependencies: node.dependencies.map(&:to_s),
|
|
428
|
-
timestamp: Time.now.utc.iso8601,
|
|
429
|
-
)
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
# Emit node_stop event
|
|
433
|
-
#
|
|
434
|
-
# @param node_name [Symbol] Name of the node
|
|
435
|
-
# @param node [Node::Builder] Node configuration
|
|
436
|
-
# @param result [Result] Node execution result
|
|
437
|
-
# @param duration [Float] Node execution duration in seconds
|
|
438
|
-
# @param skipped [Boolean] Whether execution was skipped
|
|
439
|
-
# @return [void]
|
|
440
|
-
def emit_node_stop(node_name, node, result, duration, skipped)
|
|
441
|
-
return unless LogStream.emitter
|
|
442
|
-
|
|
443
|
-
LogStream.emit(
|
|
444
|
-
type: "node_stop",
|
|
445
|
-
node: node_name.to_s,
|
|
446
|
-
agent_less: node.agent_less?,
|
|
447
|
-
skipped: skipped,
|
|
448
|
-
agents: node.agent_configs.map { |ac| ac[:agent].to_s },
|
|
449
|
-
duration: duration.round(3),
|
|
450
|
-
timestamp: Time.now.utc.iso8601,
|
|
451
|
-
)
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
# Execute an agent-less (computation-only) node
|
|
455
|
-
#
|
|
456
|
-
# Agent-less nodes run pure Ruby code without LLM execution.
|
|
457
|
-
# Creates a minimal Result object with the transformed content.
|
|
458
|
-
#
|
|
459
|
-
# @param node [Node::Builder] Agent-less node configuration
|
|
460
|
-
# @param input [String] Input content
|
|
461
|
-
# @return [Result] Result with transformed content
|
|
462
|
-
def execute_agent_less_node(node, input)
|
|
463
|
-
# For agent-less nodes, the "content" is just the input passed through
|
|
464
|
-
# The output transformer will do the actual work
|
|
465
|
-
Result.new(
|
|
466
|
-
content: input,
|
|
467
|
-
agent: "computation:#{node.name}",
|
|
468
|
-
logs: [],
|
|
469
|
-
duration: 0.0,
|
|
470
|
-
)
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
# Validate orchestrator configuration
|
|
474
|
-
#
|
|
475
|
-
# @return [void]
|
|
476
|
-
# @raise [ConfigurationError] If configuration is invalid
|
|
477
|
-
def validate!
|
|
478
|
-
# Validate start_node exists
|
|
479
|
-
unless @nodes.key?(@start_node)
|
|
480
|
-
raise ConfigurationError,
|
|
481
|
-
"start_node '#{@start_node}' not found. Available nodes: #{@nodes.keys.join(", ")}"
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
# Validate all nodes
|
|
485
|
-
@nodes.each_value(&:validate!)
|
|
486
|
-
|
|
487
|
-
# Validate node dependencies reference existing nodes
|
|
488
|
-
@nodes.each do |node_name, node|
|
|
489
|
-
node.dependencies.each do |dep|
|
|
490
|
-
unless @nodes.key?(dep)
|
|
491
|
-
raise ConfigurationError,
|
|
492
|
-
"Node '#{node_name}' depends on unknown node '#{dep}'"
|
|
493
|
-
end
|
|
494
|
-
end
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
# Validate all agents referenced in nodes exist (skip agent-less nodes)
|
|
498
|
-
@nodes.each do |node_name, node|
|
|
499
|
-
next if node.agent_less? # Skip validation for agent-less nodes
|
|
500
|
-
|
|
501
|
-
node.agent_configs.each do |config|
|
|
502
|
-
agent_name = config[:agent]
|
|
503
|
-
unless @agent_definitions.key?(agent_name)
|
|
504
|
-
raise ConfigurationError,
|
|
505
|
-
"Node '#{node_name}' references undefined agent '#{agent_name}'"
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
# Validate delegation targets exist
|
|
509
|
-
config[:delegates_to].each do |delegate|
|
|
510
|
-
unless @agent_definitions.key?(delegate)
|
|
511
|
-
raise ConfigurationError,
|
|
512
|
-
"Node '#{node_name}' agent '#{agent_name}' delegates to undefined agent '#{delegate}'"
|
|
513
|
-
end
|
|
514
|
-
end
|
|
515
|
-
end
|
|
516
|
-
end
|
|
517
|
-
end
|
|
518
|
-
|
|
519
235
|
# Build a swarm instance for a specific node
|
|
520
236
|
#
|
|
521
237
|
# Creates a new Swarm with only the agents specified in the node,
|
|
@@ -529,7 +245,7 @@ module SwarmSDK
|
|
|
529
245
|
# - :per_node - each node gets its own scratchpad instance
|
|
530
246
|
# - :disabled - no scratchpad
|
|
531
247
|
#
|
|
532
|
-
# @param node [
|
|
248
|
+
# @param node [Workflow::NodeBuilder] Node configuration
|
|
533
249
|
# @return [Swarm] Configured swarm instance
|
|
534
250
|
def build_swarm_for_node(node)
|
|
535
251
|
# Build hierarchical swarm_id if parent has one (nil auto-generates)
|
|
@@ -577,6 +293,100 @@ module SwarmSDK
|
|
|
577
293
|
swarm
|
|
578
294
|
end
|
|
579
295
|
|
|
296
|
+
# Cache agent instances from a swarm for potential reuse
|
|
297
|
+
#
|
|
298
|
+
# Only caches agents that have reset_context: false in this node.
|
|
299
|
+
# This allows preserving conversation history across nodes.
|
|
300
|
+
#
|
|
301
|
+
# @param swarm [Swarm] Swarm instance that just executed
|
|
302
|
+
# @param node [Workflow::Builder] Node configuration
|
|
303
|
+
# @return [void]
|
|
304
|
+
def cache_agent_instances(swarm, node)
|
|
305
|
+
return unless swarm.agents
|
|
306
|
+
|
|
307
|
+
node.agent_configs.each do |config|
|
|
308
|
+
agent_name = config[:agent]
|
|
309
|
+
reset_context = config[:reset_context]
|
|
310
|
+
|
|
311
|
+
# Only cache if reset_context: false
|
|
312
|
+
next if reset_context
|
|
313
|
+
|
|
314
|
+
# Cache primary agent
|
|
315
|
+
agent_instance = swarm.agents[agent_name]
|
|
316
|
+
@agents[agent_name] = agent_instance if agent_instance
|
|
317
|
+
|
|
318
|
+
# Cache delegation instances atomically (together with primary)
|
|
319
|
+
agent_def = @agent_definitions[agent_name]
|
|
320
|
+
agent_def.delegates_to.each do |delegate_name|
|
|
321
|
+
delegation_key = "#{delegate_name}@#{agent_name}"
|
|
322
|
+
delegation_instance = swarm.delegation_instances[delegation_key]
|
|
323
|
+
|
|
324
|
+
if delegation_instance
|
|
325
|
+
@delegation_instances[delegation_key] = delegation_instance
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
# Generate a unique execution ID for workflow
|
|
334
|
+
#
|
|
335
|
+
# Creates an execution ID that uniquely identifies a single workflow.execute() call.
|
|
336
|
+
# Format: "exec_workflow_{random_hex}"
|
|
337
|
+
#
|
|
338
|
+
# @return [String] Generated execution ID (e.g., "exec_workflow_a3f2b1c8")
|
|
339
|
+
def generate_swarm_id(name)
|
|
340
|
+
sanitized = name.to_s.gsub(/[^a-z0-9_-]/i, "_").downcase
|
|
341
|
+
"#{sanitized}_#{SecureRandom.hex(4)}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Validate workflow configuration
|
|
345
|
+
#
|
|
346
|
+
# @return [void]
|
|
347
|
+
# @raise [ConfigurationError] If configuration is invalid
|
|
348
|
+
def validate!
|
|
349
|
+
# Validate start_node exists
|
|
350
|
+
unless @nodes.key?(@start_node)
|
|
351
|
+
raise ConfigurationError,
|
|
352
|
+
"start_node '#{@start_node}' not found. Available nodes: #{@nodes.keys.join(", ")}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Validate all nodes
|
|
356
|
+
@nodes.each_value(&:validate!)
|
|
357
|
+
|
|
358
|
+
# Validate node dependencies reference existing nodes
|
|
359
|
+
@nodes.each do |node_name, node|
|
|
360
|
+
node.dependencies.each do |dep|
|
|
361
|
+
unless @nodes.key?(dep)
|
|
362
|
+
raise ConfigurationError,
|
|
363
|
+
"Node '#{node_name}' depends on unknown node '#{dep}'"
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Validate all agents referenced in nodes exist (skip agent-less nodes)
|
|
369
|
+
@nodes.each do |node_name, node|
|
|
370
|
+
next if node.agent_less? # Skip validation for agent-less nodes
|
|
371
|
+
|
|
372
|
+
node.agent_configs.each do |config|
|
|
373
|
+
agent_name = config[:agent]
|
|
374
|
+
unless @agent_definitions.key?(agent_name)
|
|
375
|
+
raise ConfigurationError,
|
|
376
|
+
"Node '#{node_name}' references undefined agent '#{agent_name}'"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Validate delegation targets exist
|
|
380
|
+
config[:delegates_to].each do |delegate|
|
|
381
|
+
unless @agent_definitions.key?(delegate)
|
|
382
|
+
raise ConfigurationError,
|
|
383
|
+
"Node '#{node_name}' agent '#{agent_name}' delegates to undefined agent '#{delegate}'"
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
580
390
|
# Clone an agent definition with node-specific overrides
|
|
581
391
|
#
|
|
582
392
|
# Allows overriding delegation and tools per node. This enables:
|
|
@@ -656,139 +466,25 @@ module SwarmSDK
|
|
|
656
466
|
order
|
|
657
467
|
end
|
|
658
468
|
|
|
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
469
|
# Inject cached agent instances into a swarm
|
|
774
470
|
#
|
|
775
471
|
# For agents with reset_context: false, reuses cached instances to preserve context.
|
|
776
472
|
# Forces agent initialization first (by accessing .agents), then swaps in cached instances.
|
|
777
473
|
#
|
|
778
474
|
# @param swarm [Swarm] Swarm instance to inject into
|
|
779
|
-
# @param node [
|
|
475
|
+
# @param node [Workflow::Builder] Node configuration
|
|
780
476
|
# @return [void]
|
|
781
477
|
def inject_cached_agents(swarm, node)
|
|
782
478
|
# Check if any agents need context preservation
|
|
783
479
|
has_preserved = node.agent_configs.any? do |c|
|
|
784
480
|
!c[:reset_context] && (
|
|
785
|
-
@
|
|
481
|
+
@agents[c[:agent]] ||
|
|
786
482
|
has_cached_delegations_for?(c[:agent])
|
|
787
483
|
)
|
|
788
484
|
end
|
|
789
485
|
return unless has_preserved
|
|
790
486
|
|
|
791
|
-
#
|
|
487
|
+
# Force initialization FIRST
|
|
792
488
|
# Without this, @agents will be replaced by initialize_all, losing our injected instances
|
|
793
489
|
swarm.agent(node.agent_configs.first[:agent]) # Triggers lazy init
|
|
794
490
|
|
|
@@ -801,7 +497,7 @@ module SwarmSDK
|
|
|
801
497
|
agent_name = config[:agent]
|
|
802
498
|
next if config[:reset_context]
|
|
803
499
|
|
|
804
|
-
cached_agent = @
|
|
500
|
+
cached_agent = @agents[agent_name]
|
|
805
501
|
next unless cached_agent
|
|
806
502
|
|
|
807
503
|
# Replace freshly initialized agent with cached instance
|
|
@@ -817,11 +513,11 @@ module SwarmSDK
|
|
|
817
513
|
|
|
818
514
|
agent_def.delegates_to.each do |delegate_name|
|
|
819
515
|
delegation_key = "#{delegate_name}@#{agent_name}"
|
|
820
|
-
cached_delegation = @
|
|
516
|
+
cached_delegation = @delegation_instances[delegation_key]
|
|
821
517
|
next unless cached_delegation
|
|
822
518
|
|
|
823
519
|
# Replace freshly initialized delegation instance
|
|
824
|
-
#
|
|
520
|
+
# Tool references intact - atomic caching preserves object graph
|
|
825
521
|
delegation_hash[delegation_key] = cached_delegation
|
|
826
522
|
end
|
|
827
523
|
end
|
|
@@ -831,7 +527,7 @@ module SwarmSDK
|
|
|
831
527
|
agent_def = @agent_definitions[agent_name]
|
|
832
528
|
agent_def.delegates_to.any? do |delegate_name|
|
|
833
529
|
delegation_key = "#{delegate_name}@#{agent_name}"
|
|
834
|
-
@
|
|
530
|
+
@delegation_instances[delegation_key]
|
|
835
531
|
end
|
|
836
532
|
end
|
|
837
533
|
|