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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/cli.rb +9 -11
  3. data/lib/claude_swarm/commands/ps.rb +1 -2
  4. data/lib/claude_swarm/configuration.rb +30 -7
  5. data/lib/claude_swarm/mcp_generator.rb +4 -10
  6. data/lib/claude_swarm/orchestrator.rb +43 -44
  7. data/lib/claude_swarm/system_utils.rb +4 -4
  8. data/lib/claude_swarm/version.rb +1 -1
  9. data/lib/claude_swarm.rb +5 -9
  10. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  11. data/lib/swarm_cli/commands/mcp_tools.rb +3 -3
  12. data/lib/swarm_cli/config_loader.rb +14 -13
  13. data/lib/swarm_cli/version.rb +1 -1
  14. data/lib/swarm_cli.rb +2 -0
  15. data/lib/swarm_memory/adapters/base.rb +4 -4
  16. data/lib/swarm_memory/adapters/filesystem_adapter.rb +0 -12
  17. data/lib/swarm_memory/core/storage.rb +66 -6
  18. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  19. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  20. data/lib/swarm_memory/integration/sdk_plugin.rb +24 -4
  21. data/lib/swarm_memory/optimization/defragmenter.rb +4 -0
  22. data/lib/swarm_memory/tools/memory_edit.rb +3 -2
  23. data/lib/swarm_memory/tools/memory_glob.rb +24 -1
  24. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  25. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  26. data/lib/swarm_memory/tools/memory_write.rb +2 -2
  27. data/lib/swarm_memory/version.rb +1 -1
  28. data/lib/swarm_memory.rb +7 -0
  29. data/lib/swarm_sdk/agent/builder.rb +33 -0
  30. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  31. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  32. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  33. data/lib/swarm_sdk/agent/chat.rb +199 -52
  34. data/lib/swarm_sdk/agent/context.rb +6 -2
  35. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  36. data/lib/swarm_sdk/agent/definition.rb +32 -23
  37. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  38. data/lib/swarm_sdk/configuration.rb +420 -103
  39. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  40. data/lib/swarm_sdk/log_collector.rb +31 -5
  41. data/lib/swarm_sdk/log_stream.rb +37 -8
  42. data/lib/swarm_sdk/model_aliases.json +4 -1
  43. data/lib/swarm_sdk/node/agent_config.rb +39 -9
  44. data/lib/swarm_sdk/node/builder.rb +158 -42
  45. data/lib/swarm_sdk/node_context.rb +75 -0
  46. data/lib/swarm_sdk/node_orchestrator.rb +492 -18
  47. data/lib/swarm_sdk/plugin.rb +73 -1
  48. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  49. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  50. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  51. data/lib/swarm_sdk/restore_result.rb +65 -0
  52. data/lib/swarm_sdk/result.rb +32 -6
  53. data/lib/swarm_sdk/snapshot.rb +156 -0
  54. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  55. data/lib/swarm_sdk/state_restorer.rb +491 -0
  56. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  57. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  58. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  59. data/lib/swarm_sdk/swarm/builder.rb +208 -11
  60. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  61. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  62. data/lib/swarm_sdk/swarm.rb +367 -90
  63. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  64. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  65. data/lib/swarm_sdk/tools/delegate.rb +94 -9
  66. data/lib/swarm_sdk/tools/read.rb +17 -5
  67. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  68. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  69. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  70. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  71. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  72. data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
  73. data/lib/swarm_sdk/tools/think.rb +4 -1
  74. data/lib/swarm_sdk/tools/todo_write.rb +20 -8
  75. data/lib/swarm_sdk/utils.rb +18 -0
  76. data/lib/swarm_sdk/validation_result.rb +33 -0
  77. data/lib/swarm_sdk/version.rb +1 -1
  78. data/lib/swarm_sdk.rb +365 -28
  79. 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
- @execution_order.each do |node_name|
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 if transformer requested skipping execution
106
- # (from Ruby block returning hash OR bash command exit 1)
107
- if transformed.is_a?(Hash) && transformed[:skip_execution]
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 = transformed[:content] || transformed["content"]
110
- else
111
- current_input = transformed
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
- current_input = node.transform_output(output_context)
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
- results.values.last
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
- swarm = Swarm.new(name: "#{@swarm_name}:#{node.name}")
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 delegation
301
- node_specific_def = clone_with_delegation(agent_def, delegates_to)
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 different delegates_to
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
- # @return [Agent::Definition] Cloned definition
317
- def clone_with_delegation(agent_def, delegates_to)
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