swarm_sdk 2.1.1 → 2.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47978b1c12d2f651d9a6cf9d902e7eff40d46505e436039e53f39bc0d2b05341
4
- data.tar.gz: ae2b9ac86ec9a6718a14e121c7b19198e519cbd028a236b7b58d8b4acb96d3b9
3
+ metadata.gz: a77daa5d8abcd0cae1b1edad441d93421d25ecbf15a0713697d9eabb6c4dc00e
4
+ data.tar.gz: 97c037f892d03992b12292a31bc3ab9359ccacead39d37466af84ef5660e4933
5
5
  SHA512:
6
- metadata.gz: 48d1f64f551285a081d55ee10e98ba4a31b4b6ee55303b7516e2170bc78f08bc895e740f61eb37c8731196450546f44917ac6a1c98cbb8d0b345c6cda2f91bf7
7
- data.tar.gz: da736412afc0e1565ea706b0680cc961531649143294f2bbfce6954ebd752cdfa87d039cfd965ae881942764b3549d102f9a1711a15fdf23980a906d3108142c
6
+ metadata.gz: e7620b869014c9f1b889795310ec1d648cbcc55fc53a3cbb7023c37b403f83c57e5f3645b4f8ffdd38d60fa4877fa24d9ab3f0ccf0de3b2be6a45420516dbf50
7
+ data.tar.gz: bc081f3a444bb410a22dc2e5971f258dd43dadaa69d53558e799d036149412bfecf8d95396aa0f6e1f0c39cf6716b986722669265b32cc2b550f7daa0a6e9c2c
@@ -415,7 +415,7 @@ module SwarmSDK
415
415
 
416
416
  # Handle nil response from provider (malformed API response)
417
417
  if response.nil?
418
- raise RubyLLM::Error, "Provider returned nil response. This usually indicates a malformed API response " \
418
+ raise StandardError, "Provider returned nil response. This usually indicates a malformed API response " \
419
419
  "that couldn't be parsed.\n\n" \
420
420
  "Provider: #{@provider.class.name}\n" \
421
421
  "API Base: #{@provider.api_base}\n" \
@@ -158,10 +158,12 @@ module SwarmSDK
158
158
  end
159
159
 
160
160
  def to_h
