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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +203 -0
  3. data/README.md +16 -6
  4. data/docs/CONFIGURATION.md +21 -1
  5. data/docs/PATTERNS.md +59 -0
  6. data/docs/PERSISTENCE.md +5 -1
  7. data/lib/smith/agent/fallback_configuration.rb +26 -3
  8. data/lib/smith/agent/provider_attempt.rb +11 -4
  9. data/lib/smith/agent/provider_call_timing.rb +62 -7
  10. data/lib/smith/agent/provider_completion.rb +8 -8
  11. data/lib/smith/agent/provider_usage.rb +6 -0
  12. data/lib/smith/agent/usage_tracking.rb +3 -0
  13. data/lib/smith/agent.rb +9 -1
  14. data/lib/smith/budget/amount_representation.rb +20 -0
  15. data/lib/smith/budget/ledger.rb +6 -2
  16. data/lib/smith/context/state_injection.rb +12 -2
  17. data/lib/smith/diagnostic_text.rb +10 -1
  18. data/lib/smith/doctor/checks/durability.rb +16 -0
  19. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  20. data/lib/smith/errors.rb +55 -4
  21. data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
  22. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  23. data/lib/smith/persistence_adapters.rb +10 -0
  24. data/lib/smith/provider_permanent_failure.rb +44 -0
  25. data/lib/smith/version.rb +1 -1
  26. data/lib/smith/workflow/budget_integration.rb +3 -3
  27. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  28. data/lib/smith/workflow/deterministic_step.rb +1 -1
  29. data/lib/smith/workflow/dsl.rb +15 -5
  30. data/lib/smith/workflow/durability.rb +1 -1
  31. data/lib/smith/workflow/evaluator_optimizer.rb +108 -10
  32. data/lib/smith/workflow/execution.rb +2 -0
  33. data/lib/smith/workflow/failure_reconstructor.rb +18 -2
  34. data/lib/smith/workflow/failure_record.rb +6 -11
  35. data/lib/smith/workflow/failure_record_validator.rb +23 -3
  36. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  37. data/lib/smith/workflow/optimization_state.rb +2 -2
  38. data/lib/smith/workflow/persistence.rb +26 -9
  39. data/lib/smith/workflow/retry_execution.rb +4 -0
  40. data/lib/smith/workflow/run_result.rb +10 -0
  41. data/lib/smith/workflow/step_completion.rb +4 -0
  42. data/lib/smith/workflow.rb +5 -3
  43. data/lib/smith.rb +2 -2
  44. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06665b52b660baf1f846ebd104caec677044595bd4aaf71ecf6ac3120e18d478
4
- data.tar.gz: '09dd67e047e0f2a3dc6afd0f6b320ab7d5b7f2f23460ddae9576860e7b2e1329'
3
+ metadata.gz: b3692cc78203afacc91c1ede53a150d8bc864d8be5b9f549739570b5fe823dde
4
+ data.tar.gz: 6f9981471f8f8ddf2c6fb4141bfe2b6d8631bd21aaf78215c4c87d311aa6d3f2
5
5
  SHA512:
6
- metadata.gz: 76771bcb4d58d45f110b16a590579a34f698f1e9e2b75cca7806a5c5864f50c8209afc4a258a587069c6dd97f6ae87c1d0a62d434afd4b415f04e199561e79a6
7
- data.tar.gz: 5160511ae311634881c774fa8040d3b69f93171e20e76aca595821c9eb7a767c76d0165ffe562190b16f624a4ba2e3e272822b3a1cf6b852ae2d2af7d7521994
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 where the backend allows; `Redis`,
221
- `ActiveRecord`, and `Memory` support optimistic locking via `store_versioned`
222
- and exact dispatch claims via `replace_exact`; `ActiveRecord` participates in
223
- the caller's transaction for host-record coordination;
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, wall_clock_ms: 30_000
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
@@ -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 same
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
- def fallback_models(*models)
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
- entries = models.flatten.compact.map { qualified_fallback_reference(_1) }
10
- @fallback_models_list = entries.uniq(&:key).freeze
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:, attempt_id: nil, duration_ms: nil)
22
- new(completion:, model_reference:, error: nil, attempt_id:, duration_ms:)
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:, attempt_id: nil, duration_ms: nil)
26
- new(completion: nil, model_reference:, error:, attempt_id:, duration_ms:)
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
- def record_provider_call_trace(attempt, attempt_index, aborted: false)
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
- def failed_provider_attempt(error, observed_reference, attempt_id, timer, attempt_index)
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: error, model_reference: observed_reference, attempt_id:, duration_ms: timer&.elapsed_ms
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, attempt_index, aborted: !provider_failure?(error))
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
- attempt_id = SecureRandom.uuid
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
- attempt = ProviderAttempt.success(
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
- account_completed_prefix(agent_class, observed_reference, new_chat_messages(chat, message_count), attempt_id:)
108
- attempt = failed_provider_attempt(e, observed_reference, attempt_id, timer, attempt_index)
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)