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