smith-agents 0.8.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +433 -0
  3. data/README.md +16 -6
  4. data/docs/CONFIGURATION.md +26 -6
  5. data/docs/PATTERNS.md +59 -0
  6. data/docs/PERSISTENCE.md +5 -1
  7. data/lib/smith/agent/completion_usage_recording.rb +21 -3
  8. data/lib/smith/agent/fallback_configuration.rb +26 -3
  9. data/lib/smith/agent/invocation_preparation.rb +24 -0
  10. data/lib/smith/agent/lifecycle.rb +4 -2
  11. data/lib/smith/agent/provider_attempt.rb +17 -4
  12. data/lib/smith/agent/provider_call_timing.rb +129 -0
  13. data/lib/smith/agent/provider_completion.rb +26 -8
  14. data/lib/smith/agent/provider_failure_handling.rb +2 -2
  15. data/lib/smith/agent/provider_usage.rb +6 -0
  16. data/lib/smith/agent/usage_entry_recording.rb +27 -4
  17. data/lib/smith/agent/usage_traces.rb +49 -0
  18. data/lib/smith/agent/usage_tracking.rb +26 -20
  19. data/lib/smith/agent.rb +9 -1
  20. data/lib/smith/attribution.rb +115 -0
  21. data/lib/smith/budget/amount_representation.rb +20 -0
  22. data/lib/smith/budget/ledger.rb +6 -2
  23. data/lib/smith/context/state_injection.rb +12 -2
  24. data/lib/smith/diagnostic_text.rb +10 -1
  25. data/lib/smith/doctor/checks/durability.rb +16 -0
  26. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  27. data/lib/smith/errors.rb +55 -4
  28. data/lib/smith/event.rb +10 -2
  29. data/lib/smith/events/bus.rb +63 -5
  30. data/lib/smith/events/step_completed.rb +3 -0
  31. data/lib/smith/events/step_failed.rb +25 -0
  32. data/lib/smith/events/subscription.rb +8 -0
  33. data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
  34. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  35. data/lib/smith/persistence_adapters.rb +10 -0
  36. data/lib/smith/provider_permanent_failure.rb +44 -0
  37. data/lib/smith/tool/capture.rb +9 -1
  38. data/lib/smith/tool.rb +10 -1
  39. data/lib/smith/trace/logger.rb +1 -0
  40. data/lib/smith/trace/memory.rb +40 -4
  41. data/lib/smith/trace/open_telemetry.rb +47 -3
  42. data/lib/smith/trace.rb +24 -5
  43. data/lib/smith/version.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +3 -3
  45. data/lib/smith/workflow/composite/effects.rb +64 -33
  46. data/lib/smith/workflow/deterministic_execution.rb +3 -2
  47. data/lib/smith/workflow/deterministic_step.rb +15 -2
  48. data/lib/smith/workflow/dsl.rb +40 -6
  49. data/lib/smith/workflow/durability.rb +1 -1
  50. data/lib/smith/workflow/evaluator_optimizer.rb +111 -11
  51. data/lib/smith/workflow/event_integration.rb +48 -1
  52. data/lib/smith/workflow/execution.rb +10 -0
  53. data/lib/smith/workflow/failure_reconstructor.rb +18 -2
  54. data/lib/smith/workflow/failure_record.rb +6 -11
  55. data/lib/smith/workflow/failure_record_validator.rb +23 -3
  56. data/lib/smith/workflow/fanout_execution.rb +1 -1
  57. data/lib/smith/workflow/guardrail_integration.rb +27 -0
  58. data/lib/smith/workflow/optimization_state.rb +2 -2
  59. data/lib/smith/workflow/parallel_execution.rb +6 -0
  60. data/lib/smith/workflow/persistence.rb +55 -6
  61. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  62. data/lib/smith/workflow/retry_execution.rb +4 -0
  63. data/lib/smith/workflow/run_result.rb +10 -0
  64. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  65. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  66. data/lib/smith/workflow/step_completion.rb +13 -0
  67. data/lib/smith/workflow/step_context.rb +46 -0
  68. data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
  69. data/lib/smith/workflow/usage_entry.rb +32 -4
  70. data/lib/smith/workflow.rb +30 -3
  71. data/lib/smith.rb +9 -4
  72. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fe948e343baa2573448f7a95a964729e96a71b0b2e6ef16226694400ba0fb97