161
- {
161
+ # Core SDK configuration (always serialized)
162
+ base_config = {
162
163
  name: @name,
163
164
  description: @description,
164
165
  model: SwarmSDK::Models.resolve_alias(@model), # Resolve model aliases
166
+ context_window: @context_window,
165
167
  directory: @directory,
166
168
  tools: @tools,
167
169
  delegates_to: @delegates_to,
@@ -179,7 +181,21 @@ module SwarmSDK
179
181
  assume_model_exists: @assume_model_exists,
180
182
  max_concurrent_tools: @max_concurrent_tools,
181
183
  hooks: @hooks,
184
+ # Permissions are core SDK functionality (not plugin-specific)
185
+ default_permissions: @default_permissions,
186
+ permissions: @agent_permissions,
182
187
  }.compact
188
+
189
+ # Allow plugins to contribute their config for serialization
190
+ # This enables plugin features (memory, skills, etc.) to be preserved
191
+ # when cloning agents without SwarmSDK knowing about plugin-specific fields
192
+ plugin_configs = SwarmSDK::PluginRegistry.all.map do |plugin|
193
+ plugin.serialize_config(agent_definition: self)
194
+ end
195
+
196
+ # Merge plugin configs into base config
197
+ # Later plugins override earlier ones if they have conflicting keys
198
+ plugin_configs.reduce(base_config) { |acc, config| acc.merge(config) }
183
199
  end
184
200
 
185
201
  # Validate agent configuration and return warnings (non-fatal issues)
@@ -6,19 +6,24 @@ module SwarmSDK
6
6
  #
7
7
  # This class enables the chainable syntax:
8
8
  # agent(:backend).delegates_to(:tester, :database)
9
+ # agent(:backend, reset_context: false) # Preserve context across nodes
9
10
  #
10
11
  # @example Basic delegation
11
12
  # agent(:backend).delegates_to(:tester)
12
13
  #
13
14
  # @example No delegation (solo agent)
14
15
  # agent(:planner)
16
+ #
17
+ # @example Preserve agent context
18
+ # agent(:architect, reset_context: false)
15
19
  class AgentConfig
16
20
  attr_reader :agent_name
17
21
 
18
- def initialize(agent_name, node_builder)
22
+ def initialize(agent_name, node_builder, reset_context: true)
19
23
  @agent_name = agent_name
20
24
  @node_builder = node_builder
21
25
  @delegates_to = []
26
+ @reset_context = reset_context
22
27
  @finalized = false
23
28
  end
24
29
 
@@ -41,7 +46,7 @@ module SwarmSDK
41
46
  def finalize
42
47
  return if @finalized
43
48
 
44
- @node_builder.register_agent(@agent_name, @delegates_to)
49
+ @node_builder.register_agent(@agent_name, @delegates_to, @reset_context)
45
50
  @finalized = true
46
51
  end
47
52
  end
@@ -46,7 +46,11 @@ module SwarmSDK
46
46
  # Returns an AgentConfig object that supports fluent delegation syntax.
47
47
  # If delegates_to is not called, the agent is registered with no delegation.
48
48
  #
49
+ # By default, agents get fresh context in each node (reset_context: true).
50
+ # Set reset_context: false to preserve conversation history across nodes.
51
+ #
49
52
  # @param name [Symbol] Agent name
53
+ # @param reset_context [Boolean] Whether to reset agent context (default: true)
50
54
  # @return [AgentConfig] Fluent configuration object
51
55
  #
52
56
  # @example With delegation
@@ -54,12 +58,15 @@ module SwarmSDK
54
58
  #
55
59
  # @example Without delegation
56
60
  # agent(:planner)
57
- def agent(name)
58
- config = AgentConfig.new(name, self)
61
+ #
62
+ # @example Preserve context across nodes
63
+ # agent(:architect, reset_context: false)
64
+ def agent(name, reset_context: true)
65
+ config = AgentConfig.new(name, self, reset_context: reset_context)
59
66
 
60
67
  # Register immediately with empty delegation
61
68
  # If delegates_to is called later, it will update this
62
- register_agent(name, [])
69
+ register_agent(name, [], reset_context)
63
70
 
64
71
  config
65
72
  end
@@ -68,17 +75,19 @@ module SwarmSDK
68
75
  #
69
76
  # @param agent_name [Symbol] Agent name
70
77
  # @param delegates_to [Array<Symbol>] Delegation targets
78
+ # @param reset_context [Boolean] Whether to reset agent context
71
79
  # @return [void]
72
- def register_agent(agent_name, delegates_to)
80
+ def register_agent(agent_name, delegates_to, reset_context = true)
73
81
  # Check if agent already registered
74
82
  existing = @agent_configs.find { |ac| ac[:agent] == agent_name }
75
83
 
76
84
  if existing
77
- # Update delegation (happens when delegates_to is called after agent())
85
+ # Update delegation and reset_context (happens when delegates_to is called after agent())
78
86
  existing[:delegates_to] = delegates_to
87
+ existing[:reset_context] = reset_context
79
88
  else
80
89
  # Add new agent configuration
81
- @agent_configs << { agent: agent_name, delegates_to: delegates_to }
90
+ @agent_configs << { agent: agent_name, delegates_to: delegates_to, reset_context: reset_context }
82
91
  end
83
92
  end
84
93
 
@@ -120,12 +129,13 @@ module SwarmSDK
120
129
  # Can also be used for side effects (logging, file I/O) since the block
121
130
  # runs at execution time, not declaration time.
122
131
  #
123
- # **Skip Execution**: Return a hash with `skip_execution: true` to skip
124
- # the node's swarm execution and immediately return the provided content.
125
- # Useful for caching, validation, or conditional execution.
132
+ # **Control Flow**: Return a hash with special keys to control execution:
133
+ # - `skip_execution: true` - Skip node's LLM execution, return content immediately
134
+ # - `halt_workflow: true` - Halt entire workflow with content as final result
135
+ # - `goto_node: :node_name` - Jump to different node with content as input
126
136
  #
127
137
  # @yield [NodeContext] Context with previous results and metadata
128
- # @return [String, Hash] Transformed input OR skip hash
138
+ # @return [String, Hash] Transformed input OR control hash
129
139
  #
130
140
  # @example Access previous result and original prompt
131
141
  # input do |ctx|
@@ -147,22 +157,26 @@ module SwarmSDK
147
157
  # @example Skip execution (caching)
148
158
  # input do |ctx|
149
159
  # cached = check_cache(ctx.content)
150
- # if cached
151
- # # Skip LLM call, return cached result
152
- # { skip_execution: true, content: cached }
153
- # else
154
- # ctx.content
155
- # end
160
+ # return ctx.skip_execution(content: cached) if cached
161
+ # ctx.content
156
162
  # end
157
163
  #
158
- # @example Skip execution (validation)
164
+ # @example Halt workflow (validation)
159
165
  # input do |ctx|
160
166
  # if ctx.content.length > 10000
161
- # # Fail early without LLM call
162
- # { skip_execution: true, content: "ERROR: Input too long" }
163
- # else
164
- # ctx.content
167
+ # # Halt entire workflow
168
+ # return ctx.halt_workflow(content: "ERROR: Input too long")
169
+ # end
170
+ # ctx.content
171
+ # end
172
+ #
173
+ # @example Jump to different node (conditional routing)
174
+ # input do |ctx|
175
+ # if ctx.content.include?("NEEDS_REVIEW")
176
+ # # Jump to review node instead
177
+ # return ctx.goto_node(:review, content: ctx.content)
165
178
  # end
179
+ # ctx.content
166
180
  # end
167
181
  def input(&block)
168
182
  @input_transformer = block
@@ -198,8 +212,12 @@ module SwarmSDK
198
212
  # Can also be used for side effects (logging, file I/O) since the block
199
213
  # runs at execution time, not declaration time.
200
214
  #
215
+ # **Control Flow**: Return a hash with special keys to control execution:
216
+ # - `halt_workflow: true` - Halt entire workflow with content as final result
217
+ # - `goto_node: :node_name` - Jump to different node with content as input
218
+ #
201
219
  # @yield [NodeContext] Context with current result and metadata
202
- # @return [String] Transformed output
220
+ # @return [String, Hash] Transformed output OR control hash
203
221
  #
204
222
  # @example Transform and save to file
205
223
  # output do |ctx|
@@ -216,12 +234,19 @@ module SwarmSDK
216
234
  # "Task: #{ctx.original_prompt}\nResult: #{ctx.content}"
217
235
  # end
218
236
  #
219
- # @example Access multiple node results
237
+ # @example Halt workflow (convergence check)
220
238
  # output do |ctx|
221
- # plan = ctx.all_results[:planning].content
222
- # impl = ctx.content
239
+ # return ctx.halt_workflow(content: ctx.content) if converged?(ctx.content)
240
+ # ctx.content
241
+ # end
223
242
  #
224
- # "Completed:\nPlan: #{plan}\nImpl: #{impl}"
243
+ # @example Jump to different node (conditional routing)
244
+ # output do |ctx|
245
+ # if needs_revision?(ctx.content)
246
+ # # Go back to revision node
247
+ # return ctx.goto_node(:revision, content: ctx.content)
248
+ # end
249
+ # ctx.content
225
250
  # end
226
251
  def output(&block)
227
252
  @output_transformer = block
@@ -264,6 +289,12 @@ module SwarmSDK
264
289
  #
265
290
  # Executes either Ruby block or bash command transformer.
266
291
  #
292
+ # **Ruby block return values:**
293
+ # - String: Transformed content
294
+ # - Hash with `skip_execution: true`: Skip node execution
295
+ # - Hash with `halt_workflow: true`: Halt entire workflow
296
+ # - Hash with `goto_node: :name`: Jump to different node
297
+ #
267
298
  # **Exit code behavior (bash commands only):**
268
299
  # - Exit 0: Use STDOUT as transformed content
269
300
  # - Exit 1: Skip node execution, use current_input unchanged (STDOUT ignored)
@@ -271,16 +302,23 @@ module SwarmSDK
271
302
  #
272
303
  # @param context [NodeContext] Context with previous results and metadata
273
304
  # @param current_input [String] Fallback content for exit 1 (skip), also used for halt error context
274
- # @return [String, Hash] Transformed input OR skip hash `{ skip_execution: true, content: "..." }`
305
+ # @return [String, Hash] Transformed input OR control hash (skip_execution, halt_workflow, goto_node)
275
306
  # @raise [ConfigurationError] If bash transformer halts workflow (exit 2)
276
307
  def transform_input(context, current_input:)
277
308
  # No transformer configured: return content as-is
278
309
  return context.content unless @input_transformer || @input_transformer_command
279
310
 
280
311
  # Ruby block transformer
281
- # Ruby blocks can return String (transformed content) OR Hash (skip_execution)
312
+ # Ruby blocks can return String (transformed content) OR Hash (control flow)
282
313
  if @input_transformer
283
- return @input_transformer.call(context)
314
+ result = @input_transformer.call(context)
315
+
316
+ # If hash, validate control flow keys
317
+ if result.is_a?(Hash)
318
+ validate_transformer_hash(result, :input)
319
+ end
320
+
321
+ return result
284
322
  end
285
323
 
286
324
  # Bash command transformer
@@ -318,22 +356,34 @@ module SwarmSDK
318
356
  #
319
357
  # Executes either Ruby block or bash command transformer.
320
358
  #
359
+ # **Ruby block return values:**
360
+ # - String: Transformed content
361
+ # - Hash with `halt_workflow: true`: Halt entire workflow
362
+ # - Hash with `goto_node: :name`: Jump to different node
363
+ #
321
364
  # **Exit code behavior (bash commands only):**
322
365
  # - Exit 0: Use STDOUT as transformed content
323
366
  # - Exit 1: Pass through unchanged, use result.content (STDOUT ignored)
324
367
  # - Exit 2: Halt workflow with error (STDOUT ignored)
325
368
  #
326
369
  # @param context [NodeContext] Context with current result and metadata
327
- # @return [String] Transformed output
370
+ # @return [String, Hash] Transformed output OR control hash (halt_workflow, goto_node)
328
371
  # @raise [ConfigurationError] If bash transformer halts workflow (exit 2)
329
372
  def transform_output(context)
330
373
  # No transformer configured: return content as-is
331
374
  return context.content unless @output_transformer || @output_transformer_command
332
375
 
333
376
  # Ruby block transformer
334
- # Simply calls the block with context and returns result
377
+ # Ruby blocks can return String (transformed content) OR Hash (control flow)
335
378
  if @output_transformer
336
- return @output_transformer.call(context)
379
+ result = @output_transformer.call(context)
380
+
381
+ # If hash, validate control flow keys
382
+ if result.is_a?(Hash)
383
+ validate_transformer_hash(result, :output)
384
+ end
385
+
386
+ return result
337
387
  end
338
388
 
339
389
  # Bash command transformer
@@ -411,6 +461,50 @@ module SwarmSDK
411
461
 
412
462
  private
413
463
 
464
+ # Validate transformer hash return value
465
+ #
466
+ # Ensures hash has valid control flow keys and required content field.
467
+ #
468
+ # @param hash [Hash] Hash returned from transformer
469
+ # @param transformer_type [Symbol] :input or :output
470
+ # @return [void]
471
+ # @raise [ConfigurationError] If hash is invalid
472
+ def validate_transformer_hash(hash, transformer_type)
473
+ # Valid control keys
474
+ valid_keys = if transformer_type == :input
475
+ [:skip_execution, :halt_workflow, :goto_node, :content]
476
+ else
477
+ [:halt_workflow, :goto_node, :content]
478
+ end
479
+
480
+ # Check for invalid keys
481
+ invalid_keys = hash.keys - valid_keys
482
+ if invalid_keys.any?
483
+ raise ConfigurationError,
484
+ "Invalid #{transformer_type} transformer hash keys: #{invalid_keys.join(", ")}. " \
485
+ "Valid keys: #{valid_keys.join(", ")}"
486
+ end
487
+
488
+ # Ensure content is present
489
+ unless hash.key?(:content)
490
+ raise ConfigurationError,
491
+ "#{transformer_type.capitalize} transformer hash must include :content key"
492
+ end
493
+
494
+ # Ensure only one control key
495
+ control_keys = hash.keys & [:skip_execution, :halt_workflow, :goto_node]
496
+ if control_keys.size > 1
497
+ raise ConfigurationError,
498
+ "#{transformer_type.capitalize} transformer hash can only have one control key, got: #{control_keys.join(", ")}"
499
+ end
500
+
501
+ # Validate goto_node has valid node name
502
+ if hash[:goto_node] && !hash[:goto_node].is_a?(Symbol)
503
+ raise ConfigurationError,
504
+ "goto_node value must be a Symbol, got: #{hash[:goto_node].class}"
505
+ end
506
+ end
507
+
414
508
  # Auto-add agents that are mentioned in delegates_to but not explicitly declared
415
509
  #
416
510
  # This allows:
@@ -418,7 +512,8 @@ module SwarmSDK
418
512
  # Without needing:
419
513
  # agent(:tester)
420
514
  #
421
- # The tester agent is automatically added to the node with no delegation.
515
+ # The tester agent is automatically added to the node with no delegation
516
+ # and reset_context: true (fresh context by default).
422
517
  #
423
518
  # @return [void]
424
519
  def auto_add_delegate_agents
@@ -429,9 +524,9 @@ module SwarmSDK
429
524
  declared_agents = @agent_configs.map { |ac| ac[:agent] }
430
525
  missing_delegates = all_delegates - declared_agents
431
526
 
432
- # Auto-add missing delegates with empty delegation
527
+ # Auto-add missing delegates with empty delegation and default reset_context
433
528
  missing_delegates.each do |delegate_name|
434
- @agent_configs << { agent: delegate_name, delegates_to: [] }
529
+ @agent_configs << { agent: delegate_name, delegates_to: [], reset_context: true }
435
530
  end
436
531
  end
437
532
  end
@@ -166,5 +166,80 @@ module SwarmSDK
166
166
  @previous_result.success?
167
167
  end
168
168
  end
169
+
170
+ # Control flow methods for transformers
171
+ # These return special hashes that NodeOrchestrator recognizes
172
+
173
+ # Skip current node's LLM execution and return content immediately
174
+ #
175
+ # Only valid for input transformers.
176
+ #
177
+ # @param content [String] Content to return (skips LLM call)
178
+ # @return [Hash] Control hash for skip_execution
179
+ # @raise [ArgumentError] If content is nil
180
+ #
181
+ # @example
182
+ # input do |ctx|
183
+ # cached = check_cache(ctx.content)
184
+ # return ctx.skip_execution(content: cached) if cached
185
+ # ctx.content
186
+ # end
187
+ def skip_execution(content:)
188
+ if content.nil?
189
+ raise ArgumentError,
190
+ "skip_execution requires content (got nil). " \
191
+ "Check that ctx.content or your content source is not nil. " \
192
+ "Node: #{@node_name}"
193
+ end
194
+ { skip_execution: true, content: content }
195
+ end
196
+
197
+ # Halt entire workflow and return content as final result
198
+ #
199
+ # Valid for both input and output transformers.
200
+ #
201
+ # @param content [String] Final content to return
202
+ # @return [Hash] Control hash for halt_workflow
203
+ # @raise [ArgumentError] If content is nil
204
+ #
205
+ # @example
206
+ # output do |ctx|
207
+ # return ctx.halt_workflow(content: ctx.content) if converged?(ctx.content)
208
+ # ctx.content
209
+ # end
210
+ def halt_workflow(content:)
211
+ if content.nil?
212
+ raise ArgumentError,
213
+ "halt_workflow requires content (got nil). " \
214
+ "Check that ctx.content or your content source is not nil. " \
215
+ "Node: #{@node_name}"
216
+ end
217
+ { halt_workflow: true, content: content }
218
+ end
219
+
220
+ # Jump to a different node with provided content as input
221
+ #
222
+ # Valid for both input and output transformers.
223
+ #
224
+ # @param node [Symbol] Node name to jump to
225
+ # @param content [String] Content to pass to target node
226
+ # @return [Hash] Control hash for goto_node
227
+ # @raise [ArgumentError] If content is nil
228
+ #
229
+ # @example
230
+ # input do |ctx|
231
+ # return ctx.goto_node(:review, content: ctx.content) if needs_review?(ctx.content)
232
+ # ctx.content
233
+ # end
234
+ def goto_node(node, content:)
235
+ if content.nil?
236
+ raise ArgumentError,
237
+ "goto_node requires content (got nil). " \
238
+ "Check that ctx.content or your content source is not nil. " \
239
+ "This often happens when the previous node failed with an error. " \
240
+ "Node: #{@node_name}, Target: #{node}"
241
+ end
242
+ { goto_node: node.to_sym, content: content }
243
+ end
169
244
  end
170
245
  end
@@ -20,16 +20,28 @@ module SwarmSDK
20
20
  class NodeOrchestrator
21
21
  attr_reader :swarm_name, :nodes, :start_node
22
22
 
23
- def initialize(swarm_name:, agent_definitions:, nodes:, start_node:)
23
+ def initialize(swarm_name:, agent_definitions:, nodes:, start_node:, scratchpad_enabled: true)
24
24
  @swarm_name = swarm_name
25
25
  @agent_definitions = agent_definitions
26
26
  @nodes = nodes
27
27
  @start_node = start_node
28
+ @scratchpad_enabled = scratchpad_enabled
29
+ @agent_instance_cache = {} # Cache for preserving agent context across nodes
28
30
 
29
31
  validate!
30
32
  @execution_order = build_execution_order
31
33
  end
32
34
 
35
+ # Alias for compatibility with Swarm interface
36
+ alias_method :name, :swarm_name
37
+
38
+ # Return the lead agent of the start node for CLI compatibility
39
+ #
40
+ # @return [Symbol] Lead agent of the start node
41
+ def lead_agent
42
+ @nodes[@start_node].lead_agent
43
+ end
44
+
33
45
  # Execute the node workflow
34
46
  #
35
47
  # Executes nodes in topological order, passing output from each node
@@ -56,7 +68,12 @@ module SwarmSDK
56
68
  LogStream.emitter = LogCollector
57
69
  end
58
70
 
59
- @execution_order.each do |node_name|
71
+ # Dynamic execution with support for goto_node
72
+ execution_index = 0
73
+ last_result = nil
74
+
75
+ while execution_index < @execution_order.size
76
+ node_name = @execution_order[execution_index]
60
77
  node = @nodes[node_name]
61
78
  node_start_time = Time.now
62
79
 
@@ -102,13 +119,26 @@ module SwarmSDK
102
119
  # - Exit 2: Halt workflow with error from STDERR (STDOUT ignored)
103
120
  transformed = node.transform_input(input_context, current_input: current_input)
104
121
 
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]
122
+ # Check for control flow from transformer
123
+ control_result = handle_transformer_control_flow(
124
+ transformed: transformed,
125
+ node_name: node_name,
126
+ node: node,
127
+ node_start_time: node_start_time,
128
+ )
129
+
130
+ case control_result[:action]
131
+ when :halt
132
+ return control_result[:result]
133
+ when :goto
134
+ execution_index = find_node_index(control_result[:target])
135
+ current_input = control_result[:content]
136
+ next
137
+ when :skip
108
138
  skip_execution = true
