swarm_memory 2.1.2 → 2.1.4
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/claude_swarm/claude_mcp_server.rb +1 -0
- data/lib/claude_swarm/cli.rb +5 -18
- data/lib/claude_swarm/configuration.rb +30 -19
- data/lib/claude_swarm/mcp_generator.rb +5 -10
- data/lib/claude_swarm/openai/chat_completion.rb +4 -12
- data/lib/claude_swarm/openai/executor.rb +3 -1
- data/lib/claude_swarm/openai/responses.rb +13 -32
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
- data/lib/swarm_cli/commands/run.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +14 -14
- data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
- data/lib/swarm_cli/interactive_repl.rb +11 -5
- data/lib/swarm_cli/ui/icons.rb +0 -23
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/adapters/base.rb +4 -4
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
- data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
- data/lib/swarm_memory/integration/cli_registration.rb +3 -2
- data/lib/swarm_memory/integration/sdk_plugin.rb +98 -12
- data/lib/swarm_memory/tools/memory_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_read.rb +3 -3
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +6 -1
- data/lib/swarm_sdk/agent/builder.rb +91 -0
- data/lib/swarm_sdk/agent/chat.rb +540 -925
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
- data/lib/swarm_sdk/agent/context.rb +8 -4
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +79 -174
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/builders/base_builder.rb +409 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
- data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
- data/lib/swarm_sdk/concerns/validatable.rb +55 -0
- data/lib/swarm_sdk/configuration/parser.rb +353 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +100 -261
- data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
- data/lib/swarm_sdk/context_compactor.rb +6 -11
- data/lib/swarm_sdk/context_management/builder.rb +128 -0
- data/lib/swarm_sdk/context_management/context.rb +328 -0
- data/lib/swarm_sdk/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +199 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +192 -16
- data/lib/swarm_sdk/log_stream.rb +66 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node_context.rb +1 -1
- data/lib/swarm_sdk/observer/builder.rb +81 -0
- data/lib/swarm_sdk/observer/config.rb +45 -0
- data/lib/swarm_sdk/observer/manager.rb +236 -0
- data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
- data/lib/swarm_sdk/plugin.rb +93 -3
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
- data/lib/swarm_sdk/state_restorer.rb +476 -0
- data/lib/swarm_sdk/state_snapshot.rb +334 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +69 -407
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
- data/lib/swarm_sdk/swarm.rb +366 -631
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- data/lib/swarm_sdk/tools/delegate.rb +127 -24
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +9 -1
- data/lib/swarm_sdk/tools/grep.rb +7 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
- data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
- data/lib/swarm_sdk/tools/read.rb +28 -18
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/think.rb +4 -1
- data/lib/swarm_sdk/tools/todo_write.rb +27 -8
- data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
- data/lib/swarm_sdk/tools/write.rb +8 -13
- data/lib/swarm_sdk/utils.rb +18 -0
- data/lib/swarm_sdk/validation_result.rb +33 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
- data/lib/swarm_sdk/workflow/builder.rb +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/workflow.rb +554 -0
- data/lib/swarm_sdk.rb +393 -22
- metadata +51 -16
- data/lib/swarm_memory/chat_extension.rb +0 -34
- data/lib/swarm_sdk/node_orchestrator.rb +0 -591
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
|
@@ -1,591 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module SwarmSDK
|
|
4
|
-
# NodeOrchestrator executes a multi-node workflow
|
|
5
|
-
#
|
|
6
|
-
# Each node represents a mini-swarm execution stage. The orchestrator:
|
|
7
|
-
# - Builds execution order from node dependencies (topological sort)
|
|
8
|
-
# - Creates a separate swarm instance for each node
|
|
9
|
-
# - Passes output from one node as input to dependent nodes
|
|
10
|
-
# - Supports input/output transformers for data flow customization
|
|
11
|
-
#
|
|
12
|
-
# @example
|
|
13
|
-
# orchestrator = NodeOrchestrator.new(
|
|
14
|
-
# swarm_name: "Dev Team",
|
|
15
|
-
# agent_definitions: { backend: def1, tester: def2 },
|
|
16
|
-
# nodes: { planning: node1, implementation: node2 },
|
|
17
|
-
# start_node: :planning
|
|
18
|
-
# )
|
|
19
|
-
# result = orchestrator.execute("Build auth system")
|
|
20
|
-
class NodeOrchestrator
|
|
21
|
-
attr_reader :swarm_name, :nodes, :start_node
|
|
22
|
-
|
|
23
|
-
def initialize(swarm_name:, agent_definitions:, nodes:, start_node:, scratchpad_enabled: true)
|
|
24
|
-
@swarm_name = swarm_name
|
|
25
|
-
@agent_definitions = agent_definitions
|
|
26
|
-
@nodes = nodes
|
|
27
|
-
@start_node = start_node
|
|
28
|
-
@scratchpad_enabled = scratchpad_enabled
|
|
29
|
-
@agent_instance_cache = {} # Cache for preserving agent context across nodes
|
|
30
|
-
|
|
31
|
-
validate!
|
|
32
|
-
@execution_order = build_execution_order
|
|
33
|
-
end
|
|
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
|
-
|
|
45
|
-
# Execute the node workflow
|
|
46
|
-
#
|
|
47
|
-
# Executes nodes in topological order, passing output from each node
|
|
48
|
-
# to its dependents. Supports streaming logs if block given.
|
|
49
|
-
#
|
|
50
|
-
# @param prompt [String] Initial prompt for the workflow
|
|
51
|
-
# @yield [Hash] Log entry if block given (for streaming)
|
|
52
|
-
# @return [Result] Final result from last node execution
|
|
53
|
-
def execute(prompt, &block)
|
|
54
|
-
logs = []
|
|
55
|
-
current_input = prompt
|
|
56
|
-
results = {}
|
|
57
|
-
@original_prompt = prompt # Store original prompt for NodeContext
|
|
58
|
-
|
|
59
|
-
# Setup logging if block given
|
|
60
|
-
if block_given?
|
|
61
|
-
# Register callback to collect logs and forward to user's block
|
|
62
|
-
LogCollector.on_log do |entry|
|
|
63
|
-
logs << entry
|
|
64
|
-
block.call(entry)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Set LogStream to use LogCollector as emitter
|
|
68
|
-
LogStream.emitter = LogCollector
|
|
69
|
-
end
|
|
70
|
-
|
|
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]
|
|
77
|
-
node = @nodes[node_name]
|
|
78
|
-
node_start_time = Time.now
|
|
79
|
-
|
|
80
|
-
# Emit node_start event
|
|
81
|
-
emit_node_start(node_name, node)
|
|
82
|
-
|
|
83
|
-
# Transform input if node has transformer (Ruby block or bash command)
|
|
84
|
-
skip_execution = false
|
|
85
|
-
skip_content = nil
|
|
86
|
-
|
|
87
|
-
if node.has_input_transformer?
|
|
88
|
-
# Build NodeContext based on dependencies
|
|
89
|
-
#
|
|
90
|
-
# For single dependency: previous_result has original Result metadata,
|
|
91
|
-
# transformed_content has output from previous transformer
|
|
92
|
-
# For multiple dependencies: previous_result is hash of Results
|
|
93
|
-
# For no dependencies: previous_result is initial prompt string
|
|
94
|
-
previous_result = if node.dependencies.size > 1
|
|
95
|
-
# Multiple dependencies: pass hash of original results
|
|
96
|
-
node.dependencies.to_h { |dep| [dep, results[dep]] }
|
|
97
|
-
elsif node.dependencies.size == 1
|
|
98
|
-
# Single dependency: pass the original result
|
|
99
|
-
results[node.dependencies.first]
|
|
100
|
-
else
|
|
101
|
-
# No dependencies: initial prompt
|
|
102
|
-
current_input
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Create NodeContext for input transformer
|
|
106
|
-
input_context = NodeContext.for_input(
|
|
107
|
-
previous_result: previous_result,
|
|
108
|
-
all_results: results,
|
|
109
|
-
original_prompt: @original_prompt,
|
|
110
|
-
node_name: node_name,
|
|
111
|
-
dependencies: node.dependencies,
|
|
112
|
-
transformed_content: node.dependencies.size == 1 ? current_input : nil,
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
# Apply input transformer (passes current_input for bash command fallback)
|
|
116
|
-
# Bash transformer exit codes:
|
|
117
|
-
# - Exit 0: Use STDOUT as transformed content
|
|
118
|
-
# - Exit 1: Skip node execution, use current_input unchanged (STDOUT ignored)
|
|
119
|
-
# - Exit 2: Halt workflow with error from STDERR (STDOUT ignored)
|
|
120
|
-
transformed = node.transform_input(input_context, current_input: current_input)
|
|
121
|
-
|
|
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
|
|
138
|
-
skip_execution = true
|
|
139
|
-
skip_content = control_result[:content]
|
|
140
|
-
when :continue
|
|
141
|
-
current_input = control_result[:content]
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Execute node (or skip if requested)
|
|
146
|
-
if skip_execution
|
|
147
|
-
# Skip execution: return result immediately with provided content
|
|
148
|
-
result = Result.new(
|
|
149
|
-
content: skip_content,
|
|
150
|
-
agent: "skipped:#{node_name}",
|
|
151
|
-
logs: [],
|
|
152
|
-
duration: 0.0,
|
|
153
|
-
)
|
|
154
|
-
elsif node.agent_less?
|
|
155
|
-
# Agent-less node: run pure computation without LLM
|
|
156
|
-
result = execute_agent_less_node(node, current_input)
|
|
157
|
-
else
|
|
158
|
-
# Normal node: build mini-swarm and execute with LLM
|
|
159
|
-
# NOTE: Don't pass block to mini-swarm - LogCollector already captures all logs
|
|
160
|
-
mini_swarm = build_swarm_for_node(node)
|
|
161
|
-
result = mini_swarm.execute(current_input)
|
|
162
|
-
|
|
163
|
-
# Cache agent instances for context preservation
|
|
164
|
-
cache_agent_instances(mini_swarm, node)
|
|
165
|
-
|
|
166
|
-
# If result has error, log it with backtrace
|
|
167
|
-
if result.error
|
|
168
|
-
RubyLLM.logger.error("NodeOrchestrator: Node '#{node_name}' failed: #{result.error.message}")
|
|
169
|
-
RubyLLM.logger.error(" Backtrace: #{result.error.backtrace&.first(5)&.join("\n ")}")
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
results[node_name] = result
|
|
174
|
-
last_result = result
|
|
175
|
-
|
|
176
|
-
# Transform output for next node using NodeContext
|
|
177
|
-
output_context = NodeContext.for_output(
|
|
178
|
-
result: result,
|
|
179
|
-
all_results: results,
|
|
180
|
-
original_prompt: @original_prompt,
|
|
181
|
-
node_name: node_name,
|
|
182
|
-
)
|
|
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
|
|
206
|
-
|
|
207
|
-
# For agent-less nodes, update the result with transformed content
|
|
208
|
-
# This ensures all_results contains the actual output, not the input
|
|
209
|
-
if node.agent_less? && current_input != result.content
|
|
210
|
-
results[node_name] = Result.new(
|
|
211
|
-
content: current_input,
|
|
212
|
-
agent: result.agent,
|
|
213
|
-
logs: result.logs,
|
|
214
|
-
duration: result.duration,
|
|
215
|
-
error: result.error,
|
|
216
|
-
)
|
|
217
|
-
last_result = results[node_name]
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
# Emit node_stop event
|
|
221
|
-
node_duration = Time.now - node_start_time
|
|
222
|
-
emit_node_stop(node_name, node, result, node_duration, skip_execution)
|
|
223
|
-
|
|
224
|
-
execution_index += 1
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
last_result
|
|
228
|
-
ensure
|
|
229
|
-
# Reset logging state for next execution
|
|
230
|
-
LogCollector.reset!
|
|
231
|
-
LogStream.reset!
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
private
|
|
235
|
-
|
|
236
|
-
# Emit node_start event
|
|
237
|
-
#
|
|
238
|
-
# @param node_name [Symbol] Name of the node
|
|
239
|
-
# @param node [Node::Builder] Node configuration
|
|
240
|
-
# @return [void]
|
|
241
|
-
def emit_node_start(node_name, node)
|
|
242
|
-
return unless LogStream.emitter
|
|
243
|
-
|
|
244
|
-
LogStream.emit(
|
|
245
|
-
type: "node_start",
|
|
246
|
-
node: node_name.to_s,
|
|
247
|
-
agent_less: node.agent_less?,
|
|
248
|
-
agents: node.agent_configs.map { |ac| ac[:agent].to_s },
|
|
249
|
-
dependencies: node.dependencies.map(&:to_s),
|
|
250
|
-
timestamp: Time.now.utc.iso8601,
|
|
251
|
-
)
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# Emit node_stop event
|
|
255
|
-
#
|
|
256
|
-
# @param node_name [Symbol] Name of the node
|
|
257
|
-
# @param node [Node::Builder] Node configuration
|
|
258
|
-
# @param result [Result] Node execution result
|
|
259
|
-
# @param duration [Float] Node execution duration in seconds
|
|
260
|
-
# @param skipped [Boolean] Whether execution was skipped
|
|
261
|
-
# @return [void]
|
|
262
|
-
def emit_node_stop(node_name, node, result, duration, skipped)
|
|
263
|
-
return unless LogStream.emitter
|
|
264
|
-
|
|
265
|
-
LogStream.emit(
|
|
266
|
-
type: "node_stop",
|
|
267
|
-
node: node_name.to_s,
|
|
268
|
-
agent_less: node.agent_less?,
|
|
269
|
-
skipped: skipped,
|
|
270
|
-
agents: node.agent_configs.map { |ac| ac[:agent].to_s },
|
|
271
|
-
duration: duration.round(3),
|
|
272
|
-
timestamp: Time.now.utc.iso8601,
|
|
273
|
-
)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Execute an agent-less (computation-only) node
|
|
277
|
-
#
|
|
278
|
-
# Agent-less nodes run pure Ruby code without LLM execution.
|
|
279
|
-
# Creates a minimal Result object with the transformed content.
|
|
280
|
-
#
|
|
281
|
-
# @param node [Node::Builder] Agent-less node configuration
|
|
282
|
-
# @param input [String] Input content
|
|
283
|
-
# @return [Result] Result with transformed content
|
|
284
|
-
def execute_agent_less_node(node, input)
|
|
285
|
-
# For agent-less nodes, the "content" is just the input passed through
|
|
286
|
-
# The output transformer will do the actual work
|
|
287
|
-
Result.new(
|
|
288
|
-
content: input,
|
|
289
|
-
agent: "computation:#{node.name}",
|
|
290
|
-
logs: [],
|
|
291
|
-
duration: 0.0,
|
|
292
|
-
)
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# Validate orchestrator configuration
|
|
296
|
-
#
|
|
297
|
-
# @return [void]
|
|
298
|
-
# @raise [ConfigurationError] If configuration is invalid
|
|
299
|
-
def validate!
|
|
300
|
-
# Validate start_node exists
|
|
301
|
-
unless @nodes.key?(@start_node)
|
|
302
|
-
raise ConfigurationError,
|
|
303
|
-
"start_node '#{@start_node}' not found. Available nodes: #{@nodes.keys.join(", ")}"
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Validate all nodes
|
|
307
|
-
@nodes.each_value(&:validate!)
|
|
308
|
-
|
|
309
|
-
# Validate node dependencies reference existing nodes
|
|
310
|
-
@nodes.each do |node_name, node|
|
|
311
|
-
node.dependencies.each do |dep|
|
|
312
|
-
unless @nodes.key?(dep)
|
|
313
|
-
raise ConfigurationError,
|
|
314
|
-
"Node '#{node_name}' depends on unknown node '#{dep}'"
|
|
315
|
-
end
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
# Validate all agents referenced in nodes exist (skip agent-less nodes)
|
|
320
|
-
@nodes.each do |node_name, node|
|
|
321
|
-
next if node.agent_less? # Skip validation for agent-less nodes
|
|
322
|
-
|
|
323
|
-
node.agent_configs.each do |config|
|
|
324
|
-
agent_name = config[:agent]
|
|
325
|
-
unless @agent_definitions.key?(agent_name)
|
|
326
|
-
raise ConfigurationError,
|
|
327
|
-
"Node '#{node_name}' references undefined agent '#{agent_name}'"
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
# Validate delegation targets exist
|
|
331
|
-
config[:delegates_to].each do |delegate|
|
|
332
|
-
unless @agent_definitions.key?(delegate)
|
|
333
|
-
raise ConfigurationError,
|
|
334
|
-
"Node '#{node_name}' agent '#{agent_name}' delegates to undefined agent '#{delegate}'"
|
|
335
|
-
end
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# Build a swarm instance for a specific node
|
|
342
|
-
#
|
|
343
|
-
# Creates a new Swarm with only the agents specified in the node,
|
|
344
|
-
# configured with the node's delegation topology.
|
|
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
|
-
#
|
|
351
|
-
# @param node [Node::Builder] Node configuration
|
|
352
|
-
# @return [Swarm] Configured swarm instance
|
|
353
|
-
def build_swarm_for_node(node)
|
|
354
|
-
swarm = Swarm.new(
|
|
355
|
-
name: "#{@swarm_name}:#{node.name}",
|
|
356
|
-
scratchpad_enabled: @scratchpad_enabled,
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
# Add each agent specified in this node
|
|
360
|
-
node.agent_configs.each do |config|
|
|
361
|
-
agent_name = config[:agent]
|
|
362
|
-
delegates_to = config[:delegates_to]
|
|
363
|
-
|
|
364
|
-
# Get global agent definition
|
|
365
|
-
agent_def = @agent_definitions[agent_name]
|
|
366
|
-
|
|
367
|
-
# Clone definition with node-specific delegation
|
|
368
|
-
node_specific_def = clone_with_delegation(agent_def, delegates_to)
|
|
369
|
-
|
|
370
|
-
swarm.add_agent(node_specific_def)
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
# Set lead agent
|
|
374
|
-
swarm.lead = node.lead_agent
|
|
375
|
-
|
|
376
|
-
# Inject cached agent instances for context preservation
|
|
377
|
-
inject_cached_agents(swarm, node)
|
|
378
|
-
|
|
379
|
-
swarm
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
# Clone an agent definition with different delegates_to
|
|
383
|
-
#
|
|
384
|
-
# @param agent_def [Agent::Definition] Original definition
|
|
385
|
-
# @param delegates_to [Array<Symbol>] New delegation targets
|
|
386
|
-
# @return [Agent::Definition] Cloned definition
|
|
387
|
-
def clone_with_delegation(agent_def, delegates_to)
|
|
388
|
-
config = agent_def.to_h
|
|
389
|
-
config[:delegates_to] = delegates_to
|
|
390
|
-
Agent::Definition.new(agent_def.name, config)
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
# Build execution order using topological sort (Kahn's algorithm)
|
|
394
|
-
#
|
|
395
|
-
# Processes all nodes in dependency order, starting from start_node.
|
|
396
|
-
# Ensures all nodes are reachable from start_node.
|
|
397
|
-
#
|
|
398
|
-
# @return [Array<Symbol>] Ordered list of node names
|
|
399
|
-
# @raise [CircularDependencyError] If circular dependency detected
|
|
400
|
-
def build_execution_order
|
|
401
|
-
# Build in-degree map and adjacency list
|
|
402
|
-
in_degree = {}
|
|
403
|
-
adjacency = Hash.new { |h, k| h[k] = [] }
|
|
404
|
-
|
|
405
|
-
@nodes.each do |node_name, node|
|
|
406
|
-
in_degree[node_name] = node.dependencies.size
|
|
407
|
-
node.dependencies.each do |dep|
|
|
408
|
-
adjacency[dep] << node_name
|
|
409
|
-
end
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
# Start with nodes that have no dependencies
|
|
413
|
-
queue = in_degree.select { |_, degree| degree == 0 }.keys
|
|
414
|
-
order = []
|
|
415
|
-
|
|
416
|
-
while queue.any?
|
|
417
|
-
# Process nodes with all dependencies satisfied
|
|
418
|
-
node_name = queue.shift
|
|
419
|
-
order << node_name
|
|
420
|
-
|
|
421
|
-
# Reduce in-degree for dependent nodes
|
|
422
|
-
adjacency[node_name].each do |dependent|
|
|
423
|
-
in_degree[dependent] -= 1
|
|
424
|
-
queue << dependent if in_degree[dependent] == 0
|
|
425
|
-
end
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
# Check for circular dependencies
|
|
429
|
-
if order.size < @nodes.size
|
|
430
|
-
unprocessed = @nodes.keys - order
|
|
431
|
-
raise CircularDependencyError,
|
|
432
|
-
"Circular dependency detected. Unprocessed nodes: #{unprocessed.join(", ")}. " \
|
|
433
|
-
"Use goto_node in transformers to create loops instead of circular depends_on."
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
# Verify start_node is in the execution order
|
|
437
|
-
unless order.include?(@start_node)
|
|
438
|
-
raise ConfigurationError,
|
|
439
|
-
"start_node '#{@start_node}' is not reachable in the dependency graph"
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# Verify start_node is actually first (or rearrange to make it first)
|
|
443
|
-
# This ensures we start from the declared start_node
|
|
444
|
-
start_index = order.index(@start_node)
|
|
445
|
-
if start_index && start_index > 0
|
|
446
|
-
# start_node has dependencies - this violates the assumption
|
|
447
|
-
raise ConfigurationError,
|
|
448
|
-
"start_node '#{@start_node}' has dependencies: #{@nodes[@start_node].dependencies.join(", ")}. " \
|
|
449
|
-
"start_node must have no dependencies."
|
|
450
|
-
end
|
|
451
|
-
|
|
452
|
-
order
|
|
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
|
|
590
|
-
end
|
|
591
|
-
end
|