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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +33 -0
  3. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  4. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  5. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  6. data/lib/swarm_sdk/agent/chat.rb +198 -51
  7. data/lib/swarm_sdk/agent/context.rb +6 -2
  8. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  9. data/lib/swarm_sdk/agent/definition.rb +14 -2
  10. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  11. data/lib/swarm_sdk/configuration.rb +387 -94
  12. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  13. data/lib/swarm_sdk/log_collector.rb +31 -5
  14. data/lib/swarm_sdk/log_stream.rb +37 -8
  15. data/lib/swarm_sdk/model_aliases.json +4 -1
  16. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  17. data/lib/swarm_sdk/node/builder.rb +39 -18
  18. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  19. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  20. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  21. data/lib/swarm_sdk/restore_result.rb +65 -0
  22. data/lib/swarm_sdk/snapshot.rb +156 -0
  23. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  24. data/lib/swarm_sdk/state_restorer.rb +491 -0
  25. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  26. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  27. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  28. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  29. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  30. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  31. data/lib/swarm_sdk/swarm.rb +337 -42
  32. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  33. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  34. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  35. data/lib/swarm_sdk/tools/read.rb +17 -5
  36. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  37. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  38. data/lib/swarm_sdk/utils.rb +18 -0
  39. data/lib/swarm_sdk/validation_result.rb +33 -0
  40. data/lib/swarm_sdk/version.rb +1 -1
  41. data/lib/swarm_sdk.rb +40 -8
  42. metadata +17 -6
  43. 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:, scratchpad_enabled: true)
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
- @scratchpad_enabled = scratchpad_enabled
29
- @agent_instance_cache = {} # Cache for preserving agent context across nodes
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
- # Inherits scratchpad_enabled setting from NodeOrchestrator.
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
- scratchpad_enabled: @scratchpad_enabled,
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 delegation
368
- 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)
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 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
383
585
  #
384
586
  # @param agent_def [Agent::Definition] Original definition
385
587
  # @param delegates_to [Array<Symbol>] New delegation targets
386
- # @return [Agent::Definition] Cloned definition
387
- 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)
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 # Only cache if agents were initialized
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 is false
753
+ # Only cache if reset_context: false
550
754
  next if reset_context
551
755
 
552
- # Cache the agent instance
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
- has_preserved_agents = node.agent_configs.any? { |c| !c[:reset_context] && @agent_instance_cache[c[:agent]] }
569
- return unless has_preserved_agents
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
- # Force agent initialization by accessing .agents (triggers lazy init)
572
- # Then inject cached instances
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
- reset_context = config[:reset_context]
802
+ next if config[:reset_context]
578
803
 
579
- # Skip if reset_context is true (want fresh instance)
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
- # Inject the cached instance (replace the freshly initialized one)
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
- # Build parameter schema for a tool parameter
399
+ # Empty parameter schema for tools with no parameters
396
400
  #
397
- # @param param [RubyLLM::Tool::Parameter] Parameter to convert
398
- # @return [Hash] Parameter schema
399
- def param_schema(param)
401
+ # @return [Hash] Empty JSON Schema matching OpenAI's format
402
+ def empty_parameters_schema
400
403
  {
401
- type: param.type,
402
- description: param.description,
403
- }.compact
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