swarm_sdk 2.1.3 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +33 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +198 -51
- data/lib/swarm_sdk/agent/context.rb +6 -2
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +14 -2
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +387 -94
- data/lib/swarm_sdk/events_to_messages.rb +181 -0
- data/lib/swarm_sdk/log_collector.rb +31 -5
- data/lib/swarm_sdk/log_stream.rb +37 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node/agent_config.rb +33 -8
- data/lib/swarm_sdk/node/builder.rb +39 -18
- data/lib/swarm_sdk/node_orchestrator.rb +293 -26
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
- data/lib/swarm_sdk/state_restorer.rb +491 -0
- data/lib/swarm_sdk/state_snapshot.rb +369 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +208 -12
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
- data/lib/swarm_sdk/swarm.rb +337 -42
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/delegate.rb +92 -7
- data/lib/swarm_sdk/tools/read.rb +17 -5
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
- data/lib/swarm_sdk/utils.rb +18 -0
- data/lib/swarm_sdk/validation_result.rb +33 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +40 -8
- metadata +17 -6
- data/lib/swarm_sdk/mcp.rb +0 -16
|
@@ -18,15 +18,39 @@ 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
|
|
28
|
-
@
|
|
29
|
-
@
|
|
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
|
|
30
54
|
|
|
31
55
|
validate!
|
|
32
56
|
@execution_order = build_execution_order
|
|
@@ -35,6 +59,72 @@ module SwarmSDK
|
|
|
35
59
|
# Alias for compatibility with Swarm interface
|
|
36
60
|
alias_method :name, :swarm_name
|
|
37
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
|
+
|
|
38
128
|
# Return the lead agent of the start node for CLI compatibility
|
|
39
129
|
#
|
|
40
130
|
# @return [Symbol] Lead agent of the start node
|
|
@@ -56,6 +146,9 @@ module SwarmSDK
|
|
|
56
146
|
results = {}
|
|
57
147
|
@original_prompt = prompt # Store original prompt for NodeContext
|
|
58
148
|
|
|
149
|
+
# Set fiber-local execution context for entire workflow
|
|
150
|
+
Fiber[:execution_id] = generate_execution_id
|
|
151
|
+
|
|
59
152
|
# Setup logging if block given
|
|
60
153
|
if block_given?
|
|
61
154
|
# Register callback to collect logs and forward to user's block
|
|
@@ -77,6 +170,12 @@ module SwarmSDK
|
|
|
77
170
|
node = @nodes[node_name]
|
|
78
171
|
node_start_time = Time.now
|
|
79
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
|
+
|
|
80
179
|
# Emit node_start event
|
|
81
180
|
emit_node_start(node_name, node)
|
|
82
181
|
|
|
@@ -226,13 +325,92 @@ module SwarmSDK
|
|
|
226
325
|
|
|
227
326
|
last_result
|
|
228
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
|
+
|
|
229
333
|
# Reset logging state for next execution
|
|
230
334
|
LogCollector.reset!
|
|
231
335
|
LogStream.reset!
|
|
232
336
|
end
|
|
233
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
|
+
|
|
234
402
|
private
|
|
235
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
|
+
|
|
236
414
|
# Emit node_start event
|
|
237
415
|
#
|
|
238
416
|
# @param node_name [Symbol] Name of the node
|
|
@@ -346,26 +524,46 @@ module SwarmSDK
|
|
|
346
524
|
# For agents with reset_context: false, injects cached instances
|
|
347
525
|
# to preserve conversation history across nodes.
|
|
348
526
|
#
|
|
349
|
-
#
|
|
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
|
|
350
531
|
#
|
|
351
532
|
# @param node [Node::Builder] Node configuration
|
|
352
533
|
# @return [Swarm] Configured swarm instance
|
|
353
534
|
def build_swarm_for_node(node)
|
|
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
|
+
|
|
354
538
|
swarm = Swarm.new(
|
|
355
539
|
name: "#{@swarm_name}:#{node.name}",
|
|
356
|
-
|
|
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,
|
|
357
545
|
)
|
|
358
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
|
|
555
|
+
|
|
359
556
|
# Add each agent specified in this node
|
|
360
557
|
node.agent_configs.each do |config|
|
|
361
558
|
agent_name = config[:agent]
|
|
362
559
|
delegates_to = config[:delegates_to]
|
|
560
|
+
tools_override = config[:tools]
|
|
363
561
|
|
|
364
562
|
# Get global agent definition
|
|
365
563
|
agent_def = @agent_definitions[agent_name]
|
|
366
564
|
|
|
367
|
-
# Clone definition with node-specific
|
|
368
|
-
node_specific_def =
|
|
565
|
+
# Clone definition with node-specific overrides
|
|
566
|
+
node_specific_def = clone_agent_for_node(agent_def, delegates_to, tools_override)
|
|
369
567
|
|
|
370
568
|
swarm.add_agent(node_specific_def)
|
|
371
569
|
end
|
|
@@ -379,14 +577,20 @@ module SwarmSDK
|
|
|
379
577
|
swarm
|
|
380
578
|
end
|
|
381
579
|
|
|
382
|
-
# Clone an agent definition with
|
|
580
|
+
# Clone an agent definition with node-specific overrides
|
|
581
|
+
#
|
|
582
|
+
# Allows overriding delegation and tools per node. This enables:
|
|
583
|
+
# - Different delegation topology per node
|
|
584
|
+
# - Different tool sets per workflow stage
|
|
383
585
|
#
|
|
384
586
|
# @param agent_def [Agent::Definition] Original definition
|
|
385
587
|
# @param delegates_to [Array<Symbol>] New delegation targets
|
|
386
|
-
# @
|
|
387
|
-
|
|
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)
|
|
388
591
|
config = agent_def.to_h
|
|
389
592
|
config[:delegates_to] = delegates_to
|
|
593
|
+
config[:tools] = tools if tools # Only override if explicitly set
|
|
390
594
|
Agent::Definition.new(agent_def.name, config)
|
|
391
595
|
end
|
|
392
596
|
|
|
@@ -540,18 +744,29 @@ module SwarmSDK
|
|
|
540
744
|
# @param node [Node::Builder] Node configuration
|
|
541
745
|
# @return [void]
|
|
542
746
|
def cache_agent_instances(swarm, node)
|
|
543
|
-
return unless swarm.agents
|
|
747
|
+
return unless swarm.agents
|
|
544
748
|
|
|
545
749
|
node.agent_configs.each do |config|
|
|
546
750
|
agent_name = config[:agent]
|
|
547
751
|
reset_context = config[:reset_context]
|
|
548
752
|
|
|
549
|
-
# Only cache if reset_context
|
|
753
|
+
# Only cache if reset_context: false
|
|
550
754
|
next if reset_context
|
|
551
755
|
|
|
552
|
-
# Cache
|
|
756
|
+
# Cache primary agent
|
|
553
757
|
agent_instance = swarm.agents[agent_name]
|
|
554
|
-
@agent_instance_cache[agent_name] = agent_instance if agent_instance
|
|
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
|
|
555
770
|
end
|
|
556
771
|
end
|
|
557
772
|
|
|
@@ -565,27 +780,79 @@ module SwarmSDK
|
|
|
565
780
|
# @return [void]
|
|
566
781
|
def inject_cached_agents(swarm, node)
|
|
567
782
|
# Check if any agents need context preservation
|
|
568
|
-
|
|
569
|
-
|
|
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
|
|
570
790
|
|
|
571
|
-
#
|
|
572
|
-
#
|
|
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
|
|
573
796
|
agents_hash = swarm.agents
|
|
797
|
+
delegation_hash = swarm.delegation_instances
|
|
574
798
|
|
|
799
|
+
# Inject cached PRIMARY agents
|
|
575
800
|
node.agent_configs.each do |config|
|
|
576
801
|
agent_name = config[:agent]
|
|
577
|
-
|
|
802
|
+
next if config[:reset_context]
|
|
578
803
|
|
|
579
|
-
|
|
580
|
-
next if reset_context
|
|
581
|
-
|
|
582
|
-
# Check if we have a cached instance
|
|
583
|
-
cached_agent = @agent_instance_cache[agent_name]
|
|
804
|
+
cached_agent = @agent_instance_cache[:primary][agent_name]
|
|
584
805
|
next unless cached_agent
|
|
585
806
|
|
|
586
|
-
#
|
|
807
|
+
# Replace freshly initialized agent with cached instance
|
|
587
808
|
agents_hash[agent_name] = cached_agent
|
|
588
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
|
|
589
856
|
end
|
|
590
857
|
end
|
|
591
858
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
# Helper methods for working with Procs and Lambdas
|
|
5
|
+
#
|
|
6
|
+
# Provides functionality to convert regular Proc objects into Lambdas to enable
|
|
7
|
+
# safe use of return statements in DSL blocks (like input/output transformers).
|
|
8
|
+
module ProcHelpers
|
|
9
|
+
class << self
|
|
10
|
+
# Convert a Proc to a Lambda
|
|
11
|
+
#
|
|
12
|
+
# The fundamental difference between a Proc and a Lambda is in how they handle
|
|
13
|
+
# return statements. In a Proc, return exits the enclosing method (or program),
|
|
14
|
+
# while in a Lambda, return only exits the lambda itself.
|
|
15
|
+
#
|
|
16
|
+
# This method converts a Proc to a Lambda by:
|
|
17
|
+
# 1. Converting the proc to an unbound method via define_method
|
|
18
|
+
# 2. Wrapping it in a lambda that binds and calls the method
|
|
19
|
+
# 3. In the method, return exits the method (not the original scope)
|
|
20
|
+
#
|
|
21
|
+
# This allows users to write natural control flow with return statements:
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# my_proc = proc { |x| return x * 2 if x > 0; 0 }
|
|
25
|
+
# my_lambda = ProcHelpers.to_lambda(my_proc)
|
|
26
|
+
# my_lambda.call(5) # => 10 (return works safely!)
|
|
27
|
+
#
|
|
28
|
+
# @param proc [Proc] The proc to convert
|
|
29
|
+
# @return [Proc] A lambda with the same behavior but safe return semantics
|
|
30
|
+
def to_lambda(proc)
|
|
31
|
+
return proc if proc.lambda?
|
|
32
|
+
|
|
33
|
+
# Save local reference to proc so we can use it in module_exec/lambda scopes
|
|
34
|
+
source_proc = proc
|
|
35
|
+
|
|
36
|
+
# Convert proc to unbound method
|
|
37
|
+
# define_method with a block converts the block to a method, where return
|
|
38
|
+
# exits the method (not the original scope)
|
|
39
|
+
unbound_method = Module.new.module_exec do
|
|
40
|
+
instance_method(define_method(:_proc_call, &source_proc))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Return lambda which binds our unbound method to correct receiver and calls it
|
|
44
|
+
lambda do |*args, **kwargs, &block|
|
|
45
|
+
# Bind method to the original proc's receiver (the context where it was defined)
|
|
46
|
+
# This preserves access to instance variables, local variables via closure, etc.
|
|
47
|
+
receiver = source_proc.binding.eval("self")
|
|
48
|
+
unbound_method.bind(receiver).call(*args, **kwargs, &block)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -145,7 +145,7 @@ module SwarmSDK
|
|
|
145
145
|
rescue NoMethodError => e
|
|
146
146
|
# Catch fetch/dig errors on nil and provide better context
|
|
147
147
|
if e.message.include?("undefined method") && (e.message.include?("fetch") || e.message.include?("dig"))
|
|
148
|
-
log_parse_error(e.class.name, e.message, response.body)
|
|
148
|
+
log_parse_error(e.class.name, e.message, response.body, e.backtrace)
|
|
149
149
|
nil
|
|
150
150
|
else
|
|
151
151
|
raise
|
|
@@ -377,30 +377,36 @@ module SwarmSDK
|
|
|
377
377
|
# This differs from chat/completions which nests under 'function':
|
|
378
378
|
# { type: "function", function: { name: "tool_name", ... } }
|
|
379
379
|
#
|
|
380
|
+
# RubyLLM 1.9.0+: Uses tool.params_schema for unified schema generation.
|
|
381
|
+
# This supports both old param helper and new params DSL, and includes
|
|
382
|
+
# proper JSON Schema formatting (strict, additionalProperties, etc.)
|
|
383
|
+
#
|
|
380
384
|
# @param tool [RubyLLM::Tool] Tool to convert
|
|
381
385
|
# @return [Hash] Tool definition in Responses API format
|
|
382
386
|
def responses_tool_for(tool)
|
|
387
|
+
# Use tool.params_schema which returns a complete JSON Schema hash
|
|
388
|
+
# This works with both param helper and params DSL
|
|
389
|
+
parameters_schema = tool.params_schema || empty_parameters_schema
|
|
390
|
+
|
|
383
391
|
{
|
|
384
392
|
type: "function",
|
|
385
393
|
name: tool.name,
|
|
386
394
|
description: tool.description,
|
|
387
|
-
parameters:
|
|
388
|
-
type: "object",
|
|
389
|
-
properties: tool.parameters.transform_values { |param| param_schema(param) },
|
|
390
|
-
required: tool.parameters.select { |_, p| p.required }.keys,
|
|
391
|
-
},
|
|
395
|
+
parameters: parameters_schema,
|
|
392
396
|
}
|
|
393
397
|
end
|
|
394
398
|
|
|
395
|
-
#
|
|
399
|
+
# Empty parameter schema for tools with no parameters
|
|
396
400
|
#
|
|
397
|
-
# @
|
|
398
|
-
|
|
399
|
-
def param_schema(param)
|
|
401
|
+
# @return [Hash] Empty JSON Schema matching OpenAI's format
|
|
402
|
+
def empty_parameters_schema
|
|
400
403
|
{
|
|
401
|
-
type
|
|
402
|
-
|
|
403
|
-
|
|
404
|
+
"type" => "object",
|
|
405
|
+
"properties" => {},
|
|
406
|
+
"required" => [],
|
|
407
|
+
"additionalProperties" => false,
|
|
408
|
+
"strict" => true,
|
|
409
|
+
}
|
|
404
410
|
end
|
|
405
411
|
|
|
406
412
|
# Parse Responses API response
|
|
@@ -562,7 +568,7 @@ module SwarmSDK
|
|
|
562
568
|
# @param error_class [String] Error class name
|
|
563
569
|
# @param error_message [String] Error message
|
|
564
570
|
# @param response_body [Object] Response body that failed to parse
|
|
565
|
-
def log_parse_error(error_class, error_message, response_body)
|
|
571
|
+
def log_parse_error(error_class, error_message, response_body, error_backtrace = nil)
|
|
566
572
|
if @agent_name
|
|
567
573
|
# Emit structured JSON log through LogStream
|
|
568
574
|
LogStream.emit(
|
|
@@ -570,11 +576,12 @@ module SwarmSDK
|
|
|
570
576
|
agent: @agent_name,
|
|
571
577
|
error_class: error_class,
|
|
572
578
|
error_message: error_message,
|
|
579
|
+
error_backtrace: error_backtrace,
|
|
573
580
|
response_body: response_body.inspect,
|
|
574
581
|
)
|
|
575
582
|
else
|
|
576
583
|
# Fallback to RubyLLM logger if agent name not set
|
|
577
|
-
RubyLLM.logger.error("SwarmSDK: #{error_class}: #{error_message}\nResponse: #{response_body.inspect}")
|
|
584
|
+
RubyLLM.logger.error("SwarmSDK: #{error_class}: #{error_message}\nResponse: #{response_body.inspect}\nError backtrace: #{error_backtrace.join("\n")}")
|
|
578
585
|
end
|
|
579
586
|
end
|
|
580
587
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
# Result object returned from snapshot restore operations
|
|
5
|
+
#
|
|
6
|
+
# Provides information about the restore process, including any warnings
|
|
7
|
+
# about agents or delegations that couldn't be restored due to configuration
|
|
8
|
+
# mismatches.
|
|
9
|
+
#
|
|
10
|
+
# @example Successful restore
|
|
11
|
+
# result = swarm.restore(snapshot_data)
|
|
12
|
+
# if result.success?
|
|
13
|
+
# puts "All agents restored successfully"
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example Partial restore with warnings
|
|
17
|
+
# result = swarm.restore(snapshot_data)
|
|
18
|
+
# if result.partial_restore?
|
|
19
|
+
# puts result.summary
|
|
20
|
+
# result.warnings.each do |warning|
|
|
21
|
+
# puts " - #{warning[:message]}"
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
class RestoreResult
|
|
25
|
+
attr_reader :warnings, :skipped_agents, :skipped_delegations
|
|
26
|
+
|
|
27
|
+
# Initialize restore result
|
|
28
|
+
#
|
|
29
|
+
# @param warnings [Array<Hash>] Warning messages with details
|
|
30
|
+
# @param skipped_agents [Array<Symbol>] Names of agents that couldn't be restored
|
|
31
|
+
# @param skipped_delegations [Array<String>] Names of delegation instances that couldn't be restored
|
|
32
|
+
def initialize(warnings:, skipped_agents:, skipped_delegations:)
|
|
33
|
+
@warnings = warnings
|
|
34
|
+
@skipped_agents = skipped_agents
|
|
35
|
+
@skipped_delegations = skipped_delegations
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if restore was completely successful
|
|
39
|
+
#
|
|
40
|
+
# @return [Boolean] true if all agents restored without warnings
|
|
41
|
+
def success?
|
|
42
|
+
warnings.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if restore was partial (some agents skipped)
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean] true if some agents were skipped
|
|
48
|
+
def partial_restore?
|
|
49
|
+
!warnings.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get human-readable summary of restore result
|
|
53
|
+
#
|
|
54
|
+
# @return [String] Summary message
|
|
55
|
+
def summary
|
|
56
|
+
if success?
|
|
57
|
+
"Snapshot restored successfully. All agents restored."
|
|
58
|
+
else
|
|
59
|
+
"Snapshot restored with warnings. " \
|
|
60
|
+
"#{skipped_agents.size} agents skipped, " \
|
|
61
|
+
"#{skipped_delegations.size} delegation instances skipped."
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|