swarm_sdk 3.0.0.alpha2 → 3.0.0.alpha3
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/v3/agent.rb +98 -0
- data/lib/swarm_sdk/v3/loop/executor.rb +147 -0
- data/lib/swarm_sdk/v3/loop/iteration.rb +63 -0
- data/lib/swarm_sdk/v3/loop/result.rb +74 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 569ac707625b9bcfc78080af26a7c1d48895b5057302c23f98543b9b44e6a706
|
|
4
|
+
data.tar.gz: ab685ff041e4106046ce4da5e4ec9b3937fd4d6290e1f5deeb7d7991f579da6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd68128f22e9758ae66548a4ae2f48b210e92b2c79f4bd78c46fd47cd1d34a9d282740ee35881c27c515ac7eb8b294db6f36c8a70e762ea01ad1d52085da0b5b
|
|
7
|
+
data.tar.gz: d802eb7a4112874bf7b2ad414564af4fb9806074b8d8d108a471efc33943133942ee646360c7f4ce8390728d44cc031cede119fca993a79a8c6f1e6a7a6c7ee1
|
data/lib/swarm_sdk/v3/agent.rb
CHANGED
|
@@ -305,6 +305,77 @@ module SwarmSDK
|
|
|
305
305
|
false
|
|
306
306
|
end
|
|
307
307
|
|
|
308
|
+
# Run an iterative refinement loop over the agent
|
|
309
|
+
#
|
|
310
|
+
# Executes a kickoff prompt followed by repeated iterate prompts,
|
|
311
|
+
# optionally checking for convergence via embedding similarity
|
|
312
|
+
# between consecutive responses. Each iteration is a normal ask()
|
|
313
|
+
# call — hooks fire, memory ingests, and events stream normally.
|
|
314
|
+
#
|
|
315
|
+
# @param kickoff [String] Prompt for the first iteration
|
|
316
|
+
# @param iterate [String] Prompt for subsequent iterations
|
|
317
|
+
# @param max_iterations [Integer] Maximum number of iterations (>= 1)
|
|
318
|
+
# @param convergence_threshold [Float] Similarity threshold for convergence (0.0..1.0)
|
|
319
|
+
# @param converge [Boolean] Whether to check for convergence via embeddings
|
|
320
|
+
# @yield [event] Optional block receives ALL events (content_chunk, loop_*, etc.)
|
|
321
|
+
# @yieldparam event [Hash] Event hash with :type, :timestamp, and event-specific fields
|
|
322
|
+
# @return [Loop::Result] Aggregate result with iterations and convergence status
|
|
323
|
+
#
|
|
324
|
+
# @example Basic iterative refinement
|
|
325
|
+
# result = agent.loop(
|
|
326
|
+
# kickoff: "Write a poem about the sea",
|
|
327
|
+
# iterate: "Improve the poem, making it more vivid",
|
|
328
|
+
# max_iterations: 5,
|
|
329
|
+
# )
|
|
330
|
+
# puts result.final_response.content
|
|
331
|
+
# puts "Converged: #{result.converged?}"
|
|
332
|
+
#
|
|
333
|
+
# @example Without convergence checking
|
|
334
|
+
# result = agent.loop(
|
|
335
|
+
# kickoff: "Draft an outline",
|
|
336
|
+
# iterate: "Expand the next section",
|
|
337
|
+
# max_iterations: 3,
|
|
338
|
+
# converge: false,
|
|
339
|
+
# )
|
|
340
|
+
#
|
|
341
|
+
# @example With event streaming
|
|
342
|
+
# agent.loop(kickoff: "Start", iterate: "Continue", max_iterations: 5) do |event|
|
|
343
|
+
# case event[:type]
|
|
344
|
+
# when "loop_iteration_completed"
|
|
345
|
+
# puts "Iteration #{event[:iteration]}, delta: #{event[:delta_score]}"
|
|
346
|
+
# when "content_chunk"
|
|
347
|
+
# print event[:content]
|
|
348
|
+
# end
|
|
349
|
+
# end
|
|
350
|
+
#
|
|
351
|
+
# @raise [ArgumentError] If max_iterations < 1 or convergence_threshold out of range
|
|
352
|
+
def loop(kickoff:, iterate:, max_iterations: 10, convergence_threshold: 0.95, converge: true, &block)
|
|
353
|
+
validate_loop_params!(max_iterations, convergence_threshold)
|
|
354
|
+
|
|
355
|
+
embedder = converge ? loop_embedder : nil
|
|
356
|
+
ask_callable = ->(prompt) { ask(prompt, &block) }
|
|
357
|
+
|
|
358
|
+
executor = Loop::Executor.new(
|
|
359
|
+
ask_callable: ask_callable,
|
|
360
|
+
embedder: embedder,
|
|
361
|
+
agent_id: @id,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Wrap with block emitter so loop lifecycle events
|
|
365
|
+
# (loop_started, loop_iteration_completed, loop_completed)
|
|
366
|
+
# reach the caller's block. Each ask() inside the executor
|
|
367
|
+
# will also set/restore the block emitter for its own events.
|
|
368
|
+
with_block_emitter(block) do
|
|
369
|
+
executor.run(
|
|
370
|
+
kickoff: kickoff,
|
|
371
|
+
iterate: iterate,
|
|
372
|
+
max_iterations: max_iterations,
|
|
373
|
+
convergence_threshold: convergence_threshold,
|
|
374
|
+
converge: converge,
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
308
379
|
# Run memory defragmentation (compression, consolidation, promotion, pruning)
|
|
309
380
|
#
|
|
310
381
|
# Call this between sessions, on a schedule, or whenever appropriate.
|
|
@@ -1160,6 +1231,33 @@ module SwarmSDK
|
|
|
1160
1231
|
@mcp_connectors.each(&:disconnect!)
|
|
1161
1232
|
@mcp_connectors.clear
|
|
1162
1233
|
end
|
|
1234
|
+
|
|
1235
|
+
# Validate loop parameters
|
|
1236
|
+
#
|
|
1237
|
+
# @param max_iterations [Integer] Must be >= 1
|
|
1238
|
+
# @param convergence_threshold [Float] Must be between 0.0 and 1.0
|
|
1239
|
+
# @raise [ArgumentError] If parameters are invalid
|
|
1240
|
+
# @return [void]
|
|
1241
|
+
def validate_loop_params!(max_iterations, convergence_threshold)
|
|
1242
|
+
unless max_iterations.is_a?(Integer) && max_iterations >= 1
|
|
1243
|
+
raise ArgumentError, "max_iterations must be an integer >= 1, got #{max_iterations.inspect}"
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1246
|
+
unless convergence_threshold.is_a?(Numeric) && convergence_threshold >= 0.0 && convergence_threshold <= 1.0
|
|
1247
|
+
raise ArgumentError,
|
|
1248
|
+
"convergence_threshold must be between 0.0 and 1.0, got #{convergence_threshold.inspect}"
|
|
1249
|
+
end
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# Resolve or create an embedder for loop convergence detection
|
|
1253
|
+
#
|
|
1254
|
+
# Reuses the memory store's embedder if available (ONNX model
|
|
1255
|
+
# already loaded), otherwise creates a standalone instance.
|
|
1256
|
+
#
|
|
1257
|
+
# @return [Memory::Embedder]
|
|
1258
|
+
def loop_embedder
|
|
1259
|
+
@memory_store&.embedder || Memory::Embedder.new
|
|
1260
|
+
end
|
|
1163
1261
|
end
|
|
1164
1262
|
end
|
|
1165
1263
|
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module V3
|
|
5
|
+
module Loop
|
|
6
|
+
# Core loop engine for iterative refinement
|
|
7
|
+
#
|
|
8
|
+
# Runs a sequence of ask() calls through a provided callable,
|
|
9
|
+
# optionally checking for convergence via embedding similarity
|
|
10
|
+
# between consecutive responses. Emits events for each iteration
|
|
11
|
+
# and the overall loop lifecycle.
|
|
12
|
+
#
|
|
13
|
+
# The Executor is stateless between runs — all configuration is
|
|
14
|
+
# passed to {#run}. The ask callable and embedder are injected
|
|
15
|
+
# at construction for testability.
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage (called internally by Agent#loop)
|
|
18
|
+
# executor = Executor.new(
|
|
19
|
+
# ask_callable: ->(prompt) { agent.ask(prompt) },
|
|
20
|
+
# embedder: Memory::Embedder.new,
|
|
21
|
+
# agent_id: "writer_abc123",
|
|
22
|
+
# )
|
|
23
|
+
# result = executor.run(
|
|
24
|
+
# kickoff: "Write a poem about the sea",
|
|
25
|
+
# iterate: "Improve the poem",
|
|
26
|
+
# max_iterations: 5,
|
|
27
|
+
# convergence_threshold: 0.95,
|
|
28
|
+
# converge: true,
|
|
29
|
+
# )
|
|
30
|
+
class Executor
|
|
31
|
+
include Memory::Adapters::VectorUtils
|
|
32
|
+
|
|
33
|
+
# Create a new Executor
|
|
34
|
+
#
|
|
35
|
+
# @param ask_callable [#call] Lambda wrapping agent.ask(prompt)
|
|
36
|
+
# @param embedder [Memory::Embedder, nil] Embedder for convergence detection (nil if converge: false)
|
|
37
|
+
# @param agent_id [String] Agent identifier for event emission
|
|
38
|
+
def initialize(ask_callable:, embedder:, agent_id:)
|
|
39
|
+
@ask_callable = ask_callable
|
|
40
|
+
@embedder = embedder
|
|
41
|
+
@agent_id = agent_id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Execute the iterative loop
|
|
45
|
+
#
|
|
46
|
+
# Runs the kickoff prompt first, then the iterate prompt for
|
|
47
|
+
# subsequent iterations. If convergence checking is enabled,
|
|
48
|
+
# computes embedding similarity between consecutive responses
|
|
49
|
+
# and stops when it exceeds the threshold.
|
|
50
|
+
#
|
|
51
|
+
# @param kickoff [String] Prompt for the first iteration
|
|
52
|
+
# @param iterate [String] Prompt for subsequent iterations
|
|
53
|
+
# @param max_iterations [Integer] Maximum number of iterations (>= 1)
|
|
54
|
+
# @param convergence_threshold [Float] Similarity threshold for convergence (0.0..1.0)
|
|
55
|
+
# @param converge [Boolean] Whether to check for convergence
|
|
56
|
+
# @return [Result] Aggregate result with all iterations
|
|
57
|
+
#
|
|
58
|
+
# @example Run with convergence
|
|
59
|
+
# result = executor.run(
|
|
60
|
+
# kickoff: "Draft an essay",
|
|
61
|
+
# iterate: "Revise and improve",
|
|
62
|
+
# max_iterations: 10,
|
|
63
|
+
# convergence_threshold: 0.95,
|
|
64
|
+
# converge: true,
|
|
65
|
+
# )
|
|
66
|
+
# result.converged? #=> true or false
|
|
67
|
+
def run(kickoff:, iterate:, max_iterations:, convergence_threshold:, converge:)
|
|
68
|
+
EventStream.emit(
|
|
69
|
+
type: "loop_started",
|
|
70
|
+
agent: @agent_id,
|
|
71
|
+
max_iterations: max_iterations,
|
|
72
|
+
convergence_threshold: convergence_threshold,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
iterations = []
|
|
76
|
+
converged = false
|
|
77
|
+
previous_content = nil
|
|
78
|
+
|
|
79
|
+
max_iterations.times do |index|
|
|
80
|
+
prompt = index.zero? ? kickoff : iterate
|
|
81
|
+
response = @ask_callable.call(prompt)
|
|
82
|
+
|
|
83
|
+
# Handle interrupted agent (ask() returns nil)
|
|
84
|
+
break if response.nil?
|
|
85
|
+
|
|
86
|
+
current_content = response.content.to_s
|
|
87
|
+
input_tokens = response.respond_to?(:input_tokens) ? (response.input_tokens || 0) : 0
|
|
88
|
+
output_tokens = response.respond_to?(:output_tokens) ? (response.output_tokens || 0) : 0
|
|
89
|
+
|
|
90
|
+
delta_score = nil
|
|
91
|
+
if converge && previous_content && @embedder
|
|
92
|
+
delta_score = compute_delta(previous_content, current_content)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
iteration = Iteration.new(
|
|
96
|
+
number: index + 1,
|
|
97
|
+
response: response,
|
|
98
|
+
prompt: prompt,
|
|
99
|
+
tokens: { input: input_tokens, output: output_tokens },
|
|
100
|
+
delta_score: delta_score,
|
|
101
|
+
)
|
|
102
|
+
iterations << iteration
|
|
103
|
+
|
|
104
|
+
EventStream.emit(
|
|
105
|
+
type: "loop_iteration_completed",
|
|
106
|
+
agent: @agent_id,
|
|
107
|
+
iteration: index + 1,
|
|
108
|
+
delta_score: delta_score,
|
|
109
|
+
converged: false,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if delta_score && delta_score >= convergence_threshold
|
|
113
|
+
converged = true
|
|
114
|
+
break
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
previous_content = current_content
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
EventStream.emit(
|
|
121
|
+
type: "loop_completed",
|
|
122
|
+
agent: @agent_id,
|
|
123
|
+
iterations: iterations.size,
|
|
124
|
+
converged: converged,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
Result.new(iterations: iterations, converged: converged)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Compute cosine similarity between two response texts
|
|
133
|
+
#
|
|
134
|
+
# Uses batch embedding for efficiency — both texts are embedded
|
|
135
|
+
# in a single call to the underlying model.
|
|
136
|
+
#
|
|
137
|
+
# @param previous_content [String] Previous iteration's response text
|
|
138
|
+
# @param current_content [String] Current iteration's response text
|
|
139
|
+
# @return [Float] Cosine similarity (0.0..1.0 for typical text)
|
|
140
|
+
def compute_delta(previous_content, current_content)
|
|
141
|
+
vectors = @embedder.embed_batch([previous_content, current_content])
|
|
142
|
+
similarity(vectors[0], vectors[1])
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module V3
|
|
5
|
+
module Loop
|
|
6
|
+
# Immutable record of a single loop iteration
|
|
7
|
+
#
|
|
8
|
+
# Captures the prompt sent, the LLM response received, token usage,
|
|
9
|
+
# and the embedding-similarity delta from the previous iteration.
|
|
10
|
+
# Frozen on creation to prevent accidental mutation.
|
|
11
|
+
#
|
|
12
|
+
# @example First iteration (no delta)
|
|
13
|
+
# iteration = Iteration.new(
|
|
14
|
+
# number: 1,
|
|
15
|
+
# response: response,
|
|
16
|
+
# prompt: "Write a poem",
|
|
17
|
+
# tokens: { input: 10, output: 20 },
|
|
18
|
+
# delta_score: nil,
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @example Subsequent iteration with convergence score
|
|
22
|
+
# iteration = Iteration.new(
|
|
23
|
+
# number: 2,
|
|
24
|
+
# response: response,
|
|
25
|
+
# prompt: "Improve the poem",
|
|
26
|
+
# tokens: { input: 15, output: 25 },
|
|
27
|
+
# delta_score: 0.87,
|
|
28
|
+
# )
|
|
29
|
+
class Iteration
|
|
30
|
+
# @return [Integer] 1-indexed iteration number
|
|
31
|
+
attr_reader :number
|
|
32
|
+
|
|
33
|
+
# @return [RubyLLM::Message] LLM response from ask()
|
|
34
|
+
attr_reader :response
|
|
35
|
+
|
|
36
|
+
# @return [String] Prompt used for this iteration (kickoff or iterate)
|
|
37
|
+
attr_reader :prompt
|
|
38
|
+
|
|
39
|
+
# @return [Hash{Symbol => Integer}] Token usage { input:, output: }
|
|
40
|
+
attr_reader :tokens
|
|
41
|
+
|
|
42
|
+
# @return [Float, nil] Cosine similarity to previous iteration (nil for iteration 1)
|
|
43
|
+
attr_reader :delta_score
|
|
44
|
+
|
|
45
|
+
# Create a new Iteration record
|
|
46
|
+
#
|
|
47
|
+
# @param number [Integer] 1-indexed iteration number
|
|
48
|
+
# @param response [RubyLLM::Message] LLM response
|
|
49
|
+
# @param prompt [String] Prompt used
|
|
50
|
+
# @param tokens [Hash{Symbol => Integer}] Token usage { input:, output: }
|
|
51
|
+
# @param delta_score [Float, nil] Similarity to previous iteration
|
|
52
|
+
def initialize(number:, response:, prompt:, tokens:, delta_score:)
|
|
53
|
+
@number = number
|
|
54
|
+
@response = response
|
|
55
|
+
@prompt = prompt
|
|
56
|
+
@tokens = tokens.freeze
|
|
57
|
+
@delta_score = delta_score
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module V3
|
|
5
|
+
module Loop
|
|
6
|
+
# Aggregate result of a completed loop execution
|
|
7
|
+
#
|
|
8
|
+
# Contains all iterations and whether the loop converged.
|
|
9
|
+
# Frozen on creation — both the Result and its iterations array
|
|
10
|
+
# are immutable after construction.
|
|
11
|
+
#
|
|
12
|
+
# @example Converged loop
|
|
13
|
+
# result = executor.run(kickoff: "Write a poem", iterate: "Improve it", ...)
|
|
14
|
+
# result.converged? #=> true
|
|
15
|
+
# result.iteration_count #=> 3
|
|
16
|
+
# result.final_response #=> RubyLLM::Message
|
|
17
|
+
# result.total_tokens #=> { input: 45, output: 60 }
|
|
18
|
+
#
|
|
19
|
+
# @example Non-converged loop (hit max_iterations)
|
|
20
|
+
# result.converged? #=> false
|
|
21
|
+
# result.iteration_count #=> 10
|
|
22
|
+
class Result
|
|
23
|
+
# @return [Array<Iteration>] All iterations (frozen)
|
|
24
|
+
attr_reader :iterations
|
|
25
|
+
|
|
26
|
+
# @return [Boolean] Whether the loop converged below the threshold
|
|
27
|
+
attr_reader :converged
|
|
28
|
+
alias_method :converged?, :converged
|
|
29
|
+
|
|
30
|
+
# Create a new Result
|
|
31
|
+
#
|
|
32
|
+
# @param iterations [Array<Iteration>] Completed iterations
|
|
33
|
+
# @param converged [Boolean] Whether convergence was detected
|
|
34
|
+
def initialize(iterations:, converged:)
|
|
35
|
+
@iterations = iterations.freeze
|
|
36
|
+
@converged = converged
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The last iteration's LLM response
|
|
41
|
+
#
|
|
42
|
+
# @return [RubyLLM::Message, nil] Final response, or nil if no iterations
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# puts result.final_response.content
|
|
46
|
+
def final_response
|
|
47
|
+
@iterations.last&.response
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Number of iterations executed
|
|
51
|
+
#
|
|
52
|
+
# @return [Integer]
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# result.iteration_count #=> 3
|
|
56
|
+
def iteration_count
|
|
57
|
+
@iterations.size
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Aggregate token usage across all iterations
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash{Symbol => Integer}] { input:, output: }
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# result.total_tokens #=> { input: 45, output: 60 }
|
|
66
|
+
def total_tokens
|
|
67
|
+
input = @iterations.sum { |i| i.tokens[:input] }
|
|
68
|
+
output = @iterations.sum { |i| i.tokens[:output] }
|
|
69
|
+
{ input: input, output: output }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: swarm_sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.0.0.
|
|
4
|
+
version: 3.0.0.alpha3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paulo Arruda
|
|
@@ -139,6 +139,9 @@ files:
|
|
|
139
139
|
- lib/swarm_sdk/v3/hooks/context.rb
|
|
140
140
|
- lib/swarm_sdk/v3/hooks/result.rb
|
|
141
141
|
- lib/swarm_sdk/v3/hooks/runner.rb
|
|
142
|
+
- lib/swarm_sdk/v3/loop/executor.rb
|
|
143
|
+
- lib/swarm_sdk/v3/loop/iteration.rb
|
|
144
|
+
- lib/swarm_sdk/v3/loop/result.rb
|
|
142
145
|
- lib/swarm_sdk/v3/mcp/connector.rb
|
|
143
146
|
- lib/swarm_sdk/v3/mcp/mcp_error.rb
|
|
144
147
|
- lib/swarm_sdk/v3/mcp/server_definition.rb
|