4
- data.tar.gz: 8f3d35e4ee6998ca41a4be0e19dcce0b5e843b44c0f28167aff3d5326d1be573
3
+ metadata.gz: b3692cc78203afacc91c1ede53a150d8bc864d8be5b9f549739570b5fe823dde
4
+ data.tar.gz: 6f9981471f8f8ddf2c6fb4141bfe2b6d8631bd21aaf78215c4c87d311aa6d3f2
5
5
  SHA512:
6
- metadata.gz: 85bc7336ef0cb273979c4c7df1a4c0c724eb74cb34c4b49c1b23768e9454c700a1f33827ac9a1b33ff9680efd55b302b282db0fc553c5e20c52b4d13970044fa
7
- data.tar.gz: afe1281c6ef7967292c1c43b7370ca5ab3795abcc98722aa8e10c03266347dcd2411d9c44878f1c0d4aa41df5f88ffd636d46084ff996f2008f60baa650ce5fc
6
+ metadata.gz: 870199be5d2163608606ab5a3eacca29c967e49e3f1f3fd193d88f4bdd57e455e973f03346b2b9769387b363c77147aaeb78a614837e4a665fca1fd4cfc66dc8
7
+ data.tar.gz: d555df7da59578922841547c53607a81ab7262a427d80a2140e3b9072158764cc33acca058a1fb380b20242315aa5161ca8659c7a26107e35527baa9efea9391
data/CHANGELOG.md CHANGED
@@ -4,6 +4,439 @@ 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
+
210
+ ## [0.10.0] - 2026-08-24
211
+
212
+ ### Added
213
+
214
+ - Last-serial-agent-execution attribution. A deterministic (`compute`) step can
215
+ now read the model and provider that actually served the most recent serial
216
+ `execute :agent` step via `DeterministicStep#last_agent_model` /
217
+ `#last_agent_provider` (symmetric with `#last_output`, which returns that
218
+ step's content). The value is the resolved model/provider after fallback
219
+ resolution, so a step that failed its primary and completed on a
220
+ `fallback_models` entry reports the model that actually ran, never the
221
+ configured primary. It is durable lifecycle state (persisted in `to_state` as
222
+ `last_agent_execution`, restored across crash/resume), not a trace scrape, and
223
+ is `nil` until a serial agent step runs. Backward compatible: pre-upgrade
224
+ states have no `last_agent_execution` key and restore to `nil`; old readers
225
+ slice off the unknown key.
226
+
227
+ ### Fixed
228
+
229
+ - Structured agent outputs recorded as session messages (Hash or Array
230
+ content) are now serialized to JSON at the RubyLLM provider boundary when
231
+ replayed to a later agent step. RubyLLM treats a Hash message content as
232
+ attachments and opens each value as a file, so replaying a prior structured
233
+ output to the next agent in a workflow session raised `Errno::ENOENT`. The
234
+ session store is unaffected: `last_output` and persisted `session_messages`
235
+ keep the raw structured value; only the provider-facing copy is serialized.
236
+
237
+ ## [0.9.0] - 2026-08-03
238
+
239
+ ### Upgrade notes
240
+
241
+ - Rollback after running fan-out branches under this version is a one-way
242
+ door for those runs: composite branch effects written with attribution
243
+ values (usage entries) or batch-correlated `tool_call_id` capture entries
244
+ fail an older gem's exact-key validation at reduction or recovery.
245
+ Drain in-flight composite runs before rolling the gem back; plain
246
+ checkpoint payloads are unaffected (restored pre-attribution documents
247
+ re-serialize byte-identically, and old readers slice off unknown keys).
248
+ - Hosts with a configured trace adapter see new output on upgrade without
249
+ any host change: one `:provider_call` line per provider attempt, one
250
+ `:cost` line per priced completed invocation, failed `:transition` lines
251
+ marked `outcome: :failed`, and ambient attribution keys (including
252
+ `execution_key`, the Smith persistence key) merged into every payload.
253
+ Identifier-only, but plan for the volume and shape change, especially
254
+ with `Smith::Trace::Logger` in production.
255
+ - Hosts with a `trace_fields` allowlist for `:transition` must add
256
+ `:outcome` (and the error keys they want) or failed transitions render
257
+ indistinguishable from successes under the allowlist.
258
+ - `Smith::Trace::Memory` is now bounded (default 10,000 entries, silent
259
+ drop with `dropped_count`); previously it accumulated without limit.
260
+
261
+ ### Added
262
+
263
+ - Add `Smith::Attribution`, an immutable thread-local execution attribution
264
+ context (`execution_key`, `transition`, `from`, `to`, `branch_key`,
265
+ `round`). Workflow execution installs it per step (the `execution_key`
266
+ defaults to the persistence key of a persisted run), fan-out carries it
267
+ into branch threads and overlays the branch key, and evaluator-optimizer
268
+ rounds overlay the round index. Hosts can seed an outer scope with
269
+ `Smith::Attribution.with(execution_key: ...)` around non-persisted runs.
270
+ Restoration inside workflow execution rides `ThreadContextSnapshot`, which
271
+ now tracks the attribution thread key. Scope overlays are nil-ignoring
272
+ (`Context#merge`), but the per-step facts (`transition`, `from`, `to`)
273
+ are replaced verbatim, nil included (`Context#override`): a nested
274
+ child's `from`-less transition never inherits the parent step's `from`,
275
+ and the failed `:transition` trace keeps `from`/`to` present even when
276
+ nil for the same reason.
277
+ - Merge ambient attribution fields into every `Smith::Trace.record` payload.
278
+ Attribution keys are identifiers, not content: caller-supplied keys win on
279
+ conflict, the content policy is unaffected, and a configured
280
+ `trace_fields` allowlist stays authoritative (add attribution keys to an
281
+ allowlist to receive them). Disable with `Smith.config.trace_attribution =
282
+ false` (default true).
283
+ - Bound `Smith::Trace::Memory` (default 10,000 entries) with a
284
+ `dropped_count` reader and a `snapshot` method for readers racing
285
+ concurrent recording.
286
+ - Tag `Workflow::UsageEntry` with the ambient attribution at recording time:
287
+ new optional members `transition`, `branch_key`, `round`, and `attempt_id`.
288
+ All four are nil on entries restored from checkpoints written by earlier
289
+ Smith versions and are omitted from serialization when nil, so restored
290
+ pre-attribution documents re-serialize byte-identically (hosts that digest
291
+ whole persisted documents in exact-mutation proofs depend on this). A
292
+ rolled-back gem drops the new keys from plain checkpoint payloads
293
+ (`from_h` slices to known members). Composite branch effects are the
294
+ exception: effects written by this version from a fan-out branch carry
295
+ real attribution values, and an older gem's exact-key effects validation
296
+ rejects them, so see the upgrade notes below before rolling back.
297
+ `recorded_at` now carries microsecond precision (`iso8601(6)`) on new
298
+ entries. Recording symbolizes `transition`/`branch_key` Strings exactly
299
+ as `from_h` does on restore, so a host seeding String attribution through
300
+ `Smith::Attribution.with` gets entries equal to their restored form.
301
+ - Measure each provider attempt with a monotonic clock around the whole chat
302
+ completion (including any provider tool loop) and emit one
303
+ `:provider_call` trace per attempt (success or failure) carrying `model`,
304
+ `provider`, `duration_ms`, `attempt_id`, `attempt_index`, and `outcome`.
305
+ Every usage entry the attempt produced shares its `attempt_id` (an attempt
306
+ with an N-round tool loop records N entries): join there for the attempt's
307
+ single duration, never sum across entries. Gate with
308
+ `Smith.config.trace_provider_calls` (default true). `ProviderAttempt`
309
+ gains optional `attempt_id` and `duration_ms`.
310
+ - Add a public read-only `Workflow#usage_entries` (frozen copy under the
311
+ recording mutex) so hosts can diff usage across a step boundary without a
312
+ full `to_state` serialization; `to_state` and `snapshot_usage_entries` now
313
+ read the ledger under the same mutex, so a state written mid-fan-out never
314
+ captures a torn array.
315
+ - Thread real correlation identity through events: `Smith::Event#execution_id`
316
+ and `#trace_id` default to the ambient attribution execution key (the
317
+ persistence key during persisted runs) instead of a fresh random UUID per
318
+ event; the random fallback remains for events built outside any execution
319
+ scope. The `:tool_call` trace gains a nullable `tool_call_id` when the
320
+ invocation came from a provider batch (the tool-results capture entry
321
+ gains the same key under a later bullet in this release; composite
322
+ effects accept the extended shape with bounded value validation).
323
+
324
+ - Emit from the step-failure paths, closing the success-only observation
325
+ gap: both `handle_step_failure` and the unresolved-transition handler now
326
+ record a `:transition` trace with `outcome: :failed` plus bounded
327
+ classification (`error_class`, `error_family` from FailureRecord's
328
+ taxonomy, `retryable`) and emit a new `Smith::Events::StepFailed` event.
329
+ Raw error messages never ride either; an emission failure is logged and
330
+ can never mask the original step error. The marker key is `outcome`
331
+ because `result` is a reserved content key in the trace pipeline. An
332
+ unresolved transition with no `:fail` transition still re-raises without
333
+ emitting: that path was never treated as a step. The unresolved handler
334
+ runs outside any step context, so it seeds the run identity explicitly;
335
+ both failure paths stamp persisted-run events with the persistence key.
336
+ Emission is terminal-per-step: a step that retries internally and then
337
+ succeeds emits only its `StepCompleted`; intermediate step-body retry
338
+ attempts stay dark at the step layer (provider-level failures remain
339
+ visible as `:provider_call` failure traces). `StepFailed` handlers run
340
+ outside the step snapshot's interrupt-masked region (emission is staged
341
+ in the failure rescue and flushed after the mask closes), interruptible
342
+ exactly like `StepCompleted` handlers. A step body that surfaces Smith's
343
+ own `UnresolvedTransitionError` emits exactly one `StepFailed` under the
344
+ real step identity; the unresolved handler recognizes the already-emitted
345
+ error instead of emitting a second event under the requested (never
346
+ executed) name.
347
+ - Emit the long-advertised `:cost` trace: one per completed agent
348
+ invocation, whose value is the sum of that invocation's per-response
349
+ usage-entry costs. Summing per response is what tiered catalogs actually
350
+ bill; pricing the aggregate token totals as one call would resolve the
351
+ wrong tier for multi-response tool loops. Emitted only for fully metered,
352
+ fully priced invocations (a partially priced or partially metered
353
+ invocation emits nothing rather than presenting an incomplete figure);
354
+ gated by the existing `trace_cost` setting (the per-type gates live in
355
+ the built-in adapters; a custom adapter receives every type). `:cost`
356
+ traces are not a spend total: billed failed and partial attempts appear
357
+ only in usage entries. The same per-response sum now becomes
358
+ `agent_result.cost`, so budget settlement, result surfaces, recorded
359
+ entries, and the trace all agree on one invocation cost.
360
+ - The OpenTelemetry adapter now creates retroactive spans with real
361
+ durations (span start backdated by `:tool_call` seconds or
362
+ `:provider_call` milliseconds; instant spans otherwise), preserves
363
+ numeric attribute types instead of stringifying everything, and uses only
364
+ the documented opentelemetry-api surface (`Tracer#start_span` with
365
+ `start_timestamp`, `Span#finish` with `end_timestamp`).
366
+
367
+ - Add a `workflow` discriminator to the ambient attribution, every trace
368
+ payload, usage entries, and the `StepCompleted`/`StepFailed` events: the
369
+ emitting workflow's class name ("anonymous" when unnamed), so
370
+ nested-child graph facts are distinguishable from parent facts under the
371
+ shared root execution identity. Nil-omitted from serialized entries like
372
+ the other attribution members.
373
+ - Every provider attempt now emits its `:provider_call` trace: `outcome` is
374
+ `:success`, `:failure` (provider failure, fallback may continue), or
375
+ `:aborted` (a non-provider error that re-raises), so prefix-accounted
376
+ usage entries always have their attempt join target.
377
+ - The tool-results capture entry gains `tool_call_id` when the invocation
378
+ came from a provider batch (omitted otherwise, so direct-invocation and
379
+ pre-existing payloads keep their exact two-key shape); composite effects
380
+ accept the extended shape while still rejecting unknown keys.
381
+ - `StepFailed` handlers now run outside the step snapshot's
382
+ interrupt-masked region: emission is staged in the failure rescue and
383
+ flushed after the mask closes with explicitly seeded run identity, so a
384
+ slow host handler can no longer make the workflow thread unkillable and
385
+ handlers match `StepCompleted`'s interruptibility.
386
+
387
+ ### Fixed
388
+
389
+ - A user-declared `:fail` transition no longer inherits the early order
390
+ position of the auto-generated placeholder created by `state :failed`:
391
+ redeclaring a generated transition takes a fresh declaration position, so
392
+ it can no longer shadow a same-origin primary transition at run time.
393
+ Genuine user redefinitions keep their original position, and subclasses
394
+ inherit the bookkeeping.
395
+ - Budget cost settlement now consumes the per-response priced sum instead
396
+ of pricing the invocation's aggregate token totals. Under linear pricing
397
+ the figures are identical; under tiered pricing the aggregate resolved
398
+ the wrong tier (or missed every tier and settled zero), so a
399
+ cost-budgeted workflow could keep spending after its real billed cost
400
+ exceeded the budget.
401
+
402
+ ### Removed
403
+
404
+ - Remove the never-read `trace_retention` and `trace_tenant_isolation`
405
+ settings. Both were silent no-ops since introduction; reading or writing
406
+ them now raises, so a host relying on the illusion fails loudly instead
407
+ of silently.
408
+
409
+ ### Changed
410
+
411
+ - Workflow step failure is now observable: subscribers to the events bus
412
+ receive `StepFailed` where previously failures emitted nothing (the
413
+ success-only scope is gone), and trace consumers see failed `:transition`
414
+ payloads distinguished by `outcome: :failed`.
415
+ - `Workflow::Composite::Effects` validates usage-entry keys as
416
+ required-plus-allowed instead of exact: entries from an older producer
417
+ (missing the optional attribution keys) stay valid, current entries with
418
+ attribution pass, and unknown keys still reject. The optional keys are
419
+ bounded values, not just bounded keys: `transition`, `branch_key`, and
420
+ `workflow` must be non-empty Strings up to 256 characters, `round` a
421
+ non-negative Integer, and `attempt_id` a UUID when present. Tool results
422
+ accept the extended capture shape: `tool_call_id`, when present, must be
423
+ a non-empty String up to 1024 characters (`tool` keeps its exact prior
424
+ validation).
425
+
426
+
427
+ - `Smith::Events` subscriptions now live in per-class buckets guarded by a
428
+ mutex: emit touches only the buckets for the event's ancestors instead of
429
+ scanning every subscription, dispatch order remains registration order,
430
+ and `is_a?` matching semantics are unchanged (instance-extended modules
431
+ dispatch through the singleton class; immediate values, which have no
432
+ singleton class and cannot be extended, dispatch through their class
433
+ ancestors). `Subscription#cancel` now
434
+ detaches from the registry, so cancelled subscriptions (including
435
+ `Events.within` scopes) no longer leak. Handlers run outside the registry
436
+ lock, so a handler may subscribe or cancel without deadlocking.
437
+ - `Smith::Trace::Memory#record` and `#clear!` are mutex-guarded and safe
438
+ under parallel fan-out branches.
439
+
7
440
  ## [0.8.0] - 2026-07-25
