smith-agents 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c3bc332fe08b2ff077b3039603ddc8356ac65d5034f47abcf4253c3e167f8c6
4
- data.tar.gz: 9eaf0e8548b473da25838206b63e2a5639d7ee4391f7cb6e30980a5b5aa46e67
3
+ metadata.gz: d24b9d2b16aa4bc15335adf1ac519a2b1979306e78c5adbd9d79d7c4c37b4ae1
4
+ data.tar.gz: f89dc27bdede7609559dd8b943c6f8a8ca430d8b65384cb2bee098589d9389b5
5
5
  SHA512:
6
- metadata.gz: 1ff7f020acb2efd11f7143a074d3c65093137be5bc2646e10a668e1e5242b37255db15909f0c99aa3b6073ad73e31a2557f61fa3d2a35d2de716ba122d7deb4e
7
- data.tar.gz: fab4fd19cd33c21b6800a8d75740bc62533aa4100a2d9ae5e26bad31ad5a1fa57b4869cce9efc9a351b41366ee5ee5a8cb24c0d21dc8369effdbe5b8fd8e703c
6
+ metadata.gz: 568b8f7e26296999a607319f95cebd0f6eab757a6bc109449b4a1e53738f74d57bbad20c13bfe02949204ea3e428ce19135b0713b8cb57d0f15c2f4806ec13ec
7
+ data.tar.gz: 403c1afd783f9a811b8c9a3e1f8ecadef3ab340b4250f75e73ee596104ea5fa9246bdcb964ca01f4ce55fc9b4861a59d528a25625e37b23d43834248024c4602
data/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ All notable changes to Smith are documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Smith is pre-1.0 and under active development; expect occasional contract tightening between minor versions until 1.0.
6
6
 