109
- skip_content = transformed[:content] || transformed["content"]
110
- else
111
- current_input = transformed
139
+ skip_content = control_result[:content]
140
+ when :continue
141
+ current_input = control_result[:content]
112
142
  end
113
143
  end
114
144
 
@@ -130,6 +160,9 @@ module SwarmSDK
130
160
  mini_swarm = build_swarm_for_node(node)
131
161
  result = mini_swarm.execute(current_input)
132
162
 
163
+ # Cache agent instances for context preservation
164
+ cache_agent_instances(mini_swarm, node)
165
+
133
166
  # If result has error, log it with backtrace
134
167
  if result.error
135
168
  RubyLLM.logger.error("NodeOrchestrator: Node '#{node_name}' failed: #{result.error.message}")
@@ -138,6 +171,7 @@ module SwarmSDK
138
171
  end
139
172
 
140
173
  results[node_name] = result
174
+ last_result = result
141
175
 
142
176
  # Transform output for next node using NodeContext
143
177
  output_context = NodeContext.for_output(
@@ -146,7 +180,29 @@ module SwarmSDK
146
180
  original_prompt: @original_prompt,
147
181
  node_name: node_name,
148
182
  )
149
- current_input = node.transform_output(output_context)
183
+ transformed_output = node.transform_output(output_context)
184
+
185
+ # Check for control flow from output transformer
186
+ control_result = handle_output_transformer_control_flow(
187
+ transformed: transformed_output,
188
+ node_name: node_name,
189
+ node: node,
190
+ node_start_time: node_start_time,
191
+ skip_execution: skip_execution,
192
+ result: result,
193
+ )
194
+
195
+ case control_result[:action]
196
+ when :halt
197
+ return control_result[:result]
198
+ when :goto
199
+ execution_index = find_node_index(control_result[:target])
200
+ current_input = control_result[:content]
201
+ emit_node_stop(node_name, node, result, Time.now - node_start_time, skip_execution)
202
+ next
203
+ when :continue
204
+ current_input = control_result[:content]
205
+ end
150
206
 
