claude_swarm 1.0.4 → 1.0.6

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/Rakefile +4 -4
  4. data/docs/v2/CHANGELOG.swarm_cli.md +19 -0
  5. data/docs/v2/CHANGELOG.swarm_memory.md +19 -0
  6. data/docs/v2/CHANGELOG.swarm_sdk.md +92 -0
  7. data/docs/v2/README.md +56 -22
  8. data/docs/v2/guides/MEMORY_DEFRAG_GUIDE.md +811 -0
  9. data/docs/v2/guides/complete-tutorial.md +115 -3
  10. data/docs/v2/guides/getting-started.md +6 -6
  11. data/docs/v2/guides/rails-integration.md +6 -6
  12. data/docs/v2/reference/architecture-flow.md +407 -0
  13. data/docs/v2/reference/event_payload_structures.md +471 -0
  14. data/docs/v2/reference/execution-flow.md +600 -0
  15. data/docs/v2/reference/ruby-dsl.md +138 -5
  16. data/docs/v2/reference/swarm_memory_technical_details.md +2090 -0
  17. data/examples/v2/swarm_with_hooks.yml +1 -1
  18. data/lib/claude_swarm/cli.rb +9 -11
  19. data/lib/claude_swarm/commands/ps.rb +1 -2
  20. data/lib/claude_swarm/configuration.rb +2 -3
  21. data/lib/claude_swarm/mcp_generator.rb +4 -10
  22. data/lib/claude_swarm/orchestrator.rb +43 -44
  23. data/lib/claude_swarm/system_utils.rb +4 -4
  24. data/lib/claude_swarm/version.rb +1 -1
  25. data/lib/claude_swarm.rb +4 -9
  26. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  27. data/lib/swarm_cli/commands/mcp_tools.rb +3 -3
  28. data/lib/swarm_cli/config_loader.rb +14 -13
  29. data/lib/swarm_cli/version.rb +1 -1
  30. data/lib/swarm_cli.rb +2 -0
  31. data/lib/swarm_memory/adapters/base.rb +4 -4
  32. data/lib/swarm_memory/adapters/filesystem_adapter.rb +0 -12
  33. data/lib/swarm_memory/core/storage.rb +66 -6
  34. data/lib/swarm_memory/integration/sdk_plugin.rb +14 -0
  35. data/lib/swarm_memory/optimization/defragmenter.rb +4 -0
  36. data/lib/swarm_memory/tools/memory_edit.rb +1 -0
  37. data/lib/swarm_memory/tools/memory_glob.rb +24 -1
  38. data/lib/swarm_memory/tools/memory_write.rb +2 -2
  39. data/lib/swarm_memory/version.rb +1 -1
  40. data/lib/swarm_memory.rb +2 -0
  41. data/lib/swarm_sdk/agent/chat.rb +1 -1
  42. data/lib/swarm_sdk/agent/definition.rb +18 -21
  43. data/lib/swarm_sdk/configuration.rb +34 -10
  44. data/lib/swarm_sdk/mcp.rb +16 -0
  45. data/lib/swarm_sdk/node/agent_config.rb +7 -2
  46. data/lib/swarm_sdk/node/builder.rb +130 -35
  47. data/lib/swarm_sdk/node_context.rb +75 -0
  48. data/lib/swarm_sdk/node_orchestrator.rb +219 -12
  49. data/lib/swarm_sdk/plugin.rb +73 -1
  50. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  51. data/lib/swarm_sdk/result.rb +32 -6
  52. data/lib/swarm_sdk/swarm/builder.rb +1 -0
  53. data/lib/swarm_sdk/swarm.rb +32 -50
  54. data/lib/swarm_sdk/tools/delegate.rb +2 -2
  55. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  56. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  57. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  58. data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
  59. data/lib/swarm_sdk/tools/think.rb +4 -1
  60. data/lib/swarm_sdk/tools/todo_write.rb +20 -8
  61. data/lib/swarm_sdk/version.rb +1 -1
  62. data/lib/swarm_sdk.rb +332 -27
  63. data/swarm_sdk.gemspec +1 -1
  64. metadata +9 -3
@@ -85,6 +85,7 @@ module SwarmMemory
85
85
 
86
86
  param :replace_all,
87
87
  desc: "Replace all occurrences of old_string (default false)",
88
+ type: :boolean,
88
89
  required: false
89
90
 
90
91
  # Initialize with storage instance and agent name