7
+ ## [0.14.0] - 2026-11-09
8
+
9
+ ### Added
10
+
11
+ - `optimize` takes `mode:` (`:sync`, `:batch`, or a callable of the context
12
+ and attempt) and `on_request_change:`, as `execute` and `fan_out` do. In
13
+ batch mode each call of the loop, a round's generator and then its
14
+ evaluator, is a batch request of its own, queued under a wait of its own,
15
+ and the run waits for its result without holding a worker; the pending
16
+ batch keeps the loop's `progress` (the round, the call it waits for, the
17
+ candidate, feedback and last score, and the verdicts so far, as JSON gives
18
+ them). A delivery runs the step again at its attempt and the loop goes on
19
+ from that progress: the call it waited for takes its result, nothing
20
+ before it runs again, and the next call is batched in turn, so each call is
21
+ made, charged and held on a budget ledger once. The mode callable is asked
22
+ at each call. A failed or missing result fails its call, and `retry_on`
23
+ runs the step again with its loop afresh, as any retry of an optimize step
24
+ does, making and paying for the earlier rounds' calls again. Progress that
25
+ is not JSON, or that would take a stored run past `persistence_max_bytes`,
26
+ fails the step before anything is queued. Restore checks the progress (its
27
+ round, call, last score and kept verdicts) and that it is the progress of
28
+ the call the run's wait was made for, and refuses any other with
29
+ `Smith::SerializationError`; lowering `max_rounds` or dropping batch mode
30
+ therefore strands runs waiting under the old setting. Runtime readiness
31
+ checks the generator and the evaluator for a batch interface.
32
+
33
+ ### Changed
34
+
35
+ - A batch wait's results settle their holds on a host's budget ledger before
36
+ the step runs again, so the request the step holds next (a retry's, or an
37
+ optimize step's next call) never overlaps them; a ledger with room for
38
+ one hold no longer refuses that request. Restore refuses a pending batch
39
+ whose attempt is not the one its wait's token was made for
40
+ (`Smith::SerializationError`); a run stored by Smith 0.13 restores as it
41
+ is.
42
+
43
+ ### Upgrading
44
+
45
+ - Restore checks that a stored batch wait's pending batch is the one its
46
+ token was made for (its attempt, and an optimize step's round and call).
47
+ Runs stored by Smith 0.13 pass; a pending batch edited by hand, or moved
48
+ to another attempt, is refused with `Smith::SerializationError`.
49
+ - A host ledger now sees a delivered result's hold settled before the
50
+ step's next hold is taken; a ledger that counted both at once no longer
51
+ needs room for them together.
52
+ - While runs wait on an optimize step's batch, keep its `max_rounds` and its
53
+ batch mode as they were: restore refuses progress the step could not go
54
+ on from, so lowering one or dropping the other strands those runs until
55
+ their waits have drained.
56
+ - `Smith::EXECUTION_SEMANTICS_VERSION` stays `"7"`: a workflow that does not
57
+ declare batch mode on an optimize step runs and stores as before.
58
+
7
59
  ## [0.13.1] - 2026-11-08
8
60
 
9
61
  ### Changed
data/README.md CHANGED
@@ -30,7 +30,7 @@ What you get:
30
30
 
31
31
  ```ruby
32
32
  # Gemfile
33
- gem "smith-agents", "0.13.1", require: "smith"
33
+ gem "smith-agents", "0.14.0", require: "smith"
34
34
  ```
35
35
 
36
36
  In a Rails app:
@@ -206,7 +206,8 @@ anything is sent, and a step can wait for budget instead.
206
206
  register as the model writes it. Treat it as a draft: Smith checks the reply
207
207
  only once it is complete. `execute :triager, mode: :batch` sends the request
208
208
  through Anthropic's batch API at half price; the run waits, and your app
209
- delivers the result when the batch ends.
209
+ delivers the result when the batch ends. `fan_out` and `optimize` take the same
210
+ `mode:`; an `optimize` step batches each call of its loop in turn.
210
211
  [`docs/AGENTS.md`](docs/AGENTS.md)
211
212
 
212
213
  ## Untrusted content
data/docs/AGENTS.md CHANGED
@@ -219,7 +219,7 @@ end
219
219
  ```
220
220
 
221
221
  `mode:` is `:sync` (the default), `:batch`, or a callable of the context and
222
- the step attempt returning one, on `execute` and `fan_out`; a batch step
222
+ the step attempt returning one, on `execute`, `fan_out` and `optimize`; a batch step
223
223
  declares `on_failure`, and cannot stream or be `parallel:`. Smith prepares
224
224
  the call as a synchronous one, admits it against the budget, and hands the
225
225
  exact request (the provider payload and the call's own headers, never the
@@ -253,3 +253,34 @@ built in, and a batch request is one turn, so a batching agent has no tools;
253
253
  runtime readiness reports a missing queue (`:batch_queue_missing`) and an
254
254
  agent that cannot batch (`:batch_unavailable`), and graph inspection shows the
255
255
  mode.
256
+
257
+ An `optimize` step in batch mode sends each call of its loop as a batch
258
+ request of its own, a round's generator and then its evaluator, since the
259
+ evaluator reads what the generator wrote: a round is two waits. Each call is
260
+ queued under a wait of its own (its round and call tell the token apart), and
261
+ the run's pending batch keeps the loop's progress (`progress`): the round, the
262
+ call it waits for, the candidate, the feedback and last score, and the
263
+ verdicts so far, as JSON gives them. A candidate or feedback that is not JSON
264
+ fails the step before anything is queued, as does progress that would take a
265
+ stored run past `persistence_max_bytes` (`Smith::PersistedStateTooLarge`). A
266
+ delivery settles its result's hold, then runs the step again at its attempt,
267
+ and the loop goes on from that progress: the call it waited for takes its
268
+ result, nothing before it runs again (`before_eval` included), and the next
269
+ call is batched in turn, so each call is made, charged and held on a budget
270
+ ledger once. The mode callable is asked at each call; a call it gives `:sync`
271
+ goes over the wire and the loop goes on.
272
+
273
+ A failed or missing result fails its call as on any batch step, and
274
+ `retry_on` (in process, as an optimize step takes no durable retry) runs the
275
+ step again: as on any retry of an optimize step, the loop starts afresh, so
276
+ the new attempt makes, and pays for, the earlier rounds' calls again, under
277
+ the mode the callable gives it (`attempt == 1 ? :batch : :sync` sends the
278
+ whole loop over the wire). A delivery that crashes leaves the stored wait as
279
+ it was: in lax mode the host delivers the results again and the loop goes on
280
+ as it would have; in strict mode the delivery was stored as a step under way,
281
+ so the run raises `Smith::StepInProgressOnRestore`, as any batch step's does,
282
+ and the host decides. Restore refuses progress the step could not go on from
283
+ (a round past `max_rounds`, or a call the run's wait was not made for) with
284
+ `Smith::SerializationError`, so lowering `max_rounds`, or dropping batch mode,
285
+ strands the runs waiting under the old setting: keep both until those waits
286
+ have drained.
data/docs/PATTERNS.md CHANGED
@@ -403,6 +403,10 @@ value, including a Hash with `accept: true`, is ignored and the evaluator
403
403
  judges as before. A deterministic check that makes a candidate unacceptable
404
404
  can reject it this way without spending an evaluator call.
405
405
 
406
+ `mode: :batch` (or a callable of the context and attempt) sends each call of
407
+ the loop through the provider's batch API, the run waiting for each result
408
+ and going on from where the loop stood: docs/AGENTS.md, Batch mode.
409
+
406
410
  Every round's verdict is an output. Once its loop has run, the step's record
407
411
  carries its verdicts under `:evaluations`, in attempt and round order, whether
408
412
  the step completed or failed (a step that failed before its loop, or any other
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.13.1"
4
+ VERSION = "0.14.0"
5
5
  EXECUTION_SEMANTICS_VERSION = "7"
6
6
  end
@@ -16,17 +16,21 @@ module Smith
16
16
  Thread.current[:smith_call_reservation] = ledger.reserve_spent!(estimates)
17
17
  end
18
18
 
19
- def charge_untaken_batch_results(transition)
19
+ def charge_untaken_batch_results(transition, delivered)
20
20
  @batch_delivery.untaken.each do |branch_key, entry|
21
21
  next unless entry.result["type"] == "succeeded"
22
22
 
23
23
  Attribution.with(branch_key:) do
24
- charge_batch_result(batch_agent_class(transition, branch_key), entry.result.fetch("message"))
24
+ charge_batch_result(batch_agent_class(transition, branch_key, delivered), entry.result.fetch("message"))
25
25
  end
26
26
  end
27
27
  end
28
28
 
29
- def batch_agent_class(transition, branch_key)
29
+ # The agent whose call a result answers: a fan-out branch's, an
30
+ # optimize step's generator or evaluator (the call its pending batch
31
+ # `delivered` waited for), or the step's own.
32
+ def batch_agent_class(transition, branch_key, delivered)
33
+ return optimizer_batch_agent(transition, delivered.fetch("progress")) if transition.optimized?
30
34
  return resolve_agent_class(transition) unless branch_key
31
35
 
32
36
  resolve_fanout_agent_class(transition, transition.fanout_config.fetch(:branches).fetch(branch_key))
@@ -17,15 +17,19 @@ module Smith
17
17
  module BatchExecution
18
18
  # Batches end within 24 hours; the wait allows one more.
19
19
  WAIT_SECONDS = 25 * 3600
20
- # The requests an attempt captured, for the step to queue.
21
- Captured = Data.define(:token, :requests, :attempt, :key_generation)
20
+ # The requests an attempt captured, for the step to queue, and an
21
+ # optimize step's `progress` (OptimizerBatch).
22
+ Captured = Data.define(:token, :requests, :attempt, :key_generation, :progress) do
23
+ def initialize(progress: nil, **) = super
24
+ end
22
25
 
23
26
  private
24
27
 
25
28
  # The attempt sends its requests as a batch: its mode says so and no
26
- # result of it is being delivered.
29
+ # result of it is being delivered. An optimize step batches each of
30
+ # its loop's calls instead (OptimizerBatch).
27
31
  def batch_send?(transition)
28
- transition.batches? && !delivering_batch? && batch_mode_for(transition) == :batch
32
+ transition.batches? && !transition.optimized? && !delivering_batch? && batch_mode_for(transition) == :batch
29
33
  end
30
34
 
31
35
  # A delivery runs only the stored batch wait's step (BatchWait).
@@ -45,10 +49,11 @@ module Smith
45
49
 
46
50
  # An agent step's batch: its one call.
47
51
  def send_agent_batch(transition, agent_class, prepared_input)
48
- send_batch(transition, nil => lambda do
52
+ capture = lambda do
49
53
  admit_batch_call { reserve_for_serial(transition, effective_call_ledger) }
50
54
  with_agent_context(agent_class) { capture_batch_request(agent_class, prepared_input) }
51
- end)
55
+ end
56
+ send_batch(transition, { nil => capture })
52
57
  end
53
58
 
54
59
  # A fan-out's batch: each branch's call, captured in its own branch
@@ -82,11 +87,11 @@ module Smith
82
87
 
83
88
  # The request the agent's call would send (BatchExchange throws it out
84
89
  # of the call before anything is sent), and its hold.
85
- def capture_batch_request(agent_class, prepared_input)
90
+ def capture_batch_request(agent_class, prepared_input, **call)
86
91
  @batch_capture = nil
87
92
  request = catch(Providers::BatchExchange::CAPTURED) do
88
93
  @batch_capturing = true
89
- invoke_agent(agent_class, prepared_input)
94
+ invoke_agent(agent_class, prepared_input, **call)
90
95
  nil
91
96
  ensure
92
97
  @batch_capturing = false
@@ -94,13 +99,15 @@ module Smith
94
99
  [request || raise(BatchModeUnavailable, "a batch step's agent made no provider request"), @batch_capture.hold]
95
100
  end
96
101
 
97
- def send_batch(transition, captures)
102
+ # `position` tells apart the waits of one attempt (an optimize step's
103
+ # calls), `progress` is its loop's.
104
+ def send_batch(transition, captures, position: [], progress: nil)
98
105
  refuse_batch_wait!
99
- token = wait_token_for(transition, ["batch", @step_attempt || 1])
106
+ token = wait_token_for(transition, ["batch", @step_attempt || 1, *position])
100
107
  captured = captures.transform_values(&:call)
101
108
  hold_batch!(token, captured.transform_values(&:last))
102
109
  Captured.new(token:, requests: captured.transform_values(&:first), attempt: @step_attempt || 1,
103
- key_generation: @step_key_generation || 0)
110
+ key_generation: @step_key_generation || 0, progress:)
104
111
  end
105
112
 
106
113
  # The run can wait for a batch: a queue takes its requests, it is not
@@ -141,8 +148,11 @@ module Smith
141
148
  end
142
149
 
143
150
  def pending_batch_record(transition, captured, ids)
144
- { "transition" => transition.name.to_s, "attempt" => captured.attempt,
145
- "key_generation" => captured.key_generation, "requests" => ids.transform_values { Batch.digest(_1) } }.freeze
151
+ record = { "transition" => transition.name.to_s, "attempt" => captured.attempt,
152
+ "key_generation" => captured.key_generation,
153
+ "requests" => ids.transform_values { Batch.digest(_1) } }
154
+ record["progress"] = captured.progress if captured.progress
155
+ record.freeze
146
156
  end
147
157
  end
148
158
  end
@@ -40,12 +40,13 @@ module Smith
40
40
  # Each result settles the hold its request took on a host's budget
41
41
  # ledger (BatchExecution), taken by a call or not: a succeeded one
42
42
  # reconciles what it cost, any other (or a wait that expired) releases.
43
- def settle_batch_holds(transition, token, entries)
43
+ # `delivered` is the pending batch the results answer.
44
+ def settle_batch_holds(transition, token, entries, delivered)
44
45
  ledger = settling_ledger
45
46
  return unless ledger
46
47
 
47
48
  entries.each do |branch_key, entry|
48
- spent = batch_spent(transition, branch_key, entry.result)
49
+ spent = batch_spent(transition, branch_key, entry.result, delivered)
49
50
  Budget::HostLedger.settle(ledger, Batch.custom_id(token, branch_key), spent)
50
51
  end
51
52
  end
@@ -60,12 +61,12 @@ module Smith
60
61
  nil
61
62
  end
62
63
 
63
- def batch_spent(transition, branch_key, result)
64
+ def batch_spent(transition, branch_key, result, delivered)
64
65
  return unless result["type"] == "succeeded"
65
66
 
66
67
  message = result.fetch("message")
67
68
  usage = batch_reply_usage(message)
68
- model = batch_model_id(batch_agent_class(transition, branch_key), message)
69
+ model = batch_model_id(batch_agent_class(transition, branch_key, delivered), message)
69
70
  Budget::HostLedger.amount(usage, model:, provider: "anthropic", mode: :batch) if usage
70
71
  end
71
72
  end
@@ -16,8 +16,10 @@ module Smith
16
16
  # by a sibling's failure) is charged all the same (BatchCharges).
17
17
  module BatchWait
18
18
  PENDING_BATCH_FIELDS = %w[attempt key_generation requests transition].freeze
19
+ # An optimize step's pending batch also keeps its loop's progress.
20
+ PENDING_PROGRESS_FIELDS = (PENDING_BATCH_FIELDS + %w[progress]).sort.freeze
19
21
  DIGEST = /\A\h{64}\z/
20
- private_constant :PENDING_BATCH_FIELDS, :DIGEST
22
+ private_constant :PENDING_BATCH_FIELDS, :PENDING_PROGRESS_FIELDS, :DIGEST
21
23
 
22
24
  private
23
25
 
@@ -55,17 +57,19 @@ module Smith
55
57
 
56
58
  # The step runs again at the batch's attempt, each call taking its
57
59
  # result. It may enter another wait (a batch sent again, or a durable
58
- # retry's backoff); otherwise it counts. A completion that raises
59
- # leaves the run waiting.
60
+ # retry's backoff); otherwise it counts. The results settle their
61
+ # holds first, so a request the step holds next never overlaps them. A
62
+ # completion that raises leaves the run waiting.
60
63
  def complete_batch_wait(transition, entries, done)
61
64
  record = @awaiting
65
+ delivered = @pending_batch
62
66
  @awaiting = nil
63
67
  @batch_delivery = Batch::Delivery.new(entries, on_change: transition.on_request_change || :keep)
68
+ settle_batch_holds(transition, record.fetch("token"), entries, delivered)
64
69
  step = with_step_context(transition) do
65
70
  Execution.instance_method(:execute_step_body).bind_call(self, transition)
66
71
  ensure
67
- settle_batch_holds(transition, record.fetch("token"), entries)
68
- charge_untaken_batch_results(transition)
72
+ charge_untaken_batch_results(transition, delivered)
69
73
  end
70
74
  settle_batch_wait(step)
71
75
  return done if wait_pending?(step)
@@ -99,26 +103,49 @@ module Smith
99
103
  # A persisted pending batch names the stored batch wait's transition
100
104
  # (which WaitRecord checked is still a batch step), an attempt its
101
105
  # retry policy allows, a key generation only a declared
102
- # `new_idempotency_key_on` moves, and the digest of each request the
103
- # step's branches send; a stored batch wait needs one. Anything else
104
- # raises SerializationError.
106
+ # `new_idempotency_key_on` moves, the digest of each request the
107
+ # step's branches send, and for an optimize step its loop's progress
108
+ # (OptimizerBatch); a stored batch wait needs one, the one its token
109
+ # was made for. Anything else raises SerializationError.
105
110
  def restore_pending_batch(value)
106
111
  return if value.nil? && !batch_waiting?
107
112
 
108
113
  record = value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
114
+ check_pending_batch!(record)
115
+ record.transform_values(&:freeze).freeze
116
+ end
117
+
118
+ def check_pending_batch!(record)
109
119
  raise Smith::SerializationError, "persisted workflow pending_batch is invalid" unless
110
120
  well_formed_pending_batch?(record)
121
+ return if batch_wait_made_for?(record)
111
122
 
112
- record.transform_values(&:freeze).freeze
123
+ raise Smith::SerializationError, "persisted workflow pending_batch is not the one its wait was made for"
124
+ end
125
+
126
+ # The wait's token is the one the step makes for the record's attempt
127
+ # (and an optimize step's round and call), so no stored record moves a
128
+ # wait to another attempt or call.
129
+ def batch_wait_made_for?(record)
130
+ transition = self.class.transition_named(@awaiting["transition"])
131
+ position = transition.optimized? ? record["progress"].values_at("round", "role") : []
132
+ wait_token_for(transition, ["batch", record["attempt"], *position]) == @awaiting["token"]
113
133
  end
114
134
 
115
135
  def well_formed_pending_batch?(record)
116
136
  transition = self.class.transition_named(@awaiting["transition"]) if batch_waiting?
117
- transition && record.keys.sort == PENDING_BATCH_FIELDS && record["transition"] == transition.name.to_s &&
118
- allowed_batch_position?(transition, *record.values_at("attempt", "key_generation")) &&
119
- batch_digests?(transition, record["requests"])
137
+ transition && record.keys.sort == pending_batch_fields(transition) &&
138
+ record["transition"] == transition.name.to_s && pending_batch_parts?(transition, record)
139
+ end
140
+
141
+ def pending_batch_parts?(transition, record)
142
+ allowed_batch_position?(transition, *record.values_at("attempt", "key_generation")) &&
143
+ batch_digests?(transition, record["requests"]) &&
144
+ (!transition.optimized? || optimizer_progress?(transition, record["progress"], record["attempt"]))
120
145
  end
121
146
 
147
+ def pending_batch_fields(transition) = transition.optimized? ? PENDING_PROGRESS_FIELDS : PENDING_BATCH_FIELDS
148
+
122
149
  def allowed_batch_position?(transition, attempt, generation)
123
150
  config = transition.retry_config
124
151
  attempt.is_a?(Integer) && attempt.between?(1, config&.fetch(:attempts) || 1) && generation.is_a?(Integer) &&
@@ -5,10 +5,13 @@ require "json"
5
5
  require_relative "agent_result"
6
6
  require_relative "execution_result_snapshot"
7
7
  require_relative "optimization_state"
8
+ require_relative "optimizer_batch"
8
9
 
9
10
  module Smith
10
11
  class Workflow
11
12
  module EvaluatorOptimizer
13
+ include OptimizerBatch
14
+
12
15
  private
13
16
 
14
17
  def execute_optimization_step(transition, prepared_input: nil)
@@ -26,15 +29,21 @@ module Smith
26
29
  role: :evaluator
27
30
  )
28
31
  pend_evaluations
29
- run_optimization_loop(state)
32
+ run_optimization_loop(state, transition)
30
33
  end
31
34
 
32
- def run_optimization_loop(state)
33
- state.config[:max_rounds].times do |round|
35
+ # From round 0, or from the round and call a batch delivery goes on
36
+ # from (OptimizerBatch).
37
+ def run_optimization_loop(state, transition)
38
+ first, call = resume_optimizer_progress(state)
39
+ ((first || 0)...state.config[:max_rounds]).each do |round|
40
+ at_evaluator = round == first && call == "evaluator"
34
41
  # The overlay scopes every trace and usage fact from this round's
35
42
  # generator and evaluator calls to the round that produced them.
36
43
  result = Attribution.with(round: round) do
37
- IdempotencyKey.__send__(:within, :round, round) { run_optimization_round(state, round) }
44
+ IdempotencyKey.__send__(:within, :round, round) do
45
+ run_optimization_round(state, round, transition, at_evaluator:)
46
+ end
38
47
  end
39
48
  return result if result
40
49
  end
@@ -44,12 +53,13 @@ module Smith
44
53
  end
45
54
 
46
55
  # Each role is a position of its own in the round, so the generator,
47
- # the before_eval callback and the evaluator never share a key.
48
- def run_optimization_round(state, round)
49
- IdempotencyKey.__send__(:within, :generator) { generate_candidate!(state, round) }
50
- rejection = IdempotencyKey.__send__(:within, :before_eval) { before_eval_rejection(state) }
51
- evaluation = rejection ||
52
- normalize_evaluation(IdempotencyKey.__send__(:within, :evaluator) { evaluate_candidate(state) })
56
+ # the before_eval callback and the evaluator never share a key. A
57
+ # round a delivery goes on from at its evaluator has its candidate.
58
+ def run_optimization_round(state, round, transition, at_evaluator: false)
59
+ rejection = generate_and_screen(state, round, transition) unless at_evaluator
60
+ evaluation = rejection || normalize_evaluation(
61
+ IdempotencyKey.__send__(:within, :evaluator) { evaluate_candidate(state, round, transition) }
62
+ )
53
63
  validate_evaluation_structure!(evaluation)
54
64
  validate_evaluation_fields!(evaluation, state.config)
55
65
  record_evaluation(state, evaluation, round, rejection ? :before_eval : :evaluator)
@@ -201,15 +211,19 @@ module Smith
201
211
  end
202
212
  end
203
213
 
204
- def generate_candidate!(state, round)
214
+ # The round's candidate, and a before_eval rejection of it, if any.
215
+ def generate_and_screen(state, round, transition)
216
+ IdempotencyKey.__send__(:within, :generator) { generate_candidate!(state, round, transition) }
217
+ IdempotencyKey.__send__(:within, :before_eval) { before_eval_rejection(state) }
218
+ end
219
+
220
+ def generate_candidate!(state, round, transition)
205
221
  input = prepare_generator_input(state.prepared_input, round, state.candidate, state.feedback)
206
- result = invoke_agent_with_budget(state.generator_class, input)
207
- state.candidate = result
222
+ state.candidate = optimizer_call(transition, state, round, "generator", input)
208
223
  end
209
224
 
210
- def evaluate_candidate(state)
211
- input = build_evaluator_input(state)
212
- invoke_with_evaluator_schema(state.evaluator_class, state.config[:evaluator_schema], input)
225
+ def evaluate_candidate(state, round, transition)
226
+ optimizer_call(transition, state, round, "evaluator", build_evaluator_input(state))
213
227
  end
214
228
 
215
229
  # evaluator_context: :inject_state appends the candidate as a
@@ -248,10 +262,6 @@ module Smith
248
262
  evaluation if evaluation.key?(:accept) && evaluation[:accept].equal?(false)
249
263
  end
250
264
 
251
- def invoke_with_evaluator_schema(evaluator_class, schema, input)
252
- invoke_agent_with_budget(evaluator_class, input, output_schema: schema)
253
- end
254
-
255
265
  def invoke_agent_with_budget(agent_class, prepared_input, output_schema: agent_class.output_schema)
256
266
  Thread.current[:smith_last_agent_result] = nil
257
267
  clear_failed_billable_attempts
@@ -76,7 +76,7 @@ module Smith
76
76
  prepared_input = step_prepared_input(transition)
77
77
  return send_agent_batch(transition, agent_class, prepared_input) if batch_send?(transition)
78
78
 
79
- guarded_agent_output(transition, agent_class, prepared_input)
79
+ optimizer_batch_output(transition) { guarded_agent_output(transition, agent_class, prepared_input) }
80
80
  end
81
81
 
82
82
  def guarded_agent_output(transition, agent_class, prepared_input)
@@ -27,6 +27,7 @@ module Smith
27
27
 
28
28
  def agents(transition)
29
29
  return transition.fanout_config.fetch(:branches).values if transition.fanout?
30
+ return transition.optimization_config.values_at(:generator, :evaluator) if transition.optimized?
30
31
 
31
32
  [transition.agent_name]
32
33
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Smith
6
+ class Workflow
7
+ # Batch mode on an optimize step (`optimize ..., mode: :batch`). Each of
8
+ # the loop's calls, a round's generator and then its evaluator, is a
9
+ # batch request of its own, as the evaluator reads what the generator
10
+ # wrote. The call is captured as an agent step's is (BatchExecution), the
11
+ # attempt ends, and the run waits for the result, its pending batch
12
+ # keeping the loop's progress (`progress`: the round, the call it waits for,
13
+ # the candidate, feedback and last score, and the verdicts so far, as
14
+ # JSON gives them). A delivery runs the step again at its attempt, and
15
+ # the loop goes on from that progress: the call it waited for takes its
16
+ # result, and the next call is batched in turn, so each call is made,
17
+ # and charged, once. The step's mode is asked at each call.
18
+ module OptimizerBatch
19
+ ROLES = %w[generator evaluator].freeze
20
+ PROGRESS_FIELDS = %w[candidate evaluations feedback last_score role round].freeze
21
+ RECORD_FIELDS = %w[round source verdict].freeze
22
+ SOURCES = %w[evaluator before_eval].freeze
23
+ ATTEMPT = /\A[1-9]\d*\z/
24
+ CAPTURED = :smith_optimizer_batch
25
+ private_constant :ROLES, :PROGRESS_FIELDS, :RECORD_FIELDS, :SOURCES, :ATTEMPT, :CAPTURED
26
+
27
+ private
28
+
29
+ # The step's output, or the call its loop batched, thrown out of the
30
+ # loop for the step to queue.
31
+ def optimizer_batch_output(transition, &)
32
+ return yield unless transition.optimized? && transition.batches?
33
+
34
+ catch(CAPTURED, &)
35
+ end
36
+
37
+ # One of the loop's calls (`role` "generator" or "evaluator"): batched
38
+ # when the step's mode says so and no result of it is being
39
+ # delivered, else made as ever.
40
+ def optimizer_call(transition, state, round, role, input)
41
+ if transition.batches? && !batch_result_due? && batch_mode_for(transition) == :batch
42
+ throw(CAPTURED, batch_optimizer_call(transition, state, round, role, input))
43
+ end
44
+
45
+ agent_class, output_schema = optimizer_agent(state, role)
46
+ invoke_agent_with_budget(agent_class, input, output_schema:)
47
+ end
48
+
49
+ def optimizer_agent(state, role)
50
+ return [state.generator_class, state.generator_class.output_schema] if role == "generator"
51
+
52
+ [state.evaluator_class, state.config[:evaluator_schema]]
53
+ end
54
+
55
+ # The call's request, admitted and held as an agent step's is, under
56
+ # a wait of its own: the round and the call tell its token apart. A
57
+ # stored run whose progress would take it past persistence_max_bytes
58
+ # fails here, before anything is queued.
59
+ def batch_optimizer_call(transition, state, round, role, input)
60
+ agent_class, output_schema = optimizer_agent(state, role)
61
+ progress = optimizer_progress(state, round, role)
62
+ StepCompletion.instance_method(:check_result_storable!).bind_call(self, progress)
63
+ capture = lambda do
64
+ admit_batch_call { reserve_serial_budget(effective_call_ledger, agent_budget: agent_class&.budget) }
65
+ with_agent_context(agent_class) { capture_batch_request(agent_class, input, output_schema:) }
66
+ end
67
+ send_batch(transition, { nil => capture }, position: [round, role], progress:)
68
+ end
69
+
70
+ # What a delivery's loop goes on from, as JSON gives it.
71
+ def optimizer_progress(state, round, role)
72
+ progress = { "round" => round, "role" => role, "candidate" => state.candidate, "feedback" => state.feedback,
73
+ "last_score" => state.last_score, "evaluations" => kept_evaluations }
74
+ Batch.deep_freeze(JSON.parse(JSON.generate(progress)))
75
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
76
+ raise WorkflowError, "an optimize step waits for a batch only with JSON values for its candidate and feedback"
77
+ end
78
+
79
+ # Every attempt's verdicts so far, by attempt.
80
+ def kept_evaluations
81
+ @pending_evaluations.to_h do |attempt, records|
82
+ [attempt.to_s, records.map { |record| record.merge(source: record[:source].to_s) }]
83
+ end
84
+ end
85
+
86
+ # The round and call the loop goes on from, with its state and
87
+ # verdicts as the pending batch kept them; none unless a result of
88
+ # this step's loop is being delivered.
89
+ def resume_optimizer_progress(state)
90
+ progress = @pending_batch&.fetch("progress", nil) if delivering_batch?
91
+ return unless progress
92
+
93
+ state.candidate, state.feedback, state.last_score =
94
+ snapshot_value(progress.values_at("candidate", "feedback", "last_score"))
95
+ restore_kept_evaluations(progress.fetch("evaluations"))
96
+ state.evaluations = @pending_evaluations.values.last.dup.freeze
97
+ progress.values_at("round", "role")
98
+ end
99
+
100
+ # Kept verdicts back as the loop records them, in attempt order, the
101
+ # attempt under way last.
102
+ def restore_kept_evaluations(kept)
103
+ restored = kept.to_h do |attempt, records|
104
+ [attempt.to_i, records.map { kept_record(_1) }]
105
+ end
106
+ @pending_evaluations = @pending_evaluations.merge(restored).sort.to_h
107
+ end
108
+
109
+ def kept_record(record)
110
+ { round: record.fetch("round"), source: record.fetch("source").to_sym,
111
+ verdict: frozen_verdict(normalize_evaluation(record.fetch("verdict"))) }.freeze
112
+ end
113
+
114
+ # The agent whose call an optimize step's pending batch waited for.
115
+ def optimizer_batch_agent(transition, progress)
116
+ role = progress.fetch("role").to_sym
117
+ resolve_registered_agent!(transition.optimization_config.fetch(role), workflow_class: self.class,
118
+ transition_name: transition.name, role:)
119
+ end
120
+
121
+ # A stored pending batch's `progress` (BatchWait): a call of a
122
+ # round the step runs, a last score that is a number or none, and kept
123
+ # verdicts of attempts up to the batch's.
124
+ def optimizer_progress?(transition, progress, attempt)
125
+ progress.is_a?(Hash) && progress.keys.sort == PROGRESS_FIELDS && optimizer_position?(transition, progress) &&
126
+ (progress["last_score"].nil? || progress["last_score"].is_a?(Numeric)) &&
127
+ kept_evaluations?(progress["evaluations"], attempt)
128
+ end
129
+
130
+ def optimizer_position?(transition, progress)
131
+ round = progress["round"]
132
+ ROLES.include?(progress["role"]) && round.is_a?(Integer) &&
133
+ round.between?(0, transition.optimization_config.fetch(:max_rounds) - 1)
134
+ end
135
+
136
+ def kept_evaluations?(kept, attempt)
137
+ kept.is_a?(Hash) && kept.all? do |key, records|
138
+ key.is_a?(String) && ATTEMPT.match?(key) && key.to_i <= attempt && records.is_a?(Array) &&
139
+ records.all? { kept_record?(_1) }
140
+ end
141
+ end
142
+
143
+ def kept_record?(record)
144
+ record.is_a?(Hash) && record.keys.sort == RECORD_FIELDS && record["round"].is_a?(Integer) &&
145
+ !record["round"].negative? && SOURCES.include?(record["source"]) && record["verdict"].is_a?(Hash)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -125,6 +125,8 @@ module Smith
125
125
  commit_execution_primitive!("workflow")
126
126
  end
127
127
 
128
+ # `mode:` and `on_request_change:` as on `execute`: in batch mode each
129
+ # of the loop's calls is a batch request of its own (OptimizerBatch).
128
130
  def optimize(generator:, evaluator:, max_rounds:, evaluator_schema:,
129
131
  improvement_threshold: nil,
130
132
  evaluator_context: nil,
@@ -133,16 +135,19 @@ module Smith
133
135
  on_converged: :raise,
134
136
  on_threshold: :raise,
135
137
  input: nil,
136
- output_to: nil)
138
+ output_to: nil,
139
+ mode: nil,
140
+ on_request_change: nil)
137
141
  validate_execution_primitive_conflict!("optimize")
138
142
  assign_step_io!(input, output_to)
143
+ assign_batch_mode!(mode, on_request_change)
139
144
  validate_optimize_controls!(generator, evaluator, max_rounds, evaluator_schema)
140
145
  validate_optimize_exit_modes!(on_exhaustion: on_exhaustion, on_converged: on_converged,
141
146
  on_threshold: on_threshold)
142
147
  validate_optimize_evaluator_context!(evaluator_context)
143
148
  validate_optimize_before_eval!(before_eval)
144
149
 
145
- config = {
150
+ @optimization_config = {
146
151
  generator: normalize_agent_reference!(generator, "optimizer generator"),
147
152
  evaluator: normalize_agent_reference!(evaluator, "optimizer evaluator"),
148
153
  max_rounds: max_rounds,
@@ -153,8 +158,6 @@ module Smith
153
158
  on_converged: on_converged,
154
159
  on_threshold: on_threshold
155
160
  }.freeze
156
-
157
- @optimization_config = config
158
161
  commit_execution_primitive!("optimize")
159
162
  end
160
163
 
@@ -174,8 +177,7 @@ module Smith
174
177
  def fan_out(branches:, input: nil, output_to: nil, mode: nil, on_request_change: nil)
175
178
  validate_execution_primitive_conflict!("fan_out")
176
179
  assign_step_io!(input, output_to)
177
- @batch_mode = batch_mode!(mode, stream: nil, parallel: false)
178
- @on_request_change = request_change!(on_request_change)
180
+ assign_batch_mode!(mode, on_request_change)
179
181
 
180
182
  normalized = normalize_fanout_branches!(branches)
181
183
  @fanout_branch_lookup = normalized.to_h do |key, agent|
@@ -323,6 +325,13 @@ module Smith
323
325
  mode
324
326
  end
325
327
 
328
+ # `mode:` and `on_request_change:` of a step that has no stream and no
329
+ # parallel copies (`fan_out`, `optimize`).
330
+ def assign_batch_mode!(mode, on_request_change)
331
+ @batch_mode = batch_mode!(mode, stream: nil, parallel: false)
332
+ @on_request_change = request_change!(on_request_change)
333
+ end
334
+
326
335
  # What a batch result does when the step renders another request by
327
336
  # the time it comes: :keep it (the default), :resend the request over
328
337
  # the wire, or :fail the step with BatchRequestChanged.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -522,6 +522,7 @@ files:
522
522
  - lib/smith/workflow/message_value_normalizer.rb
523
523
  - lib/smith/workflow/nested_execution.rb
524
524
  - lib/smith/workflow/optimization_state.rb
525
+ - lib/smith/workflow/optimizer_batch.rb
525
526
  - lib/smith/workflow/orchestration_state.rb
526
527
  - lib/smith/workflow/orchestrator_worker.rb
527
528
  - lib/smith/workflow/parallel.rb