151
207
  # For agent-less nodes, update the result with transformed content
152
208
  # This ensures all_results contains the actual output, not the input
@@ -158,14 +214,17 @@ module SwarmSDK
158
214
  duration: result.duration,
159
215
  error: result.error,
160
216
  )
217
+ last_result = results[node_name]
161
218
  end
162
219
 
163
220
  # Emit node_stop event
164
221
  node_duration = Time.now - node_start_time
165
222
  emit_node_stop(node_name, node, result, node_duration, skip_execution)
223
+
224
+ execution_index += 1
166
225
  end
167
226
 
168
- results.values.last
227
+ last_result
169
228
  ensure
170
229
  # Reset logging state for next execution
171
230
  LogCollector.reset!
@@ -284,10 +343,18 @@ module SwarmSDK
284
343
  # Creates a new Swarm with only the agents specified in the node,
285
344
  # configured with the node's delegation topology.
286
345
  #
346
+ # For agents with reset_context: false, injects cached instances
347
+ # to preserve conversation history across nodes.
348
+ #
349
+ # Inherits scratchpad_enabled setting from NodeOrchestrator.
350
+ #
287
351
  # @param node [Node::Builder] Node configuration
288
352
  # @return [Swarm] Configured swarm instance
289
353
  def build_swarm_for_node(node)
