smith-agents 0.10.0 → 0.11.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 +203 -0
- data/README.md +16 -6
- data/docs/CONFIGURATION.md +21 -1
- data/docs/PATTERNS.md +59 -0
- data/docs/PERSISTENCE.md +5 -1
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/provider_attempt.rb +11 -4
- data/lib/smith/agent/provider_call_timing.rb +62 -7
- data/lib/smith/agent/provider_completion.rb +8 -8
- data/lib/smith/agent/provider_usage.rb +6 -0
- data/lib/smith/agent/usage_tracking.rb +3 -0
- data/lib/smith/agent.rb +9 -1
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/ledger.rb +6 -2
- data/lib/smith/context/state_injection.rb +12 -2
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/durability.rb +16 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/errors.rb +55 -4
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +3 -3
- data/lib/smith/workflow/deterministic_execution.rb +1 -1
- data/lib/smith/workflow/deterministic_step.rb +1 -1
- data/lib/smith/workflow/dsl.rb +15 -5
- data/lib/smith/workflow/durability.rb +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +108 -10
- data/lib/smith/workflow/execution.rb +2 -0
- data/lib/smith/workflow/failure_reconstructor.rb +18 -2
- data/lib/smith/workflow/failure_record.rb +6 -11
- data/lib/smith/workflow/failure_record_validator.rb +23 -3
- data/lib/smith/workflow/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/persistence.rb +26 -9
- data/lib/smith/workflow/retry_execution.rb +4 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/step_completion.rb +4 -0
- data/lib/smith/workflow.rb +5 -3
- data/lib/smith.rb +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3692cc78203afacc91c1ede53a150d8bc864d8be5b9f549739570b5fe823dde
|
|
4
|
+
data.tar.gz: 6f9981471f8f8ddf2c6fb4141bfe2b6d8631bd21aaf78215c4c87d311aa6d3f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 870199be5d2163608606ab5a3eacca29c967e49e3f1f3fd193d88f4bdd57e455e973f03346b2b9769387b363c77147aaeb78a614837e4a665fca1fd4cfc66dc8
|
|
7
|
+
data.tar.gz: d555df7da59578922841547c53607a81ab7262a427d80a2140e3b9072158764cc33acca058a1fb380b20242315aa5161ca8659c7a26107e35527baa9efea9391
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,209 @@ 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.11.0] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `fallback_models` accepts a context block, matching the dynamic
|
|
12
|
+
`model { |context| ... }`. `fallback_models { |context| [...] }` is evaluated
|
|
13
|
+
once per agent invocation with the workflow context, and its entries pass
|
|
14
|
+
exactly the static form's validation: every entry needs an explicit
|
|
15
|
+
provider. An unqualified or invalid entry fails the step with
|
|
16
|
+
`Smith::WorkflowError` before any provider attempt, and that error is not
|
|
17
|
+
retried: `Smith::Errors.retryable?` is false for it, so a `retry_on` without
|
|
18
|
+
error classes, `retry_on Smith::AgentError`, and host retries built on
|
|
19
|
+
`Smith::Errors.retryable_classes` all leave it failed, unlike an invalid
|
|
20
|
+
`model` block result, which raises the retryable `Smith::AgentError`. The
|
|
21
|
+
chain keeps its de-duplication. Declaring entries and a block together raises
|
|
22
|
+
`ArgumentError`; a later static declaration clears the block and a later
|
|
23
|
+
block clears the static list; subclasses inherit the block. Graph
|
|
24
|
+
inspection, runtime readiness, and the doctor model check never call the
|
|
25
|
+
block: `fallback_models` returns `nil` for the block form and
|
|
26
|
+
`fallback_models_block` exposes the block. `resolve_fallback_models(context)`
|
|
27
|
+
evaluates the block, or returns the static qualified list (empty when none
|
|
28
|
+
is declared) when no block is configured. Resolution is `O(F)` time and
|
|
29
|
+
space for `F` returned entries. The static form is unchanged.
|
|
30
|
+
- Every `:provider_call` trace carries `agent_name`, the registered name of
|
|
31
|
+
the agent that made the attempt (the value its usage entries carry), so a
|
|
32
|
+
generator and an evaluator sharing one `optimize` round, and so one
|
|
33
|
+
transition and round, stay apart; it is omitted for an unregistered agent.
|
|
34
|
+
When the provider reported usage, the trace also carries `input_tokens` and
|
|
35
|
+
`output_tokens`: the totals of the attempt's usage entries, covering a
|
|
36
|
+
successful completion, a failed attempt's completed tool-loop prefix, and
|
|
37
|
+
the usage a failed attempt's error reported. Both are omitted when the
|
|
38
|
+
provider reported none. A trace adapter can therefore record a complete
|
|
39
|
+
attempt when it ends instead of waiting for a step checkpoint. Token counts
|
|
40
|
+
are metadata, never content: `trace_content false` does not hide them.
|
|
41
|
+
Hosts with a `trace_fields` allowlist for `:provider_call` must add these
|
|
42
|
+
keys to receive them. The fields are documented in `docs/CONFIGURATION.md`.
|
|
43
|
+
- `optimize`'s `before_eval` may reject a candidate. A returned Hash whose
|
|
44
|
+
`accept` (Symbol or String key) is `false` is that round's evaluation: it is
|
|
45
|
+
normalized and validated exactly as evaluator output (`feedback` required,
|
|
46
|
+
a numeric `score` when `improvement_threshold` is set), the evaluator is not
|
|
47
|
+
called for the round, and the loop continues as a rejection, so the
|
|
48
|
+
feedback reaches the generator's refinement turn and `on_exhaustion` applies
|
|
49
|
+
when rounds run out. Any other return value, including `nil` and a Hash
|
|
50
|
+
with `accept: true`, is ignored exactly as before and the evaluator judges.
|
|
51
|
+
A rejected round records only the generator's usage and `:provider_call`.
|
|
52
|
+
Documented in the Evaluator-Optimizer section of `docs/PATTERNS.md`.
|
|
53
|
+
- `optimize`'s verdicts are an output. Each valid round's evaluation is
|
|
54
|
+
recorded as `{ round:, source:, verdict: }`: the optimizer round (the one
|
|
55
|
+
usage entries and traces carry), `:evaluator` or `:before_eval` for the one
|
|
56
|
+
that gave it, and the evaluation as normalized, every field the evaluator's
|
|
57
|
+
schema declares included, as a frozen copy. Once its loop has run, the
|
|
58
|
+
step's record carries its verdicts under `:evaluations` whether the step
|
|
59
|
+
completed or failed (a completion that fails after the loop, such as a split
|
|
60
|
+
step's snapshot, included), the verdict that ends the loop included, each
|
|
61
|
+
also naming its `attempt`, the retry policy's attempt that ran the loop: a
|
|
62
|
+
step `retry_on` runs again starts its loop afresh with rounds from 0, and the
|
|
63
|
+
earlier attempt's verdicts stay before the new ones. `RunResult#evaluations`
|
|
64
|
+
lists every optimize step's verdicts in step, attempt and round order as
|
|
65
|
+
frozen records with the step's `transition`, and
|
|
66
|
+
`OptimizationState#evaluations` gives a callable a frozen copy of its own
|
|
67
|
+
loop's verdicts so far. A step that failed before its loop, and every other
|
|
68
|
+
step, gains no key; a nested workflow's verdicts stay on its own run.
|
|
69
|
+
Verdicts are content, so no trace carries them, and like `steps` they
|
|
70
|
+
belong to the run that executed the step: nothing is persisted and a
|
|
71
|
+
terminal restore has none. Before this, a rejection's feedback lived only in
|
|
72
|
+
the loop and an accepted verdict was discarded, so a host could not see why
|
|
73
|
+
a candidate was sent back. Documented in the Evaluator-Optimizer section of
|
|
74
|
+
`docs/PATTERNS.md`.
|
|
75
|
+
- `Smith::StepInProgressOnRestore` gains `state`, the persisted state the
|
|
76
|
+
interrupted step started from (a Symbol), and `transition`, the next
|
|
77
|
+
transition the payload records (such as a routed one), else `nil`, so a
|
|
78
|
+
host can tell which step died without reading Smith's payload. Both are set
|
|
79
|
+
wherever restore raises it and default to `nil` for any other construction;
|
|
80
|
+
the constructor stays backward compatible and the message gains only
|
|
81
|
+
` state=...` when the state is known.
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- Contract tightening: an `optimize` round's verdict, now recorded as an
|
|
86
|
+
output, must hold JSON values only (Hashes with String or Symbol keys,
|
|
87
|
+
Arrays, Strings, Symbols, Integers, finite Floats, true, false, nil), and a
|
|
88
|
+
step's verdicts together must fit the limits a split step's record is held
|
|
89
|
+
to (`ExecutionResultSnapshot`'s depth, node and byte caps). A round whose
|
|
90
|
+
verdict breaks either, most likely a rejection `before_eval` returned
|
|
91
|
+
holding an object or a very large feedback, now fails the step with
|
|
92
|
+
`Smith::WorkflowError` at that round, in every run mode, keeping the
|
|
93
|
+
verdicts recorded before it. Before, such a verdict was never recorded and
|
|
94
|
+
the loop went on.
|
|
95
|
+
- **Breaking:** a deterministic step's `write_outcome` is deep-symbolized
|
|
96
|
+
when written, matching restore. A live `RunResult#outcome_payload` (and
|
|
97
|
+
`RunResult#outcome`) now has Symbol keys at every depth, so a host reading
|
|
98
|
+
String keys from a live payload must switch to Symbol keys. Restored
|
|
99
|
+
payloads were already symbolized, so an outcome now reads identically live
|
|
100
|
+
and after a JSON restore. Persisted JSON is unchanged.
|
|
101
|
+
- Contract tightening: `ActiveRecordStore` refuses to write with a non-nil
|
|
102
|
+
TTL instead of silently ignoring it, since it has no expiry column.
|
|
103
|
+
`store`, `store_versioned`, and `replace_exact` raise `ArgumentError`
|
|
104
|
+
before touching the database. Reads and deletes take no TTL, and resolving
|
|
105
|
+
the adapter never refuses, so `restore`, `persisted_state_exists?`,
|
|
106
|
+
`clear_persisted!`, `stuck_for?`, and `heartbeat_age` keep working while a
|
|
107
|
+
TTL is configured. A global or per-workflow `persistence_ttl` with this
|
|
108
|
+
adapter is refused by the initial checkpoint that `run_persisted!` and
|
|
109
|
+
`advance_persisted!` write before their first step, so no step runs before
|
|
110
|
+
the refusal. A nil TTL (the default) is unaffected. `smith doctor
|
|
111
|
+
--durability` fails a new `durability.ttl` check when the configured
|
|
112
|
+
adapter is an `ActiveRecordStore` and `Smith.config.persistence_ttl` is
|
|
113
|
+
set. The README, `docs/CONFIGURATION.md`, and the `persistence_ttl` DSL
|
|
114
|
+
comment no longer claim this adapter honours TTL.
|
|
115
|
+
- Contract tightening: workflow and agent `budget` declarations raise
|
|
116
|
+
`ArgumentError`, naming the accepted keys, when given a key Smith does not
|
|
117
|
+
read; previously such a key was accepted and ignored. Workflow budgets
|
|
118
|
+
accept `total_tokens`, `token_limit`, `total_cost`, `tool_calls`, and
|
|
119
|
+
`wall_clock`; agent budgets accept `token_limit`, `cost`, `wall_clock`,
|
|
120
|
+
`tool_calls`, `total_tokens`, and `total_cost`. `wall_clock` is in seconds:
|
|
121
|
+
the README budget example used the ignored `wall_clock_ms: 30_000` and now
|
|
122
|
+
uses `wall_clock: 30`. The check is `O(K)` time and space for `K` declared
|
|
123
|
+
keys. State written by 0.10.0 keeps restoring after the host removes such a
|
|
124
|
+
key: 0.10.0 reserved every declared key at each agent call, so it could
|
|
125
|
+
persist a consumed entry like `"wall_clock_ms" => 0`, and restore now drops
|
|
126
|
+
consumed entries (String or Symbol keys) for dimensions the workflow no
|
|
127
|
+
longer declares. An undeclared dimension has no limit, so dropping it is
|
|
128
|
+
safe. The filter is `O(D + C)` for `D` declared and `C` consumed keys.
|
|
129
|
+
|
|
130
|
+
### Fixed
|
|
131
|
+
|
|
132
|
+
- `DeterministicStep#last_output` returned `nil` after a JSON restore,
|
|
133
|
+
because a restored session message carries a Symbol key with a String role
|
|
134
|
+
value. `last_output` now accepts a Symbol or String role under either key
|
|
135
|
+
form. Restored messages keep the shape 0.10.0 gives them (Symbol keys, JSON
|
|
136
|
+
values such as `role: "assistant"`).
|
|
137
|
+
- Restored failures keep their type. `ProviderPermanentFailure`,
|
|
138
|
+
`BudgetExceeded`, and `GuardrailFailed` get their own failure families
|
|
139
|
+
(`provider_permanent_failure`, `budget_exceeded`, `guardrail_failed`) where
|
|
140
|
+
they were `other`, and restore as their own classes where they restored as
|
|
141
|
+
`RuntimeError`. `BlankAgentOutputError` keeps the `agent_error` family and
|
|
142
|
+
restores as itself where it restored as a plain `AgentError`.
|
|
143
|
+
`ProviderPermanentFailure` and `BlankAgentOutputError` gain `details` (each
|
|
144
|
+
value bounded to 512 bytes) and `from_details`, so `provider`, `model_id`,
|
|
145
|
+
`source_error_class`, `agent_name`, and `model_used` survive a restore.
|
|
146
|
+
Details stay JSON-normalized, and `FailureRecordValidator` rejects malformed
|
|
147
|
+
details, or one of these classes claiming another family, with
|
|
148
|
+
`Smith::PersistedFailureInvalid`. Records written by earlier versions (these
|
|
149
|
+
classes under `other`, or `BlankAgentOutputError` without details) keep
|
|
150
|
+
restoring exactly as before. The new families also appear as `error_family`
|
|
151
|
+
on failed `:transition` traces and `StepFailed` events. Composite branch
|
|
152
|
+
failure evidence (`Workflow::Composite::ErrorEvidence`) still classifies
|
|
153
|
+
these three errors as `other`. Rollback: 0.10.0 checks `error_family` and
|
|
154
|
+
`error_cause_family` against its own family list, so any persisted payload
|
|
155
|
+
whose `last_failed_step`, or its cause, carries one of the new families
|
|
156
|
+
fails to restore under 0.10.0, whether the run is terminal or not; clear or
|
|
157
|
+
settle such runs before rolling back.
|
|
158
|
+
- A failed or aborted attempt's `:provider_call` trace now carries
|
|
159
|
+
`error_class`: the exception's class name, bounded to 512 bytes by the
|
|
160
|
+
diagnostic text helper (`anonymous_error` for an anonymous class). It also
|
|
161
|
+
carries `error_cause_class`, the class name of the error's direct cause
|
|
162
|
+
(`Exception#cause`, else the error's `wrapped_exception`), captured the same
|
|
163
|
+
way and omitted when there is no cause: a `Faraday::ConnectionFailed`
|
|
164
|
+
wraps both never-connected causes (`Errno::ECONNREFUSED`, `SocketError`,
|
|
165
|
+
`Net::OpenTimeout`) and lost-after-sending ones (`EOFError`,
|
|
166
|
+
`Errno::ECONNRESET`), and a host deciding whether an attempt may have been
|
|
167
|
+
billed needs to tell them apart. Messages never ride the trace. Hosts with a
|
|
168
|
+
`trace_fields` allowlist for `:provider_call` must add `:error_class` and
|
|
169
|
+
`:error_cause_class` to receive them.
|
|
170
|
+
- `inject_state` injects only non-blank text. A formatter returning `nil` or
|
|
171
|
+
whitespace-only text no longer adds a bare `[smith:injected-state]` system
|
|
172
|
+
message, and when a formatter that returned text now returns blank, the
|
|
173
|
+
stale injected-state message is removed from session history. Injection
|
|
174
|
+
remains one linear scan over the session.
|
|
175
|
+
- Restore and timestamp asymmetries. The `provider` in
|
|
176
|
+
`last_agent_execution`, read by `DeterministicStep#last_agent_provider`,
|
|
177
|
+
restores as the Symbol a live run holds where it restored as a String.
|
|
178
|
+
`created_at` is now written with microsecond precision (`iso8601(6)`), so a
|
|
179
|
+
`wall_clock` deadline no longer fires up to a second early; the early firing
|
|
180
|
+
affected live runs too, since the deadline is always computed from the
|
|
181
|
+
stored string. `updated_at` is written with the same precision at every
|
|
182
|
+
step, so it never reads earlier than `created_at`. Payloads with
|
|
183
|
+
whole-second timestamps still restore.
|
|
184
|
+
- `optimize` sends a structured candidate (a Hash or Array from an
|
|
185
|
+
`output_schema` generator) to the evaluator, and replays it as the
|
|
186
|
+
refinement round's assistant turn, as JSON, using the serialization Smith
|
|
187
|
+
applies at the provider boundary, where it sent Ruby `inspect` notation.
|
|
188
|
+
String candidates are unchanged.
|
|
189
|
+
- A budget reservation of the reported remaining amount no longer raises
|
|
190
|
+
`BudgetExceeded` far below the limit. `Budget::Ledger#remaining` returned
|
|
191
|
+
the Float nearest the exact remaining amount, and reserving it re-reads its
|
|
192
|
+
shortest decimal form, which after Float-priced costs sits above the exact
|
|
193
|
+
amount about half the time: a serial step on a $0.50 `total_cost` budget
|
|
194
|
+
failed its third call at $0.0707. `remaining` now returns a value that
|
|
195
|
+
never reads back above the exact amount, so it may read one ulp lower than
|
|
196
|
+
before. `Budget::Ledger#remaining_share(key, parts)` is a new public method
|
|
197
|
+
that returns one of `parts` equal shares that all fit, which fan-out branch
|
|
198
|
+
estimates now use (a `total_cost: 0.2` budget split across three branches
|
|
199
|
+
failed with nothing spent). Integers keep floor division; a Float steps
|
|
200
|
+
down in `O(1)` ulp steps. Amounts stay Integer or Float in and JSON-safe
|
|
201
|
+
numerics out.
|
|
202
|
+
- `smith doctor`'s `persistence.capabilities` check reports an adapter that
|
|
203
|
+
fails to resolve as a failed "Persistence adapter configuration is invalid"
|
|
204
|
+
check carrying the resolution error, where it reported "No persistence
|
|
205
|
+
adapter configured".
|
|
206
|
+
- A swallowed `on_step:` callback error is logged with its class after the
|
|
207
|
+
message (`Smith::Workflow on_step callback error: <message> (<class>)`),
|
|
208
|
+
where only the message was logged. It is still swallowed.
|
|
209
|
+
|
|
7
210
|
## [0.10.0] - 2026-08-24
|
|
8
211
|
|
|
9
212
|
### Added
|
data/README.md
CHANGED
|
@@ -217,10 +217,12 @@ message batch, appends it under Smith's workflow lifecycle lock, and returns an
|
|
|
217
217
|
immutable digest witness. The host still owns session identity, transaction
|
|
218
218
|
coordination, persistence, idempotency, and resume policy.
|
|
219
219
|
|
|
220
|
-
Built-in adapters (all support TTL
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
220
|
+
Built-in adapters (all except `ActiveRecord` support TTL; `ActiveRecord` has no
|
|
221
|
+
expiry column, so its writes refuse a non-nil `persistence_ttl`, while reads and
|
|
222
|
+
deletes keep working and `smith doctor --durability` flags the combination;
|
|
223
|
+
`Redis`, `ActiveRecord`, and `Memory` support optimistic locking via
|
|
224
|
+
`store_versioned` and exact dispatch claims via `replace_exact`; `ActiveRecord`
|
|
225
|
+
participates in the caller's transaction for host-record coordination;
|
|
224
226
|
`Redis` and `Memory` also support heartbeat probes via `record_heartbeat` /
|
|
225
227
|
`last_heartbeat`):
|
|
226
228
|
|
|
@@ -407,10 +409,16 @@ Guardrails run as input/output gates around agent calls. See [`docs/TOOLS_AND_GU
|
|
|
407
409
|
|
|
408
410
|
```ruby
|
|
409
411
|
class BudgetedWorkflow < Smith::Workflow
|
|
410
|
-
budget total_tokens: 10_000, total_cost: 0.50,
|
|
412
|
+
budget total_tokens: 10_000, total_cost: 0.50, wall_clock: 30
|
|
411
413
|
end
|
|
412
414
|
```
|
|
413
415
|
|
|
416
|
+
`wall_clock` is in seconds. A workflow budget accepts `total_tokens`,
|
|
417
|
+
`token_limit`, `total_cost`, `tool_calls`, and `wall_clock`; an agent budget
|
|
418
|
+
accepts `token_limit`, `cost`, `wall_clock`, `tool_calls`, `total_tokens`, and
|
|
419
|
+
`total_cost`. Any other key raises `ArgumentError` naming the accepted keys. A
|
|
420
|
+
restored run drops consumed amounts for keys the workflow no longer declares.
|
|
421
|
+
|
|
414
422
|
Budgets reserve serially at each step and reconcile after the agent call. Parallel branches reserve scoped envelopes that release back to the parent ledger. The `Workflow::RunResult` carries `total_tokens`, `total_cost`, and per-call `usage_entries`.
|
|
415
423
|
|
|
416
424
|
Direct ledger integrations settle the exact receipt returned by reservation:
|
|
@@ -422,7 +430,9 @@ ledger.reconcile_many!(reservation, actual: { total_tokens: 640, total_cost: 0.0
|
|
|
422
430
|
|
|
423
431
|
`release!` / `release_many!` accept the same receipt when work never starts.
|
|
424
432
|
Receipts are ledger-owned and one-shot: another ledger, a replay, or an
|
|
425
|
-
amount-only settlement is rejected.
|
|
433
|
+
amount-only settlement is rejected. `ledger.remaining(key)` never reads back
|
|
434
|
+
above the exact remaining amount, and `ledger.remaining_share(key, parts)`
|
|
435
|
+
returns one of `parts` equal shares of it such that reserving all `parts` fits.
|
|
426
436
|
|
|
427
437
|
Budget amounts accept finite, non-negative `Integer` and `Float` values. Smith
|
|
428
438
|
uses an exact `BigDecimal` representation derived from each value's canonical
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -124,7 +124,7 @@ observes a provider but finds only a model-only rate, it raises
|
|
|
124
124
|
| `logger` | Smith's runtime logger | Usually the first setting to add |
|
|
125
125
|
| `persistence_adapter` | Adapter for durable workflow state | `:redis`, `:rails_cache`, `:active_record`, `:memory`, or a custom object |
|
|
126
126
|
| `persistence_options` | Per-adapter options (client, namespace, model, columns) | See "Built-In Persistence Adapters" |
|
|
127
|
-
| `persistence_ttl` | Global TTL for persisted state (Integer/Float seconds; nil = no expiry) | Set when long-tail abandoned workflows accumulate in storage |
|
|
127
|
+
| `persistence_ttl` | Global TTL for persisted state (Integer/Float seconds; nil = no expiry). `:active_record` cannot expire state, so its writes refuse a non-nil TTL (reads and deletes still work, and `smith doctor --durability` flags the combination) | Set when long-tail abandoned workflows accumulate in storage |
|
|
128
128
|
| `persistence_retry_policy` | Exponential-backoff policy for transient adapter I/O failures | Defaults to `{ attempts: 3, base_delay: 0.1, max_delay: 1.0 }` |
|
|
129
129
|
| `retry_attempt_limit` | Maximum attempts accepted by workflow and persistence retry policies | Defaults to `100` |
|
|
130
130
|
| `parallel_branch_limit` | Maximum branches accepted by one homogeneous or heterogeneous fan-out | Defaults to `1_000` |
|
|
@@ -135,6 +135,26 @@ observes a provider but finds only a model-only rate, it raises
|
|
|
135
135
|
| `trace_normalizer` | Emit `:normalizer_decision` trace events from `Smith::Models::Normalizer` | Useful when debugging cross-provider request shaping |
|
|
136
136
|
| `ruby_llm_model_registry` | `:database` to require an AR-backed RubyLLM model registry; `:bundled` for the JSON fallback | Leave at default unless you've migrated to DB-backed |
|
|
137
137
|
|
|
138
|
+
### The `:provider_call` Trace
|
|
139
|
+
|
|
140
|
+
With `trace_provider_calls` on, every provider attempt emits one
|
|
141
|
+
`:provider_call` trace when it ends: `model`, `provider`, `attempt_id` (shared
|
|
142
|
+
with the attempt's usage entries), `attempt_index` (its position in the
|
|
143
|
+
fallback chain), `outcome` (`:success`, `:failure`, or `:aborted`), and
|
|
144
|
+
`duration_ms` once the timed call started. `agent_name` is the registered name
|
|
145
|
+
of the agent that made the attempt, so a generator and an evaluator sharing an
|
|
146
|
+
optimize round stay apart. `input_tokens` and `output_tokens` total the
|
|
147
|
+
provider-reported usage that the attempt's usage entries record (a completed
|
|
148
|
+
tool-loop prefix, and a failed attempt's own reported usage); both are omitted
|
|
149
|
+
when the provider reported none. A failed or aborted attempt adds `error_class`,
|
|
150
|
+
and `error_cause_class` for the error's direct cause (`Exception#cause`, else a
|
|
151
|
+
wrapping error's `wrapped_exception`, such as the `EOFError` inside a
|
|
152
|
+
`Faraday::ConnectionFailed`), omitted when there is none. Class names and token
|
|
153
|
+
counts are metadata, never content: `trace_content false` keeps them, messages
|
|
154
|
+
never appear, and a `trace_fields` allowlist for `:provider_call` must name
|
|
155
|
+
each field it wants. Ambient attribution (`transition`, `round`, and so on)
|
|
156
|
+
merges in as for every trace.
|
|
157
|
+
|
|
138
158
|
### Recommended First Additions
|
|
139
159
|
|
|
140
160
|
Add settings in this order:
|
data/docs/PATTERNS.md
CHANGED
|
@@ -377,6 +377,65 @@ class TranslationWorkflow < Smith::Workflow
|
|
|
377
377
|
end
|
|
378
378
|
```
|
|
379
379
|
|
|
380
|
+
`before_eval: ->(state, context) { ... }` runs after each candidate is
|
|
381
|
+
generated and before the evaluator is called, and may write to the workflow
|
|
382
|
+
context. When it returns a Hash whose `accept` (Symbol or String key) is
|
|
383
|
+
`false`, that Hash is the round's evaluation: Smith validates it exactly as
|
|
384
|
+
evaluator output (`feedback` is required, and a numeric `score` when
|
|
385
|
+
`improvement_threshold` is set), skips the evaluator for that round, and
|
|
386
|
+
continues as a rejection, so the feedback reaches the generator's refinement
|
|
387
|
+
turn and `on_exhaustion` applies when rounds run out. Convergence and the
|
|
388
|
+
improvement threshold read it as they read evaluator output. Any other return
|
|
389
|
+
value, including a Hash with `accept: true`, is ignored and the evaluator
|
|
390
|
+
judges as before. A deterministic check that makes a candidate unacceptable
|
|
391
|
+
can reject it this way without spending an evaluator call.
|
|
392
|
+
|
|
393
|
+
Every round's verdict is an output. Once its loop has run, the step's record
|
|
394
|
+
carries its verdicts under `:evaluations`, in attempt and round order, whether
|
|
395
|
+
the step completed or failed (a step that failed before its loop, or any other
|
|
396
|
+
step, has no such key), and `RunResult#evaluations` lists every optimize step's
|
|
397
|
+
verdicts with the step's transition:
|
|
398
|
+
|
|
399
|
+
```ruby
|
|
400
|
+
result = TranslationWorkflow.new.run!
|
|
401
|
+
result.evaluations
|
|
402
|
+
# => [{ transition: :translate, attempt: 1, round: 0, source: :evaluator,
|
|
403
|
+
# verdict: { accept: false, feedback: "Keep the formal register.", score: 0.6 } },
|
|
404
|
+
# { transition: :translate, attempt: 1, round: 1, source: :evaluator,
|
|
405
|
+
# verdict: { accept: true, feedback: "ok", score: 0.93 } }]
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`attempt` is the retry policy's attempt that ran the loop, from 1: when
|
|
409
|
+
`retry_on` runs the step again, the loop starts afresh and its rounds count
|
|
410
|
+
from 0 again, so an earlier attempt's verdicts, given and paid for all the
|
|
411
|
+
same, stay before it, and an attempt that failed before its loop leaves a gap.
|
|
412
|
+
`round` is the optimizer round, the one usage entries and traces carry.
|
|
413
|
+
`source` is `:evaluator`, or `:before_eval` for a rejection `before_eval`
|
|
414
|
+
returned. `verdict` is the evaluation as normalized (Symbol keys at every
|
|
415
|
+
depth), with every field the evaluator's schema declares, not only the
|
|
416
|
+
contract's. A verdict holds JSON values only (Hashes with String or Symbol
|
|
417
|
+
keys, Arrays, Strings, Symbols, Integers, finite Floats, true, false, nil), as
|
|
418
|
+
a provider's structured output does; anything else, most likely an object a
|
|
419
|
+
`before_eval` returned, fails the step with `Smith::WorkflowError`, the same
|
|
420
|
+
way in every run mode. So does a round whose verdict would take the step's
|
|
421
|
+
verdicts past the limits a split step's record is held to (depth, nodes and
|
|
422
|
+
bytes, `ExecutionResultSnapshot`'s), keeping the verdicts before it; a step's
|
|
423
|
+
output counts toward those limits only in a split step. The records on the
|
|
424
|
+
step and on `RunResult#evaluations` are frozen copies; a split step's
|
|
425
|
+
execution snapshot hands the host its own mutable copy of the whole step,
|
|
426
|
+
verdicts included. Only a valid verdict is recorded, and the one that ends the
|
|
427
|
+
loop is kept, whether it accepts, converges, or trips the improvement
|
|
428
|
+
threshold. A step whose completion fails after its loop (a split step's
|
|
429
|
+
snapshot over its limits) keeps its verdicts on the failure record. A nested
|
|
430
|
+
workflow's verdicts stay on its own run: the parent's step carries the child's
|
|
431
|
+
usage, not its verdicts. A callable
|
|
432
|
+
(`before_eval` or an exit mode) reads its own loop's verdicts so far from
|
|
433
|
+
`state.evaluations`, a frozen copy without `attempt`, so it cannot rewrite the
|
|
434
|
+
step's. Verdicts are content, so they are an output and never a
|
|
435
|
+
trace: nothing about them reaches a trace adapter. Like `steps`, they belong to
|
|
436
|
+
the run that executed the step, so a terminal restore has none; a host that
|
|
437
|
+
needs them later stores them when the run returns.
|
|
438
|
+
|
|
380
439
|
Why this matters:
|
|
381
440
|
|
|
382
441
|
- the loop is explicit, bounded, and observable
|
data/docs/PERSISTENCE.md
CHANGED
|
@@ -534,7 +534,11 @@ before writing when an open transaction has no exact identity capability.
|
|
|
534
534
|
If execution raises or the process dies after external work, the durable marker
|
|
535
535
|
remains set. A strict restore therefore fails closed with
|
|
536
536
|
`StepInProgressOnRestore`; the host must reconcile operation results or classify
|
|
537
|
-
the run as uncertain rather than blindly replaying the transition. The
|
|
537
|
+
the run as uncertain rather than blindly replaying the transition. The error's
|
|
538
|
+
`state` is the persisted state the interrupted step started from (a Symbol) and
|
|
539
|
+
its `transition` is the next transition the payload records, such as a routed
|
|
540
|
+
one, or nil when it records none, so the host can tell which step died without
|
|
541
|
+
reading Smith's payload itself. The same
|
|
538
542
|
in-memory workflow object cannot retry an attempted transition. If a host
|
|
539
543
|
transaction rolls back after a successful `persist!`,
|
|
540
544
|
`complete_persisted_step!` rejects the rolled-back checkpoint. When the adapter
|
|
@@ -3,15 +3,38 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Agent
|
|
5
5
|
module FallbackConfiguration
|
|
6
|
-
|
|
6
|
+
attr_reader :fallback_models_block
|
|
7
|
+
|
|
8
|
+
def fallback_models(*models, &block)
|
|
9
|
+
return configure_dynamic_fallback_models(models, block) if block
|
|
7
10
|
return @fallback_models_list if models.empty?
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
@fallback_models_list =
|
|
12
|
+
@fallback_models_block = nil
|
|
13
|
+
@fallback_models_list = qualified_fallback_references(models)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Evaluated per invocation with the workflow context. The entries pass
|
|
17
|
+
# the static form's validation and de-duplication: O(F) time and space
|
|
18
|
+
# for F returned entries.
|
|
19
|
+
def resolve_fallback_models(context)
|
|
20
|
+
return @fallback_models_list || [].freeze unless fallback_models_block
|
|
21
|
+
|
|
22
|
+
qualified_fallback_references([fallback_models_block.call(context)])
|
|
11
23
|
end
|
|
12
24
|
|
|
13
25
|
private
|
|
14
26
|
|
|
27
|
+
def configure_dynamic_fallback_models(models, block)
|
|
28
|
+
raise ArgumentError, "fallback_models can take model entries OR a block, not both" if models.any?
|
|
29
|
+
|
|
30
|
+
@fallback_models_list = nil
|
|
31
|
+
@fallback_models_block = block
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def qualified_fallback_references(models)
|
|
35
|
+
models.flatten.compact.map { qualified_fallback_reference(_1) }.uniq(&:key).freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
15
38
|
def qualified_fallback_reference(model)
|
|
16
39
|
reference = ModelReference.coerce(model)
|
|
17
40
|
return reference if reference.provider
|
|
@@ -4,6 +4,7 @@ require "dry-struct"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../types"
|
|
6
6
|
require_relative "model_reference"
|
|
7
|
+
require_relative "provider_usage"
|
|
7
8
|
|
|
8
9
|
module Smith
|
|
9
10
|
class Agent
|
|
@@ -17,13 +18,19 @@ module Smith
|
|
|
17
18
|
# completion call started.
|
|
18
19
|
attribute :attempt_id, Types::String.optional.default(nil)
|
|
19
20
|
attribute :duration_ms, Types::Integer.optional.default(nil)
|
|
21
|
+
# The candidate's position in the model chain, the registered name of
|
|
22
|
+
# the agent that made the attempt, and the provider-reported usage its
|
|
23
|
+
# usage entries record (nil when the provider reported none).
|
|
24
|
+
attribute :attempt_index, Types::Integer.optional.default(nil)
|
|
25
|
+
attribute :agent_name, (Types::Symbol | Types::String).optional.default(nil)
|
|
26
|
+
attribute :usage, Types.Instance(ProviderUsage).optional.default(nil)
|
|
20
27
|
|
|
21
|
-
def self.success(completion:, model_reference:,
|
|
22
|
-
new(completion:, model_reference:, error: nil,
|
|
28
|
+
def self.success(completion:, model_reference:, **facts)
|
|
29
|
+
new(completion:, model_reference:, error: nil, **facts)
|
|
23
30
|
end
|
|
24
31
|
|
|
25
|
-
def self.failure(error:, model_reference:,
|
|
26
|
-
new(completion: nil, model_reference:, error:,
|
|
32
|
+
def self.failure(error:, model_reference:, **facts)
|
|
33
|
+
new(completion: nil, model_reference:, error:, **facts)
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
def success?
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../diagnostic_text"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
class Agent
|
|
5
7
|
# Monotonic timing and trace emission for one provider attempt: the
|
|
@@ -7,6 +9,9 @@ module Smith
|
|
|
7
9
|
# (including any provider-side tool loop). Per-network-round timing
|
|
8
10
|
# belongs to the host's RubyLLM notification subscriptions, not Smith.
|
|
9
11
|
module ProviderCallTiming
|
|
12
|
+
EXCEPTION_CAUSE = Exception.instance_method(:cause)
|
|
13
|
+
private_constant :EXCEPTION_CAUSE
|
|
14
|
+
|
|
10
15
|
class Timer
|
|
11
16
|
def self.start
|
|
12
17
|
new
|
|
@@ -38,7 +43,9 @@ module Smith
|
|
|
38
43
|
# durations across entries. duration_ms is present only when the timed
|
|
39
44
|
# provider call actually started: a failure before dispatch (model
|
|
40
45
|
# resolution, chat construction) emits its attempt without a duration.
|
|
41
|
-
|
|
46
|
+
# input_tokens and output_tokens total the attempt's usage entries, so
|
|
47
|
+
# a host can record the whole attempt when it ends.
|
|
48
|
+
def record_provider_call_trace(attempt, aborted: false)
|
|
42
49
|
Smith::Trace.record(
|
|
43
50
|
type: :provider_call,
|
|
44
51
|
data: {
|
|
@@ -46,12 +53,23 @@ module Smith
|
|
|
46
53
|
provider: attempt.model_reference.provider,
|
|
47
54
|
duration_ms: attempt.duration_ms,
|
|
48
55
|
attempt_id: attempt.attempt_id,
|
|
49
|
-
attempt_index: attempt_index,
|
|
50
|
-
outcome: provider_call_outcome(attempt, aborted)
|
|
56
|
+
attempt_index: attempt.attempt_index,
|
|
57
|
+
outcome: provider_call_outcome(attempt, aborted),
|
|
58
|
+
**provider_call_error_classes(attempt.error),
|
|
59
|
+
agent_name: attempt.agent_name,
|
|
60
|
+
**provider_call_usage(attempt.usage)
|
|
51
61
|
}.compact
|
|
52
62
|
)
|
|
53
63
|
end
|
|
54
64
|
|
|
65
|
+
# Token counts are metadata, never content, so the content policy
|
|
66
|
+
# leaves them in place.
|
|
67
|
+
def provider_call_usage(usage)
|
|
68
|
+
return {} unless usage
|
|
69
|
+
|
|
70
|
+
{ input_tokens: usage.input_tokens, output_tokens: usage.output_tokens }
|
|
71
|
+
end
|
|
72
|
+
|
|
55
73
|
def provider_call_outcome(attempt, aborted)
|
|
56
74
|
return :aborted if aborted
|
|
57
75
|
return :success if attempt.success?
|
|
@@ -59,14 +77,51 @@ module Smith
|
|
|
59
77
|
:failure
|
|
60
78
|
end
|
|
61
79
|
|
|
80
|
+
# Class names only, never a message: the same bounded identifier the
|
|
81
|
+
# failed :transition trace carries, for the error and its direct cause.
|
|
82
|
+
def provider_call_error_classes(error)
|
|
83
|
+
return {} unless error
|
|
84
|
+
|
|
85
|
+
cause = provider_call_error_cause(error)
|
|
86
|
+
{
|
|
87
|
+
error_class: DiagnosticText.error_class_name(error),
|
|
88
|
+
error_cause_class: cause && DiagnosticText.error_class_name(cause)
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A transport error wraps what actually happened (never connected, or
|
|
93
|
+
# lost the connection after sending); Faraday keeps it as
|
|
94
|
+
# wrapped_exception when the error was not raised inside a rescue.
|
|
95
|
+
def provider_call_error_cause(error)
|
|
96
|
+
cause = EXCEPTION_CAUSE.bind_call(error)
|
|
97
|
+
cause ||= error.wrapped_exception if error.respond_to?(:wrapped_exception)
|
|
98
|
+
cause if cause.is_a?(Exception)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def completed_provider_attempt(completion, observed_reference, timer, facts)
|
|
104
|
+
attempt = ProviderAttempt.success(
|
|
105
|
+
completion:, model_reference: observed_reference, duration_ms: timer.elapsed_ms,
|
|
106
|
+
usage: ProviderUsage.sum(completion.provider_usages), **facts
|
|
107
|
+
)
|
|
108
|
+
record_provider_call_trace(attempt)
|
|
109
|
+
attempt
|
|
110
|
+
end
|
|
111
|
+
|
|
62
112
|
# Aborted (non-provider) attempts emit a :provider_call too, so the
|
|
63
113
|
# prefix-accounted usage entries stamped with this attempt_id always
|
|
64
|
-
# have their join target; the error then propagates unchanged.
|
|
65
|
-
|
|
114
|
+
# have their join target; the error then propagates unchanged. A
|
|
115
|
+
# provider failure's own reported usage is accounted beside the
|
|
116
|
+
# completed prefix, so its trace totals both.
|
|
117
|
+
def failed_provider_attempt(error, observed_reference, timer, facts, prefix_usages)
|
|
118
|
+
aborted = !provider_failure?(error)
|
|
119
|
+
usages = aborted ? prefix_usages : [*prefix_usages, ProviderUsage.from_message(error)].compact
|
|
66
120
|
attempt = ProviderAttempt.failure(
|
|
67
|
-
error
|
|
121
|
+
error:, model_reference: observed_reference, duration_ms: timer&.elapsed_ms,
|
|
122
|
+
usage: ProviderUsage.sum(usages), **facts
|
|
68
123
|
)
|
|
69
|
-
record_provider_call_trace(attempt,
|
|
124
|
+
record_provider_call_trace(attempt, aborted:)
|
|
70
125
|
attempt
|
|
71
126
|
end
|
|
72
127
|
end
|
|
@@ -68,6 +68,8 @@ module Smith
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def fallback_model_references(agent_class)
|
|
71
|
+
return agent_class.resolve_fallback_models(@context || {}) if agent_class.fallback_models_block
|
|
72
|
+
|
|
71
73
|
Array(agent_class.fallback_models).map { |model| ModelReference.coerce(model) }
|
|
72
74
|
end
|
|
73
75
|
|
|
@@ -87,7 +89,7 @@ module Smith
|
|
|
87
89
|
# completion, prefix accounting on failure); splitting it would scatter
|
|
88
90
|
# the rescue-path accounting away from what it accounts for.
|
|
89
91
|
def attempt_model(agent_class, prepared_input, model_reference, output_schema:, attempt_index:)
|
|
90
|
-
|
|
92
|
+
facts = { attempt_id: SecureRandom.uuid, attempt_index:, agent_name: agent_class.register_as }
|
|
91
93
|
chat = prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
|
|
92
94
|
message_count = chat_message_count(chat)
|
|
93
95
|
observed_reference = observed_model_reference(chat, fallback: model_reference)
|
|
@@ -96,16 +98,14 @@ module Smith
|
|
|
96
98
|
timer.stop
|
|
97
99
|
completion = Completion.from_messages(response: response, messages: new_chat_messages(chat, message_count))
|
|
98
100
|
|
|
99
|
-
|
|
100
|
-
completion:, model_reference: observed_reference, attempt_id:, duration_ms: timer.elapsed_ms
|
|
101
|
-
)
|
|
102
|
-
record_provider_call_trace(attempt, attempt_index)
|
|
103
|
-
attempt
|
|
101
|
+
completed_provider_attempt(completion, observed_reference, timer, facts)
|
|
104
102
|
rescue StandardError => e
|
|
105
103
|
timer&.stop
|
|
106
104
|
observed_reference ||= observed_model_reference(chat, fallback: model_reference)
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
prefix_usages = account_completed_prefix(
|
|
106
|
+
agent_class, observed_reference, new_chat_messages(chat, message_count), attempt_id: facts[:attempt_id]
|
|
107
|
+
)
|
|
108
|
+
attempt = failed_provider_attempt(e, observed_reference, timer, facts, prefix_usages)
|
|
109
109
|
raise unless provider_failure?(e)
|
|
110
110
|
|
|
111
111
|
attempt
|
|
@@ -20,6 +20,12 @@ module Smith
|
|
|
20
20
|
new(input_tokens:, output_tokens:)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def self.sum(usages)
|
|
24
|
+
return if usages.empty?
|
|
25
|
+
|
|
26
|
+
new(input_tokens: usages.sum(&:input_tokens), output_tokens: usages.sum(&:output_tokens))
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
def total_tokens
|
|
24
30
|
input_tokens + output_tokens
|
|
25
31
|
end
|
|
@@ -23,9 +23,12 @@ module Smith
|
|
|
23
23
|
record_failed_usage(agent_class, model_reference, input, output, attempt_id:)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Returns the provider usages it recorded, so the attempt's trace can
|
|
27
|
+
# carry the same totals its usage entries hold.
|
|
26
28
|
def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil)
|
|
27
29
|
completion = Completion.from_messages(response: nil, messages: messages)
|
|
28
30
|
record_completion_usage(agent_class, completion, :partial_attempt, model_reference, attempt_id:)
|
|
31
|
+
completion.provider_usages
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_id: nil)
|