smith-agents 0.11.0 → 0.12.1

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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +712 -0
  3. data/README.md +622 -15
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +80 -14
  6. data/docs/PATTERNS.md +30 -13
  7. data/docs/PERSISTENCE.md +733 -5
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +67 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/invocation_preparation.rb +37 -14
  18. data/lib/smith/agent/lifecycle.rb +16 -5
  19. data/lib/smith/agent/observed_model.rb +29 -0
  20. data/lib/smith/agent/output_contract_check.rb +53 -0
  21. data/lib/smith/agent/output_integrity.rb +137 -0
  22. data/lib/smith/agent/output_policy.rb +92 -0
  23. data/lib/smith/agent/output_recovery.rb +129 -0
  24. data/lib/smith/agent/provider_attempt.rb +6 -0
  25. data/lib/smith/agent/provider_call_timing.rb +13 -4
  26. data/lib/smith/agent/provider_completion.rb +35 -32
  27. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  28. data/lib/smith/agent/provider_request.rb +36 -0
  29. data/lib/smith/agent/provider_streaming.rb +155 -0
  30. data/lib/smith/agent/provider_usage.rb +70 -3
  31. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  32. data/lib/smith/agent/usage_traces.rb +9 -2
  33. data/lib/smith/agent/usage_tracking.rb +64 -51
  34. data/lib/smith/agent.rb +5 -0
  35. data/lib/smith/artifacts/active_record_store.rb +79 -0
  36. data/lib/smith/artifacts/file.rb +45 -18
  37. data/lib/smith/artifacts/memory.rb +47 -36
  38. data/lib/smith/artifacts/s3.rb +126 -0
  39. data/lib/smith/artifacts/scoped_store.rb +55 -7
  40. data/lib/smith/artifacts/store_rules.rb +49 -0
  41. data/lib/smith/artifacts.rb +98 -1
  42. data/lib/smith/batch/message_shape.rb +36 -0
  43. data/lib/smith/batch.rb +166 -0
  44. data/lib/smith/budget/amount.rb +25 -0
  45. data/lib/smith/budget/continuation.rb +153 -0
  46. data/lib/smith/budget/host_ledger.rb +137 -0
  47. data/lib/smith/budget/ledger.rb +4 -2
  48. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  49. data/lib/smith/budget/request_hold.rb +211 -0
  50. data/lib/smith/budget/spent_reservation.rb +18 -0
  51. data/lib/smith/child_run.rb +41 -0
  52. data/lib/smith/child_runs_failed.rb +56 -0
  53. data/lib/smith/content.rb +96 -0
  54. data/lib/smith/context/session.rb +14 -2
  55. data/lib/smith/context/state_injection.rb +10 -5
  56. data/lib/smith/context.rb +28 -0
  57. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  58. data/lib/smith/doctor/checks/configuration.rb +0 -11
  59. data/lib/smith/doctor/checks/durability.rb +20 -18
  60. data/lib/smith/doctor/checks/state_model.rb +63 -0
  61. data/lib/smith/doctor/installer.rb +9 -2
  62. data/lib/smith/doctor.rb +4 -0
  63. data/lib/smith/errors.rb +249 -2
  64. data/lib/smith/idempotency_key/canonical.rb +74 -0
  65. data/lib/smith/idempotency_key.rb +145 -0
  66. data/lib/smith/launchers/inline.rb +46 -0
  67. data/lib/smith/message.rb +76 -0
  68. data/lib/smith/models/tool_routing.rb +47 -44
  69. data/lib/smith/output_failure.rb +130 -0
  70. data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
  71. data/lib/smith/pricing/catalog_validation.rb +91 -0
  72. data/lib/smith/pricing.rb +95 -64
  73. data/lib/smith/providers/anthropic/batches.rb +116 -0
  74. data/lib/smith/providers/batch_exchange.rb +77 -0
  75. data/lib/smith/providers/openai/responses.rb +45 -20
  76. data/lib/smith/providers/output_limit.rb +68 -0
  77. data/lib/smith/providers/request_holds.rb +139 -0
  78. data/lib/smith/providers/response_facts.rb +108 -0
  79. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  80. data/lib/smith/providers/schema_constraints.rb +87 -0
  81. data/lib/smith/providers/schema_rules.rb +66 -0
  82. data/lib/smith/providers/stop_reason.rb +39 -0
  83. data/lib/smith/providers/stream_facts.rb +192 -0
  84. data/lib/smith/response_memo.rb +152 -0
  85. data/lib/smith/setting_constructors.rb +47 -0
  86. data/lib/smith/streaming/json_field.rb +136 -0
  87. data/lib/smith/streaming/json_string.rb +91 -0
  88. data/lib/smith/streaming/session.rb +77 -0
  89. data/lib/smith/streaming/think_tags.rb +67 -0
  90. data/lib/smith/streaming.rb +64 -0
  91. data/lib/smith/testing/checks.rb +58 -0
  92. data/lib/smith/testing/hooks.rb +85 -0
  93. data/lib/smith/testing/minitest.rb +37 -0
  94. data/lib/smith/testing/rspec.rb +52 -0
  95. data/lib/smith/testing/stub.rb +110 -0
  96. data/lib/smith/testing.rb +158 -0
  97. data/lib/smith/tool/scoped_context.rb +9 -12
  98. data/lib/smith/tool.rb +14 -0
  99. data/lib/smith/trace/logger.rb +1 -1
  100. data/lib/smith/trace/memory.rb +1 -1
  101. data/lib/smith/trace/open_telemetry.rb +1 -1
  102. data/lib/smith/trace.rb +11 -4
  103. data/lib/smith/untrusted.rb +74 -0
  104. data/lib/smith/version.rb +2 -2
  105. data/lib/smith/workflow/active_clock.rb +61 -0
  106. data/lib/smith/workflow/agent_result.rb +37 -0
  107. data/lib/smith/workflow/artifact_integration.rb +57 -7
  108. data/lib/smith/workflow/await_step.rb +42 -0
  109. data/lib/smith/workflow/batch_charges.rb +74 -0
  110. data/lib/smith/workflow/batch_execution.rb +149 -0
  111. data/lib/smith/workflow/batch_holds.rb +73 -0
  112. data/lib/smith/workflow/batch_wait.rb +134 -0
  113. data/lib/smith/workflow/budget_integration.rb +11 -5
  114. data/lib/smith/workflow/budget_pause.rb +51 -0
  115. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  116. data/lib/smith/workflow/child_run_execution.rb +150 -0
  117. data/lib/smith/workflow/child_run_results.rb +55 -0
  118. data/lib/smith/workflow/composite/effects.rb +37 -6
  119. data/lib/smith/workflow/composite/error.rb +1 -1
  120. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  121. data/lib/smith/workflow/composite/input.rb +11 -4
  122. data/lib/smith/workflow/context_keys.rb +77 -0
  123. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  124. data/lib/smith/workflow/deterministic_execution.rb +61 -10
  125. data/lib/smith/workflow/deterministic_step.rb +55 -6
  126. data/lib/smith/workflow/dsl.rb +59 -0
  127. data/lib/smith/workflow/durability.rb +139 -30
  128. data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
  129. data/lib/smith/workflow/event_integration.rb +9 -0
  130. data/lib/smith/workflow/execution.rb +30 -23
  131. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  132. data/lib/smith/workflow/failure_reconstructor.rb +27 -4
  133. data/lib/smith/workflow/failure_record.rb +6 -1
  134. data/lib/smith/workflow/failure_record_validator.rb +11 -4
  135. data/lib/smith/workflow/fanout_execution.rb +5 -8
  136. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  137. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  138. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  139. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  140. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  141. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  142. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  143. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  144. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  145. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  146. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  147. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  148. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  149. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  150. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  151. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  152. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  153. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  154. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  155. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  156. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  157. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  158. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  159. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  160. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  161. data/lib/smith/workflow/graph.rb +12 -0
  162. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  163. data/lib/smith/workflow/message_batch.rb +6 -0
  164. data/lib/smith/workflow/message_roles.rb +25 -0
  165. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  166. data/lib/smith/workflow/nested_execution.rb +47 -4
  167. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  168. data/lib/smith/workflow/parallel_execution.rb +10 -0
  169. data/lib/smith/workflow/persistence.rb +136 -13
  170. data/lib/smith/workflow/remembered_calls.rb +92 -0
  171. data/lib/smith/workflow/retry_execution.rb +68 -9
  172. data/lib/smith/workflow/retry_wait.rb +134 -0
  173. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  174. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  175. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  176. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  177. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  178. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  179. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  180. data/lib/smith/workflow/step_completion.rb +36 -7
  181. data/lib/smith/workflow/step_context.rb +21 -0
  182. data/lib/smith/workflow/step_io.rb +132 -0
  183. data/lib/smith/workflow/step_stream.rb +52 -0
  184. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  185. data/lib/smith/workflow/transition.rb +267 -13
  186. data/lib/smith/workflow/transition_actionability.rb +2 -0
  187. data/lib/smith/workflow/usage_entry.rb +24 -3
  188. data/lib/smith/workflow/wait_completion.rb +131 -0
  189. data/lib/smith/workflow/wait_execution.rb +180 -0
  190. data/lib/smith/workflow/wait_record.rb +157 -0
  191. data/lib/smith/workflow.rb +113 -17
  192. data/lib/smith.rb +101 -45
  193. metadata +88 -1