290
- swarm = Swarm.new(name: "#{@swarm_name}:#{node.name}")
354
+ swarm = Swarm.new(
355
+ name: "#{@swarm_name}:#{node.name}",
356
+ scratchpad_enabled: @scratchpad_enabled,
357
+ )
291
358
 
292
359
  # Add each agent specified in this node
293
360
  node.agent_configs.each do |config|
@@ -306,6 +373,9 @@ module SwarmSDK
306
373
  # Set lead agent
307
374
  swarm.lead = node.lead_agent
308
375
 
376
+ # Inject cached agent instances for context preservation
377
+ inject_cached_agents(swarm, node)
378
+
309
379
  swarm
310
380
  end
311
381
 
@@ -359,7 +429,8 @@ module SwarmSDK
359
429
  if order.size < @nodes.size
360
430
  unprocessed = @nodes.keys - order
361
431
  raise CircularDependencyError,
362
- "Circular dependency detected. Unprocessed nodes: #{unprocessed.join(", ")}"
432
+ "Circular dependency detected. Unprocessed nodes: #{unprocessed.join(", ")}. " \
433
+ "Use goto_node in transformers to create loops instead of circular depends_on."
363
434
  end
364
435
 
365
436
  # Verify start_node is in the execution order
@@ -380,5 +451,141 @@ module SwarmSDK
380
451
 
