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
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Reconstructs RubyLLM::Message objects from SwarmSDK event streams
5
+ #
6
+ # This class enables conversation replay and analysis from event logs.
7
+ # It uses timestamps to maintain chronological ordering of messages.
8
+ #
9
+ # ## Limitations
10
+ #
11
+ # This reconstructs ONLY conversation messages. It does NOT restore:
12
+ # - Context state (warning thresholds, compression, todowrite index)
13
+ # - Scratchpad contents
14
+ # - Read tracking information
15
+ # - Full swarm state
16
+ #
17
+ # For full state restoration, use StateSnapshot/StateRestorer or SnapshotFromEvents.
18
+ #
19
+ # ## Usage
20
+ #
21
+ # # Collect events during execution
22
+ # events = []
23
+ # swarm.execute("Build feature") do |event|
24
+ # events << event
25
+ # end
26
+ #
27
+ # # Reconstruct conversation for an agent
28
+ # messages = SwarmSDK::EventsToMessages.reconstruct(events, agent: :backend)
29
+ #
30
+ # # View conversation
31
+ # messages.each do |msg|
32
+ # puts "[#{msg.role}] #{msg.content}"
33
+ # end
34
+ #
35
+ # ## Event Requirements
36
+ #
37
+ # Events must have:
38
+ # - `:timestamp` field (ISO 8601 format) for ordering
39
+ # - `:agent` field to filter by agent
40
+ # - `:type` field to identify event type
41
+ #
42
+ # Supported event types:
43
+ # - `user_prompt`: Reconstructs user message (prompt in metadata or top-level)
44
+ # - `agent_step`: Reconstructs assistant message with tool calls
45
+ # - `agent_stop`: Reconstructs final assistant message
46
+ # - `tool_result`: Reconstructs tool result message
47
+ class EventsToMessages
48
+ class << self
49
+ # Reconstruct messages for an agent from event stream
50
+ #
51
+ # @param events [Array<Hash>] Event stream with timestamps
52
+ # @param agent [Symbol, String] Agent name to reconstruct messages for
53
+ # @return [Array<RubyLLM::Message>] Reconstructed messages in chronological order
54
+ #
55
+ # @example
56
+ # messages = EventsToMessages.reconstruct(events, agent: :backend)
57
+ # messages.each { |msg| puts msg.content }
58
+ def reconstruct(events, agent:)
59
+ new(events, agent).reconstruct
60
+ end
61
+ end
62
+
63
+ # Initialize reconstructor
64
+ #
65
+ # @param events [Array<Hash>] Event stream
66
+ # @param agent [Symbol, String] Agent name
67
+ def initialize(events, agent)
68
+ @events = events
69
+ @agent = agent.to_sym
70
+ end
71
+
72
+ # Reconstruct messages from events
73
+ #
74
+ # Filters events by agent, sorts by timestamp, and converts to RubyLLM::Message objects.
75
+ #
76
+ # @return [Array<RubyLLM::Message>] Reconstructed messages
77
+ def reconstruct
78
+ messages = []
79
+
80
+ # Filter events for this agent and sort by timestamp
81
+ agent_events = @events
82
+ .select { |e| normalize_agent(e[:agent]) == @agent }
83
+ .sort_by { |e| parse_timestamp(e[:timestamp]) }
84
+
85
+ agent_events.each do |event|
86
+ message = case event[:type]&.to_s
87
+ when "user_prompt"
88
+ reconstruct_user_message(event)
89
+ when "agent_step", "agent_stop"
90
+ reconstruct_assistant_message(event)
91
+ when "tool_result"
92
+ reconstruct_tool_result_message(event)
93
+ end
94
+
95
+ messages << message if message
96
+ end
97
+
98
+ messages
99
+ end
100
+
101
+ private
102
+
103
+ # Reconstruct user message from user_prompt event
104
+ #
105
+ # Extracts prompt from metadata or top-level field.
106
+ #
107
+ # @param event [Hash] user_prompt event
108
+ # @return [RubyLLM::Message, nil] User message or nil if prompt not found
109
+ def reconstruct_user_message(event)
110
+ # Try to extract prompt from metadata (current location) or top-level (potential future location)
111
+ prompt = event.dig(:metadata, :prompt) || event[:prompt]
112
+ return unless prompt && !prompt.to_s.empty?
113
+
114
+ RubyLLM::Message.new(
115
+ role: :user,
116
+ content: prompt,
117
+ )
118
+ end
119
+
120
+ # Reconstruct assistant message from agent_step or agent_stop event
121
+ #
122
+ # Converts tool_calls array to hash format expected by RubyLLM.
123
+ #
124
+ # @param event [Hash] agent_step or agent_stop event
125
+ # @return [RubyLLM::Message] Assistant message
126
+ def reconstruct_assistant_message(event)
127
+ # Convert tool_calls array to hash (RubyLLM format)
128
+ # Events emit tool_calls as Array, but RubyLLM expects Hash<String, ToolCall>
129
+ tool_calls_hash = if event[:tool_calls] && !event[:tool_calls].empty?
130
+ event[:tool_calls].each_with_object({}) do |tc, hash|
131
+ hash[tc[:id].to_s] = RubyLLM::ToolCall.new(
132
+ id: tc[:id],
133
+ name: tc[:name],
134
+ arguments: tc[:arguments] || {},
135
+ )
136
+ end
137
+ end
138
+
139
+ RubyLLM::Message.new(
140
+ role: :assistant,
141
+ content: event[:content] || "",
142
+ tool_calls: tool_calls_hash,
143
+ input_tokens: event.dig(:usage, :input_tokens),
144
+ output_tokens: event.dig(:usage, :output_tokens),
145
+ model_id: event[:model],
146
+ )
147
+ end
148
+
149
+ # Reconstruct tool result message from tool_result event
150
+ #
151
+ # @param event [Hash] tool_result event
152
+ # @return [RubyLLM::Message] Tool result message
153
+ def reconstruct_tool_result_message(event)
154
+ RubyLLM::Message.new(
155
+ role: :tool,
156
+ content: event[:result].to_s,
157
+ tool_call_id: event[:tool_call_id],
158
+ )
159
+ end
160
+
161
+ # Parse timestamp string to Time object
162
+ #
163
+ # @param timestamp [String, nil] ISO 8601 timestamp
164
+ # @return [Time] Parsed time or epoch if nil/invalid
165
+ def parse_timestamp(timestamp)
166
+ return Time.at(0) unless timestamp
167
+
168
+ Time.parse(timestamp)
169
+ rescue ArgumentError
170
+ Time.at(0)
171
+ end
172
+
173
+ # Normalize agent name to symbol
174
+ #
175
+ # @param agent [Symbol, String, nil] Agent name
176
+ # @return [Symbol] Normalized agent name
177
+ def normalize_agent(agent)
178
+ agent.to_s.to_sym
179
+ end
180
+ end
181
+ end
@@ -7,6 +7,20 @@ module SwarmSDK
7
7
  # to user-registered callbacks. It's designed to be set as the LogStream
