smith-agents 0.10.0 → 0.12.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +901 -0
  3. data/README.md +636 -20
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +100 -14
  6. data/docs/PATTERNS.md +88 -12
  7. data/docs/PERSISTENCE.md +738 -6
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +63 -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/fallback_configuration.rb +26 -3
  18. data/lib/smith/agent/invocation_preparation.rb +37 -14
  19. data/lib/smith/agent/lifecycle.rb +16 -5
  20. data/lib/smith/agent/observed_model.rb +29 -0
  21. data/lib/smith/agent/output_contract_check.rb +53 -0
  22. data/lib/smith/agent/output_integrity.rb +137 -0
  23. data/lib/smith/agent/output_policy.rb +92 -0
  24. data/lib/smith/agent/output_recovery.rb +129 -0
  25. data/lib/smith/agent/provider_attempt.rb +17 -4
  26. data/lib/smith/agent/provider_call_timing.rb +71 -7
  27. data/lib/smith/agent/provider_completion.rb +41 -38
  28. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  29. data/lib/smith/agent/provider_request.rb +36 -0
  30. data/lib/smith/agent/provider_streaming.rb +155 -0
  31. data/lib/smith/agent/provider_usage.rb +75 -2
  32. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  33. data/lib/smith/agent/usage_traces.rb +9 -2
  34. data/lib/smith/agent/usage_tracking.rb +64 -48
  35. data/lib/smith/agent.rb +14 -1
  36. data/lib/smith/artifacts/active_record_store.rb +79 -0
  37. data/lib/smith/artifacts/file.rb +45 -18
  38. data/lib/smith/artifacts/memory.rb +47 -36
  39. data/lib/smith/artifacts/s3.rb +126 -0
  40. data/lib/smith/artifacts/scoped_store.rb +55 -7
  41. data/lib/smith/artifacts/store_rules.rb +49 -0
  42. data/lib/smith/artifacts.rb +98 -1
  43. data/lib/smith/batch/message_shape.rb +36 -0
  44. data/lib/smith/batch.rb +166 -0
  45. data/lib/smith/budget/amount.rb +25 -0
  46. data/lib/smith/budget/amount_representation.rb +20 -0
  47. data/lib/smith/budget/continuation.rb +153 -0
  48. data/lib/smith/budget/host_ledger.rb +137 -0
  49. data/lib/smith/budget/ledger.rb +10 -4
  50. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  51. data/lib/smith/budget/request_hold.rb +211 -0
  52. data/lib/smith/budget/spent_reservation.rb +18 -0
  53. data/lib/smith/child_run.rb +41 -0
  54. data/lib/smith/child_runs_failed.rb +56 -0
  55. data/lib/smith/content.rb +96 -0
  56. data/lib/smith/context/session.rb +14 -2
  57. data/lib/smith/context/state_injection.rb +21 -6
  58. data/lib/smith/context.rb +28 -0
  59. data/lib/smith/diagnostic_text.rb +10 -1
  60. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  61. data/lib/smith/doctor/checks/configuration.rb +0 -11
  62. data/lib/smith/doctor/checks/durability.rb +35 -17
  63. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  64. data/lib/smith/doctor/checks/state_model.rb +63 -0
  65. data/lib/smith/doctor/installer.rb +9 -2
  66. data/lib/smith/doctor.rb +4 -0
  67. data/lib/smith/errors.rb +304 -6
  68. data/lib/smith/idempotency_key/canonical.rb +74 -0
  69. data/lib/smith/idempotency_key.rb +145 -0
  70. data/lib/smith/launchers/inline.rb +46 -0
  71. data/lib/smith/message.rb +76 -0
  72. data/lib/smith/models/tool_routing.rb +47 -44
  73. data/lib/smith/output_failure.rb +130 -0
  74. data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
  75. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  76. data/lib/smith/persistence_adapters.rb +10 -0
  77. data/lib/smith/pricing/catalog_validation.rb +91 -0
  78. data/lib/smith/pricing.rb +95 -64
  79. data/lib/smith/provider_permanent_failure.rb +44 -0
  80. data/lib/smith/providers/anthropic/batches.rb +116 -0
  81. data/lib/smith/providers/batch_exchange.rb +77 -0
  82. data/lib/smith/providers/openai/responses.rb +45 -20
  83. data/lib/smith/providers/output_limit.rb +68 -0
  84. data/lib/smith/providers/request_holds.rb +139 -0
  85. data/lib/smith/providers/response_facts.rb +108 -0
  86. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  87. data/lib/smith/providers/schema_constraints.rb +87 -0
  88. data/lib/smith/providers/schema_rules.rb +66 -0
  89. data/lib/smith/providers/stop_reason.rb +39 -0
  90. data/lib/smith/providers/stream_facts.rb +192 -0
  91. data/lib/smith/response_memo.rb +152 -0
  92. data/lib/smith/setting_constructors.rb +47 -0
  93. data/lib/smith/streaming/json_field.rb +136 -0
  94. data/lib/smith/streaming/json_string.rb +91 -0
  95. data/lib/smith/streaming/session.rb +77 -0
  96. data/lib/smith/streaming/think_tags.rb +67 -0
  97. data/lib/smith/streaming.rb +64 -0
  98. data/lib/smith/testing/checks.rb +58 -0
  99. data/lib/smith/testing/hooks.rb +85 -0
  100. data/lib/smith/testing/minitest.rb +37 -0
  101. data/lib/smith/testing/rspec.rb +52 -0
  102. data/lib/smith/testing/stub.rb +110 -0
  103. data/lib/smith/testing.rb +158 -0
  104. data/lib/smith/tool/scoped_context.rb +9 -12
  105. data/lib/smith/tool.rb +14 -0
  106. data/lib/smith/trace/logger.rb +1 -1
  107. data/lib/smith/trace/memory.rb +1 -1
  108. data/lib/smith/trace/open_telemetry.rb +1 -1
  109. data/lib/smith/trace.rb +11 -4
  110. data/lib/smith/untrusted.rb +74 -0
  111. data/lib/smith/version.rb +2 -2
  112. data/lib/smith/workflow/active_clock.rb +61 -0
  113. data/lib/smith/workflow/agent_result.rb +37 -0
  114. data/lib/smith/workflow/artifact_integration.rb +57 -7
  115. data/lib/smith/workflow/await_step.rb +42 -0
  116. data/lib/smith/workflow/batch_charges.rb +74 -0
  117. data/lib/smith/workflow/batch_execution.rb +149 -0
  118. data/lib/smith/workflow/batch_holds.rb +73 -0
  119. data/lib/smith/workflow/batch_wait.rb +134 -0
  120. data/lib/smith/workflow/budget_integration.rb +14 -8
  121. data/lib/smith/workflow/budget_pause.rb +51 -0
  122. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  123. data/lib/smith/workflow/child_run_execution.rb +150 -0
  124. data/lib/smith/workflow/child_run_results.rb +55 -0
  125. data/lib/smith/workflow/composite/effects.rb +37 -6
  126. data/lib/smith/workflow/composite/error.rb +1 -1
  127. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  128. data/lib/smith/workflow/composite/input.rb +11 -4
  129. data/lib/smith/workflow/context_keys.rb +77 -0
  130. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  131. data/lib/smith/workflow/deterministic_execution.rb +62 -11
  132. data/lib/smith/workflow/deterministic_step.rb +56 -7
  133. data/lib/smith/workflow/dsl.rb +74 -5
  134. data/lib/smith/workflow/durability.rb +140 -31
  135. data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
  136. data/lib/smith/workflow/event_integration.rb +9 -0
  137. data/lib/smith/workflow/execution.rb +32 -23
  138. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  139. data/lib/smith/workflow/failure_reconstructor.rb +41 -2
  140. data/lib/smith/workflow/failure_record.rb +12 -12
  141. data/lib/smith/workflow/failure_record_validator.rb +32 -5
  142. data/lib/smith/workflow/fanout_execution.rb +5 -8
  143. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  144. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  145. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  146. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  147. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  148. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  149. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  150. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  151. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  152. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  153. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  154. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  155. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  156. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  157. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  158. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  159. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  160. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  161. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  162. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  163. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  164. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  165. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  166. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  167. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  168. data/lib/smith/workflow/graph.rb +12 -0
  169. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  170. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  171. data/lib/smith/workflow/message_batch.rb +6 -0
  172. data/lib/smith/workflow/message_roles.rb +25 -0
  173. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  174. data/lib/smith/workflow/nested_execution.rb +47 -4
  175. data/lib/smith/workflow/optimization_state.rb +2 -2
  176. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  177. data/lib/smith/workflow/parallel_execution.rb +10 -0
  178. data/lib/smith/workflow/persistence.rb +162 -22
  179. data/lib/smith/workflow/remembered_calls.rb +92 -0
  180. data/lib/smith/workflow/retry_execution.rb +71 -8
  181. data/lib/smith/workflow/retry_wait.rb +134 -0
  182. data/lib/smith/workflow/run_result.rb +10 -0
  183. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  184. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  185. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  186. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  187. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  188. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  189. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  190. data/lib/smith/workflow/step_completion.rb +40 -7
  191. data/lib/smith/workflow/step_context.rb +21 -0
  192. data/lib/smith/workflow/step_io.rb +132 -0
  193. data/lib/smith/workflow/step_stream.rb +52 -0
  194. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  195. data/lib/smith/workflow/transition.rb +267 -13
  196. data/lib/smith/workflow/transition_actionability.rb +2 -0
  197. data/lib/smith/workflow/usage_entry.rb +24 -3
  198. data/lib/smith/workflow/wait_completion.rb +131 -0
  199. data/lib/smith/workflow/wait_execution.rb +180 -0
  200. data/lib/smith/workflow/wait_record.rb +157 -0
  201. data/lib/smith/workflow.rb +117 -19
  202. data/lib/smith.rb +103 -47
  203. metadata +88 -1