381
452
  order
382
453
  end
454
+
455
+ # Handle control flow from input transformer
456
+ #
457
+ # @param transformed [String, Hash] Result from transformer
458
+ # @param node_name [Symbol] Current node name
459
+ # @param node [Node::Builder] Node configuration
460
+ # @param node_start_time [Time] Node execution start time
461
+ # @return [Hash] Control result with :action and relevant data
462
+ def handle_transformer_control_flow(transformed:, node_name:, node:, node_start_time:)
463
+ return { action: :continue, content: transformed } unless transformed.is_a?(Hash)
464
+
465
+ if transformed[:halt_workflow]
466
+ # Halt entire workflow
467
+ halt_result = Result.new(
468
+ content: transformed[:content],
469
+ agent: "halted:#{node_name}",
470
+ logs: [],
471
+ duration: Time.now - node_start_time,
472
+ )
473
+ emit_node_stop(node_name, node, halt_result, Time.now - node_start_time, false)
474
+ { action: :halt, result: halt_result }
475
+ elsif transformed[:goto_node]
476
+ # Jump to different node
477
+ { action: :goto, target: transformed[:goto_node], content: transformed[:content] }
478
+ elsif transformed[:skip_execution]
479
+ # Skip node execution
480
+ { action: :skip, content: transformed[:content] }
481
+ else
482
+ # No control flow - continue normally
483
+ { action: :continue, content: transformed[:content] }
484
+ end
485
+ end
486
+
487
+ # Handle control flow from output transformer
488
+ #
489
+ # @param transformed [String, Hash] Result from transformer
490
+ # @param node_name [Symbol] Current node name
491
+ # @param node [Node::Builder] Node configuration
492
+ # @param node_start_time [Time] Node execution start time
493
+ # @param skip_execution [Boolean] Whether node execution was skipped
494
+ # @param result [Result] Node execution result
495
+ # @return [Hash] Control result with :action and relevant data
496
+ def handle_output_transformer_control_flow(transformed:, node_name:, node:, node_start_time:, skip_execution:, result:)
497
+ # If not a hash, it's just transformed content - continue normally
498
+ return { action: :continue, content: transformed } unless transformed.is_a?(Hash)
499
+
500
+ if transformed[:halt_workflow]
501
+ # Halt entire workflow
502
+ halt_result = Result.new(
503
+ content: transformed[:content],
504
+ agent: result.agent,
505
+ logs: result.logs,
506
+ duration: result.duration,
507
+ )
508
+ emit_node_stop(node_name, node, halt_result, Time.now - node_start_time, skip_execution)
509
+ { action: :halt, result: halt_result }
510
+ elsif transformed[:goto_node]
511
+ # Jump to different node
512
+ { action: :goto, target: transformed[:goto_node], content: transformed[:content] }
513
+ else
514
+ # Hash without control flow keys - treat as regular hash with :content key
515
+ # This handles the case where transformer returns a hash that's not for control flow
516
+ { action: :continue, content: transformed[:content] || transformed }
517
+ end
518
+ end
519
+
520
+ # Find the index of a node in the execution order
521
+ #
522
+ # @param node_name [Symbol] Node name to find
523
+ # @return [Integer] Index in execution order
524
+ # @raise [ConfigurationError] If node not found
525
+ def find_node_index(node_name)
526
+ index = @execution_order.index(node_name)
527
+ unless index
528
+ raise ConfigurationError,
529
+ "goto_node target '#{node_name}' not found. Available nodes: #{@execution_order.join(", ")}"
530
+ end
531
+ index
532
+ end
533
+
534
+ # Cache agent instances from a swarm for potential reuse
535
+ #
536
+ # Only caches agents that have reset_context: false in this node.
537
+ # This allows preserving conversation history across nodes.
538
+ #
539
+ # @param swarm [Swarm] Swarm instance that just executed
540
+ # @param node [Node::Builder] Node configuration
541
+ # @return [void]
542
+ def cache_agent_instances(swarm, node)
543
+ return unless swarm.agents # Only cache if agents were initialized
544
+
545
+ node.agent_configs.each do |config|
546
+ agent_name = config[:agent]
547
+ reset_context = config[:reset_context]
548
+
549
+ # Only cache if reset_context is false
550
+ next if reset_context
551
+
552
+ # Cache the agent instance
553
+ agent_instance = swarm.agents[agent_name]
554
+ @agent_instance_cache[agent_name] = agent_instance if agent_instance
555
+ end
556
+ end
557
+
558
+ # Inject cached agent instances into a swarm
559
+ #
560
+ # For agents with reset_context: false, reuses cached instances to preserve context.
561
+ # Forces agent initialization first (by accessing .agents), then swaps in cached instances.
562
+ #
563
+ # @param swarm [Swarm] Swarm instance to inject into
564
+ # @param node [Node::Builder] Node configuration
565
+ # @return [void]
566
+ def inject_cached_agents(swarm, node)
567
+ # 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
570
+
571
+ # Force agent initialization by accessing .agents (triggers lazy init)
572
+ # Then inject cached instances
573
+ agents_hash = swarm.agents
574
+
575
+ node.agent_configs.each do |config|
576
+ agent_name = config[:agent]
577
+ reset_context = config[:reset_context]
578
+
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]
584
+ next unless cached_agent
585
+
586
+ # Inject the cached instance (replace the freshly initialized one)
587
+ agents_hash[agent_name] = cached_agent
588
+ end
589
+ end
383
590
  end