8
8
  # emitter during swarm execution.
9
9
  #
10
+ # ## Thread Safety for Multi-Threaded Environments (Puma, Sidekiq)
11
+ #
12
+ # Callbacks are stored in Fiber-local storage (Fiber[:log_callbacks]) instead
13
+ # of class instance variables. This ensures callbacks registered in the parent
14
+ # thread/fiber are accessible to child fibers created by Async reactor.
15
+ #
16
+ # Why: In Puma/Sidekiq, class instance variables (@callbacks) are thread-isolated
17
+ # and don't properly propagate to child fibers. Using Fiber-local storage ensures
18
+ # events emitted from within Async blocks can reach registered callbacks.
19
+ #
20
+ # Child fibers inherit parent fiber-local storage automatically, so events
21
+ # emitted from agent callbacks (on_tool_call, on_end_message, etc.) executing
22
+ # in child fibers can still reach the parent's registered callbacks.
23
+ #
10
24
  # ## Usage
11
25
  #
12
26
  # # Register a callback (before execution starts)
@@ -24,19 +38,31 @@ module SwarmSDK
24
38
  class << self
25
39
  # Register a callback to receive log events
26
40
  #
41
+ # Stores callback in Fiber-local storage to ensure accessibility
42
+ # from child fibers in multi-threaded environments.
43
+ #
27
44
  # @yield [Hash] Log event entry
28
45
  def on_log(&block)
29
- @callbacks ||= []
30
- @callbacks << block
46
+ Fiber[:log_callbacks] ||= []
47
+ Fiber[:log_callbacks] << block
31
48
  end
32
49
 
33
50
  # Emit an event to all registered callbacks
34
51
  #
52
+ # Automatically adds a timestamp if one doesn't exist.
53
+ # Reads callbacks from Fiber-local storage to support multi-threaded execution.
54
+ #
35
55
  # @param entry [Hash] Log event entry
36
56
  # @return [void]
37
57
  def emit(entry)
38
- Array(@callbacks).each do |callback|
39
- callback.call(entry)
58
+ # Ensure timestamp exists (LogStream adds it, but direct calls might not)
59
+ # Use microsecond precision (6 digits) for proper event ordering
60
+ entry_with_timestamp = entry.key?(:timestamp) ? entry : entry.merge(timestamp: Time.now.utc.iso8601(6))
61
+
62
+ # Read callbacks from Fiber-local storage (set by on_log in parent fiber)
63
+ callbacks = Fiber[:log_callbacks] || []
64
+ callbacks.each do |callback|
65
+ callback.call(entry_with_timestamp)
40
66
  end
41
67
  end
42
68
 
@@ -44,7 +70,7 @@ module SwarmSDK
44
70
  #
45
71
  # @return [void]
46
72
  def reset!
47
- @callbacks = []
73
+ Fiber[:log_callbacks] = []
48
74
  end
49
75
  end
50
76
  end
@@ -16,9 +16,15 @@ module SwarmSDK
16
16
  # message_count: 5
17
17
  # )
18
18
  #
19
- # ## Fiber Safety
19
+ # ## Thread Safety
20
20
  #
21
- # LogStream is fiber-safe when following this pattern:
21
+ # LogStream is thread-safe and fiber-safe:
22
+ # - Uses Fiber storage for per-request isolation in multi-threaded servers (Puma, Sidekiq)
23
+ # - Each thread/request has its own emitter instance
24
+ # - Child fibers inherit the emitter from their parent fiber
25
+ # - No cross-thread contamination of log events
26
+ #
27
+ # Usage pattern:
22
28
  # 1. Set emitter BEFORE starting Async execution
23
29
  # 2. During Async execution, only emit() (reads emitter)
24
30
  # 3. Each event includes agent context for identification
@@ -35,34 +41,57 @@ module SwarmSDK
35
41
  # Emit a log event
36
42
  #
37
43
  # Adds timestamp and forwards to the registered emitter.
44
+ # Auto-injects execution_id, swarm_id, and parent_swarm_id from Fiber storage.
45
+ # Explicit values in data override auto-injected ones.
38
46
  #
39
47
  # @param data [Hash] Event data (type, agent, and event-specific fields)
40
48
  # @return [void]
41
49
  def emit(**data)
42
- return unless @emitter
50
+ emitter = Fiber[:log_stream_emitter]
51
+ return unless emitter
52
+
53
+ # Auto-inject execution context from Fiber storage
54
+ # Explicit values in data override auto-injected ones
55
+ auto_injected = {
56
+ execution_id: Fiber[:execution_id],
57
+ swarm_id: Fiber[:swarm_id],
58
+ parent_swarm_id: Fiber[:parent_swarm_id],
59
+ }.compact
43
60
 
44
- entry = data.merge(timestamp: Time.now.utc.iso8601).compact
61
+ entry = auto_injected.merge(data).merge(timestamp: Time.now.utc.iso8601(6)).compact
45
62
 
46
- @emitter.emit(entry)
63
+ emitter.emit(entry)
47
64
  end
48
65
 
49
66
  # Set the emitter (for dependency injection in tests)
50
67
  #
68
+ # Stores emitter in Fiber storage for thread-safe, per-request isolation.
69
+ #
51
70
  # @param emitter [#emit] Object responding to emit(Hash)
52
- attr_accessor :emitter
71
+ # @return [void]
72
+ def emitter=(emitter)
73
+ Fiber[:log_stream_emitter] = emitter
74
+ end
75
+
76
+ # Get the current emitter
77
+ #
78
+ # @return [#emit, nil] Current emitter or nil if not set
79
+ def emitter
80
+ Fiber[:log_stream_emitter]
81
+ end
53
82
 
54
83
  # Reset the emitter (for test cleanup)
55
84
  #
56
85
  # @return [void]
57
86
  def reset!
58
- @emitter = nil
87
+ Fiber[:log_stream_emitter] = nil
59
88
  end
60
89
 
61
90
  # Check if logging is enabled
62
91
  #
63
92
  # @return [Boolean] true if an emitter is configured
64
93
  def enabled?
65
- !@emitter.nil?
94
+ !Fiber[:log_stream_emitter].nil?
66
95
  end
67
96
  end
68
97
  end
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "sonnet": "claude-sonnet-4-5-20250929",
3
3
  "opus": "claude-opus-4-1-20250805",
4
- "haiku": "claude-haiku-4-5-20251001"
4
+ "haiku": "claude-haiku-4-5-20251001",
5
+ "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
6
+ "claude-opus-4-1": "claude-opus-4-1-20250805",
7
+ "claude-haiku-4-5": "claude-haiku-4-5-20251001"
5
8
  }