data/README.md CHANGED
@@ -17,13 +17,33 @@ environment before calling the slice complete.
17
17
 
18
18
  ```ruby
19
19
  # Gemfile
20
- gem "smith-agents", "0.5.0", require: "smith"
20
+ gem "smith-agents", "0.12.0", require: "smith"
21
21
  ```
22
22
 
23
23
  ```bash
24
24
  bundle install
25
+ bin/rails generate smith:install # in a Rails app
26
+ bin/rails db:migrate
25
27
  ```
26
28
 
29
+ `smith:install` writes `config/initializers/smith.rb` and a table and model
30
+ for workflow state (`WorkflowState`, Smith's `:active_record` persistence
31
+ adapter): a unique string key (child runs are stored under 64-character
32
+ keys of their own, so the key is not a host row's id), the state as JSON
33
+ text, the `lock_version` Smith's versioned writes compare, and a CHECK on
34
+ the payload's size. The initializer sets `persistence_max_bytes` (1 MiB): a
35
+ step whose result would take a stored run past it fails with
36
+ `Smith::PersistedStateTooLarge` before the result is kept, as with any
37
+ error (its usage recorded, its `on_failure` taken), so the run stays
38
+ storable; any other state past it is refused before anything is written.
39
+ The answer that did not fit is not kept, so set the bound above the largest
40
+ output a step keeps, or keep large outputs in the artifact store. The CHECK
41
+ sits a third above the bound, room for a column the host encrypts (Rails
42
+ `encrypts` holds runs, but not split-step exact writes; the doctor warns).
43
+ Options: `--state-model=NAME` (a namespaced model gets its table name set),
44
+ `--max-state-bytes=N` (`0` for no bound and no CHECK), and
45
+ `--skip-state-model` (the initializer only).
46
+
27
47
  The Ruby module namespace stays `Smith::`; only the gem name is namespaced because `smith` on RubyGems is taken. The `require: "smith"` in the Gemfile tells bundler to load the actual file name.
28
48
 
29
49
  ## Quickstart
@@ -72,7 +92,7 @@ result.steps # => [{ transition: :reply, from: :idle, to: :done, output: ...
72
92
  |---|---|
73
93
  | `Smith::Agent` | A `RubyLLM` agent plus model, instructions, output schema, tools, budget, and fallback models. Identifies itself to the workflow via `register_as :name`. |
74
94
  | `Smith::Workflow` | A state machine of named transitions. Each transition calls an agent, runs deterministic code, routes, or composes a nested workflow. |
75
- | `Smith::Context` | Declares which workflow context keys persist across restore, and how those keys become agent-visible input via `inject_state`. |
95
+ | `Smith::Context` | Declares which workflow context keys persist across restore, how those keys become agent-visible input via `inject_state`, and which keys hold untrusted outside content (`untrusted`). |
76
96
  | `Smith::Tool` | A `RubyLLM` tool plus provider-compatibility metadata and guardrail hooks. |
77
97
  | Persistence adapters | Host-owned storage. Smith ships `Memory`, `RedisStore`, `CacheStore`, `RailsCache`, `ActiveRecordStore`. |
78
98
  | Trace adapters | Host-owned observability. Smith ships `Memory`, `Logger`, `OpenTelemetry`. |
@@ -100,6 +120,7 @@ end
100
120
  | Evaluator-Optimizer | `optimize generator:, evaluator:, ...` | Generate-then-critique refinement loops. |
101
121
  | Orchestrator-Worker | `orchestrate orchestrator:, worker:, ...` | Dynamic task fan-out with delegation rounds. |
102
122
  | Deterministic | `compute { |step| ... }` | Pure Ruby step inside the state machine. |
123
+ | Wait | `await :event, into:, timeout:` / `delay seconds` | Durable wait for an event the host delivers, or until a time. |
103
124
 
104
125
  The full pattern guide with working examples for each lives in [`docs/PATTERNS.md`](docs/PATTERNS.md).
105
126
 
@@ -116,7 +137,272 @@ For bounded dynamic delegation, use the separate Orchestrator-Worker pattern.
116
137
  | Evaluator-Optimizer | Executable | `optimize`, bounded by `max_rounds` plus structured evaluator output. |
117
138
  | Deterministic repair | Not first-class yet | Can be handwritten with `compute` / `run` only when the workflow author owns the exact guard, repair, revalidation, and exit policy. Deterministic steps may declare inspectable `routes: [...]`, but that is not a native repair-loop contract. |
118
139
  | Guarded state re-entry | Not first-class yet | `compute` / `run` can declare and route to named transitions with `routes: [...]`, but Smith does not yet own persisted entry counts, mutation policy, or safe re-entry contracts. |
119
- | Polling / wait | Host-owned | Use the host app's queue/timer plus Smith persistence helpers. Smith must not model durable polling with busy-waits or sleep loops. |
140
+ | Wait for an event or a time | Executable | `await` and `delay`, bounded by a required timeout and timeout route. Smith records the wait and its deadline; delivering the event (`resume!`), scheduling the expiry (`expire_wait!`) and waking the run stay host-owned. Smith never polls for an event or sleeps until a deadline. |
141
+
142
+ ### Isolation
143
+
144
+ Each agent step can get its own messages and write its output to the context
145
+ instead of a shared transcript:
146
+
147
+ ```ruby
148
+ class ReviewContext < Smith::Context
149
+ persist :source, :fields
150
+ untrusted :source, :fields # outside text, and what an agent drew from it
151
+ end
152
+
153
+ class DocumentReview < Smith::Workflow
154
+ context_manager ReviewContext
155
+ session :isolated # no shared transcript; every agent step declares input:
156
+ initial_state :fetched
157
+ state :extracted
158
+
159
+ transition :extract, from: :fetched, to: :extracted do
160
+ execute :field_extractor,
161
+ input: ->(ctx) { [{ role: :user, content: Smith::Untrusted.wrap(ctx[:source], source: "document") }] },
162
+ output_to: :fields
163
+ end
164
+ end
165
+ ```
166
+
167
+ Context keys declared `untrusted` never reach `inject_state`, the
168
+ `seed_messages` builder, or an agent's `inputs`, in the workflow or any
169
+ workflow it nests. See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Step
170
+ Isolation and Untrusted Content.
171
+
172
+ A message in `input:` can carry content parts: text, and documents and images
173
+ stored as artifacts of the run (a child run reads its ancestors' too), with
174
+ `cache: true` marking a prefix Anthropic may cache; seed messages take text
175
+ parts:
176
+
177
+ ```ruby
178
+ transition :store, from: :fetched, to: :stored do
179
+ compute { |step| step.write_context(:contract_ref, Smith.artifacts.store(Contracts.pdf(step.read_context(:id)))) }
180
+ end
181
+
182
+ transition :extract, from: :stored, to: :extracted do
183
+ execute :field_extractor,
184
+ input: lambda { |ctx|
185
+ [Smith::Message.user([Smith::Content.document(ctx[:contract_ref], media_type: "application/pdf"),
186
+ Smith::Content.text("List the parties and the term.")])]
187
+ },
188
+ output_to: :fields
189
+ end
190
+ ```
191
+
192
+ Anthropic gets native blocks; other providers get the parts in order as text,
193
+ with the files attached where they stand. Documents are read once, checked
194
+ against their media type and bounds, and only from runs allowed to read them.
195
+ See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Content Parts.
196
+
197
+ ### Waiting
198
+
199
+ A step can wait, durably and bounded, for an event the host delivers or for a
200
+ time to pass:
201
+
202
+ ```ruby
203
+ class DocumentReview < Smith::Workflow
204
+ initial_state :requested
205
+
206
+ transition :fetch_source, from: :requested, to: :fetched do
207
+ await :source_ready, into: :source, timeout: 6 * 3600 do |step|
208
+ SourceClient.request(ref: step.read_context(:source_ref), idempotency_key: step.await_token)
209
+ end
210
+ on_timeout :source_unavailable
211
+ end
212
+ transition :source_unavailable, from: :requested, to: :unavailable
213
+
214
+ transition(:cool_off, from: :fetched, to: :reviewed) { delay 600 }
215
+ end
216
+
217
+ workflow.run_persisted!(key) # stops when the run waits
218
+ workflow.waiting? # => true
219
+ workflow.awaiting # => Smith::Workflow::Wait (event, token, deadline_at, ...)
220
+
221
+ DocumentReview.resume!(key, event: :source_ready, token:, payload: reply) # => :resumed, :failed, :missing or :stale
222
+ DocumentReview.expire_wait!(key, token:, now: Time.now.utc) # => :expired, :not_due, :missing or :stale
223
+ ```
224
+
225
+ The host indexes `awaiting` to route events and to schedule the expiry call.
226
+ See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Waiting.
227
+
228
+ ### Child runs
229
+
230
+ A step can start other workflows as runs of their own, which the host launches,
231
+ persists and runs, and wait for their results:
232
+
233
+ ```ruby
234
+ Smith.config.run_launcher = MyLauncher.new # launch(children:, parent_key:, await_token:)
235
+
236
+ class DocumentReview < Smith::Workflow
237
+ initial_state :split
238
+ state :failed
239
+
240
+ transition :review_sections, from: :split, to: :reviewed do
241
+ map :section_ids, workflow: SectionReview, max_items: 500, timeout: 6 * 3600,
242
+ input: ->(section_id, ctx) { { document_id: ctx[:document_id], section_id: } },
243
+ into: :section_reviews, collect: :outputs, on_item_failure: :collect
244
+ on_failure :fail
245
+ end
246
+
247
+ transition :summarize, from: :reviewed, to: :summarized do
248
+ call Summarize, input: ->(ctx) { { document_id: ctx[:document_id] } }, timeout: 3600, into: :summary
249
+ on_failure :fail
250
+ end
251
+
252
+ transition :notify, from: :summarized, to: :done do
253
+ spawn :watcher_ids, workflow: NotifyWatcher, max_items: 1_000, input: ->(id, _ctx) { { watcher_id: id } }
254
+ end
255
+ end
256
+ ```
257
+
258
+ `map` launches a run per item (more than `max_items` fails the step before
259
+ anything launches; the launcher is idempotent on each child's `key`) and waits,
260
+ bounded by `timeout:`, for the host to resume the parent with
261
+ `resume!(key, event: :children, token:, payload:)`, one `Smith::ChildRun.result`
262
+ entry per child. `call` is a map of one whose `into:`
263
+ gets the child's output; `spawn` launches and continues. A child starts with
264
+ only what `input:` returns, and keeps any of its parent's untrusted keys passed
265
+ under the same name. With `on_item_failure: :fail` (the default) a failed child
266
+ fails the step with `Smith::ChildRunsFailed`, so the step declares `on_failure`;
267
+ these steps take no `retry_on`. `Smith::Launchers::Inline` runs the children in
268
+ process, for tests and scripts. See
269
+ [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Child Runs.
270
+
271
+ ### Streaming
272
+
273
+ An agent step can send its reply's text to the host as the model writes it:
274
+
275
+ ```ruby
276
+ # Called when a streamed step starts; returns the step's sink, or nil to
277
+ # stream nothing (nobody is watching). Each step execution gets its own.
278
+ Smith.config.stream_sinks = {
279
+ answer: ->(info) { AnswerStream.new(info[:execution_key]) if Screens.watching?(info[:execution_key]) }
280
+ }
281
+
282
+ class AnswerStream
283
+ def initialize(key)
284
+ @key = key
285
+ @reply = Smith::Streaming::JsonField.new(:reply) # the output schema's reply field, as it grows
286
+ end
287
+
288
+ def delta(text) = (piece = @reply.push(text)) && Screens.append(@key, piece)
289
+ def reset(_reason) = @reply.reset || Screens.clear(@key)
290
+ def finish(output) = Screens.final(@key, output["reply"])
291
+ def fail(_error_class) = Screens.failed(@key)
292
+ end
293
+
294
+ class Answer < Smith::Workflow
295
+ initial_state :asked
296
+ state :failed
297
+
298
+ transition :answer, from: :asked, to: :answered do
299
+ execute :answerer, stream: :answer, output_to: :answer
300
+ on_failure :fail
301
+ end
302
+ end
303
+ ```
304
+
305
+ A sink defines any of `start(info)` (each provider call: the step's info with
306
+ the attempt, model and provider), `delta(text)` (each text chunk; tool calls,
307
+ thinking and `<think>` text are not sent), `reset(reason)` (Smith discarded the
308
+ text sent so far: `:retry`, `:fallback`, `:truncation` or `:repair`; and
309
+ `:retry_wait`, the last word, while a durable retry waits), `finish(output)`
310
+ (the output the step accepted, after guardrails, the output contract and
311
+ `output_to:`, once the step is stored) and `fail(error_class)`. Streamed text is
312
+ a draft: guardrails and output checks run on the whole reply, so nothing should
313
+ be saved or acted on before `finish`, and a step with an output schema streams
314
+ JSON text (`Smith::Streaming::JsonField` gives the text each delta adds to one
315
+ string field). A sink that raises is logged and the step goes on; a sink name
316
+ that is not registered fails the step before its call. Stop reasons are read
317
+ from the stream, so cut-off and refused replies are handled as without it, and a
318
+ stream that ends without one counts as cut off (`stream_incomplete`). Smith's
319
+ OpenAI Responses path does not stream: its sink gets each reply as one delta.
320
+ Streaming changes nothing durable (each usage entry keeps its response's stop
321
+ reason and message id, with `mode: :stream`), and a replayed step streams again
322
+ (unless a response memo kept its reply: then its sink gets the text at once).
323
+ Graph inspection shows the stream, and runtime readiness reports a missing sink
324
+ (`:stream_sink_missing`) and an agent whose calls go to the Responses API
325
+ (`:stream_whole_replies`).
326
+
327
+ ### Batch mode
328
+
329
+ An agent step, or a fan-out, can send its requests through a provider's batch
330
+ API (half price, answered within 24 hours) instead of over the wire:
331
+
332
+ ```ruby
333
+ # The host keeps each request until a scheduled job submits it (a table, in
334
+ # production); enqueue is idempotent on custom_id.
335
+ class HostBatchQueue
336
+ Entry = Struct.new(:custom_id, :await_token, :request, :run_key, :batch_id)
337
+
338
+ def initialize = @entries = {}
339
+ def enqueue(custom_id:, await_token:, request:, execution_key:)
340
+ @entries[custom_id] ||= Entry.new(custom_id, await_token, request, execution_key)
341
+ end
342
+ def unsubmitted = @entries.values.reject(&:batch_id)
343
+ def entry(custom_id) = @entries.fetch(custom_id)
344
+ end
345
+ Smith.config.batch_queue = HostBatchQueue.new
346
+
347
+ class Triage < Smith::Workflow
348
+ initial_state :queued
349
+ state :failed
350
+
351
+ transition :triage, from: :queued, to: :triaged do
352
+ execute :triager, mode: ->(ctx) { ctx[:urgent] ? :sync : :batch }, output_to: :verdict
353
+ on_failure :fail
354
+ end
355
+ end
356
+
357
+ # One scheduled job submits what is queued; another collects an ended batch
358
+ # and resumes each run once with every result of its wait.
359
+ queue = Smith.config.batch_queue
360
+ batches = Smith::Providers::Anthropic::Batches.new
361
+ entries = queue.unsubmitted
362
+ batch_id = batches.submit(entries.map { { custom_id: _1.custom_id, request: _1.request } })
363
+ entries.each { _1.batch_id = batch_id }
364
+ if batches.status(batch_id)[:status] == "ended"
365
+ payloads = Hash.new { |hash, wait| hash[wait] = {} }
366
+ batches.results(batch_id) do |custom_id, result|
367
+ entry = queue.entry(custom_id)
368
+ payloads[[entry.run_key, entry.await_token]][custom_id] = result
369
+ end
370
+ payloads.each { |(run_key, token), payload| Triage.resume!(run_key, event: :batch_result, token:, payload:) }
371
+ end
372
+ ```
373
+
374
+ `mode:` is `:sync` (the default), `:batch`, or a callable of the context and
375
+ the step attempt returning one, on `execute` and `fan_out`; a batch step
376
+ declares `on_failure`, and cannot stream or be `parallel:`. Smith prepares
377
+ the call as a synchronous one, admits it against the budget, and hands the
378
+ exact request (the provider payload and the call's own headers, never the
379
+ connection's credentials) to the queue under a `custom_id` (the wait's token;
380
+ each fan-out branch its own) once the step's attempt has ended without error,
381
+ then the run waits on `:batch_result`. Smith never submits or polls, and waits
382
+ without sleeping: `Smith::Providers::Anthropic::Batches` gives the host
383
+ `submit`, `status`, `results` and `cancel`. `resume!` with
384
+ `{custom_id => result}` runs the step again, each call taking its result as
385
+ the reply it would have got. A call that renders another request by then (a
386
+ date in its prompt, data read afresh, a prompt changed in a deploy) does what
387
+ its step's `on_request_change:` says: `:keep` (the default) takes the result,
388
+ which answers the request as sent, and its `:provider_call` trace says
389
+ `request_changed: true`; `:resend` sends the request as rendered now over the
390
+ wire instead; `:fail` fails the step with `Smith::BatchRequestChanged` (an
391
+ `AgentError`, so `retry_on` can send it again, synchronously if the mode
392
+ callable says so). A result the call does not use is charged all the same.
393
+ Then the output checks, a follow-up call (over the
394
+ wire), guardrails and `output_to:` as ever, usage recorded with `mode: :batch`
395
+ and priced with the catalog's `batch_discount`, and charged even past a
396
+ budget, as the money is spent. A request the provider refused fails the step
397
+ (`Smith::BatchRequestInvalid`); a failed, canceled or expired result, or none
398
+ by the wait's deadline (`expire_wait!`, 25 hours), raises
399
+ `Smith::BatchResultFailed`, which `retry_on` sends again (synchronously, if the
400
+ mode callable says so at the next attempt; an in-process `retry_on` backoff
401
+ sleeps as on any step, while `durable: true` waits). Only Anthropic's batch interface is
402
+ built in, and a batch request is one turn, so a batching agent has no tools;
403
+ runtime readiness reports a missing queue (`:batch_queue_missing`) and an
404
+ agent that cannot batch (`:batch_unavailable`), and graph inspection shows the
405
+ mode.
120
406
 
121
407
  ## Workflow Graph Inspection
122
408
 
@@ -167,12 +453,14 @@ require "smith"
167
453
  Smith.configure do |config|
168
454
  config.logger = Logger.new($stdout)
169
455
  config.trace_adapter = Smith::Trace::Memory.new
170
- config.artifact_store = Smith::Artifacts::Memory.new
456
+ config.artifact_store = :active_record # or :s3 (S3, R2), :file, :memory
457
+ config.artifact_options = { model: "SmithArtifact" }
171
458
 
172
459
  # Persistence
173
460
  config.persistence_adapter = :rails_cache
174
461
  config.persistence_options = { namespace: "smith" }
175
462
  config.persistence_ttl = 1.day.to_i
463
+ config.persistence_max_bytes = 1_048_576 # a stored state's bound (nil: none); see Installation
176
464
  config.persistence_retry_policy = { attempts: 3, base_delay: 0.1, max_delay: 1.0 }
177
465
 
178
466
  # Generic runtime resource bounds
@@ -184,13 +472,18 @@ Smith.configure do |config|
184
472
  # OpenAI /v1/responses routing for gpt-5 + tools + thinking. :auto (default) or :off.
185
473
  config.openai_api_mode = :auto
186
474
 
187
- config.pricing = {
188
- "gpt-4.1-nano" => { input_cost_per_token: 1.0e-7, output_cost_per_token: 4.0e-7 }
189
- }
475
+ config.pricing = Smith::Pricing.validate_catalog!(
476
+ %w[openai gpt-4.1-nano] => {
477
+ input_cost_per_token: 1.0e-7, output_cost_per_token: 4.0e-7,
478
+ cache_read_cost_per_token: 2.5e-8, # optional: prompt-cache reads (0 when absent)
479
+ batch_discount: 0.5 # optional: off the cost of batch-mode calls (0 when absent)
480
+ }
481
+ )
190
482
  end
191
483
  ```
192
484
 
193
- All settings are optional for a first run. See [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) for the full reference.
485
+ All settings are optional for a first run, except that storing artifacts or
486
+ attaching documents needs `artifact_store` (outside `test_mode`). See [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) for the full reference.
194
487
 
195
488
  ## Persistence and Resume
196
489
 
@@ -217,10 +510,12 @@ message batch, appends it under Smith's workflow lifecycle lock, and returns an
217
510
  immutable digest witness. The host still owns session identity, transaction
218
511
  coordination, persistence, idempotency, and resume policy.
219
512
 
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;
513
+ Built-in adapters (all except `ActiveRecord` support TTL; `ActiveRecord` has no
514
+ expiry column, so its writes refuse a non-nil `persistence_ttl`, while reads and
515
+ deletes keep working and `smith doctor --durability` flags the combination;
516
+ `Redis`, `ActiveRecord`, and `Memory` support optimistic locking via
517
+ `store_versioned` and exact dispatch claims via `replace_exact`; `ActiveRecord`
518
+ participates in the caller's transaction for host-record coordination;
224
519
  `Redis` and `Memory` also support heartbeat probes via `record_heartbeat` /
225
520
  `last_heartbeat`):
226
521
 
@@ -361,11 +656,24 @@ cache invalidation.
361
656
 
362
657
  Smith ships `Tools::WebSearch`, `Tools::UrlFetcher`, and `Tools::Think`. Tools declare provider compatibility via `compatible_with`; Smith's normalizer routes or drops them per-attempt.
363
658
 
659
+ `Tools::WebSearch` and `Tools::UrlFetcher` are stubs: they declare each
660
+ tool's parameters and description, and their `perform` raises
661
+ `NotImplementedError`. A host subclasses them with its own search or fetch
662
+ behind `perform`. `Tools::Think` works as shipped.
663
+
364
664
  ```ruby
665
+ class WebSearch < Smith::Tools::WebSearch
666
+ def perform(query:, max_results: 5) = SearchClient.search(query, limit: max_results)
667
+ end
668
+
669
+ class UrlFetcher < Smith::Tools::UrlFetcher
670
+ def perform(url:) = PageClient.fetch(url)
671
+ end
672
+
365
673
  class SearchAgent < Smith::Agent
366
674
  register_as :search_agent
367
675
  model "claude-opus-4-7"
368
- tools Smith::Tools::WebSearch, Smith::Tools::UrlFetcher
676
+ tools WebSearch, UrlFetcher # the host's subclasses
369
677
  budget tool_calls: 3
370
678
  tool_budget_exhaustion :complete
371
679
  end
@@ -407,11 +715,25 @@ Guardrails run as input/output gates around agent calls. See [`docs/TOOLS_AND_GU
407
715
 
408
716
  ```ruby
409
717
  class BudgetedWorkflow < Smith::Workflow
410
- budget total_tokens: 10_000, total_cost: 0.50, wall_clock_ms: 30_000
718
+ budget total_tokens: 10_000, total_cost: 0.50, wall_clock: 30
411
719
  end
412
720
  ```
413
721
 
414
- 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`.
722
+ `wall_clock` is in seconds. A workflow's `wall_clock` bounds the time its steps
723
+ spend running, counted across restores in the persisted `active_seconds`, not
724
+ the time since the run was created: time spent waiting, or between steps a host
725
+ advances from a queue, does not count. A step that raised or whose process died
726
+ is charged, on the next restore, the time from its start, at most what is left
727
+ of the budget, so a run that keeps failing stays bounded across retries.
728
+ Durable composite branches each start from what is left; their running time is
729
+ not added back. An agent's `wall_clock` bounds each of
730
+ its calls. A workflow budget accepts `total_tokens`,
731
+ `token_limit`, `total_cost`, `tool_calls`, and `wall_clock`; an agent budget
732
+ accepts `token_limit`, `cost`, `wall_clock`, `tool_calls`, `total_tokens`, and
733
+ `total_cost`. Any other key raises `ArgumentError` naming the accepted keys. A
734
+ restored run drops consumed amounts for keys the workflow no longer declares.
735
+
736
+ 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`. Token budgets and totals count prompt-cache reads and writes beside uncached input and output, and settle every billed response, including the completed rounds of a tool loop whose attempt then failed.
415
737
 
416
738
  Direct ledger integrations settle the exact receipt returned by reservation:
417
739
 
@@ -422,7 +744,9 @@ ledger.reconcile_many!(reservation, actual: { total_tokens: 640, total_cost: 0.0
422
744
 
423
745
  `release!` / `release_many!` accept the same receipt when work never starts.
424
746
  Receipts are ledger-owned and one-shot: another ledger, a replay, or an
425
- amount-only settlement is rejected.
747
+ amount-only settlement is rejected. `ledger.remaining(key)` never reads back
748
+ above the exact remaining amount, and `ledger.remaining_share(key, parts)`
749
+ returns one of `parts` equal shares of it such that reserving all `parts` fits.
426
750
 
427
751
  Budget amounts accept finite, non-negative `Integer` and `Float` values. Smith
428
752
  uses an exact `BigDecimal` representation derived from each value's canonical
@@ -437,6 +761,91 @@ threads with `Thread#raise` or `Thread#kill`. Arbitrary asynchronous thread
437
761
  termination can make any in-process return value outcome-ambiguous; hosts must
438
762
  use cancellation signals, bounded I/O, and process isolation for hard stops.
439
763
 
764
+ ### Budget ledgers
765
+
766
+ A run's `budget` bounds that run. A budget shared by many runs (an account's
767
+ monthly spend, say) lives in the host, and a workflow names it with
768
+ `budget_ledger`:
769
+
770
+ ```ruby
771
+ class MonthlyLedger
772
+ def initialize(account) = @account = account
773
+
774
+ # Admit the hold (true; anything else refuses it) and keep it under key
775
+ # until expires_at, after which it counts for nothing.
776
+ def reserve!(amount, key:, expires_at:) = ...
777
+ def reconcile!(key, amount) = ... # what the request spent, in place of its hold
778
+ def release!(key) = ... # it spent nothing
779
+ end
780
+
781
+ class DocumentReview < Smith::Workflow
782
+ budget_ledger(:account_id) { |scope| MonthlyLedger.new(scope[:account_id]) }
783
+ end
784
+ ```
785
+
786
+ The block gets the run's scope: the values the named keys have in its trusted
787
+ context when the run is created (JSON scalars, kept with the run), so nothing a
788
+ step or a model writes later chooses another ledger, and an untrusted key is
789
+ refused. Smith builds the ledger at the first request a step makes, never
790
+ persists it, and never needs it to restore a run; nil means none. A nested
791
+ workflow without a `budget_ledger` of its own charges the enclosing run's; a
792
+ child run (`call`, `map`, `spawn`), inline or not, uses its own class's, so
793
+ give each child workflow its own (runtime readiness warns of one that has none,
794
+ `:child_run_unbudgeted`) and pass its scope keys in each child's input.
795
+
796
+ Before a call's first request over the wire (each fallback model, each
797
+ follow-up, streamed or not, OpenAI's Responses route included) Smith asks the
798
+ ledger to admit its hold. If the ledger refuses, the step fails with
799
+ `Smith::BudgetUnavailable` (a `BudgetExceeded`, not retried by default) before
800
+ anything is sent; `retry_on Smith::BudgetUnavailable, durable: true, ...` waits
801
+ for budget instead (the host ends the wait with `expire_wait!`, and ends it
802
+ early, once there is budget, with a `now:` at its `deadline_at`). After the request, Smith reconciles what it
803
+ spent: its usage, or its hold when it answered without usage or its stream
804
+ failed part way; a request that failed before anything came back releases its
805
+ hold. If building the ledger or `reserve!` raises, the step fails with
806
+ `Smith::BudgetLedgerError` (nothing was sent, no fallback model is tried); one
807
+ from `reconcile!` or `release!` is logged by class and the step goes on, as
808
+ the money is spent.
809
+
810
+ A tool loop's later rounds continue a call whose tools already ran, so
811
+ stopping one would leave the tools' effects without their answer. A step whose
812
+ `retry_on` names `BudgetUnavailable` holds them too: when the ledger refuses
813
+ one, the loop pauses, keeping its tool calls and their results (at most 1 MiB)
814
+ with the step's retry, and the retry continues the loop from them, running
815
+ none of its tools again. Any other step's later rounds, or a loop too large to
816
+ keep, go on unheld and are reconciled with what they spent, so give tool-using
817
+ agents a `tool_calls` budget.
818
+
819
+ A hold (`Smith::Budget::Amount`: `input_tokens`, `output_tokens`, `cost`, and
820
+ `tokens`, their sum) is the request's output cap (its `max_tokens`, or its
821
+ provider's name for it, else the model's) and an estimate of its input: text
822
+ at one token per 3 bytes, an image at 1,600 tokens, a PDF at 2,000 tokens a
823
+ page, 8 tokens a message for its framing, capped at the model's context window.
824
+ Images and documents count so as RubyLLM attachments and in raw content blocks
825
+ (base64 data or a data URL). `Smith.config.budget_hold_output_tokens`, when
826
+ set, caps the output part (an agent with no `max_tokens` is otherwise held for
827
+ the model's maximum, which can be tens of thousands of tokens, and a longer
828
+ reply is reconciled at what it cost). It is priced from the pricing catalog
829
+ under the id RubyLLM resolves the model to; `cost` is nil for an unpriced
830
+ model, which runtime readiness warns of (`:budget_ledger_unpriced`, for every
831
+ agent that charges the ledger), and its readiness metrics name the scope keys.
832
+
833
+ A key is the provider attempt's `attempt_id` (as on its `:provider_call` trace)
834
+ and the request's round, `"<attempt_id>:<n>"`, and for a batch request its
835
+ `custom_id`. A batch request is held when its step captures it, at the batch
836
+ discount, and settled when its result comes: a succeeded result, used or not,
837
+ is reconciled at what it cost; one that failed, or never came, is released.
838
+ A process that dies during a request never settles its hold, so every hold
839
+ carries `expires_at`: `Smith.config.budget_hold_ttl` from now (an hour by
840
+ default; it must outlast the longest request), and for a batch request its
841
+ 25-hour wait longer. Smith runs no timer; the ledger leaves holds past
842
+ `expires_at` out of what it admits, and still records a reconcile that comes
843
+ later (a batch delivered late). A key may come again after a crash (a batch
844
+ request captured again, a delivery that ran twice): a reserve of it replaces
845
+ its hold, a release ends it, and its reconcile is final, recorded once.
846
+ Fan-out branches call the ledger from their own threads. RubyLLM's own retries
847
+ (`max_retries`) resend a request under its one hold.
848
+
440
849
  ## Doctor
441
850
 
442
851
  After adding Smith, verify the integration:
@@ -456,6 +865,73 @@ bin/rails generate smith:install
456
865
  ```
457
866
 
458
867
  Doctor verifies: Smith loads, RubyLLM loads, minimal workflow boots, configuration is non-empty, serialization round-trips, persistence adapter works, and (with `--live`) a real provider call succeeds.
868
+ An `:active_record` persistence adapter's model is checked on every run: its
869
+ `lock_version` (Rails' locking column), a text payload, and a string key the
870
+ database keeps unique, at least 64 characters wide, read from schema
871
+ metadata (skipped when the database cannot be reached; an encrypted payload
872
+ is a warning). The durability probe writes as runs write (`store_versioned`
873
+ where the adapter has it, and `persistence_max_bytes`), so it meets what a
874
+ run would.
875
+
876
+ ## Testing
877
+
878
+ `require "smith/testing"` runs a host's workflows in its tests without a
879
+ provider. A stubbed agent's requests are answered where they would go over
880
+ the wire, so everything above runs as for a provider's reply: the tool loop,
881
+ budget holds, the response memo, streaming, output checks and repairs, usage
882
+ entries and budgets.
883
+
884
+ ```ruby
885
+ # test/test_helper.rb
886
+ require "smith/testing/minitest" # or "smith/testing/rspec" for matchers
887
+
888
+ class NoticeTest < ActiveSupport::TestCase
889
+ include Smith::Testing::Assertions
890
+
891
+ test "drafts with the venue's hours, then waits for approval" do
892
+ Smith::Testing.stub_agent(Writer, outputs: [Smith::Testing.tool_call(Lookup, venue: "the hall"),
893
+ "The hall opens at 8."],
894
+ usage: { input_tokens: 30, output_tokens: 6 })
895
+ run = Notice.new(context: {})
896
+
897
+ Smith::Testing.run_until_waiting(run)
898
+ assert_waiting_for run, :approval
899
+ Smith::Testing.resume_with(run, payload: { approved: true })
900
+ assert_transitioned_through run, :draft, :approve, :publish
901
+ assert_usage_recorded run, agent_name: Writer, stop_reason: "end_turn", input_tokens: 30
902
+ end
903
+ end
904
+ ```
905
+
906
+ - `stub_agent(agent, outputs:, usage:, stop_reason:, stream:)` answers the
907
+ agent's requests (its class, or its registered name) with `outputs` in
908
+ turn, the last answering every request after it: a String reply, a Hash as
909
+ JSON text (for an output schema), or `tool_call(ToolClass, **arguments)`
910
+ (an Array of them for one round), which runs the agent's own tools before
911
+ its next request. A tool request naming a tool the agent does not have
912
+ fails the call. `stop_reason: "max_tokens"` tests a truncated reply;
913
+ `stream: n` sends a streamed step's reply in pieces of n characters. The
914
+ stub keeps the messages each request sent (`calls`). Only the agent's own
915
+ requests are answered, never a tool's own provider call. The chat is still
916
+ built, so configure the provider (any key). An agent with no stub goes over
917
+ the wire; `Smith::Testing.unstubbed = :refuse` fails its call with
918
+ `Smith::Testing::UnstubbedCall` instead, so a mistyped stub never reaches a
919
+ paid provider.
920
+ - `run_until_waiting(run)` runs until the run waits or ends and returns its
921
+ wait (a durable retry's backoff is ended at once unless `retries: false`);
922
+ `resume_with(run, payload:)` delivers the wait's event and runs on;
923
+ `expire(run)` ends the wait as if its deadline had passed and runs on.
924
+ - A batch step's request is captured as ever (the batch queue gets it);
925
+ `resume_with(run, payload: Smith::Testing.batch_payload(run, Smith::Testing.batch_result("text")))`
926
+ answers it, and a fan-out's `batch_payload` takes results by branch key.
927
+ - Minitest: `assert_waiting_for(run, :event)` (or `kind: :batch`),
928
+ `assert_transitioned_through(run, *transitions)` (in order; the steps the
929
+ instance completed, not a failed or timed-out one),
930
+ `assert_usage_recorded(run, count:, **usage_entry_attributes)`. RSpec:
931
+ `be_waiting_for`, `have_transitioned_through`, `have_recorded_usage`.
932
+ - Stubs belong to the process; both helpers drop them after each test. An
933
+ in-process `retry_on` backoff still sleeps: stub `sleep` on the workflow,
934
+ or declare the retry `durable: true`.
459
935
 
460
936
  ## Capability-aware request shaping
461
937
 
@@ -486,11 +962,69 @@ fallback provider from the primary model or from a model id because the same id
486
962
  may be registered by more than one provider. Unqualified fallback entries fail
487
963
  closed during agent configuration.
488
964
 
965
+ ## Agent outputs
966
+
967
+ Smith reads how each response ended from the provider's stop reason. A
968
+ response cut off at its output limit, a refused one, and a structured output
969
+ that is not what the agent declares are not accepted as the step's output:
970
+
971
+ ```ruby
972
+ class Reviewer < Smith::Agent
973
+ model "claude-opus-5-5", provider: :anthropic
974
+ output_schema ReviewSchema # its root is an object
975
+ output_contract ReviewContract # call(hash) => true, false, or a result with success? and errors
976
+ output_repairs 1 # the default; 0 turns repair off
977
+ on_truncation retry_with: { max_tokens: 64_000 } # or :fail (the default), or :accept
978
+ on_refusal fallback: "openai/gpt-5" # or :fail (the default)
979
+ end
980
+ ```
981
+
982
+ - A cut-off response raises `Smith::OutputTruncated`, unless the agent
983
+ accepts it or retries it once on the same model with a larger output limit,
984
+ mapped to the provider's own parameter (Anthropic, OpenAI, Gemini, Vertex AI
985
+ and Bedrock; another provider is not retried). There is no retry when the
986
+ response ran out of context rather than output, or when the new limit is no
987
+ larger than the request had (the agent's own parameter, or for Anthropic the
988
+ model's maximum, which RubyLLM already sends); a later repair keeps the
989
+ larger limit.
990
+ - A refused response (a Gemini prompt block included) raises
991
+ `Smith::OutputRefused`, unless the agent falls back once to another model.
992
+ - Every other response then goes through the agent's `after_completion`. A
993
+ structured output (an `output_schema`, or a static RubyLLM `schema`) that is
994
+ not a JSON object, breaks a schema constraint, or fails the output contract
995
+ goes back to the same model up to `output_repairs` times: the step's messages
996
+ as the provider got them, the rejected reply as the assistant's, and the
997
+ problems as "path: problem" lines. It then raises `Smith::OutputInvalid`,
998
+ whose `problems` lists at most 20 of them. A blank output still raises
999
+ `Smith::BlankAgentOutputError`.
1000
+ - No follow-up call is made once a tool has run in the step (the tools would
1001
+ run again; the step fails with `Smith::ToolOutcomeUncertain` around the output
1002
+ error) or once the call has spent what its step reserved from a budget. A
1003
+ follow-up that fails raises the output error it was making up for, with the
1004
+ provider's error as its cause.
1005
+ - Anthropic's structured outputs reject numeric, string-length and most
1006
+ array-size constraints: Smith sends Anthropic the schema without them, with
1007
+ `additionalProperties: false` on every object, and checks them on the parsed
1008
+ output for every provider. An object that allows other properties or
1009
+ declares none cannot be sent to Anthropic; the step fails before the call
1010
+ with a `Smith::WorkflowError`, and runtime readiness reports
1011
+ `:output_schema_unsupported` for an agent declared for Anthropic.
1012
+ - An agent with no model and no fallback models fails its step with a
1013
+ `Smith::WorkflowError`; runtime readiness reports it (`:agent_without_model`).
1014
+
1015
+ Every response is billed and settles into budgets, whatever happens to its
1016
+ output, and each rejected one leaves an `:output_rejected` trace naming its
1017
+ attempt. The output errors share the failure family `output_failure`, persist
1018
+ with a failed step, and are never retried by `Smith::Errors.retryable?`, a job
1019
+ retry on `Smith::Errors.retryable_classes`, or a transition's
1020
+ `retry_on Smith::AgentError`: Smith's own retry, fallback and repairs have run.
1021
+
489
1022
  ## Errors and retry
490
1023
 
491
1024
  ```ruby
492
1025
  Smith::Errors.retryable?(error)
493
1026
  # AgentError, DeadlineExceeded => true (always)
1027
+ # OutputTruncated, OutputRefused, OutputInvalid (OutputFailure) => false
494
1028
  # DeterministicStepFailure, ToolGuardrailFailed => honors error.retryable
495
1029
  # everything else => false
496
1030
 
@@ -501,6 +1035,11 @@ Smith::Errors.retry_forbidden?(error)
501
1035
  # => true for terminal tool-evidence failures that must never be replayed
502
1036
  ```
503
1037
 
1038
+ A nested workflow's step fails with the child's own error when the child
1039
+ raises it, so these answers carry through nesting. When a tool ran inside the
1040
+ child, the parent step's failure becomes `ToolOutcomeUncertain`, which is never
1041
+ retried.
1042
+
504
1043
  Workflow transitions can also declare a bounded local retry policy:
505
1044
 
506
1045
  ```ruby
@@ -510,9 +1049,86 @@ transition :draft, from: :idle, to: :done do
510
1049
  end
511
1050
  ```
512
1051
 
513
- When no classes are passed, `retry_on` uses `Smith::Errors.retryable?`.
514
- This is a bounded local transition retry policy. Durable scheduling, long waits,
515
- and external idempotency guarantees remain host-owned.
1052
+ When no classes are passed, `retry_on` uses `Smith::Errors.retryable?`. No
1053
+ attempt starts once the workflow's wall-clock deadline has passed.
1054
+ This is a bounded local transition retry policy. What a receiver does with an
1055
+ idempotency key remains host-owned.
1056
+
1057
+ For backoffs too long to sleep through in a worker, `durable: true` makes each
1058
+ one a wait of the run instead:
1059
+
1060
+ ```ruby
1061
+ transition :charge, from: :approved, to: :charged do
1062
+ compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
1063
+ retry_on Payments::Timeout, attempts: 5, backoff: 60, max_delay: 3600, durable: true
1064
+ on_failure :charge_failed
1065
+ end
1066
+ ```
1067
+
1068
+ A failed attempt the policy retries stores the run waiting (`awaiting.kind ==
1069
+ :retry`, due when the backoff ends); the host schedules `expire_wait!` for that
1070
+ time and then continues the run, which makes the next attempt under the step's
1071
+ keys (new ones only after an attempt declared settled). See
1072
+ `docs/PERSISTENCE.md`, Durable Retry.
1073
+
1074
+ Steps and tools get idempotency keys for the requests they send outside the
1075
+ system, the same on every attempt and after a restore:
1076
+
1077
+ ```ruby
1078
+ transition :charge, from: :approved, to: :charged do
1079
+ compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
1080
+ retry_on Payments::Timeout, Payments::Declined, attempts: 3,
1081
+ new_idempotency_key_on: [Payments::Declined]
1082
+ end
1083
+ ```
1084
+
1085
+ A retry keeps the keys, so a receiver that honours them drops a request that
1086
+ took effect before a timeout. An error named in `new_idempotency_key_on:` (or
1087
+ `step.fail!(..., new_idempotency_key: true)`) shows the attempt's own requests
1088
+ took no effect, and the attempts after it use new keys. See
1089
+ `docs/PERSISTENCE.md`, Replay Safety.
1090
+
1091
+ ### Response memo
1092
+
1093
+ A step replayed after its process died makes its provider calls again, and
1094
+ pays for them again. A host's response memo keeps what they got:
1095
+
1096
+ ```ruby
1097
+ class ResponseMemo
1098
+ def fetch(key) = Rails.cache.read("smith-memo/#{key}")
1099
+ def store(key, record) = Rails.cache.write("smith-memo/#{key}", record, expires_in: 2.days)
1100
+ end
1101
+
1102
+ Smith.config.response_memo = ResponseMemo.new
1103
+ ```
1104
+
1105
+ Each agent call (its tool loop included) is kept under a key of its position
1106
+ (the run, the step, any branch or nested position, the key generation), the
1107
+ attempts it runs under (its step's, and each enclosing run's for a nested
1108
+ workflow, so a retry never takes the attempt before's answer), its attempt
1109
+ index (its model in the fallback chain, or a follow-up's place), its place
1110
+ among the attempt's calls, and a digest of its request (model, messages,
1111
+ tools, schema, params). A replay reaches the same key; a call whose request
1112
+ changed since (a deployed prompt, another model, a date in the prompt) misses
1113
+ and is sent again, as a miss always may be.
1114
+
1115
+ Its record (`{"version" => 1, "messages" => [...], "complete" => true | false}`,
1116
+ JSON) holds the messages the call added: the model's replies (structured ones
1117
+ too) with their usage, stop reason and message id (a stream's included), its
1118
+ tool calls and their results. It is stored after each round's tool results
1119
+ and when the call ends; a complete record ends with the model's reply. A
1120
+ replayed call that ended sends nothing: its reply is the step's (a streamed
1121
+ step's sink gets the reply's text at once, not the earlier rounds'), its usage
1122
+ is recorded as if it had just come, once, and its `:provider_call` trace says
1123
+ `memo: true`; a reply that was cut off or refused fails the step as it did.
1124
+ One that had not ended continues from its tools' results, its live part going
1125
+ on from the kept rounds (on a budget ledger, a later round). Kept tool results
1126
+ count as tools the step ran, so no retry or fallback runs them again. A call
1127
+ whose messages cannot be kept (an attachment, past 1 MiB) is not stored, and
1128
+ is logged; a record Smith does not keep is ignored, and an error from `fetch`
1129
+ or `store` is logged by class: a memo never fails a step. Keeping records and
1130
+ dropping them (a TTL) are the host's; batch requests are not memoized, as
1131
+ their `custom_id` already identifies them.
516
1132
 
517
1133
  ## Runtime Complexity
518
1134
 
@@ -566,5 +1182,5 @@ bundle exec rspec
566
1182
  bundle exec rubocop
567
1183
  ```
568
1184
 
569
- 1,175 examples, MIT licensed. See [`CHANGELOG.md`](CHANGELOG.md) for the current
1185
+ 2,420 examples, MIT licensed. See [`CHANGELOG.md`](CHANGELOG.md) for the current
570
1186
  release surface.