384
591
  end
@@ -7,7 +7,32 @@ module SwarmSDK
7
7
  # Plugins are self-registering - they call SwarmSDK::PluginRegistry.register
8
8
  # when the gem is loaded.
9
9
  #
10
- # @example Implementing a plugin
10
+ # ## Adding Custom Attributes to Agents
11
+ #
12
+ # Plugins can add custom attributes to Agent::Definition that are preserved
13
+ # when agents are cloned (e.g., in NodeOrchestrator). To do this:
14
+ #
15
+ # 1. Add attr_reader to Agent::Definition for your attribute
16
+ # 2. Parse the attribute in Agent::Definition#initialize
17
+ # 3. Implement serialize_config to preserve it during serialization
18
+ #
19
+ # @example Plugin with custom agent attributes
20
+ # # 1. Extend Agent::Definition (in your plugin gem)
21
+ # module SwarmSDK
22
+ # module Agent
23
+ # class Definition
24
+ # attr_reader :my_custom_config
25
+ #
26
+ # alias_method :original_initialize, :initialize
27
+ # def initialize(name, config = {})
28
+ # @my_custom_config = config[:my_custom_config]
29
+ # original_initialize(name, config)
30
+ # end
31
+ # end
32
+ # end
33
+ # end
34
+ #
35
+ # # 2. Implement plugin with serialize_config
11
36
  # class MyPlugin < SwarmSDK::Plugin
12
37
  # def name
13
38
  # :my_plugin
@@ -20,9 +45,34 @@ module SwarmSDK
20
45
  # def create_tool(tool_name, context)
21
46
  # # Create and return tool instance
22
47
  # end
48
+ #
49
+ # # Preserve custom config when agents are cloned
50
+ # def serialize_config(agent_definition:)
51
+ # return {} unless agent_definition.my_custom_config
52
+ #
53
+ # { my_custom_config: agent_definition.my_custom_config }
54
+ # end
23
55
  # end
24
56
  #
25
57
  # SwarmSDK::PluginRegistry.register(MyPlugin.new)
58
+ #
59
+ # Now agents can use your custom config:
60
+ #
61
+ # agent :researcher do
62
+ # my_custom_config { option: "value" }
63
+ # end
64
+ #
65
+ # And it will be preserved when NodeOrchestrator clones the agent!
66
+ #
67
+ # @example Real-world: SwarmMemory plugin
68
+ # # SwarmMemory adds 'memory' attribute to agents
69
+ # class SDKPlugin < SwarmSDK::Plugin
70
+ # def serialize_config(agent_definition:)
71
+ # return {} unless agent_definition.memory
72
+ # { memory: agent_definition.memory }
73
+ # end
74
+ # end
75
+ #
26
76
  class Plugin
27
77
  # Plugin name (must be unique)
28
78
  #
@@ -143,5 +193,27 @@ module SwarmSDK
143
193
  def on_user_message(agent_name:, prompt:, is_first_message:)
144
194
  []
145
195
  end