@@ -7,6 +7,7 @@ module SwarmSDK
7
7
  # This class enables the chainable syntax:
8
8
  # agent(:backend).delegates_to(:tester, :database)
9
9
  # agent(:backend, reset_context: false) # Preserve context across nodes
10
+ # agent(:backend).tools(:Read, :Edit) # Override tools for this node
10
11
  #
11
12
  # @example Basic delegation
12
13
  # agent(:backend).delegates_to(:tester)
@@ -16,6 +17,12 @@ module SwarmSDK
16
17
  #
17
18
  # @example Preserve agent context
18
19
  # agent(:architect, reset_context: false)
20
+ #
21
+ # @example Override tools for this node
22
+ # agent(:backend).tools(:Read, :Think)
23
+ #
24
+ # @example Combine delegation and tool override
25
+ # agent(:backend).delegates_to(:tester).tools(:Read, :Edit, :Write)
19
26
  class AgentConfig
20
27
  attr_reader :agent_name
21
28
 
@@ -24,6 +31,7 @@ module SwarmSDK
24
31
  @node_builder = node_builder
25
32
  @delegates_to = []
26
33
  @reset_context = reset_context
34
+ @tools = nil # nil means use global agent definition tools
27
35
  @finalized = false
28
36
  end
29
37
 
@@ -33,21 +41,38 @@ module SwarmSDK
33
41
  # @return [self] For method chaining
34
42
  def delegates_to(*agent_names)
35
43
  @delegates_to = agent_names.map(&:to_sym)
36
- finalize
44
+ update_registration
37
45
  self
38
46
  end
39
47
 
40
- # Finalize agent configuration (called automatically)
48
+ # Override tools for this agent in this node
49
+ #
50
+ # @param tool_names [Array<Symbol>] Tool names to use (overrides global agent definition)
51
+ # @return [self] For method chaining
41
52
  #
42
- # Registers this agent configuration with the parent node builder.
43
- # If delegates_to was never called, registers with empty delegation.
53
+ # @example
54
+ # agent(:backend).tools(:Read, :Edit)
55
+ def tools(*tool_names)
56
+ @tools = tool_names.map(&:to_sym)
57
+ update_registration
58
+ self
59
+ end
60
+
61
+ # Update agent registration (called after each fluent method)
62
+ #
63
+ # Always updates the registration with current state.
64
+ # This allows chaining: .delegates_to(...).tools(...)
44
65
  #
45
66
  # @return [void]
46
- def finalize
47
- return if @finalized
67
+ def update_registration
68
+ @node_builder.register_agent(@agent_name, @delegates_to, @reset_context, @tools)
69
+ end
48
70
 
49
- @node_builder.register_agent(@agent_name, @delegates_to, @reset_context)
50
- @finalized = true
71
+ # Finalize agent configuration (backward compatibility)
72
+ #
73
+ # @return [void]
74
+ def finalize
75
+ update_registration
51
76
  end
52
77
  end
53
78
  end
@@ -43,8 +43,8 @@ module SwarmSDK
43
43
 
44
44
  # Configure an agent for this node
45
45
  #
46
- # Returns an AgentConfig object that supports fluent delegation syntax.
47
- # If delegates_to is not called, the agent is registered with no delegation.
46
+ # Returns an AgentConfig object that supports fluent delegation and tool override syntax.
47
+ # If delegates_to/tools are not called, the agent uses global configuration.
48
48
  #
49
49
  # By default, agents get fresh context in each node (reset_context: true).
50
50
  # Set reset_context: false to preserve conversation history across nodes.
@@ -61,12 +61,18 @@ module SwarmSDK
61
61
  #
62
62
  # @example Preserve context across nodes
63
63
  # agent(:architect, reset_context: false)
64
+ #
65
+ # @example Override tools for this node
66
+ # agent(:backend).tools(:Read, :Think)
67
+ #
68
+ # @example Combine delegation and tools
69
+ # agent(:backend).delegates_to(:tester).tools(:Read, :Edit, :Write)
64
70
  def agent(name, reset_context: true)
65
71
  config = AgentConfig.new(name, self, reset_context: reset_context)
66
72
 
67
- # Register immediately with empty delegation
68
- # If delegates_to is called later, it will update this
69
- register_agent(name, [], reset_context)
73
+ # Register immediately with empty delegation and no tool override
74
+ # If delegates_to/tools are called later, they will update this
75
+ register_agent(name, [], reset_context, nil)
70
76
 
71
77
  config
72
78
  end
