swarm_sdk 2.0.0.pre.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +118 -21
- data/lib/swarm_sdk/agent/definition.rb +121 -12
- data/lib/swarm_sdk/configuration.rb +44 -11
- data/lib/swarm_sdk/hooks/context.rb +34 -0
- data/lib/swarm_sdk/hooks/registry.rb +4 -0
- data/lib/swarm_sdk/log_collector.rb +3 -35
- data/lib/swarm_sdk/node/agent_config.rb +49 -0
- data/lib/swarm_sdk/node/builder.rb +439 -0
- data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
- data/lib/swarm_sdk/node_context.rb +170 -0
- data/lib/swarm_sdk/node_orchestrator.rb +384 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +32 -3
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +81 -3
- data/lib/swarm_sdk/swarm/builder.rb +286 -21
- data/lib/swarm_sdk/swarm/tool_configurator.rb +1 -0
- data/lib/swarm_sdk/swarm.rb +71 -6
- data/lib/swarm_sdk/tools/delegate.rb +15 -3
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +73 -0
- metadata +9 -4
@@ -0,0 +1,439 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Node
|
5
|
+
# Builder provides DSL for configuring nodes (mini-swarms within a workflow)
|
6
|
+
#
|
7
|
+
# A node represents a stage in a multi-step workflow where a specific set
|
8
|
+
# of agents collaborate. Each node creates an independent swarm execution.
|
9
|
+
#
|
10
|
+
# @example Solo agent node
|
11
|
+
# node :planning do
|
12
|
+
# agent(:architect)
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# @example Multi-agent node with delegation
|
16
|
+
# node :implementation do
|
17
|
+
# agent(:backend).delegates_to(:tester, :database)
|
18
|
+
# agent(:tester).delegates_to(:database)
|
19
|
+
# agent(:database)
|
20
|
+
#
|
21
|
+
# depends_on :planning
|
22
|
+
# end
|
23
|
+
class Builder
|
24
|
+
attr_reader :name,
|
25
|
+
:agent_configs,
|
26
|
+
:dependencies,
|
27
|
+
:lead_override,
|
28
|
+
:input_transformer,
|
29
|
+
:output_transformer,
|
30
|
+
:input_transformer_command,
|
31
|
+
:output_transformer_command
|
32
|
+
|
33
|
+
def initialize(name)
|
34
|
+
@name = name
|
35
|
+
@agent_configs = []
|
36
|
+
@dependencies = []
|
37
|
+
@lead_override = nil
|
38
|
+
@input_transformer = nil # Ruby block
|
39
|
+
@output_transformer = nil # Ruby block
|
40
|
+
@input_transformer_command = nil # Bash command
|
41
|
+
@output_transformer_command = nil # Bash command
|
42
|
+
end
|
43
|
+
|
44
|
+
# Configure an agent for this node
|
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.
|
48
|
+
#
|
49
|
+
# @param name [Symbol] Agent name
|
50
|
+
# @return [AgentConfig] Fluent configuration object
|
51
|
+
#
|
52
|
+
# @example With delegation
|
53
|
+
# agent(:backend).delegates_to(:tester, :database)
|
54
|
+
#
|
55
|
+
# @example Without delegation
|
56
|
+
# agent(:planner)
|
57
|
+
def agent(name)
|
58
|
+
config = AgentConfig.new(name, self)
|
59
|
+
|
60
|
+
# Register immediately with empty delegation
|
61
|
+
# If delegates_to is called later, it will update this
|
62
|
+
register_agent(name, [])
|
63
|
+
|
64
|
+
config
|
65
|
+
end
|
66
|
+
|
67
|
+
# Register an agent configuration (called by AgentConfig)
|
68
|
+
#
|
69
|
+
# @param agent_name [Symbol] Agent name
|
70
|
+
# @param delegates_to [Array<Symbol>] Delegation targets
|
71
|
+
# @return [void]
|
72
|
+
def register_agent(agent_name, delegates_to)
|
73
|
+
# Check if agent already registered
|
74
|
+
existing = @agent_configs.find { |ac| ac[:agent] == agent_name }
|
75
|
+
|
76
|
+
if existing
|
77
|
+
# Update delegation (happens when delegates_to is called after agent())
|
78
|
+
existing[:delegates_to] = delegates_to
|
79
|
+
else
|
80
|
+
# Add new agent configuration
|
81
|
+
@agent_configs << { agent: agent_name, delegates_to: delegates_to }
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Declare dependencies (nodes that must execute before this one)
|
86
|
+
#
|
87
|
+
# @param node_names [Array<Symbol>] Names of prerequisite nodes
|
88
|
+
# @return [void]
|
89
|
+
#
|
90
|
+
# @example Single dependency
|
91
|
+
# depends_on :planning
|
92
|
+
#
|
93
|
+
# @example Multiple dependencies
|
94
|
+
# depends_on :frontend, :backend
|
95
|
+
def depends_on(*node_names)
|
96
|
+
@dependencies.concat(node_names.map(&:to_sym))
|
97
|
+
end
|
98
|
+
|
99
|
+
# Override the lead agent (first agent is lead by default)
|
100
|
+
#
|
101
|
+
# @param agent_name [Symbol] Name of agent to make lead
|
102
|
+
# @return [void]
|
103
|
+
#
|
104
|
+
# @example
|
105
|
+
# agent(:backend).delegates_to(:tester)
|
106
|
+
# agent(:tester)
|
107
|
+
# lead :tester # tester is lead instead of backend
|
108
|
+
def lead(agent_name)
|
109
|
+
@lead_override = agent_name.to_sym
|
110
|
+
end
|
111
|
+
|
112
|
+
# Define input transformer for this node
|
113
|
+
#
|
114
|
+
# The transformer receives a NodeContext object with access to:
|
115
|
+
# - Previous node's result (convenience: ctx.content)
|
116
|
+
# - Original user prompt (ctx.original_prompt)
|
117
|
+
# - All previous node results (ctx.all_results[:node_name])
|
118
|
+
# - Current node metadata (ctx.node_name, ctx.dependencies)
|
119
|
+
#
|
120
|
+
# Can also be used for side effects (logging, file I/O) since the block
|
121
|
+
# runs at execution time, not declaration time.
|
122
|
+
#
|
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.
|
126
|
+
#
|
127
|
+
# @yield [NodeContext] Context with previous results and metadata
|
128
|
+
# @return [String, Hash] Transformed input OR skip hash
|
129
|
+
#
|
130
|
+
# @example Access previous result and original prompt
|
131
|
+
# input do |ctx|
|
132
|
+
# # Convenience accessor
|
133
|
+
# previous_content = ctx.content
|
134
|
+
#
|
135
|
+
# # Access original prompt
|
136
|
+
# "Original: #{ctx.original_prompt}\nPrevious: #{previous_content}"
|
137
|
+
# end
|
138
|
+
#
|
139
|
+
# @example Access results from specific nodes
|
140
|
+
# input do |ctx|
|
141
|
+
# plan = ctx.all_results[:planning].content
|
142
|
+
# design = ctx.all_results[:design].content
|
143
|
+
#
|
144
|
+
# "Implement based on:\nPlan: #{plan}\nDesign: #{design}"
|
145
|
+
# end
|
146
|
+
#
|
147
|
+
# @example Skip execution (caching)
|
148
|
+
# input do |ctx|
|
149
|
+
# 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
|
156
|
+
# end
|
157
|
+
#
|
158
|
+
# @example Skip execution (validation)
|
159
|
+
# input do |ctx|
|
160
|
+
# 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
|
165
|
+
# end
|
166
|
+
# end
|
167
|
+
def input(&block)
|
168
|
+
@input_transformer = block
|
169
|
+
end
|
170
|
+
|
171
|
+
# Set input transformer as bash command (YAML API)
|
172
|
+
#
|
173
|
+
# The command receives NodeContext as JSON on STDIN and outputs transformed content.
|
174
|
+
#
|
175
|
+
# **Exit codes:**
|
176
|
+
# - 0: Success, use STDOUT as transformed content
|
177
|
+
# - 1: Skip node execution, use current_input unchanged (STDOUT ignored)
|
178
|
+
# - 2: Halt workflow with error, show STDERR (STDOUT ignored)
|
179
|
+
#
|
180
|
+
# @param command [String] Bash command to execute
|
181
|
+
# @param timeout [Integer] Timeout in seconds (default: 60)
|
182
|
+
# @return [void]
|
183
|
+
#
|
184
|
+
# @example
|
185
|
+
# input_command("scripts/validate.sh", timeout: 30)
|
186
|
+
def input_command(command, timeout: TransformerExecutor::DEFAULT_TIMEOUT)
|
187
|
+
@input_transformer_command = { command: command, timeout: timeout }
|
188
|
+
end
|
189
|
+
|
190
|
+
# Define output transformer for this node
|
191
|
+
#
|
192
|
+
# The transformer receives a NodeContext object with access to:
|
193
|
+
# - Current node's result (convenience: ctx.content)
|
194
|
+
# - Original user prompt (ctx.original_prompt)
|
195
|
+
# - All completed node results (ctx.all_results[:node_name])
|
196
|
+
# - Current node metadata (ctx.node_name)
|
197
|
+
#
|
198
|
+
# Can also be used for side effects (logging, file I/O) since the block
|
199
|
+
# runs at execution time, not declaration time.
|
200
|
+
#
|
201
|
+
# @yield [NodeContext] Context with current result and metadata
|
202
|
+
# @return [String] Transformed output
|
203
|
+
#
|
204
|
+
# @example Transform and save to file
|
205
|
+
# output do |ctx|
|
206
|
+
# # Side effect: save to file
|
207
|
+
# File.write("results/plan.txt", ctx.content)
|
208
|
+
#
|
209
|
+
# # Return transformed output for next node
|
210
|
+
# "Key decisions: #{extract_decisions(ctx.content)}"
|
211
|
+
# end
|
212
|
+
#
|
213
|
+
# @example Access original prompt
|
214
|
+
# output do |ctx|
|
215
|
+
# # Include original context in output
|
216
|
+
# "Task: #{ctx.original_prompt}\nResult: #{ctx.content}"
|
217
|
+
# end
|
218
|
+
#
|
219
|
+
# @example Access multiple node results
|
220
|
+
# output do |ctx|
|
221
|
+
# plan = ctx.all_results[:planning].content
|
222
|
+
# impl = ctx.content
|
223
|
+
#
|
224
|
+
# "Completed:\nPlan: #{plan}\nImpl: #{impl}"
|
225
|
+
# end
|
226
|
+
def output(&block)
|
227
|
+
@output_transformer = block
|
228
|
+
end
|
229
|
+
|
230
|
+
# Set output transformer as bash command (YAML API)
|
231
|
+
#
|
232
|
+
# The command receives NodeContext as JSON on STDIN and outputs transformed content.
|
233
|
+
#
|
234
|
+
# **Exit codes:**
|
235
|
+
# - 0: Success, use STDOUT as transformed content
|
236
|
+
# - 1: Pass through unchanged, use result.content (STDOUT ignored)
|
237
|
+
# - 2: Halt workflow with error, show STDERR (STDOUT ignored)
|
238
|
+
#
|
239
|
+
# @param command [String] Bash command to execute
|
240
|
+
# @param timeout [Integer] Timeout in seconds (default: 60)
|
241
|
+
# @return [void]
|
242
|
+
#
|
243
|
+
# @example
|
244
|
+
# output_command("scripts/format.sh", timeout: 30)
|
245
|
+
def output_command(command, timeout: TransformerExecutor::DEFAULT_TIMEOUT)
|
246
|
+
@output_transformer_command = { command: command, timeout: timeout }
|
247
|
+
end
|
248
|
+
|
249
|
+
# Check if node has any input transformer (block or command)
|
250
|
+
#
|
251
|
+
# @return [Boolean]
|
252
|
+
def has_input_transformer?
|
253
|
+
@input_transformer || @input_transformer_command
|
254
|
+
end
|
255
|
+
|
256
|
+
# Check if node has any output transformer (block or command)
|
257
|
+
#
|
258
|
+
# @return [Boolean]
|
259
|
+
def has_output_transformer?
|
260
|
+
@output_transformer || @output_transformer_command
|
261
|
+
end
|
262
|
+
|
263
|
+
# Transform input using configured transformer (block or command)
|
264
|
+
#
|
265
|
+
# Executes either Ruby block or bash command transformer.
|
266
|
+
#
|
267
|
+
# **Exit code behavior (bash commands only):**
|
268
|
+
# - Exit 0: Use STDOUT as transformed content
|
269
|
+
# - Exit 1: Skip node execution, use current_input unchanged (STDOUT ignored)
|
270
|
+
# - Exit 2: Halt workflow with error (STDOUT ignored)
|
271
|
+
#
|
272
|
+
# @param context [NodeContext] Context with previous results and metadata
|
273
|
+
# @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: "..." }`
|
275
|
+
# @raise [ConfigurationError] If bash transformer halts workflow (exit 2)
|
276
|
+
def transform_input(context, current_input:)
|
277
|
+
# No transformer configured: return content as-is
|
278
|
+
return context.content unless @input_transformer || @input_transformer_command
|
279
|
+
|
280
|
+
# Ruby block transformer
|
281
|
+
# Ruby blocks can return String (transformed content) OR Hash (skip_execution)
|
282
|
+
if @input_transformer
|
283
|
+
return @input_transformer.call(context)
|
284
|
+
end
|
285
|
+
|
286
|
+
# Bash command transformer
|
287
|
+
# Bash commands use exit codes to control behavior:
|
288
|
+
# - Exit 0: Success, use STDOUT as transformed content
|
289
|
+
# - Exit 1: Skip node execution, use current_input unchanged (STDOUT ignored)
|
290
|
+
# - Exit 2: Halt workflow with error (STDOUT ignored)
|
291
|
+
if @input_transformer_command
|
292
|
+
result = TransformerExecutor.execute(
|
293
|
+
command: @input_transformer_command[:command],
|
294
|
+
context: context,
|
295
|
+
event: "input",
|
296
|
+
node_name: @name,
|
297
|
+
fallback_content: current_input, # Used for exit 1 (skip)
|
298
|
+
timeout: @input_transformer_command[:timeout],
|
299
|
+
)
|
300
|
+
|
301
|
+
# Handle transformer result based on exit code
|
302
|
+
if result.halt?
|
303
|
+
# Exit 2: Halt workflow with error
|
304
|
+
raise ConfigurationError,
|
305
|
+
"Input transformer halted workflow for node '#{@name}': #{result.error_message}"
|
306
|
+
elsif result.skip_execution?
|
307
|
+
# Exit 1: Skip node execution, return skip hash
|
308
|
+
# Content is current_input unchanged (STDOUT was ignored)
|
309
|
+
{ skip_execution: true, content: result.content }
|
310
|
+
else
|
311
|
+
# Exit 0: Return transformed content from STDOUT
|
312
|
+
result.content
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
# Transform output using configured transformer (block or command)
|
318
|
+
#
|
319
|
+
# Executes either Ruby block or bash command transformer.
|
320
|
+
#
|
321
|
+
# **Exit code behavior (bash commands only):**
|
322
|
+
# - Exit 0: Use STDOUT as transformed content
|
323
|
+
# - Exit 1: Pass through unchanged, use result.content (STDOUT ignored)
|
324
|
+
# - Exit 2: Halt workflow with error (STDOUT ignored)
|
325
|
+
#
|
326
|
+
# @param context [NodeContext] Context with current result and metadata
|
327
|
+
# @return [String] Transformed output
|
328
|
+
# @raise [ConfigurationError] If bash transformer halts workflow (exit 2)
|
329
|
+
def transform_output(context)
|
330
|
+
# No transformer configured: return content as-is
|
331
|
+
return context.content unless @output_transformer || @output_transformer_command
|
332
|
+
|
333
|
+
# Ruby block transformer
|
334
|
+
# Simply calls the block with context and returns result
|
335
|
+
if @output_transformer
|
336
|
+
return @output_transformer.call(context)
|
337
|
+
end
|
338
|
+
|
339
|
+
# Bash command transformer
|
340
|
+
# Bash commands use exit codes to control behavior:
|
341
|
+
# - Exit 0: Success, use STDOUT as transformed content
|
342
|
+
# - Exit 1: Pass through unchanged, use result.content (STDOUT ignored)
|
343
|
+
# - Exit 2: Halt workflow with error from STDERR (STDOUT ignored)
|
344
|
+
if @output_transformer_command
|
345
|
+
result = TransformerExecutor.execute(
|
346
|
+
command: @output_transformer_command[:command],
|
347
|
+
context: context,
|
348
|
+
event: "output",
|
349
|
+
node_name: @name,
|
350
|
+
fallback_content: context.content, # result.content for exit 1
|
351
|
+
timeout: @output_transformer_command[:timeout],
|
352
|
+
)
|
353
|
+
|
354
|
+
# Handle transformer result based on exit code
|
355
|
+
if result.halt?
|
356
|
+
# Exit 2: Halt workflow with error
|
357
|
+
raise ConfigurationError,
|
358
|
+
"Output transformer halted workflow for node '#{@name}': #{result.error_message}"
|
359
|
+
else
|
360
|
+
# Exit 0: Return transformed content from STDOUT
|
361
|
+
# Exit 1: Return fallback (result.content unchanged)
|
362
|
+
result.content
|
363
|
+
end
|
364
|
+
end
|
365
|
+
end
|
366
|
+
|
367
|
+
# Get the lead agent for this node
|
368
|
+
#
|
369
|
+
# @return [Symbol] Lead agent name
|
370
|
+
def lead_agent
|
371
|
+
@lead_override || @agent_configs.first&.dig(:agent)
|
372
|
+
end
|
373
|
+
|
374
|
+
# Check if this is an agent-less (computation-only) node
|
375
|
+
#
|
376
|
+
# Agent-less nodes run pure Ruby code without LLM execution.
|
377
|
+
# They must have at least one transformer (input or output).
|
378
|
+
#
|
379
|
+
# @return [Boolean]
|
380
|
+
def agent_less?
|
381
|
+
@agent_configs.empty?
|
382
|
+
end
|
383
|
+
|
384
|
+
# Validate node configuration
|
385
|
+
#
|
386
|
+
# Also auto-adds agents that are referenced in delegates_to but not explicitly declared.
|
387
|
+
# This allows writing: agent(:backend).delegates_to(:verifier)
|
388
|
+
# without needing: agent(:verifier)
|
389
|
+
#
|
390
|
+
# @return [void]
|
391
|
+
# @raise [ConfigurationError] If configuration is invalid
|
392
|
+
def validate!
|
393
|
+
# Auto-add agents mentioned in delegates_to but not explicitly declared
|
394
|
+
auto_add_delegate_agents
|
395
|
+
|
396
|
+
# Agent-less nodes (pure computation) are allowed but need transformers
|
397
|
+
if @agent_configs.empty?
|
398
|
+
unless has_input_transformer? || has_output_transformer?
|
399
|
+
raise ConfigurationError,
|
400
|
+
"Agent-less node '#{@name}' must have at least one transformer (input or output). " \
|
401
|
+
"Either add agents with agent(:name) or add input/output transformers."
|
402
|
+
end
|
403
|
+
end
|
404
|
+
|
405
|
+
# If has agents, validate lead override
|
406
|
+
if @lead_override && !@agent_configs.any? { |ac| ac[:agent] == @lead_override }
|
407
|
+
raise ConfigurationError,
|
408
|
+
"Node '#{@name}' lead agent '#{@lead_override}' not found in node's agents"
|
409
|
+
end
|
410
|
+
end
|
411
|
+
|
412
|
+
private
|
413
|
+
|
414
|
+
# Auto-add agents that are mentioned in delegates_to but not explicitly declared
|
415
|
+
#
|
416
|
+
# This allows:
|
417
|
+
# agent(:backend).delegates_to(:tester)
|
418
|
+
# Without needing:
|
419
|
+
# agent(:tester)
|
420
|
+
#
|
421
|
+
# The tester agent is automatically added to the node with no delegation.
|
422
|
+
#
|
423
|
+
# @return [void]
|
424
|
+
def auto_add_delegate_agents
|
425
|
+
# Collect all agents mentioned in delegates_to
|
426
|
+
all_delegates = @agent_configs.flat_map { |ac| ac[:delegates_to] }.uniq
|
427
|
+
|
428
|
+
# Find delegates that aren't explicitly declared
|
429
|
+
declared_agents = @agent_configs.map { |ac| ac[:agent] }
|
430
|
+
missing_delegates = all_delegates - declared_agents
|
431
|
+
|
432
|
+
# Auto-add missing delegates with empty delegation
|
433
|
+
missing_delegates.each do |delegate_name|
|
434
|
+
@agent_configs << { agent: delegate_name, delegates_to: [] }
|
435
|
+
end
|
436
|
+
end
|
437
|
+
end
|
438
|
+
end
|
439
|
+
end
|
@@ -0,0 +1,248 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "json"
|
5
|
+
require "timeout"
|
6
|
+
|
7
|
+
module SwarmSDK
|
8
|
+
module Node
|
9
|
+
# Executes bash command transformers for node input/output transformation
|
10
|
+
#
|
11
|
+
# Transformers are shell commands that receive NodeContext data on STDIN as JSON
|
12
|
+
# and produce transformed content on STDOUT.
|
13
|
+
#
|
14
|
+
# ## Exit Code Behavior
|
15
|
+
#
|
16
|
+
# - **Exit 0**: Transform success
|
17
|
+
# - Use STDOUT as the transformed content
|
18
|
+
# - Node execution proceeds with transformed content
|
19
|
+
#
|
20
|
+
# - **Exit 1**: Skip node execution (pass-through)
|
21
|
+
# - STDOUT is IGNORED
|
22
|
+
# - Input transformer: Use current_input unchanged (no transformation)
|
23
|
+
# - Output transformer: Use result.content unchanged (no transformation)
|
24
|
+
# - For input transformer: Also skips the node's LLM execution
|
25
|
+
#
|
26
|
+
# - **Exit 2**: Halt entire workflow
|
27
|
+
# - STDOUT is IGNORED
|
28
|
+
# - STDERR is shown as error message
|
29
|
+
# - Workflow stops immediately with error
|
30
|
+
#
|
31
|
+
# ## JSON Input Format (STDIN)
|
32
|
+
#
|
33
|
+
# **Input transformer receives:**
|
34
|
+
# ```json
|
35
|
+
# {
|
36
|
+
# "event": "input",
|
37
|
+
# "node": "implementation",
|
38
|
+
# "original_prompt": "Build auth API",
|
39
|
+
# "content": "PLAN: Create endpoints...",
|
40
|
+
# "all_results": {
|
41
|
+
# "planning": {
|
42
|
+
# "content": "Create endpoints...",
|
43
|
+
# "agent": "planner",
|
44
|
+
# "duration": 2.5,
|
45
|
+
# "success": true
|
46
|
+
# }
|
47
|
+
# },
|
48
|
+
# "dependencies": ["planning"]
|
49
|
+
# }
|
50
|
+
# ```
|
51
|
+
#
|
52
|
+
# **Output transformer receives:**
|
53
|
+
# ```json
|
54
|
+
# {
|
55
|
+
# "event": "output",
|
56
|
+
# "node": "implementation",
|
57
|
+
# "original_prompt": "Build auth API",
|
58
|
+
# "content": "Implementation complete",
|
59
|
+
# "all_results": {
|
60
|
+
# "planning": {...},
|
61
|
+
# "implementation": {...}
|
62
|
+
# }
|
63
|
+
# }
|
64
|
+
# ```
|
65
|
+
#
|
66
|
+
# @example Input transformer that validates
|
67
|
+
# # validate.sh
|
68
|
+
# #!/bin/bash
|
69
|
+
# INPUT=$(cat)
|
70
|
+
# CONTENT=$(echo "$INPUT" | jq -r '.content')
|
71
|
+
#
|
72
|
+
# if [ ${#CONTENT} -gt 10000 ]; then
|
73
|
+
# echo "Content too long" >&2
|
74
|
+
# exit 2 # Halt workflow
|
75
|
+
# fi
|
76
|
+
#
|
77
|
+
# echo "$CONTENT"
|
78
|
+
# exit 0
|
79
|
+
#
|
80
|
+
# @example Input transformer that caches (skip execution)
|
81
|
+
# # cache_check.sh
|
82
|
+
# #!/bin/bash
|
83
|
+
# INPUT=$(cat)
|
84
|
+
# CONTENT=$(echo "$INPUT" | jq -r '.content')
|
85
|
+
#
|
86
|
+
# if cached "$CONTENT"; then
|
87
|
+
# exit 1 # Skip node execution, pass through unchanged
|
88
|
+
# fi
|
89
|
+
#
|
90
|
+
# echo "$CONTENT"
|
91
|
+
# exit 0
|
92
|
+
class TransformerExecutor
|
93
|
+
DEFAULT_TIMEOUT = 60
|
94
|
+
|
95
|
+
# Result object for transformer execution
|
96
|
+
TransformerResult = Struct.new(:success, :content, :skip_execution, :halt, :error_message, keyword_init: true) do
|
97
|
+
def skip_execution?
|
98
|
+
skip_execution
|
99
|
+
end
|
100
|
+
|
101
|
+
def halt?
|
102
|
+
halt
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
class << self
|
107
|
+
# Execute a transformer shell command
|
108
|
+
#
|
109
|
+
# @param command [String] Shell command to execute
|
110
|
+
# @param context [NodeContext] Node context for building JSON input
|
111
|
+
# @param event [String] Event type ("input" or "output")
|
112
|
+
# @param node_name [Symbol] Current node name
|
113
|
+
# @param fallback_content [String] Content to use if skip (exit 1)
|
114
|
+
# @param timeout [Integer] Timeout in seconds (default: 60)
|
115
|
+
# @return [TransformerResult] Result with transformed content or skip/halt flags
|
116
|
+
def execute(command:, context:, event:, node_name:, fallback_content:, timeout: DEFAULT_TIMEOUT)
|
117
|
+
# Build JSON input for transformer
|
118
|
+
input_json = build_transformer_input(context, event, node_name)
|
119
|
+
|
120
|
+
# Build environment variables
|
121
|
+
env = build_environment(node_name: node_name)
|
122
|
+
|
123
|
+
# Execute command with JSON stdin and timeout
|
124
|
+
stdout, stderr, status = Timeout.timeout(timeout) do
|
125
|
+
Open3.capture3(
|
126
|
+
env,
|
127
|
+
command,
|
128
|
+
stdin_data: JSON.generate(input_json),
|
129
|
+
)
|
130
|
+
end
|
131
|
+
|
132
|
+
# Handle exit code
|
133
|
+
# Exit 0: Transform success, use STDOUT
|
134
|
+
# Exit 1: Skip node execution, use fallback_content (IGNORE STDOUT)
|
135
|
+
# Exit 2: Halt workflow with error (IGNORE STDOUT)
|
136
|
+
case status.exitstatus
|
137
|
+
when 0
|
138
|
+
# Success: use STDOUT as transformed content (strip trailing newline)
|
139
|
+
TransformerResult.new(
|
140
|
+
success: true,
|
141
|
+
content: stdout.chomp, # Remove trailing newline from echo
|
142
|
+
skip_execution: false,
|
143
|
+
halt: false,
|
144
|
+
error_message: nil,
|
145
|
+
)
|
146
|
+
when 1
|
147
|
+
# Skip node execution: use fallback_content unchanged (IGNORE STDOUT)
|
148
|
+
# For input transformer: skip_execution = true (skip LLM call)
|
149
|
+
# For output transformer: skip_execution = false (just pass through)
|
150
|
+
TransformerResult.new(
|
151
|
+
success: true,
|
152
|
+
content: fallback_content,
|
153
|
+
skip_execution: (event == "input"), # Only skip LLM for input transformers
|
154
|
+
halt: false,
|
155
|
+
error_message: nil,
|
156
|
+
)
|
157
|
+
when 2
|
158
|
+
# Halt workflow: return error (IGNORE STDOUT)
|
159
|
+
error_msg = stderr.strip.empty? ? "Transformer halted workflow (exit 2)" : stderr.strip
|
160
|
+
TransformerResult.new(
|
161
|
+
success: false,
|
162
|
+
content: nil,
|
163
|
+
skip_execution: false,
|
164
|
+
halt: true,
|
165
|
+
error_message: error_msg,
|
166
|
+
)
|
167
|
+
else
|
168
|
+
# Unknown exit code: treat as error (halt)
|
169
|
+
error_msg = "Transformer exited with code #{status.exitstatus}\nSTDERR: #{stderr}"
|
170
|
+
TransformerResult.new(
|
171
|
+
success: false,
|
172
|
+
content: nil,
|
173
|
+
skip_execution: false,
|
174
|
+
halt: true,
|
175
|
+
error_message: error_msg,
|
176
|
+
)
|
177
|
+
end
|
178
|
+
rescue Timeout::Error
|
179
|
+
# Timeout: halt workflow
|
180
|
+
TransformerResult.new(
|
181
|
+
success: false,
|
182
|
+
content: nil,
|
183
|
+
skip_execution: false,
|
184
|
+
halt: true,
|
185
|
+
error_message: "Transformer command timed out after #{timeout}s",
|
186
|
+
)
|
187
|
+
rescue StandardError => e
|
188
|
+
# Execution error: halt workflow
|
189
|
+
TransformerResult.new(
|
190
|
+
success: false,
|
191
|
+
content: nil,
|
192
|
+
skip_execution: false,
|
193
|
+
halt: true,
|
194
|
+
error_message: "Transformer command failed: #{e.message}",
|
195
|
+
)
|
196
|
+
end
|
197
|
+
|
198
|
+
private
|
199
|
+
|
200
|
+
# Build JSON input for transformer command
|
201
|
+
#
|
202
|
+
# @param context [NodeContext] Node context
|
203
|
+
# @param event [String] Event type ("input" or "output")
|
204
|
+
# @param node_name [Symbol] Node name
|
205
|
+
# @return [Hash] JSON data to pass on stdin
|
206
|
+
def build_transformer_input(context, event, node_name)
|
207
|
+
base = {
|
208
|
+
event: event,
|
209
|
+
node: node_name.to_s,
|
210
|
+
original_prompt: context.original_prompt,
|
211
|
+
content: context.content,
|
212
|
+
}
|
213
|
+
|
214
|
+
# Add all_results (convert Result objects to hashes)
|
215
|
+
if context.all_results && !context.all_results.empty?
|
216
|
+
base[:all_results] = context.all_results.transform_values do |result|
|
217
|
+
{
|
218
|
+
content: result.content,
|
219
|
+
agent: result.agent,
|
220
|
+
duration: result.duration,
|
221
|
+
success: result.success?,
|
222
|
+
}
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
# Add dependencies for input transformers
|
227
|
+
if event == "input" && context.dependencies
|
228
|
+
base[:dependencies] = context.dependencies.map(&:to_s)
|
229
|
+
end
|
230
|
+
|
231
|
+
base
|
232
|
+
end
|
233
|
+
|
234
|
+
# Build environment variables for transformer execution
|
235
|
+
#
|
236
|
+
# @param node_name [Symbol] Current node name
|
237
|
+
# @return [Hash] Environment variables
|
238
|
+
def build_environment(node_name:)
|
239
|
+
{
|
240
|
+
"SWARM_SDK_PROJECT_DIR" => Dir.pwd,
|
241
|
+
"SWARM_SDK_NODE_NAME" => node_name.to_s,
|
242
|
+
"PATH" => ENV.fetch("PATH", ""),
|
243
|
+
}
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|