data/CHANGELOG.md CHANGED
@@ -4,6 +4,718 @@ 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.12.1] - 2026-10-11
8
+
9
+ ### Changed
10
+
11
+ - `rails g smith:install` sets `persistence_max_bytes` to 16 MiB (it was
12
+ 1 MiB), with the payload CHECK a third above it (22,373,717 bytes). One
13
+ step can add more than 1 MiB to a run's state (a `map` step's collected
14
+ results are bounded at 4 MiB), so the old default could refuse a run Smith
15
+ built itself. An app generated with 0.12.0 keeps its bound: raise
16
+ `config.persistence_max_bytes`, and replace the CHECK in a migration
17
+ (`remove_check_constraint` then `add_check_constraint` with the new
18
+ bound; on MySQL, also `change_column :workflow_states, :payload, :text,
19
+ limit: 22_373_717`).
20
+
21
+ ## [0.12.0] - 2026-10-11
22
+
23
+ ### Upgrade notes
24
+
25
+ - `execute` reads `mode:` as the call mode (`:sync`, `:batch`, or a callable)
26
+ and `stream:` as a stream name; neither stays in `agent_opts`, and any other
27
+ `mode:` value raises `Smith::WorkflowError` at declaration.
28
+
29
+ - The artifact store must be configured: outside `test_mode`, storing or
30
+ reading an artifact with no `artifact_store` raises
31
+ `Smith::ArtifactStoreMissing` instead of using a store in process memory
32
+ (a run that keeps no artifacts needs none). Set `config.artifact_store` to
33
+ `:s3`, `:active_record`, `:file` or `:memory` (with
34
+ `config.artifact_options`), or a store; `smith doctor` reports a missing,
35
+ unusable or process-local store, and the install templates no longer write
36
+ `Smith::Artifacts::Memory.new`.
37
+ - Artifact stores take bytes: `store` refuses anything but a String with a
38
+ `TypeError` (Memory used to keep any object, and File wrote its `inspect`),
39
+ so serialize first (`JSON.generate`). A store class now gets built with
40
+ `artifact_options` unless the class itself stores and fetches. A tenant
41
+ `namespace:` must be letters, digits, `-` and `_`, and
42
+ `artifact_tenant_isolation` and `artifact_retention` now apply to every
43
+ built-in store. `Memory#delete` drops one run's claim when given its
44
+ `execution_namespace:`; the File store's refs are random UUIDs, as before.
45
+
46
+ - `Smith::EXECUTION_SEMANTICS_VERSION` is now `"7"`: routing, context writes,
47
+ restore admission, nested failure and retry semantics changed, and durable
48
+ composite plans may carry content parts, which an earlier build would send
49
+ as JSON text. Hosts that
50
+ fold it into definition digests or cache keys see new values, and durable
51
+ composite plans prepared under 0.11.0 are refused by their integrity check.
52
+ Drain or clear in-flight persisted runs before upgrading.
53
+ - Token and cost accounting changed too (see Changed): cache tokens count in
54
+ totals and budgets, cached input on the OpenAI Responses path is priced at
55
+ the cache-read rate, and completed tool-loop rounds before a failure settle
56
+ into budgets. A run in flight across the upgrade mixes the two accountings.
57
+ - OpenAI Responses calls cost less on upgrade unless the catalog sets
58
+ `cache_read_cost_per_token`: their cached input was priced at the full input
59
+ rate and is now priced at the cache-read rate, 0 when unset.
60
+ - `append_session_messages!` now admits only the roles `user`, `assistant` and
61
+ `tool`. A host that admitted system notes should put that text in
62
+ `seed_messages`, the agent's instructions or an `inject_state` formatter. A
63
+ restored payload must hold only `system`, `user`, `assistant` and `tool`
64
+ messages; one written earlier may hold a system message the host admitted,
65
+ which still reaches agents, so rebuild such sessions to rely on the new rule.
66
+ - An agent with no model and no fallback models now fails its step with a
67
+ `Smith::WorkflowError` instead of returning nil, and runtime readiness reports
68
+ `:agent_without_model` as an error. Give placeholder agents a model (and a
69
+ stubbed chat in tests). An agent with fallback models but no primary model
70
+ used to be skipped the same way and now runs on its fallbacks.
71
+ - A response cut off at its output limit, a refused response, and a structured
72
+ output that does not parse to a JSON object now fail the step
73
+ (`Smith::OutputTruncated`, `Smith::OutputRefused`, `Smith::OutputInvalid`)
74
+ instead of passing on as the output; `on_truncation :accept` keeps the old
75
+ behavior for cut-off responses. These errors are not `AgentError`s, so a
76
+ `rescue Smith::AgentError` no longer catches such a failure. An agent's
77
+ `after_completion` now runs before the structured-output checks, and does not
78
+ run for a refused or cut-off response.
79
+
80
+ ### Added
81
+
82
+ - Durable artifact stores, configured like workflow state:
83
+ `config.artifact_store = :s3` (AWS S3, Cloudflare R2 and other
84
+ S3-compatible buckets, through the host's `aws-sdk-s3` or `client:`) or
85
+ `:active_record` (a host table), with `config.artifact_options`; the store
86
+ may also be a store class built with the options. Every built-in store
87
+ records which run stored each ref (`stored_by?`) and can `delete`, and
88
+ `smith doctor` checks the store.
89
+ - Content parts. `Smith::Message.user`, `.assistant` and `.system` take a String
90
+ or `Smith::Content` parts (`text`, and `document` and `image`, which name an
91
+ artifact of the run and a media type) in a step's `input:`; seed messages
92
+ take text parts, and only user messages take documents and images.
93
+ Anthropic gets native blocks with `cache_control` on parts marked
94
+ `cache: true` (at most 4 breakpoints per request, the chat's own included);
95
+ other providers get the parts in order as text with the files attached where
96
+ they stand, and a model that takes no such content passes the step to the
97
+ next in the chain. Documents and images are read once per call, only from a
98
+ store that records which run stored them, and checked against their media
99
+ type, a 24 MiB document and 5 MiB image bound, and a 32 MiB request bound;
100
+ any failure raises `Smith::ContentUnavailable` before the call. A message
101
+ with parts carries `"parts" => true`, which no agent output or
102
+ host-admitted message can carry, and a restored session holds parts only in
103
+ its seed messages. A child run may read its parent's artifacts and those of
104
+ the parent's nearest ancestors (`Smith::ChildRun#artifact_namespaces`, which
105
+ a queueing launcher passes to the job like `untrusted_keys`; persisted with
106
+ the child as `inherited_artifact_namespaces`).
107
+ - Durable retry. `retry_on ..., durable: true` waits out each backoff as a
108
+ wait of the run (`awaiting.kind == :retry`, due when the backoff ends)
109
+ instead of sleeping in the worker. `expire_wait!` past the deadline ends the
110
+ wait without a step, and the host continues the run, which makes the next
111
+ attempt under the recorded attempt number and key generation; the pending
112
+ retry persists (`pending_retry`, shown as `awaiting.attempt` and
113
+ `awaiting.error`) until the step ends without a wait, so a crash makes the
114
+ same attempt again under the same keys (in strict mode, the restore of a run
115
+ caught in it raises `Smith::StepInProgressOnRestore`). No retry wait is
116
+ entered past the deadline, after a tool ran, or for a backoff that does not
117
+ end within the persistence TTL, which runtime readiness reports
118
+ (`:durable_retry_outlasts_ttl`). `durable:` needs 2 attempts, a positive
119
+ backoff and `on_failure` (so a last failure settles the run rather than
120
+ being made again by a job that runs again), and is refused on await, delay,
121
+ parallel, fan_out, optimize and orchestrate steps; graph inspection shows it, and runtime readiness reports
122
+ an in-process child with a durable retry (a nested workflow, or a child run
123
+ the launcher runs inline) as `:nested_workflow_waits`. Split-step
124
+ persistence refuses a step with a durable retry. Traces gain the outcomes
125
+ `:retrying` and `:retry_due`.
126
+ - Streaming steps. `execute Agent, stream: :name` sends the reply's text, as
127
+ the model writes it, to a sink the host registers in
128
+ `Smith.config.stream_sinks`: a callable that takes the step's info
129
+ (`stream`, a frozen copy of the `execution_key`, `transition`) when the step
130
+ starts and returns that step's sink, or nil to stream nothing, so concurrent
131
+ runs never share a sink. A sink defines any of `start(info)` per provider
132
+ call (with the attempt, model and provider), `delta(text)` per text chunk
133
+ (not tool calls, thinking or `<think>` text, which `Smith::Streaming::ThinkTags`
134
+ keeps out as RubyLLM keeps it out of the message), `reset(reason)` when
135
+ Smith discards the text sent (`:retry`, `:fallback`, `:truncation`,
136
+ `:repair`; and `:retry_wait`, always, when a durable retry waits),
137
+ `finish(output)` with the accepted output (after guardrails, the output
138
+ contract and `output_to:`, and in a stored run once `persist!` has stored
139
+ the step; a store that fails gives `fail` with its error) and
140
+ `fail(error_class)`. A sink or sink callable that raises (a `ScriptError`
141
+ included) is logged by error class and the step goes on; an unregistered
142
+ name fails the step before its call (`Smith::StreamSinkMissing`). The text is
143
+ a draft until `finish`. `Smith::Streaming::JsonField#push` returns the text
144
+ each delta adds to one top-level string field of streamed JSON (keys read as
145
+ JSON reads them), in linear time. Stop reasons and message ids are read from
146
+ the stream itself (`Smith::Providers::StreamFacts`, prepended on RubyLLM's
147
+ Anthropic, OpenAI, Gemini, OpenRouter and Bedrock chunk builders with each
148
+ one's visibility kept, inherited by their subclasses, and on
149
+ `RubyLLM::Provider#complete` to tell a tool loop's requests apart), from the
150
+ step's own provider only (a tool's own RubyLLM call is not read), per
151
+ request, in fiber storage; each usage entry keeps its response's facts and
152
+ `mode: :stream`, so cut-off and refused replies are handled and recorded as
153
+ without streaming. A streamed reply with no stop reason counts as cut off:
154
+ `output_limit_reached` when its output tokens reached the request's limit,
155
+ else `stream_incomplete`. Streaming changes nothing durable, and a replayed
156
+ step streams again. `stream:` is refused on a parallel step; graph
157
+ inspection shows it, and runtime readiness reports a missing sink
158
+ (`:stream_sink_missing`) and an agent whose calls go to the Responses API,
159
+ which does not stream (`:stream_whole_replies`, decided as Smith's tool
160
+ routing decides at runtime: `Smith::Models::ToolRouting.responses?`).
161
+ - Batch mode. `execute Agent, mode: :batch` (or `:sync`, or a callable of the
162
+ context and the step attempt returning one), and the same on `fan_out`,
163
+ sends the step's requests through a provider's batch API. Smith prepares
164
+ each call as a synchronous one, admits it against the budget, captures the
165
+ exact request at RubyLLM's `sync_response` (`Smith::Providers::BatchExchange`,
166
+ bound to the call's own provider object; the connection's credentials never
167
+ leave it), and hands it to `Smith.config.batch_queue.enqueue(custom_id:,
168
+ await_token:, request:, execution_key:)`; the run waits on `:batch_result`
169
+ (kind `:batch`, 25 hours) with its `pending_batch` recorded. The host
170
+ submits, polls and collects with `Smith::Providers::Anthropic::Batches`
171
+ (`submit`, `status`, `results`, `cancel`; no connection retries, so a batch
172
+ is never submitted twice; the connection's own headers win over a request's)
173
+ and resumes the run once with `{custom_id => result}`, each result's message
174
+ checked against the provider's reply shape. The requests are queued only once
175
+ the step's attempt has ended without error; `pending_batch` keeps each
176
+ request's digest. A call that renders another request by the time its
177
+ result comes (a date in its prompt, data read afresh) does what its step's
178
+ `on_request_change:` says: `:keep` (the default) takes the result, its
179
+ `:provider_call` trace saying `request_changed: true`; `:resend` sends the
180
+ request as rendered now over the wire; `:fail` raises
181
+ `Smith::BatchRequestChanged` (an `AgentError`, which `retry_on` may retry).
182
+ A result set aside is charged as a `:failed_attempt`. The step runs again at its attempt, each call
183
+ of that attempt (only) taking its result through RubyLLM's own parser, and a
184
+ succeeded result no call took (a cancelled fan-out branch), priced under the id RubyLLM
185
+ resolves its agent's model to, is charged as a
186
+ `:failed_attempt`; in strict mode the completion runs as a marked step. A
187
+ batch step in an in-process child fails before capture. Usage with
188
+ `mode: :batch` priced
189
+ with `batch_discount` and charged even past a budget
190
+ (`Budget::Ledger#reserve_spent!`), output checks, follow-up calls over the
191
+ wire, guardrails and `output_to:` as after a synchronous call. A refused
192
+ request raises `Smith::BatchRequestInvalid`; a failed, canceled, expired or
193
+ missing result raises `Smith::BatchResultFailed`, which `retry_on` sends
194
+ again. A missing queue raises `Smith::BatchQueueMissing`, and a provider
195
+ without a batch interface in Smith (only Anthropic has one) or an agent with
196
+ tools `Smith::BatchModeUnavailable`, before anything is sent. A batch step
197
+ needs `on_failure` and cannot stream or be `parallel:`; graph inspection
198
+ shows the mode, and runtime readiness reports `:batch_queue_missing`,
199
+ `:batch_unavailable` and an in-process child with a batch step.
200
+ - A step that runs inside another run's step (an inline child run) scopes its
201
+ artifacts over the configured store itself, not over the outer run's
202
+ scoped view, through which it could read nothing of its own.
203
+ - A run's scoped artifact reads check, with a store that records it
204
+ (`stored_by?`, added to `Smith::Artifacts::Memory` and
205
+ `Smith::Artifacts::File`), that one of the runs it may read stored the ref,
206
+ not only the ref's prefix. `Smith::Artifacts::File` no longer reads a ref
207
+ outside its directory or another tenant's.
208
+ - `Smith::Workflow::Wait` gains `attempt` and `error` (nil except for a retry
209
+ wait). A wait as long as the persistence TTL is now refused too (it was
210
+ refused only when longer), since the stored run would expire as it ends.
211
+ - Child runs. `map items, workflow:, max_items:, input:, timeout:, into:,
212
+ collect:, on_item_failure:` launches a run of its own per item and waits,
213
+ bounded by `timeout:`, for the host to resume the parent with
214
+ `resume!(key, event: :children, token:, payload:)` and one
215
+ `Smith::ChildRun.result` entry per child; `call workflow, input:, timeout:,
216
+ into:` is a map of one whose `into:` gets the child's output; `spawn items,
217
+ workflow:, max_items:, input:` launches and continues. Runs start through
218
+ `Smith.config.run_launcher.launch(children:, parent_key:, await_token:)`,
219
+ which must be idempotent on `child.key` (the step's token and the child's
220
+ ref, the index of its item), so a replayed step launches the same children
221
+ and two steps never share a key.
222
+ `Smith::Launchers::Inline` (`inline?` true, the only kind of launcher whose
223
+ return value is taken as results) runs them in process. More than
224
+ `max_items` fails the step before anything launches; results that do not
225
+ match the number of children launched raise to the host and leave the run
226
+ waiting; `collect:` writes outputs, states or counts; `on_item_failure:
227
+ :fail` fails the step with `Smith::ChildRunsFailed` (failure family
228
+ `child_runs_failed`) and needs `on_failure`. No map, call or spawn takes
229
+ `retry_on`, and split-step persistence does not prepare them. A child gets
230
+ only what `input:` returns and keeps any of its parent's untrusted keys
231
+ passed under the same name, persisted with it as `inherited_untrusted_keys`.
232
+ Graph inspection shows `kind: :map`, `:call` or `:spawn` with the child and
233
+ its bounds; runtime readiness walks child workflows, reports
234
+ `:run_launcher_missing`, flags an in-process nested workflow that reaches a
235
+ map, call or spawn, and warns of a child agent that reads a key the parent
236
+ marks untrusted.
237
+ - Output integrity. Smith reads each response's stop reason (OpenAI's
238
+ message-level refusals and Gemini's prompt blocks included) as cut off or
239
+ refused. A cut-off response raises `Smith::OutputTruncated` unless the agent
240
+ declares `on_truncation :accept` or `on_truncation retry_with:
241
+ { max_tokens: n }` (one more call on the same model, with the limit mapped to
242
+ the provider's own parameter, made only when it is larger than the request
243
+ had and the response did not run out of context); a refused one raises
244
+ `Smith::OutputRefused` unless the agent declares
245
+ `on_refusal fallback: "provider/model"` (one call on that model). A
246
+ structured output (`output_schema`, or a static RubyLLM `schema`) that is not
247
+ a JSON object, breaks a schema constraint, or fails `output_contract` (whose
248
+ `call(hash)` returns true, false, or a result answering `success?` and
249
+ `errors`) goes back to the model with its problems up to `output_repairs`
250
+ times (default 1), then raises `Smith::OutputInvalid` with at most 20 problems
251
+ as "path: problem" lines. No follow-up call is made once a tool has run in
252
+ the step or the step's reserved budget is spent, and a follow-up that fails
253
+ raises the output error with the provider's error as its cause. For
254
+ Anthropic, Smith sends the schema without the numeric, string-length and
255
+ array-size constraints it rejects, with `additionalProperties: false` on
256
+ every object, refuses an object that allows other properties or declares
257
+ none (runtime readiness reports `:output_schema_unsupported`), and checks
258
+ those constraints on the parsed output for every provider. The three errors
259
+ share the failure family `output_failure` (in durable composite branches
260
+ too), persist and restore with their details, and are not retryable. Every
261
+ response is billed and settles into budgets, and a rejected one leaves an
262
+ `:output_rejected` trace.
263
+ - Usage carries prompt-cache, thinking and response facts. Each provider
264
+ response's usage (`Smith::Agent::ProviderUsage`), each `UsageEntry`, each
265
+ `AgentResult`, and the `:provider_call` trace gain `cache_read_tokens`,
266
+ `cache_write_tokens`, `thinking_tokens`, `stop_reason`, and
267
+ `provider_message_id`; usage entries also gain `mode` (`:sync` for every call
268
+ today; streaming and batch steps will record theirs). `input_tokens` keeps
269
+ meaning uncached input, as RubyLLM reports every provider; the cache counts
270
+ sit beside it, and `thinking_tokens` are already inside `output_tokens`.
271
+ `stop_reason` and `provider_message_id` are read from the raw response body
272
+ of Anthropic Messages, OpenAI Chat Completions, OpenAI Responses, Gemini and
273
+ Bedrock Converse, each recognized by its own marker (for example
274
+ `"object" => "response"`); any other body gives neither, and a body that
275
+ cannot be read never fails the call. They are bounded to 256 bytes and are
276
+ those of the last response of a tool loop, even when that response reported
277
+ no usage. Each is omitted when the provider did not report it (RubyLLM's
278
+ OpenAI adapters report a cache write count of 0): `UsageEntry#to_h` and
279
+ `ProviderUsage#to_h` leave nil optional members out, so entries written by
280
+ earlier versions re-serialize byte-identically. Usage entries also gain
281
+ `mode`, set only for streamed and batch calls (later in this release), so an
282
+ ordinary call's entry keeps its earlier shape. `:token_usage` and `:cost`
283
+ traces carry the cache counts too, and `:cost` adds `cache_unpriced: true`
284
+ when the invocation's cache tokens were priced at 0 for lack of a rate. Hosts
285
+ with a `trace_fields` allowlist must add the new keys to receive them.
286
+ - Pricing catalogs accept `cache_read_cost_per_token` and
287
+ `cache_write_cost_per_token` on an entry or a tier (a tier without its own
288
+ uses the entry's), and `batch_discount` (at least 0 and below 1) on an entry,
289
+ applied to the whole cost of a call made in batch mode. They are configured
290
+ per catalog and count as 0 when absent, so a call is never left unpriced for
291
+ lack of them. `Smith::Pricing.compute_cost` takes `cache_read_tokens:`,
292
+ `cache_write_tokens:` and `mode:` (defaults 0, 0, `:sync`) and treats a count
293
+ that is not a non-negative Integer as 0, so it still never raises. Batch mode
294
+ is priced only through `compute_cost(mode: :batch)` until batch steps ship.
295
+ - Step isolation. `input: ->(ctx) { [...] }` on `execute`, `route`,
296
+ `fan_out`, `optimize` and `orchestrate` gives a step its own messages (at
297
+ most 100, user and assistant roles only, with content, bounded like session
298
+ messages) in place of the session transcript. `output_to: :key` writes the
299
+ step's output to the context instead of the transcript, JSON-shaped, once the
300
+ step completes; a key an explicit persist list leaves out is refused before
301
+ the step runs, and validation reports `:output_to_not_persisted` (and
302
+ `:await_into_not_persisted` for an await's `into:`). A compute step's
303
+ `last_output` is the latest step output wherever it went, and the next
304
+ shared agent step still gets the continuation turn after the transcript's
305
+ last output. `session :isolated` on a workflow keeps no transcript for
306
+ agents: every agent step must declare `input:`, outputs are not appended,
307
+ and `inject_state` never runs. Graph inspection shows `input` and
308
+ `output_to`; validation reports `:isolated_step_without_input` and warns
309
+ with `:isolated_workflow_nests_shared_session`.
310
+ - `untrusted :key` on a `Smith::Context` keeps a key from `inject_state`
311
+ formatters and the `seed_messages` builder, and refuses an agent that names
312
+ it in `inputs`, in the workflow and in any workflow it nests (runtime
313
+ readiness reports `:untrusted_agent_input` and
314
+ `:nested_untrusted_agent_input`); reserved input names are not checked, and
315
+ `untrusted :auto` is refused. `Smith::Untrusted.wrap(text, source:)`
316
+ delimits outside content with a boundary the text cannot guess, derived
317
+ inside a step so a re-prepared step input is identical, and marks role
318
+ labels after any line break, and look-alike untrusted tags, as quoted text.
319
+ - Durable, bounded waits. `await event, into:, timeout:` waits for an event the
320
+ host delivers with `Workflow.resume!(key, event:, token:, payload:)`, which
321
+ writes the payload to the context key `into:` and completes the step;
322
+ `delay seconds` waits for the time alone. `timeout:` is required (seconds,
323
+ or a callable of the context), and an await must declare `on_timeout` or
324
+ `on_failure`; `expire_wait!(key, token:, now:)` past the deadline takes
325
+ `on_timeout` (with a `StepCompleted` ending in the await's own state) or
326
+ `on_failure` with the new `Smith::WaitTimedOut` (failure family
327
+ `wait_timed_out`, restored with its details). An await's optional block runs
328
+ on entry with a `Smith::Workflow::AwaitStep` (`await_token`, `read_context`,
329
+ `idempotency_key`, `resolve!`). Entering a wait is not a step: `advance!`
330
+ returns nil, `run!` and `run_persisted!` stop, the run is never terminal,
331
+ and step counts and `max_transitions` do not move. `waiting?` and `awaiting`
332
+ (a `Smith::Workflow::Wait`) let the host index waits and schedule expiry;
333
+ Smith never polls or wakes a run. The class methods store the run with the
334
+ version check, require an adapter with `store_versioned`, and return
335
+ `:resumed`, `:failed`, `:expired`, `:not_due`, `:missing` or `:stale`. A
336
+ reply that arrives before its wait is stored completes the await at once
337
+ when the token is the one the run is entering it with, and the entering
338
+ process loses its next store. One completion at a time claims a wait on an
339
+ instance. A wait longer than the persistence TTL is refused on entry, and
340
+ `stuck_for?` treats a waiting run as stuck only past its deadline. Graph
341
+ inspection shows `kind: :await` or `:delay`, the wait and
342
+ `timeout_transition`, and runtime readiness reports a nested workflow that
343
+ reaches a wait (`:nested_workflow_waits`). Split-step persistence refuses
344
+ wait steps, and an in-process nested workflow that reaches a wait fails its
345
+ parent's step. `Workflow.transition_named` finds a transition by the String
346
+ form of its name.
347
+ - Idempotency keys for requests a step sends outside the system.
348
+ `step.idempotency_key(scope = nil)` in `compute` and `run` steps,
349
+ `idempotency_key(scope)` inside a `Smith::Tool`'s `perform`, and
350
+ `Smith::IdempotencyKey.current(scope = nil)` for host code called from a
351
+ running step return 64 lowercase hex characters (a SHA-256). A key is the
352
+ same on every attempt of the step and after a restore, and unique to the
353
+ run, the step's position (step number and transition, then any nested
354
+ workflow step, parallel or fan-out branch, durable composite branch,
355
+ optimizer round and role, or orchestrator round and worker task, a task
356
+ being named by its content), the tool's `name` and the scope. Scopes are
357
+ built from Strings, Symbols, numbers (a whole Float counts as the
358
+ Integer), booleans, `nil`, Arrays and Hashes, within 32 levels and 100,000
359
+ values; NaN, Infinity and other objects raise `ArgumentError`. Outside a
360
+ running step, and in event handlers and `on_step` callbacks, they raise
361
+ `Smith::WorkflowError`. Keys survive a crash replay when the run was
362
+ checkpointed before the step, as `advance_persisted!` and `run_persisted!`
363
+ do.
364
+ - `retry_on ..., new_idempotency_key_on: [ErrorClass]` and
365
+ `step.fail!(..., new_idempotency_key: true)` declare that a failed attempt's
366
+ own requests took no effect, so the attempts after it use new keys; every
367
+ other retry keeps them. `fail!`'s flag counts only for the step that raised
368
+ it, never for an enclosing workflow's retry. `new_idempotency_key_on:` must
369
+ name classes the policy retries, each once, refuses retry-forbidden classes,
370
+ is refused on nested workflow, parallel, fan-out, optimize and orchestrate
371
+ transitions, and shows in graph inspection's retry policy when declared.
372
+ `DeterministicStepFailure#new_idempotency_key` and `#idempotency_scope`, and
373
+ `DeterministicStep#attempt` (the attempt under way, from 1).
374
+ - `UsageEntry#total_tokens`, `AgentResult#total_tokens`,
375
+ `AgentResult#billable_tokens` (every known count, which budgets settle) and
376
+ `ProviderUsage#total_input_tokens`. `Agent::Completion#usage` (the whole
377
+ completion's `ProviderUsage`), `AgentResult.usage_details`,
378
+ `AgentResult::USAGE_DETAIL_MEMBERS` and `UsageEntry::OPTIONAL_MEMBERS` are
379
+ public.
380
+ - Response memo: `Smith.config.response_memo` (`fetch(key)`,
381
+ `store(key, record)`) keeps what a step's provider calls got, so a step
382
+ replayed after its process died reuses it instead of paying again. Each
383
+ call is keyed by its position's idempotency scope, the attempts it runs
384
+ under (enclosing runs' too), its attempt index, its place among the
385
+ attempt's calls and a digest of its request (a changed request misses).
386
+ Its record (version 1) holds the messages it added (replies, structured
387
+ ones too, with their usage and facts, a stream's included; tool calls and
388
+ results), stored after each round's tool results and when it ends. A
389
+ replayed call that ended sends nothing (its `:provider_call` trace says
390
+ `memo: true`, its usage is recorded once); one that had not continues from
391
+ its tools' results. Kept tool results count as tools the step ran. A memo
392
+ never fails a step: a record Smith does not keep is ignored, errors are
393
+ logged by class.
394
+ - Budget ledgers: `budget_ledger(*scope_keys) { |scope| ... }` names the
395
+ host's ledger for a run (an account's spend across runs, say). Its scope is
396
+ the values the keys have in the run's trusted context when it is created,
397
+ kept with the run (`budget_ledger_scope`); Smith builds the ledger at the
398
+ run's first request, never persists it, and never needs it to restore a
399
+ run. A call's first request over the wire (each fallback model, each
400
+ follow-up, streamed or not, OpenAI's Responses route included) is held on
401
+ it before it is sent: `reserve!(amount, key:, expires_at:)`, admitted only
402
+ by `true`, then `reconcile!(key, amount)` with what it spent (its hold, when
403
+ it answered without usage or its stream failed part way), or
404
+ `release!(key)` when it failed before anything came back. A hold
405
+ (`Smith::Budget::Amount`: `input_tokens`, `output_tokens`, `cost`,
406
+ `tokens`) is the request's output cap and an estimate of its input (images
407
+ and PDFs counted as such, in attachments and raw content blocks), priced
408
+ under the model's resolved id. A refusal raises `Smith::BudgetUnavailable`,
409
+ a `BudgetExceeded`, before anything is sent; a ledger that cannot be built
410
+ or answer `reserve!` raises `Smith::BudgetLedgerError`; an error from
411
+ `reconcile!` or `release!` is logged by class and the step goes on. Keys
412
+ are `"<attempt_id>:<round>"`, and a batch request's `custom_id`: a batch
413
+ request is held when captured, at the batch discount, and settled when its
414
+ result comes. Every hold carries `expires_at`,
415
+ `Smith.config.budget_hold_ttl` (3600 seconds by default) from now, and a
416
+ batch request's wait longer, so a ledger drops a hold whose process died.
417
+ `Smith.config.budget_hold_output_tokens` caps a hold's output part (an
418
+ agent with no `max_tokens` is otherwise held for the model's maximum).
419
+ A tool loop's later rounds are held only by a step whose `retry_on` names
420
+ `BudgetUnavailable`: a refused round pauses the loop, its tool calls and
421
+ results kept with the step's retry (`pending_retry` `continuation`), and
422
+ the retry continues from them, running none of its tools again; any other
423
+ step's later rounds go on unheld and are reconciled. A nested workflow
424
+ without its own ledger charges the enclosing run's; a child run, inline or
425
+ not, its own class's (readiness warns of a child workflow that declares
426
+ none, `:child_run_unbudgeted`). Runtime readiness warns of an agent whose
427
+ model has no price wherever the ledger is charged
428
+ (`:budget_ledger_unpriced`), and its metrics name the scope keys.
429
+ - Testing kit: `require "smith/testing"` (and `smith/testing/minitest`,
430
+ `smith/testing/rspec`). `Smith::Testing.stub_agent(agent, outputs:,
431
+ usage:, stop_reason:, stream:)` answers an agent's requests where they
432
+ would go over the wire, so the tool loop, budget holds, the response
433
+ memo, streaming and output checks run as for a provider's reply. Outputs
434
+ are taken in turn: a String, a Hash as JSON text, or
435
+ `tool_call(ToolClass, **arguments)`, which runs the agent's own tool (a
436
+ tool the agent lacks fails the call). Only the stubbed call's own
437
+ requests are answered, and a batch step's capture and delivery are left
438
+ to batch mode; `Smith::Testing.unstubbed = :refuse` fails an agent's call
439
+ that has no stub (`UnstubbedCall`) instead of sending it.
440
+ `run_until_waiting`, `resume_with` and `expire` drive a
441
+ run through its waits (a durable retry's backoff ends at once);
442
+ `batch_result` and `batch_payload` build a batch delivery. Minitest
443
+ assertions (`assert_waiting_for`, `assert_transitioned_through`,
444
+ `assert_usage_recorded`) and RSpec matchers (`be_waiting_for`,
445
+ `have_transitioned_through`, `have_recorded_usage`) drop the stubs after
446
+ each test.
447
+ - `Smith.config.persistence_max_bytes`: a stored workflow state's bound (nil
448
+ by default). A stored run's step whose result would take it past the
449
+ bound fails with `Smith::PersistedStateTooLarge` (a `WorkflowError`,
450
+ restored as itself) before the result is kept: its usage is recorded, its
451
+ `on_failure` taken, and the run stays storable, strict mode included. A
452
+ tool loop refused for budget does not pause into a state past the bound
453
+ (it goes on unheld). Any other state past it is refused before anything
454
+ is written, before a split-step boundary counts a write as begun (a
455
+ refused checkpoint may be made again).
456
+ - `rails g smith:install` also writes a workflow-state migration and model
457
+ for the `:active_record` adapter (a unique string key, a text payload
458
+ limited so MySQL picks a type that holds it, a `lock_version`, a
459
+ payload-size CHECK a third above `persistence_max_bytes`) and configures
460
+ them; options `--state-model` (a namespaced model gets its table name
461
+ set), `--max-state-bytes` (a whole number, `0` for no bound) and
462
+ `--skip-state-model`.
463
+ - `smith doctor` checks an `:active_record` adapter's model on every run
464
+ (`persistence.state_model`: its `lock_version`, a text payload, a unique
465
+ string key at least 64 characters wide, through
466
+ `ActiveRecordStore#check_schema!`), skipping when the database cannot be
467
+ reached and warning of an encrypted payload, which `replace_exact` now
468
+ refuses with a clear error (it compares payloads in the database).
469
+
470
+ ### Changed
471
+
472
+ - The doctor's durability probe writes as runs write (`Workflow#persist!`:
473
+ `store_versioned` where the adapter has it, and `persistence_max_bytes`),
474
+ so it now fails where runs would, an `:active_record` adapter with a
475
+ `persistence_ttl` included.
476
+
477
+ - Smith's OpenAI Responses path (`openai_api_mode: :auto`) given a streaming
478
+ block no longer raises `NotImplementedError`: it completes, then passes the
479
+ block the reply's whole text as one chunk.
480
+ - **Breaking:** workflow context has one shape everywhere.
481
+ - Top-level keys must be Symbols, the rule `write_context` already applied.
482
+ `Workflow.new`, and the class-level helpers that derive a persistence key
483
+ from a context (`restore_or_initialize`, `persisted_state_exists?`,
484
+ `restorable_billing_state?`, `run_persisted!`), raise
485
+ `Smith::WorkflowError` naming the first other key. Before, String keys were
486
+ accepted live and became Symbols only on restore, and a `persistence_key`
487
+ builder saw the caller's context on the class-level path but the
488
+ workflow's own on the instance path.
489
+ - Every Hash key inside a context value becomes the Symbol of its String
490
+ form, at every depth: in `Workflow.new`, at each `write_context` call (so
491
+ `read_context` in the same step already sees it), after hooks that write
492
+ the live context, and on restore. Before, a restore symbolized only
493
+ top-level keys, so `context.dig(:fields, :title)` returned the value live
494
+ and `nil` after a JSON round trip. Integer and other keys become Symbols
495
+ too (`101` becomes `:"101"`, as JSON would make it). Hash subclasses such as
496
+ `HashWithIndifferentAccess` become plain Hashes, and default procs and
497
+ `compare_by_identity` are not kept.
498
+ - Two keys of one Hash that become the same Symbol, a value that contains
499
+ itself, and nesting deeper than 100 levels (JSON's own limit) raise
500
+ `Smith::WorkflowError` instead of keeping whichever key came last or
501
+ overflowing the stack.
502
+ - Values are not converted: Symbols, Times and other non-JSON values still
503
+ come back as Strings after a restore.
504
+ - `Workflow.new` holds a normalized copy, not the caller's Hash.
505
+ - Normalization is `O(N)` in the entries of the value being normalized.
506
+ - **Breaking:** token totals count prompt-cache input. `RunResult#total_tokens`,
507
+ the workflow's token totals, token budgets (`total_tokens`, `token_limit`),
508
+ composite effect totals and `ProviderUsage#total_tokens` add
509
+ `cache_read_tokens` and `cache_write_tokens` to input and output. Before,
510
+ cached input was not counted at all, so a workflow that cached heavily stayed
511
+ far under a token budget it was in fact spending.
512
+ - **Breaking:** `Workflow::AgentResult` gains `cache_read_tokens`,
513
+ `cache_write_tokens`, `thinking_tokens`, `stop_reason`,
514
+ `provider_message_id` and `mode` (the last set only by streaming and batch
515
+ steps), so its `to_h`, `to_a`, `==` and positional deconstruction carry six
516
+ more members.
517
+ - **Breaking:** `Smith::Pricing.validate_catalog!` refuses catalogs 0.11.0
518
+ accepted: cache rates that are not non-negative Integers or Floats, a
519
+ `batch_discount` outside 0 (inclusive) to 1 (exclusive) or not an Integer or
520
+ Float, and a `batch_discount` inside a tier, where it would never apply.
521
+ - **Breaking:** a tiered pricing entry is chosen on a call's total input
522
+ (uncached plus cache read plus cache write). Before, it was chosen on
523
+ uncached input alone, which could select a cheaper tier than the provider
524
+ bills.
525
+ - **Breaking:** Smith's OpenAI Responses adapter reports `input_tokens` with
526
+ cached and cache-written input taken out, as RubyLLM reports every other
527
+ provider, keeping the cache counts separate. Before, its `input_tokens`
528
+ included cached input, which Smith priced at the full input rate; that input
529
+ is now priced at `cache_read_cost_per_token`, 0 when the catalog has none, so
530
+ Responses-path costs fall unless the catalog sets it. Cache counts that are
531
+ not Integers are ignored, and counts larger than the reported input are
532
+ capped to it, so the reported input total is kept.
533
+ - **Breaking:** `append_session_messages!` admits only `user`, `assistant`
534
+ and `tool` messages (see Upgrade notes), and a restored session message or a
535
+ durable composite step's prepared message must have one of those roles or
536
+ `system`. Only Smith and the host's `seed_messages` builder put system
537
+ messages in a session, so the injected-state marker is now recognized only on
538
+ a system message: a user, assistant or tool message that starts with
539
+ `[smith:injected-state]` is left alone instead of being replaced or removed
540
+ as Smith's state.
541
+ - The parallel branch limit check moved into its own graph diagnostic class;
542
+ its code and output are unchanged.
543
+ - **Breaking:** a workflow's `wall_clock` budget bounds the time its steps
544
+ spend running, not the time since `created_at`. Each step gets what is left
545
+ of the budget after the run's persisted `active_seconds`, and adds its own
546
+ running time when it ends. A step that raised, or whose process died, is
547
+ charged on the next restore the time since it began (recorded in the
548
+ checkpoint written before it), at most what is left of the budget. Time spent
549
+ waiting, or between steps that a host advances from a queue, no longer
550
+ counts, so a run that used to fail its deadline while idle now continues.
551
+ Durable composite branches each start from what is left of the budget, and
552
+ their running time is not added back. Payloads gain `awaiting`,
553
+ `active_seconds` and `active_step_started_at`; a payload without them
554
+ restores as not waiting with no active time spent.
555
+ - Rollback: an earlier 0.12 build, and 0.11.0, refuse a persisted failure whose
556
+ family is `wait_timed_out`, `output_failure` or `child_runs_failed`, and a
557
+ waiting run whose wait is a map's or call's (`kind: "children"`); clear or
558
+ settle such runs before rolling back. An earlier build ignores
559
+ `inherited_untrusted_keys`, so a child run restored there loses its parent's
560
+ untrusted marks. It refuses a run waiting on a durable retry
561
+ (`kind: "retry"`) and ignores `pending_retry`, so a run due for a retry
562
+ would start the step over at its first attempt and key generation; settle
563
+ such runs first. It cannot load a class that declares `retry_on ...,
564
+ durable: true` (an unknown keyword), so remove the option first; without it
565
+ the retry sleeps its backoff, up to `max_delay`, in the worker. It ignores
566
+ `inherited_artifact_namespaces`, so a child run restored there cannot read
567
+ its parent's documents, and it sends a seed message with content parts to
568
+ providers as JSON text.
569
+ - A workflow's execution namespace (the persisted UUID that scopes its
570
+ artifacts and now its idempotency keys) is assigned when the workflow is
571
+ built instead of on first artifact use, so `to_state` always carries it,
572
+ even before the first step. Runs restored from copies of one payload
573
+ therefore share artifact scope and idempotency keys; build each run with
574
+ `new`. A payload written without a namespace gets one derived from its
575
+ class, persistence key and creation time, the same on every restore, and a
576
+ stored namespace that is not a UUID raises `Smith::SerializationError`.
577
+ - **Breaking:** Smith's per-step tool context (`Smith::Tool::ScopedContext`)
578
+ holds one more private value, `current_idempotency_scope`.
579
+ `ScopedContext.around` requires the complete context, so a host that builds
580
+ that Hash by hand rather than from `ScopedContext.capture` must include it.
581
+ - **Breaking:** an explicit `persist :a, :b` list is an allow-list for the
582
+ context in every run, persisted or not and nested or not.
583
+ - `write_context` of any other key raises `Smith::UndeclaredContextKey`
584
+ (a `WorkflowError`, with `key` and `persisted_keys`). Before, the write
585
+ took effect in the live run and was dropped at the next checkpoint,
586
+ contrary to the docs.
587
+ - A rejected write fails the step even when the step body rescues the
588
+ error, so it can never become a silently dropped write.
589
+ - Hooks that receive the live context (`before_eval`, `after_completion`,
590
+ guardrails) are checked at the end of their step. A new top-level key must
591
+ be a Symbol the list allows; otherwise the step fails and the context's
592
+ top-level values go back to how they were when the step started. New or
593
+ replaced values take the context's key shape.
594
+ - `persist` refuses keys that are not Symbols with `WorkflowError` at
595
+ definition time. Before, String keys made every write fail on restore.
596
+ - `persist :auto` and workflows with no `persist` declaration accept any
597
+ Symbol key.
598
+ - **Breaking:** restoring a payload whose position the class does not know
599
+ raises instead of restoring a run that reads as terminal.
600
+ - An unknown state, or no state while the class has an initial state,
601
+ raises `Smith::UnknownStateOnRestore` (`workflow`, `persistence_key`,
602
+ `state`). An undeclared pending transition raises
603
+ `Smith::UnknownTransitionOnRestore` (`workflow`, `persistence_key`,
604
+ `transition`). Both are `Smith::Error`s that are neither retryable nor
605
+ retry-forbidden, with bounded messages.
606
+ - A state is known when it is declared with `state` or `initial_state`, or
607
+ named by any transition's `from:` or `to:`. It is matched by its String
608
+ form (`Workflow.known_state?`, `Workflow.resolve_state_name`, `O(S + T)`),
609
+ so String-named states restore as the Strings the class declares. Before,
610
+ a String-named state reached only through transitions restored as an
611
+ undeclared Symbol and read as terminal.
612
+ - The check runs after `migrate_from` and after the `definition_digest`
613
+ check, so a digest mismatch is still reported as one. A migration that
614
+ maps an old name needs a `persistence_schema_version` bump, since
615
+ migrations run only when the stored version is behind; the error message
616
+ says so.
617
+ - Finished runs whose terminal state was renamed are refused as well, by
618
+ `restore`, `restore_or_initialize`, `restorable_billing_state?` and the
619
+ class-level `run_persisted!`. Migrate them, or read the stored JSON
620
+ directly.
621
+ - `stuck_for?` reports a stored run in an unknown state as stuck. Before,
622
+ it reported it as terminal.
623
+ - **Breaking:** a `compute` or `run` step that declares `routes:` must route.
624
+ - If its target state has an outgoing transition, finishing without
625
+ `step.route_to` raises `Smith::UnroutedDeterministicStep` (a
626
+ `WorkflowError`, with `transition` and `routes`), so `on_failure` applies
627
+ and none of the step's writes are kept. The exceptions are a transition
628
+ that declares `on_success`, and one that opts into
629
+ `compute(routes: [...], fallthrough: true)`.
630
+ - A step whose target state has no outgoing transition may finish unrouted,
631
+ and the run ends there, so `routes: [:fail]` can still name only an
632
+ exceptional route.
633
+ - Before, an unrouted step silently took the first transition declared from
634
+ its target state.
635
+ - `fallthrough:` must be `true` or `false`. `fallthrough: true` without
636
+ `routes:` raises `WorkflowError` at definition time.
637
+ - Graph inspection follows the runtime rule, so a transition reachable only
638
+ through the old fall-through is now reported as an `unreachable_transition`
639
+ warning; declare `fallthrough: true` to keep it.
640
+ - Transition snapshots of steps with `routes:` carry
641
+ `deterministic_fallthrough`; other snapshots are unchanged.
642
+ - A class-less `retry_on` does not retry the new error. `retry_on
643
+ Smith::WorkflowError` does, until its attempts run out.
644
+ - **Breaking:** nested workflow failures.
645
+ - A child that raises passes its error to the parent step unchanged,
646
+ whatever its class, so the parent's `retry_on` and
647
+ `Smith::Errors.retryable?` see the child's own error at any depth, and the
648
+ child's billable usage is rolled into the parent on every attempt. Before,
649
+ a raised `Smith::Error` became a plain non-retryable `WorkflowError`, and
650
+ the child's usage was not rolled up.
651
+ - A child that routes its failure through its own `on_failure` has handled
652
+ it. The parent still fails with a non-retryable `WorkflowError` (`nested
653
+ workflow failed: ...`, now bounded to 1 KiB), exactly as before.
654
+ - **Breaking:** tool evidence travels up through nesting. A step that runs
655
+ inside another step's execution (a nested workflow's step) marks the
656
+ enclosing step's tool execution tracker when a tool ran in it. So when a
657
+ tool ran in any child step, a failure of the parent step becomes
658
+ `Smith::ToolOutcomeUncertain` and is never retried, exactly as when the tool
659
+ runs in the parent step itself. Before, a parent could retry a child whose
660
+ tools had already run.
661
+ - **Breaking:** `retry_on` starts no attempt once the workflow's effective
662
+ wall-clock deadline has passed. Before, a step that hit its deadline could
663
+ sleep through every backoff and fail again each time.
664
+ - **Breaking:** `:deterministic_step` traces report `outcome` (`:started`,
665
+ `:success`, `:routed`, `:failed`) instead of `result`. `result` is a
666
+ reserved content key that the default content policy strips, and `outcome`
667
+ matches the `:transition` and `:provider_call` vocabulary. A failure carries
668
+ `error_class`, `error_family`, and `retryable` when known, instead of the
669
+ raw exception message, which could hold content. Hosts that read `result` or
670
+ `error` from these traces, or that name them in a `trace_fields` allowlist
671
+ for `:deterministic_step`, must switch to `outcome` and `error_class`;
672
+ otherwise they receive neither.
673
+ - **Breaking:** `trace_content :redacted` masks content only, and masks it
674
+ completely.
675
+ - The built-in Memory, Logger and OpenTelemetry adapters mask the content
676
+ fields `content`, `prompt`, `response`, `args`, and `result`, and leave
677
+ every other field readable. That includes identifiers such as `model`,
678
+ `tool`, `attempt_id`, `error_class`, and `execution_key`, which defaults
679
+ to the persistence key and may embed host identifiers. Before, they
680
+ masked every String value.
681
+ - Masking is recursive, in the central policy too (`args` and `result`):
682
+ every String and Symbol at any depth of Hashes and Arrays becomes
683
+ `"[REDACTED]"`, so nested content no longer leaks. Numbers, booleans and
684
+ nil are kept.
685
+ - Hosts that relied on `:redacted` to hide identifiers, or fields a custom
686
+ trace adds, must drop them with `trace_fields`.
687
+
688
+ ### Fixed
689
+
690
+ - A step's failed attempts no longer outlive it on its thread. Smith added
691
+ them to the thread's list in place, and the step's thread context put that
692
+ same list back when the step ended, so a thread that had made a call outside
693
+ any step carried one step's failed attempts into the next step's budget
694
+ settlement (counted twice, possibly in another run).
695
+ - Budgets settle every billed token. The completed rounds of a tool loop whose
696
+ attempt then failed, and the metered responses of a tool loop in which some
697
+ response reported no usage, were recorded in usage entries and run totals but
698
+ never reached the budget ledger. A failed attempt's own reported cache tokens
699
+ were in its `:provider_call` trace but not in its usage entry, cost, totals or
700
+ budget. Budgets may now be reached sooner than in 0.11.0.
701
+ - Flat pricing entries with String keys (`"input_cost_per_token"`), as a
702
+ catalog loaded from JSON or YAML has, are priced. They were silently
703
+ unpriced, while tiered entries already read String keys; `total_cost` budgets
704
+ that never consumed such calls now do.
705
+ - `docs/CONFIGURATION.md` said a provider-qualified call with only a
706
+ model-only rate raises `Smith::PricingConfigurationError`; `compute_cost`
707
+ never raises and leaves such usage unpriced. The docs, the README example and
708
+ the generator's pricing example now use provider-qualified keys through
709
+ `Smith::Pricing.validate_catalog!`.
710
+ - A failure whose `details` raises an error whose own `message` also raises no
711
+ longer replaces the step's error (`FailureRecord` reads that inner message
712
+ through its guarded reader). This was present in 0.11.0.
713
+ - Classifying a deterministic step's failure for its trace can never replace
714
+ the step's error, and is skipped when no trace adapter is configured.
715
+ - The `compute` example in `docs/PATTERNS.md` called `route_to` twice on one
716
+ path, which raises. It now routes once on each branch and declares its
717
+ routes.
718
+
7
719
  ## [0.11.0] - 2026-10-07
8
720
 
9
721
  ### Added