@@ -76,18 +82,25 @@ module SwarmSDK
76
82
  # @param agent_name [Symbol] Agent name
77
83
  # @param delegates_to [Array<Symbol>] Delegation targets
78
84
  # @param reset_context [Boolean] Whether to reset agent context
85
+ # @param tools [Array<Symbol>, nil] Tool override for this node (nil = use global)
79
86
  # @return [void]
80
- def register_agent(agent_name, delegates_to, reset_context = true)
87
+ def register_agent(agent_name, delegates_to, reset_context = true, tools = nil)
81
88
  # Check if agent already registered
82
89
  existing = @agent_configs.find { |ac| ac[:agent] == agent_name }
83
90
 
84
91
  if existing
85
- # Update delegation and reset_context (happens when delegates_to is called after agent())
92
+ # Update delegation, reset_context, and tools (happens when methods are called after agent())
86
93
  existing[:delegates_to] = delegates_to
87
94
  existing[:reset_context] = reset_context
95
+ existing[:tools] = tools unless tools.nil?
88
96
  else
89
97
  # Add new agent configuration
90
- @agent_configs << { agent: agent_name, delegates_to: delegates_to, reset_context: reset_context }
98
+ @agent_configs << {
99
+ agent: agent_name,
100
+ delegates_to: delegates_to,
101
+ reset_context: reset_context,
102
+ tools: tools,
103
+ }
91
104
  end
92
105
  end
93
106
 
@@ -154,32 +167,36 @@ module SwarmSDK
154
167
  # "Implement based on:\nPlan: #{plan}\nDesign: #{design}"
155
168
  # end
156
169
  #
157
- # @example Skip execution (caching)
170
+ # @example Skip execution (caching) - using return
158
171
  # input do |ctx|
159
172
  # cached = check_cache(ctx.content)
160
173
  # return ctx.skip_execution(content: cached) if cached
161
174
  # ctx.content
162
175
  # end
163
176
  #
164
- # @example Halt workflow (validation)
177
+ # @example Halt workflow (validation) - using return
165
178
  # input do |ctx|
166
179
  # if ctx.content.length > 10000
167
- # # Halt entire workflow
180
+ # # Halt entire workflow - return works safely!
168
181
  # return ctx.halt_workflow(content: "ERROR: Input too long")
169
182
  # end
170
183
  # ctx.content
171
184
  # end
172
185
  #
173
- # @example Jump to different node (conditional routing)
186
+ # @example Jump to different node (conditional routing) - using return
174
187
  # input do |ctx|
175
188
  # if ctx.content.include?("NEEDS_REVIEW")
176
- # # Jump to review node instead
189
+ # # Jump to review node instead - return works safely!
177
190
  # return ctx.goto_node(:review, content: ctx.content)
178
191
  # end
179
192
  # ctx.content
180
193
  # end
194
+ #
195
+ # @note The input block is automatically converted to a lambda, which means
196
+ # return statements work safely and only exit the transformer, not the
197
+ # entire program. This allows natural control flow patterns.
181
198
  def input(&block)
182
- @input_transformer = block
199
+ @input_transformer = ProcHelpers.to_lambda(block)
183
200
  end
184
201
 
185
202
  # Set input transformer as bash command (YAML API)
@@ -234,22 +251,26 @@ module SwarmSDK
234
251
  # "Task: #{ctx.original_prompt}\nResult: #{ctx.content}"
235
252
  # end
236
253
  #
237
- # @example Halt workflow (convergence check)
254
+ # @example Halt workflow (convergence check) - using return
238
255
  # output do |ctx|
239
256
  # return ctx.halt_workflow(content: ctx.content) if converged?(ctx.content)
240
257
  # ctx.content
241
258
  # end
242
259
  #
243
- # @example Jump to different node (conditional routing)
260
+ # @example Jump to different node (conditional routing) - using return
244
261
  # output do |ctx|
245
262
  # if needs_revision?(ctx.content)
246
- # # Go back to revision node
263
+ # # Go back to revision node - return works safely!
247
264
  # return ctx.goto_node(:revision, content: ctx.content)
248
265
  # end
249
266
  # ctx.content
250
267
  # end
268
+ #
269
+ # @note The output block is automatically converted to a lambda, which means
270
+ # return statements work safely and only exit the transformer, not the
271
+ # entire program. This allows natural control flow patterns.
251
272
  def output(&block)
252
- @output_transformer = block
273
+ @output_transformer = ProcHelpers.to_lambda(block)
253
274
  end
254
275
 
255
276
  # Set output transformer as bash command (YAML API)