@@ -100,6 +100,8 @@ module SwarmMemory
100
100
  desc: "Glob pattern - target concept/, fact/, skill/, or experience/ only (e.g., 'skill/**', 'concept/ruby/*', 'fact/people/*.md')",
101
101
  required: true
102
102
 
103
+ MAX_RESULTS = 500 # Limit results to prevent overwhelming output
104
+
103
105
  # Initialize with storage instance
104
106
  #
105
107
  # @param storage [Core::Storage] Storage instance
@@ -124,6 +126,14 @@ module SwarmMemory
124
126
  return "No entries found matching pattern '#{pattern}'"
125
127
  end
126
128
 
129
+ # Limit results
130
+ if entries.count > MAX_RESULTS
131
+ entries = entries.take(MAX_RESULTS)
132
+ truncated = true
133
+ else
134
+ truncated = false
135
+ end
136
+
127
137
  result = []
128
138
  result << "Memory entries matching '#{pattern}' (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
129
139
 
@@ -131,7 +141,20 @@ module SwarmMemory
131
141
  result << " memory://#{entry[:path]} - \"#{entry[:title]}\" (#{format_bytes(entry[:size])})"
132
142
  end
133
143
 
134
- result.join("\n")
144
+ output = result.join("\n")
145
+
146
+ # Add system reminder if truncated
147
+ if truncated
148
+ output += <<~REMINDER
149
+
150
+ <system-reminder>
151
+ Results limited to first #{MAX_RESULTS} matches (sorted by most recently modified).
152
+ Consider using a more specific pattern to narrow your search.
153
+ </system-reminder>
154
+ REMINDER
155
+ end
156
+
157
+ output
135
158
  rescue ArgumentError => e
136
159
  validation_error(e.message)
137
160
  end
@@ -45,8 +45,8 @@ module SwarmMemory
45
45
  TAGS ARE CRITICAL: Think "What would I search for in 6 months?" For skills especially, be VERY comprehensive with tags - they're your search index.
46
46
 
47
47
  EXAMPLES:
48
- - For concept: tags: ['ruby', 'oop', 'classes', 'inheritance', 'methods']
49
- - For skill: tags: ['debugging', 'api', 'http', 'errors', 'trace', 'network', 'rest']
48
+ - For concept: tags: (JSON) "['ruby', 'oop', 'classes', 'inheritance', 'methods']"
49
+ - For skill: tags: (JSON) "['debugging', 'api', 'http', 'errors', 'trace', 'network', 'rest']"
50
50
  DESC
51
51
 
52
52
  param :file_path,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmMemory
4
- VERSION = "2.1.1"
4
+ VERSION = "2.1.2"
5
5
  end
data/lib/swarm_memory.rb CHANGED
@@ -28,7 +28,9 @@ require_relative "swarm_memory/version"
28
28
  # Setup Zeitwerk loader
29
29
  require "zeitwerk"
30
30
  loader = Zeitwerk::Loader.new
31
+ loader.tag = File.basename(__FILE__, ".rb")
31
32
  loader.push_dir("#{__dir__}/swarm_memory", namespace: SwarmMemory)
33
+ loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
32
34
  loader.setup
33
35
 
34
36
  # Explicitly load DSL components and extensions to inject into SwarmSDK
@@ -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)
@@ -342,7 +358,7 @@ module SwarmSDK
342
358
 
343
359
  def render_non_coding_base_prompt
344
360
  # Simplified base prompt for non-coding agents
345
- # Includes environment info, TODO, and Scratchpad tool information
361
+ # Includes environment info only
346
362
  # Does not steer towards coding tasks
347
363
  cwd = @directory || Dir.pwd
348
364
  platform = RUBY_PLATFORM
@@ -367,25 +383,6 @@ module SwarmSDK
367
383
  Platform: #{platform}
368
384
  OS Version: #{os_version}
369
385
  </env>
370
-
371
- # Task Management
372
-
373
- You have access to the TodoWrite tool to help you manage and plan tasks. Use this tool to track your progress and give visibility into your work.
374
-
375
- When working on multi-step tasks:
376
- 1. Create a todo list with all known tasks before starting work
377
- 2. Mark each task as in_progress when you start it
378
- 3. Mark each task as completed IMMEDIATELY after finishing it
379
- 4. Complete ALL pending todos before finishing your response
380
-
381
- # Scratchpad Storage
382
-
383
- You have access to Scratchpad tools for storing and retrieving information:
384
- - **ScratchpadWrite**: Store detailed outputs, analysis, or results that are too long for direct responses
385
- - **ScratchpadRead**: Retrieve previously stored content
386
- - **ScratchpadList**: List available scratchpad entries
387
-
388
- Use the scratchpad to share information that would otherwise clutter your responses.
389
386
  PROMPT