196
+
197
+ # Contribute to agent serialization (optional)
198
+ #
199
+ # Called when Agent::Definition.to_h is invoked (e.g., for cloning agents
200
+ # in NodeOrchestrator). Plugins can return config keys that should be
201
+ # included in the serialized hash to preserve their state.
202
+ #
203
+ # This allows plugins to maintain their configuration when agents are
204
+ # cloned or serialized, without SwarmSDK needing to know about plugin-specific fields.
205
+ #
206
+ # @param agent_definition [Agent::Definition] Agent definition
207
+ # @return [Hash] Config keys to include in to_h (e.g., { memory: config })
208
+ #
209
+ # @example Memory plugin serialization
210
+ # def serialize_config(agent_definition:)
211
+ # return {} unless agent_definition.memory
212
+ #
213
+ # { memory: agent_definition.memory }
214
+ # end
215
+ def serialize_config(agent_definition:)
216
+ {}
217
+ end
146
218
  end
147
219
  end
@@ -2,17 +2,17 @@
2
2
 
3
3
  module SwarmSDK
4
4
  class Result
5
- attr_reader :content, :agent, :cost, :tokens, :duration, :logs, :error, :metadata
5
+ attr_reader :content, :agent, :duration, :logs, :error, :metadata
6
6
 
7
- def initialize(content: nil, agent:, cost: 0.0, tokens: {}, duration: 0.0, logs: [], error: nil, metadata: {})
7
+ def initialize(content: nil, agent:, cost: nil, tokens: nil, duration: 0.0, logs: [], error: nil, metadata: {})
8
8
  @content = content
9
9
  @agent = agent
10
- @cost = cost
11
- @tokens = tokens
12
10
  @duration = duration
13
11
  @logs = logs
14
12
  @error = error
15
13
  @metadata = metadata
14
+ # Legacy parameters kept for backward compatibility but not stored
15
+ # Use total_cost and tokens methods instead which calculate from logs
16
16
  end
17
17
 
18
18
  def success?
@@ -23,12 +23,38 @@ module SwarmSDK
23
23
  !success?
24
24
  end
25
25
 
26
+ # Calculate total cost from logs
27
+ #
28
+ # Delegates to total_cost for consistency. This attribute is calculated
29
+ # dynamically rather than stored.
30
+ #
31
+ # @return [Float] Total cost in dollars
32
+ def cost
33
+ total_cost
34
+ end
35
+
36
+ # Get token breakdown from logs
37
+ #
38
+ # Returns input and output tokens from the last log entry with usage data.
39
+ # This attribute is calculated dynamically rather than stored.
40
+ #
41
+ # @return [Hash] Token breakdown with :input and :output keys, or empty hash if no usage data
42
+ def tokens
43
+ last_entry = @logs.reverse.find { |entry| entry.dig(:usage, :cumulative_input_tokens) }
44
+ return {} unless last_entry
45
+
46
+ {
47
+ input: last_entry.dig(:usage, :cumulative_input_tokens) || 0,
48
+ output: last_entry.dig(:usage, :cumulative_output_tokens) || 0,
49
+ }
50
+ end
51
+
26
52
  def to_h
27
53
  {
28
54
  content: @content,
29
55
  agent: @agent,
30
- cost: @cost,
31
- tokens: @tokens,
56
+ cost: cost,
57
+ tokens: tokens,
32
58
  duration: @duration,
33
59
  success: success?,
34
60
  error: @error&.message,
@@ -380,6 +380,7 @@ module SwarmSDK
380
380
  agent_definitions: agent_definitions,
381
381
  nodes: @nodes,
382
382
  start_node: @start_node,
383
+ scratchpad_enabled: @scratchpad_enabled,
383
384
  )
384
385
  end
385
386
 
@@ -43,8 +43,8 @@ module SwarmSDK
43
43
  @delegate_target = delegate_name.to_s
44
44
  end
45
45
 
46
- # Build description dynamically based on delegate
47
- description do
46
+ # Override description to return dynamic string based on delegate
47
+ def description
48
48
  "Delegate tasks to #{@delegate_name}. #{@delegate_description}"
49
49
  end
50
50
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
- VERSION = "2.1.1"
4
+ VERSION = "2.1.2"
5
5
  end
data/lib/swarm_sdk.rb CHANGED
@@ -21,19 +21,15 @@ require_relative "swarm_sdk/version"
21
21
 
22
22
  require "zeitwerk"
23
23
  loader = Zeitwerk::Loader.new
24
+ loader.tag = File.basename(__FILE__, ".rb")
24
25
  loader.push_dir("#{__dir__}/swarm_sdk", namespace: SwarmSDK)
26
+ loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
25
27
  loader.inflector.inflect(
26
28
  "cli" => "CLI",
29
+ "openai_with_responses" => "OpenAIWithResponses",
27
30
  )
28
31
  loader.setup
29
32
 
30
- # Load plugin system explicitly (core infrastructure)
31
- require_relative "swarm_sdk/plugin"
32
- require_relative "swarm_sdk/plugin_registry"
33
-
34
- # Load custom providers explicitly (Zeitwerk doesn't eager load by default)
35
- require_relative "swarm_sdk/providers/openai_with_responses"
36
-
37
33
  module SwarmSDK
38
34
  class Error < StandardError; end
39
35
  class ConfigurationError < Error; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: swarm_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda