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 +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +3 -2
- data/docs/AGENTS.md +32 -1
- data/docs/PATTERNS.md +4 -0
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/batch_charges.rb +7 -3
- data/lib/smith/workflow/batch_execution.rb +23 -13
- data/lib/smith/workflow/batch_holds.rb +5 -4
- data/lib/smith/workflow/batch_wait.rb +39 -12
- data/lib/smith/workflow/evaluator_optimizer.rb +30 -20
- data/lib/smith/workflow/execution.rb +1 -1
- data/lib/smith/workflow/graph/batch_diagnostic.rb +1 -0
- data/lib/smith/workflow/optimizer_batch.rb +149 -0
- data/lib/smith/workflow/transition.rb +15 -6
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d24b9d2b16aa4bc15335adf1ac519a2b1979306e78c5adbd9d79d7c4c37b4ae1
|
|
4
|
+
data.tar.gz: f89dc27bdede7609559dd8b943c6f8a8ca430d8b65384cb2bee098589d9389b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 `
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
59
|
-
#
|
|
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
|
-
|
|
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,
|
|
103
|
-
# step's branches send
|
|
104
|
-
#
|
|
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
|
-
|
|
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 ==
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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)
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
rejection =
|
|
51
|
-
evaluation = rejection ||
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|