390
387
  end
391
388
 
@@ -4,17 +4,43 @@ module SwarmSDK
4
4
  class Configuration
5
5
  ENV_VAR_WITH_DEFAULT_PATTERN = /\$\{([^:}]+)(:=([^}]*))?\}/
6
6
 
7
- attr_reader :config_path, :swarm_name, :lead_agent, :agents, :all_agents_config, :swarm_hooks, :all_agents_hooks, :scratchpad_enabled
7
+ attr_reader :swarm_name, :lead_agent, :agents, :all_agents_config, :swarm_hooks, :all_agents_hooks, :scratchpad_enabled
8
8
 
9
9
  class << self
10
- def load(path)
11
- new(path).tap(&:load_and_validate)
10
+ # Load configuration from YAML file
11
+ #
12
+ # Convenience method that reads the file and uses the file's directory
13
+ # as the base directory for resolving agent file paths.
14
+ #
15
+ # @param path [String, Pathname] Path to YAML configuration file
16
+ # @return [Configuration] Validated configuration instance
17
+ # @raise [ConfigurationError] If file not found or invalid
18
+ def load_file(path)
19
+ path = Pathname.new(path).expand_path
20
+
21
+ unless path.exist?
22
+ raise ConfigurationError, "Configuration file not found: #{path}"
23
+ end
24
+
25
+ yaml_content = File.read(path)
26
+ base_dir = path.dirname
27
+
28
+ new(yaml_content, base_dir: base_dir).tap(&:load_and_validate)
29
+ rescue Errno::ENOENT
30
+ raise ConfigurationError, "Configuration file not found: #{path}"
12
31
  end
13
32
  end
14
33
 
15
- def initialize(config_path)
16
- @config_path = Pathname.new(config_path).expand_path
17
- @config_dir = @config_path.dirname
34
+ # Initialize configuration from YAML string
35
+ #
36
+ # @param yaml_content [String] YAML configuration content
37
+ # @param base_dir [String, Pathname] Base directory for resolving agent file paths (default: Dir.pwd)
38
+ def initialize(yaml_content, base_dir: Dir.pwd)
39
+ raise ArgumentError, "yaml_content cannot be nil" if yaml_content.nil?
40
+ raise ArgumentError, "base_dir cannot be nil" if base_dir.nil?
41
+
42
+ @yaml_content = yaml_content
43
+ @base_dir = Pathname.new(base_dir).expand_path
18
44
  @agents = {}
19
45
  @all_agents_config = {} # Settings applied to all agents
20
46
  @swarm_hooks = {} # Swarm-level hooks (swarm_start, swarm_stop)
@@ -22,7 +48,7 @@ module SwarmSDK
22
48
  end
23
49
 
24
50
  def load_and_validate
25
- @config = YAML.load_file(@config_path, aliases: true)
51
+ @config = YAML.safe_load(@yaml_content, permitted_classes: [Symbol], aliases: true)
26
52
 
27
53
  unless @config.is_a?(Hash)
28
54
  raise ConfigurationError, "Invalid YAML syntax: configuration must be a Hash"
@@ -37,8 +63,6 @@ module SwarmSDK
37
63
  load_agents
38
64
  detect_circular_dependencies
39
65
  self
40
- rescue Errno::ENOENT
41
- raise ConfigurationError, "Configuration file not found: #{@config_path}"
42
66
  rescue Psych::SyntaxError => e
43
67
  raise ConfigurationError, "Invalid YAML syntax: #{e.message}"
44
68
  end
@@ -260,7 +284,7 @@ module SwarmSDK
260
284
  def resolve_agent_file_path(file_path)
261
285
  return file_path if Pathname.new(file_path).absolute?
262
286
 
263
- @config_dir.join(file_path).to_s
287
+ @base_dir.join(file_path).to_s
264
288
  end
265
289
 
266
290
  def detect_circular_dependencies
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module MCP
5
+ class << self
6
+ # Lazy load ruby_llm-mcp only when MCP servers are used
7
+ def lazy_load
8
+ return if @loaded
9
+
10
+ require "ruby_llm/mcp"
11
+
12
+ @loaded = true
13
+ end
14
+ end
15
+ end
16
+ end
@@ -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