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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4feb796b3437eea5e610a13ed80923a4d2bda9841fc164d44cb330a5da20e97e
4
- data.tar.gz: 3006b77e78aedd12c2371300d6599870aef32f9bf33285c7229ce9eadd453364
3
+ metadata.gz: 569ac707625b9bcfc78080af26a7c1d48895b5057302c23f98543b9b44e6a706
4
+ data.tar.gz: ab685ff041e4106046ce4da5e4ec9b3937fd4d6290e1f5deeb7d7991f579da6b
5
5
  SHA512:
6
- metadata.gz: 87bfebe06e4c043c131a6d51d2effe8cd7de7893075593b2b008126887f5837850486c8e354f918ef2fa283c9d286638a25efc632917f0a86ab57815102333ce
7
- data.tar.gz: e2afe829f9c2394de45c07eb2b6ff692446ee2108d0b615a375447a87c797bd59b4f0745d96b1c7a8a40b7ef350e167b29c247a7a376ea366feb370ea0eaac1a
6
+ metadata.gz: fd68128f22e9758ae66548a4ae2f48b210e92b2c79f4bd78c46fd47cd1d34a9d282740ee35881c27c515ac7eb8b294db6f36c8a70e762ea01ad1d52085da0b5b
7
+ data.tar.gz: d802eb7a4112874bf7b2ad414564af4fb9806074b8d8d108a471efc33943133942ee646360c7f4ce8390728d44cc031cede119fca993a79a8c6f1e6a7a6c7ee1
@@ -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.alpha2
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