8
441
 
9
442
  ### 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
@@ -83,8 +83,8 @@ Smith.configure do |config|
83
83
  tool_call: %i[tool duration]
84
84
  }
85
85
  config.trace_content = false
86
- config.trace_retention = 86_400
87
- config.trace_tenant_isolation = false
86
+ config.trace_provider_calls = true
87
+ config.trace_attribution = true
88
88
 
89
89
  config.pricing = {
90
90
  %w[openai gpt-4.1-nano] => {
@@ -115,16 +115,16 @@ observes a provider but finds only a model-only rate, it raises
115
115
  | `trace_transitions` | Emit transition traces | Usually leave on |
116
116
  | `trace_tool_calls` | Emit tool call traces | Usually leave on |
117
117
  | `trace_token_usage` | Emit usage traces | Useful for budget visibility |
118
- | `trace_cost` | Emit cost traces | Useful once pricing is configured |
118
+ | `trace_provider_calls` | Emit one timed `:provider_call` trace per provider attempt | Usually leave on |
119
+ | `trace_cost` | Emit one `:cost` trace per completed agent invocation | Emits only once pricing is configured |
119
120
  | `trace_fields` | Allowlist structural trace fields | Use when you want tighter trace output |
120
121
  | `trace_content` | Whether content appears in traces | Leave `false` first |
121
- | `trace_retention` | Trace retention policy hook | Useful when traces leave memory |
122
- | `trace_tenant_isolation` | Trace multi-tenant isolation flag | Enable in multi-tenant systems |
122
+ | `trace_attribution` | Merge ambient execution attribution into trace payloads | Usually leave on |
123
123
  | `pricing` | Best-known model-call cost catalog | Add once you care about `total_cost` |
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