swarm_memory 2.1.3 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/claude_mcp_server.rb +1 -0
  3. data/lib/claude_swarm/cli.rb +5 -18
  4. data/lib/claude_swarm/configuration.rb +2 -15
  5. data/lib/claude_swarm/mcp_generator.rb +1 -0
  6. data/lib/claude_swarm/openai/chat_completion.rb +4 -12
  7. data/lib/claude_swarm/openai/executor.rb +3 -1
  8. data/lib/claude_swarm/openai/responses.rb +13 -32
  9. data/lib/claude_swarm/version.rb +1 -1
  10. data/lib/swarm_cli/commands/run.rb +2 -2
  11. data/lib/swarm_cli/config_loader.rb +11 -11
  12. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  13. data/lib/swarm_cli/interactive_repl.rb +11 -5
  14. data/lib/swarm_cli/ui/icons.rb +0 -23
  15. data/lib/swarm_cli/version.rb +1 -1
  16. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  17. data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
  18. data/lib/swarm_memory/version.rb +1 -1
  19. data/lib/swarm_memory.rb +1 -1
  20. data/lib/swarm_sdk/agent/builder.rb +58 -0
  21. data/lib/swarm_sdk/agent/chat.rb +527 -1059
  22. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
  23. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  24. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
  25. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  26. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  27. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  28. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +12 -12
  30. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  31. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  32. data/lib/swarm_sdk/agent/context.rb +2 -2
  33. data/lib/swarm_sdk/agent/definition.rb +66 -154
  34. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  35. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  36. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  37. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  38. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  39. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  40. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  41. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  42. data/lib/swarm_sdk/configuration.rb +65 -543
  43. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  44. data/lib/swarm_sdk/context_compactor.rb +6 -11
  45. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  46. data/lib/swarm_sdk/context_management/context.rb +328 -0
  47. data/lib/swarm_sdk/defaults.rb +196 -0
  48. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  49. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  50. data/lib/swarm_sdk/log_collector.rb +179 -29
  51. data/lib/swarm_sdk/log_stream.rb +29 -0
  52. data/lib/swarm_sdk/node_context.rb +1 -1
  53. data/lib/swarm_sdk/observer/builder.rb +81 -0
  54. data/lib/swarm_sdk/observer/config.rb +45 -0
  55. data/lib/swarm_sdk/observer/manager.rb +236 -0
  56. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  57. data/lib/swarm_sdk/plugin.rb +93 -3
  58. data/lib/swarm_sdk/snapshot.rb +6 -6
  59. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  60. data/lib/swarm_sdk/state_restorer.rb +136 -151
  61. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  62. data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
  63. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  64. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  65. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  66. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  67. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  68. data/lib/swarm_sdk/swarm/tool_configurator.rb +42 -138
  69. data/lib/swarm_sdk/swarm.rb +137 -679
  70. data/lib/swarm_sdk/tools/bash.rb +11 -3
  71. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  72. data/lib/swarm_sdk/tools/edit.rb +8 -13
  73. data/lib/swarm_sdk/tools/glob.rb +9 -1
  74. data/lib/swarm_sdk/tools/grep.rb +7 -0
  75. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  76. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  77. data/lib/swarm_sdk/tools/read.rb +11 -13
  78. data/lib/swarm_sdk/tools/registry.rb +122 -10
  79. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
  80. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  81. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  82. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  83. data/lib/swarm_sdk/tools/write.rb +8 -13
  84. data/lib/swarm_sdk/version.rb +1 -1
  85. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  86. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  87. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  88. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
  89. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  90. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  91. data/lib/swarm_sdk.rb +33 -3
  92. metadata +37 -14
  93. data/lib/swarm_memory/chat_extension.rb +0 -34
  94. 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
- # NodeOrchestrator executes a multi-node workflow
4
+ # Workflow executes a multi-node workflow
5
5
  #
6
- # Each node represents a mini-swarm execution stage. The orchestrator:
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
- # orchestrator = NodeOrchestrator.new(
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 = orchestrator.execute("Build auth system")
20
- class NodeOrchestrator
21
- attr_reader :swarm_name, :nodes, :start_node, :agent_definitions, :agent_instance_cache, :scratchpad
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
- @agent_instance_cache = {
35
- primary: {}, # { agent_name => Agent::Chat }
36
- delegations: {}, # { "delegate@delegator" => Agent::Chat }
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
- # Alias for compatibility with Swarm interface
60
- alias_method :name, :swarm_name
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("NodeOrchestrator: Accessing shared_scratchpad_storage in per-node mode. Use scratchpad_for(node_name) instead.")
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
- logs = []
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
- # orchestrator = NodeOrchestrator.new(...)
357
- # orchestrator.execute("Build feature")
358
- # snapshot = orchestrator.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 orchestrator configuration. Restores agent
368
- # conversations that exist in the agent_instance_cache.
200
+ # between snapshot and current workflow configuration. Restores agent
201
+ # conversations that exist in the cached agents.
369
202
  #
370
- # The orchestrator must be created with the SAME configuration (agent definitions,
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
- # orchestrator = NodeOrchestrator.new(...) # Same config as snapshot
214
+ # workflow = Workflow.new(...) # Same config as snapshot
382
215
  # snapshot = Snapshot.from_file("workflow_session.json")
383
- # result = orchestrator.restore(snapshot)
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 orchestrator state from snapshot
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 [Node::Builder] Node configuration
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 [Node::Builder] Node configuration
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
- @agent_instance_cache[:primary][c[:agent]] ||
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
- # V7.0 CRITICAL FIX: Force initialization FIRST
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 = @agent_instance_cache[:primary][agent_name]
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 = @agent_instance_cache[:delegations][delegation_key]
516
+ cached_delegation = @delegation_instances[delegation_key]
821
517
  next unless cached_delegation
822
518
 
823
519
  # Replace freshly initialized delegation instance
824
- # V7.0: Tool references intact - atomic caching preserves object graph
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
- @agent_instance_cache[:delegations][delegation_key]
530
+ @delegation_instances[delegation_key]
835
531
  end
836
532
  end
837
533