smith-agents 0.12.1 → 0.12.3

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/README.md +197 -1081
  4. data/docs/AGENTS.md +241 -0
  5. data/docs/BUDGETS.md +134 -0
  6. data/docs/CONFIGURATION.md +29 -3
  7. data/docs/DEVELOPMENT.md +63 -0
  8. data/docs/ERRORS_AND_RETRIES.md +177 -0
  9. data/docs/INSPECTION.md +39 -0
  10. data/docs/PATTERNS.md +2 -2
  11. data/docs/PERSISTENCE.md +175 -4
  12. data/docs/TESTING.md +88 -0
  13. data/docs/TOOLS_AND_GUARDRAILS.md +59 -1
  14. data/lib/smith/agent/completion.rb +5 -3
  15. data/lib/smith/agent/provider_completion.rb +1 -1
  16. data/lib/smith/agent/provider_streaming.rb +15 -9
  17. data/lib/smith/agent/usage_tracking.rb +1 -2
  18. data/lib/smith/batch.rb +11 -9
  19. data/lib/smith/cache_markers.rb +86 -0
  20. data/lib/smith/interruptions.rb +50 -0
  21. data/lib/smith/launchers/inline.rb +1 -0
  22. data/lib/smith/providers/request_holds.rb +1 -1
  23. data/lib/smith/providers/stream_facts.rb +29 -11
  24. data/lib/smith/response_memo.rb +150 -45
  25. data/lib/smith/setting_constructors.rb +14 -0
  26. data/lib/smith/tool/chat_execution_callbacks.rb +1 -1
  27. data/lib/smith/tool/execution_batch.rb +5 -0
  28. data/lib/smith/tool/execution_failure_handling.rb +27 -9
  29. data/lib/smith/version.rb +1 -1
  30. data/lib/smith/workflow/deterministic_execution.rb +5 -1
  31. data/lib/smith/workflow/parallel.rb +5 -1
  32. data/lib/smith/workflow/persistence.rb +4 -1
  33. data/lib/smith/workflow/remembered_calls.rb +52 -19
  34. data/lib/smith/workflow/retry_execution.rb +1 -0
  35. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +1 -0
  36. data/lib/smith/workflow/step_context.rb +1 -0
  37. data/lib/smith/workflow.rb +4 -2
  38. data/lib/smith.rb +10 -0
  39. metadata +9 -1
data/README.md CHANGED
@@ -1,1187 +1,303 @@
1
1
  # Smith
2
2
 
3
- Workflow-first multi-agent orchestration for Ruby. Smith sits on top of `RubyLLM` and adds explicit state machines, typed contracts, budgets, guardrails, persistence, tools, and tracing for production agent systems.
3
+ Smith runs AI agents inside workflows you can trust in production. A workflow
4
+ is a state machine: each step calls an agent, runs your Ruby, routes, fans out
5
+ or waits. Every run can be saved after each step and resumed later, in another
6
+ process, so a run can wait days for a person or a webhook without holding a
7
+ worker. Smith sits on top of [RubyLLM](https://rubyllm.com), which talks to
8
+ the model providers.
9
+
10
+ What you get:
11
+
12
+ - **Workflows** with named states and transitions, which you can inspect
13
+ before they run.
14
+ - **Durable runs**: saved after every step; they wait for events and timers,
15
+ start child runs, and retry later. Your app owns the storage, queues and
16
+ timers; Smith never sleeps, polls or enqueues.
17
+ - **Checked outputs**: output schemas and contracts, and handling for replies
18
+ that are cut off, refused or malformed.
19
+ - **Cost control**: token and cost budgets per run, your own ledgers across
20
+ runs, cache-aware pricing, and half-price batch calls.
21
+ - **Safety**: isolated steps, outside text marked untrusted, guardrails and
22
+ tool budgets.
23
+ - **Operations**: streaming, traces, a doctor, and a testing kit.
4
24
 
5
25
  > [!WARNING]
6
- > Smith is pre-1.0. Expect contract tightening between minor versions. Pin to an exact version in production.
26
+ > Smith is pre-1.0: contracts can tighten between minor versions. Pin an exact
27
+ > version.
7
28
 
8
- ## Verification Discipline
9
-
10
- Tests are required, but they are never enough for runtime primitive changes.
11
- Every Smith workflow slice must also run practical gem-level execution probes.
12
- When a host application consumes unreleased Smith changes, point that host app at
13
- the local Smith repository and exercise the changed workflow paths in the host
14
- environment before calling the slice complete.
15
-
16
- ## Installation
29
+ ## Install
17
30
 
18
31
  ```ruby
19
32
  # Gemfile
20
- gem "smith-agents", "0.12.1", require: "smith"
33
+ gem "smith-agents", "0.12.3", require: "smith"
21
34
  ```
22
35
 
36
+ In a Rails app:
37
+
23
38
  ```bash
24
- bundle install
25
- bin/rails generate smith:install # in a Rails app
39
+ bin/rails generate smith:install
26
40
  bin/rails db:migrate
27
41
  ```
28
42
 
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).
43
+ This writes `config/initializers/smith.rb` and a `WorkflowState` table where
44
+ runs are saved. (The gem is called `smith-agents` because `smith` was taken; the
45
+ Ruby namespace is `Smith`.)
47
46
 
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.
49
-
50
- ## Quickstart
47
+ ## A first workflow
51
48
 
52
49
  ```ruby
53
- require "ruby_llm"
54
50
  require "smith"
55
51
 
56
- RubyLLM.configure do |config|
57
- config.openai_api_key = ENV.fetch("OPENAI_API_KEY")
58
- end
59
-
60
- class ReplyAgent < Smith::Agent
61
- register_as :reply_agent
62
- model "gpt-4.1-nano"
63
-
64
- instructions { "Write a concise, professional reply." }
65
- end
52
+ RubyLLM.configure { |config| config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY") }
66
53
 
67
- class ReplyContext < Smith::Context
68
- persist :user_message
69
- inject_state { |p| "User message: #{p[:user_message]}" }
54
+ class Summarizer < Smith::Agent
55
+ register_as :summarizer
56
+ model "claude-haiku-4-5", provider: :anthropic
57
+ instructions { "Summarize the user's text in one sentence." }
70
58
  end
71
59
 
72
- class ReplyWorkflow < Smith::Workflow
73
- context_manager ReplyContext
60
+ class Summarize < Smith::Workflow
74
61
  initial_state :idle
75
62
  state :done
76
63
  state :failed
64
+ seed_messages { |context| [{ role: :user, content: context[:text] }] }
77
65
 
78
- transition :reply, from: :idle, to: :done do
79
- execute :reply_agent
66
+ transition :summarize, from: :idle, to: :done do
67
+ execute :summarizer, output_to: :summary
80
68
  on_failure :fail
81
69
  end
82
70
  end
83
71
 
84
- result = ReplyWorkflow.new(context: { user_message: "Charged twice." }).run!
85
- result.state # => :done
86
- result.output # => assistant reply
87
- result.steps # => [{ transition: :reply, from: :idle, to: :done, output: ... }]
72
+ result = Summarize.new(context: { text: "Our Q3 revenue grew 12%..." }).run!
73
+ result.state # => :done
74
+ result.context[:summary] # => "Revenue grew 12% in Q3..."
88
75
  ```
89
76
 
90
- ## Core Concepts
91
-
92
- | Concept | Purpose |
93
- |---|---|
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`. |
95
- | `Smith::Workflow` | A state machine of named transitions. Each transition calls an agent, runs deterministic code, routes, or composes a nested workflow. |
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`). |
97
- | `Smith::Tool` | A `RubyLLM` tool plus provider-compatibility metadata and guardrail hooks. |
98
- | Persistence adapters | Host-owned storage. Smith ships `Memory`, `RedisStore`, `CacheStore`, `RailsCache`, `ActiveRecordStore`. |
99
- | Trace adapters | Host-owned observability. Smith ships `Memory`, `Logger`, `OpenTelemetry`. |
100
-
101
- Agents register at class load. In Rails, register workflow-facing agents in a `to_prepare` hook so autoload doesn't drop them:
102
-
103
- ```ruby
104
- # config/initializers/smith_agents.rb
105
- Rails.application.config.to_prepare do
106
- ReplyAgent
107
- TriageAgent
108
- end
109
- ```
77
+ An agent is a model plus instructions (and optionally an output schema, tools
78
+ and fallback models). A workflow moves from state to state through
79
+ transitions; `execute` calls an agent, and `output_to:` keeps its reply in the
80
+ run's context for later steps.
110
81
 
111
- ## Patterns
82
+ ## Steps
112
83
 
113
- | Pattern | DSL | Use case |
84
+ | Step | Declared with | Does |
114
85
  |---|---|---|
115
- | Single execute | `execute :agent` | One agent call per transition. |
116
- | Pipeline | sequential transitions | Multi-step workflow with explicit success/failure routing. |
117
- | Router | `route :classifier, routes: {...}` | Branch on a classifier agent's output. |
118
- | Parallel fan-out | `execute :agent, parallel: true` | Concurrent agent calls under one ledger. |
119
- | Heterogeneous fan-out | `fan_out branches: {...}` | Concurrent calls to different agents with named branch results. |
120
- | Nested workflow | `workflow OtherWorkflow` | Reuse a subflow as one transition. |
121
- | Evaluator-Optimizer | `optimize generator:, evaluator:, ...` | Generate-then-critique refinement loops. |
122
- | Orchestrator-Worker | `orchestrate orchestrator:, worker:, ...` | Dynamic task fan-out with delegation rounds. |
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. |
86
+ | Agent call | `execute :agent` | Calls one agent. `mode: :batch` sends it through the batch API; `stream:` streams its text |
87
+ | Ruby | `compute { \|step\| ... }` | Runs your code; reads and writes the context |
88
+ | Route | `route :classifier, routes: {...}` | Picks the next transition from an agent's answer |
89
+ | Fan-out | `execute :agent, parallel: true`, `fan_out branches: {...}` | Calls agents concurrently |
90
+ | Refine | `optimize generator:, evaluator:` | Drafts and critiques in bounded rounds |
91
+ | Delegate | `orchestrate orchestrator:, worker:` | Plans tasks and hands them to workers |
92
+ | Nest | `workflow OtherWorkflow` | Runs another workflow as one step |
93
+ | Wait | `await :event, into:, timeout:`, `delay seconds` | Waits for an event your app delivers, or for a time |
94
+ | Child runs | `call`, `map`, `spawn` | Starts other workflows as runs of their own |
125
95
 
126
- The full pattern guide with working examples for each lives in [`docs/PATTERNS.md`](docs/PATTERNS.md).
96
+ Steps can declare `on_failure` (where a failure goes) and `retry_on`
97
+ (bounded retries). Examples of each: [`docs/PATTERNS.md`](docs/PATTERNS.md).
127
98
 
128
- ### Repair And Wait Boundaries
99
+ ## Durable runs
129
100
 
130
- Smith only owns repair and wait-style loop behavior when the bounds and stop
131
- conditions are explicit and enforceable inside the workflow step. Durable
132
- timers, queue delivery, and wake-up policy remain host-owned.
133
- For bounded dynamic delegation, use the separate Orchestrator-Worker pattern.
134
-
135
- | Contract | Status | Smith mapping |
136
- |---|---|---|
137
- | Retry loop | Executable | `retry_on`, bounded to one transition. |
138
- | Evaluator-Optimizer | Executable | `optimize`, bounded by `max_rounds` plus structured evaluator output. |
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. |
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. |
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:
101
+ Save a run after every step, and it can stop and pick up again anywhere. Here a
102
+ draft waits up to two days for someone to approve it:
147
103
 
148
104
  ```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
105
+ class Publish < Smith::Workflow
106
+ initial_state :idle
107
+ state :drafted
108
+ state :approved
109
+ state :expired
110
+ state :done
297
111
  state :failed
298
112
 
299
- transition :answer, from: :asked, to: :answered do
300
- execute :answerer, stream: :answer, output_to: :answer
113
+ transition :draft, from: :idle, to: :drafted do
114
+ execute :writer, output_to: :draft
301
115
  on_failure :fail
302
116
  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
117
 
352
- transition :triage, from: :queued, to: :triaged do
353
- execute :triager, mode: ->(ctx) { ctx[:urgent] ? :sync : :batch }, output_to: :verdict
354
- on_failure :fail
118
+ transition :approve, from: :drafted, to: :approved do
119
+ await :approval, into: :approval, timeout: 2 * 86_400
120
+ on_timeout :expire
355
121
  end
356
- end
122
+ transition :expire, from: :drafted, to: :expired
357
123
 
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
124
+ transition :publish, from: :approved, to: :done do
125
+ compute { |step| Posts.publish(step.read_context(:draft)) }
370
126
  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.
407
-
408
- ## Workflow Graph Inspection
409
-
410
- Smith can inspect a workflow's declared graph without running agents or advancing state. This is useful for host apps that want to render, lint, or cache a workflow shape before execution.
411
-
412
- ```ruby
413
- report = ReplyWorkflow.validate_graph
414
-
415
- report.valid? # => true
416
- report.transitions # => read-only transition snapshots
417
- report.diagnostics # => errors and warnings for missing states or routes
418
- report.metrics # => state, transition, reachability, and terminal-state counts
419
- ```
420
-
421
- Graph inspection is static and diagnostic-only. Runtime execution, persistence, progress projection, retries, and recovery remain host-owned concerns.
422
-
423
- Smith also exposes a static runtime-readiness report for checks that require
424
- declared runtime bindings but still do not execute the workflow:
425
-
426
- ```ruby
427
- readiness = ReplyWorkflow.runtime_readiness
428
-
429
- readiness.ready? # => true when no readiness/topology errors exist
430
- readiness.status # => :ready, :warning, or :not_ready
431
- readiness.diagnostics # => topology diagnostics + runtime binding diagnostics
432
- ```
433
-
434
- Runtime readiness checks graph topology, registered agent bindings, model
435
- requirements for structured runtime roles, lazy/uninspectable bindings, invalid
436
- non-agent bindings, nested workflow readiness, and fan-out branch binding counts.
437
- It does not call providers, resolve lazy container blocks, run tools, enqueue
438
- jobs, or verify host-owned durability.
439
-
440
- Readiness metrics include both direct graph counts and transitive counts folded
441
- in from nested workflows.
442
-
443
- Transition snapshots include runtime contracts for complex primitives where
444
- Smith owns executable semantics: heterogeneous fan-out, evaluator-optimizer, and
445
- orchestrator-worker transitions expose bounded settings, output shapes, and
446
- transition-level resume behavior for host renderers and compilers.
447
-
448
- ## Configuration
449
-
450
- ```ruby
451
- require "logger"
452
- require "smith"
453
-
454
- Smith.configure do |config|
455
- config.logger = Logger.new($stdout)
456
- config.trace_adapter = Smith::Trace::Memory.new
457
- config.artifact_store = :active_record # or :s3 (S3, R2), :file, :memory
458
- config.artifact_options = { model: "SmithArtifact" }
459
-
460
- # Persistence
461
- config.persistence_adapter = :rails_cache
462
- config.persistence_options = { namespace: "smith" }
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
465
- config.persistence_retry_policy = { attempts: 3, base_delay: 0.1, max_delay: 1.0 }
466
-
467
- # Generic runtime resource bounds
468
- config.parallel_branch_limit = 1_000
469
- config.parallel_concurrency = 8
470
- config.parallel_nesting_limit = 64
471
- config.retry_attempt_limit = 100
472
-
473
- # OpenAI /v1/responses routing for gpt-5 + tools + thinking. :auto (default) or :off.
474
- config.openai_api_mode = :auto
475
-
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
- )
483
127
  end
484
- ```
485
-
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.
488
-
489
- ## Persistence and Resume
490
-
491
- ```ruby
492
- # Persist after every advance
493
- result = ReplyWorkflow.run_persisted!(
494
- context: { user_message: "..." },
495
- adapter: Smith.persistence_adapter
496
- )
497
-
498
- # Resume later
499
- result = ReplyWorkflow.run_persisted!(
500
- key: "ticket:T-1042",
501
- adapter: Smith.persistence_adapter
502
- )
503
-
504
- # Admit host-supplied conversation input at a stable workflow boundary.
505
- workflow = ReplyWorkflow.restore("ticket:T-1042", adapter: Smith.persistence_adapter)
506
- admission = workflow.append_session_messages!(role: :user, content: "Continue")
507
- ```
508
-
509
- `append_session_messages!` validates and owns a bounded canonical copy of the
510
- message batch, appends it under Smith's workflow lifecycle lock, and returns an
511
- immutable digest witness. The host still owns session identity, transaction
512
- coordination, persistence, idempotency, and resume policy.
513
-
514
- Built-in adapters (all except `ActiveRecord` support TTL; `ActiveRecord` has no
515
- expiry column, so its writes refuse a non-nil `persistence_ttl`, while reads and
516
- deletes keep working and `smith doctor --durability` flags the combination;
517
- `Redis`, `ActiveRecord`, and `Memory` support optimistic locking via
518
- `store_versioned` and exact dispatch claims via `replace_exact`; `ActiveRecord`
519
- participates in the caller's transaction for host-record coordination;
520
- `Redis` and `Memory` also support heartbeat probes via `record_heartbeat` /
521
- `last_heartbeat`):
522
-
523
- - `:memory` — in-process Hash, intended for tests and `test_mode = true`
524
- - `:redis` — Redis client; uses WATCH/MULTI/EXEC for CAS
525
- - `:rails_cache`, `:solid_cache` — Rails cache backends
526
- - `:cache_store` — any object responding to `write/read/delete`
527
- - `:active_record` — keyed ActiveRecord model with an enabled optimistic-locking
528
- column for row-level CAS; Smith's logical version remains in the JSON payload
529
-
530
- See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md) for schema versioning,
531
- seed-drift validation, the `idempotency_mode :strict` step-in-progress contract,
532
- host-coordinated message admission, and opt-in restart-safe prepared recovery.
533
- Hosts that need to commit a durable attempt before provider or tool work can
534
- separate verification from execution with
535
- `authorize_prepared_step_execution!`, commit their own attempt ledger, and then
536
- consume the exact process-local capability through
537
- `execute_authorized_prepared_step!`. Smith does not own the host lease, attempt
538
- record, scheduler, or unknown-outcome policy. Authorization discovers the exact
539
- agent bindings reachable by that transition with a bounded `O(V + E)` graph
540
- walk, then resolves the bounded binding set in one registry epoch. A concurrent
541
- registry writer therefore cannot produce a mixed authorization containing
542
- bindings from two registry states or replace a captured binding between
543
- authorization and work. Every public Dry::Container mutation path participates
544
- in that epoch. Execution authorization accepts only concrete registered
545
- `Smith::Agent` classes; lazy container callbacks are not executed while
546
- authority is captured.
547
- While the capability is active, a host may call `each_agent_binding` with a
548
- block to inspect the exact captured `(registry_name, agent_class)` pairs before
549
- external work. Enumeration is process-local, performs no dispatch, and is
550
- rejected after release or consumption; it deliberately does not return a lazy
551
- enumerator that could escape the authorization scope.
552
- Reachable nested workflow transition contracts are captured and revalidated
553
- before child execution. The typed result owns a bounded snapshot of JSON-like
554
- Ruby values; unsupported mutable output values fail closed.
555
- During authorized execution, Smith also seals its private execution path against
556
- workflow subclass method-name collisions, including nested child workflows.
557
- Ordinary non-authorized runs retain normal Ruby override behavior.
558
-
559
- For `parallel: true` and `fan_out` transitions, a host can split one confirmed
560
- dispatch into independently scheduled branch operations without invoking agent
561
- classes outside Smith:
562
128
 
563
- ```ruby
564
- preparation = workflow.prepare_composite_step!
565
-
566
- # Persist preparation.plan and preparation.input in host-owned records.
567
- # Each worker recovers the same confirmed dispatch and receives one descriptor.
568
- branch = preparation.plan.branches.fetch(0)
569
- execution = preparation.plan.execution_for(branch)
570
- outcome = branch_workflow.execute_prepared_composite_branch!(
571
- execution:,
572
- input: preparation.input
573
- )
129
+ Publish.new(context: { topic: "Release notes" }).run_persisted!("post-42") # stops at the wait
574
130
 
575
- # After every branch has a terminal outcome, use another fresh recovery to
576
- # complete the Smith transition exactly once.
577
- result = reducer_workflow.reduce_prepared_composite_step!(
578
- plan: preparation.plan,
579
- input: preparation.input,
580
- outcomes: ordered_or_unordered_complete_outcome_set,
581
- primary_failure: host_committed_failed_branch_key
582
- )
131
+ wait = Publish.restore("post-42").awaiting # the event, a token, the deadline
132
+ Publish.resume!("post-42", event: :approval, token: wait.token, payload: { ok: true })
133
+ Publish.restore("post-42").run_persisted!("post-42") # runs to the end
583
134
  ```
584
135
 
585
- Plans bind the exact prepared dispatch, host-supplied complete workflow
586
- `definition_digest`, execution-semantics version, prepared input digest, budget
587
- baseline, one shared artifact execution namespace, ordered branch identities,
588
- and explicit policies. Each plan, input, outcome, or effect payload is
589
- canonical, immutable, bounded to 1 MiB after JSON encoding, rejects missing or
590
- unknown fields, and redacts exception messages and backtraces.
591
- Reduction additionally bounds cumulative effects to 1 MiB and final aggregate
592
- output to 4 MiB before consuming execution authority. Reduction accepts either
593
- an `Array` or an `Enumerator`, validates each outcome incrementally, and stops
594
- as soon as a cumulative bound is crossed. The transport hard limit is 10,000
595
- branches; every recovered worker also enforces the current
596
- `Smith.config.parallel_branch_limit` before work.
136
+ Your app delivers events (`resume!`), schedules the timeout
137
+ (`expire_wait!` once `deadline_at` passes), and runs the next step from a
138
+ job. Smith stores runs through an adapter (`:active_record`, `:redis`,
139
+ `:rails_cache`, `:memory` or your own) and refuses a state past
140
+ `persistence_max_bytes`. Waits, child runs, durable retries, replay safety
141
+ and storage: [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md).
597
142
 
598
- Planning is `O(B * D)`, where `D` is the bounded number of budget dimensions.
599
- The coordinator derives a compact selected-branch execution envelope in
600
- `O(D)` without copying the full branch collection. The scoped branch execution
601
- entry point captures and validates one selected binding in `O(D)` and constant
602
- time with respect to total branch count; the reducer validates the complete plan
603
- once. Reduction is `O(B + P log P)` in
604
- the worst case because canonical nested Hash ownership sorts keys, and uses
605
- `O(B + P)` bounded space.
606
- Smith does not persist plans or outcomes, select scheduling slots, claim work,
607
- choose an uncertain-outcome policy, or infer the primary failure. The host
608
- durably owns those decisions, gives each branch and reduction a separately
609
- recovered workflow instance, and must pass a complete outcome set. Transport or
610
- encoding faults become typed branch failures when Smith can preserve accounting
611
- evidence; otherwise the host must treat the worker result as uncertain and must
612
- not infer safe retry. Resume policy is incomplete branches only; per-branch
613
- retry is intentionally unsupported in this contract version.
614
- Composite payload digests prove canonical content integrity, not worker
615
- authenticity. Before reduction, the host must authenticate every outcome against
616
- its own claimed operation identity and current fencing token; those durable
617
- receipts remain outside Smith.
618
-
619
- The supported composite API is the three scoped workflow methods above plus the
620
- immutable transport values they return or accept: `Preparation`, `Plan`,
621
- `Input`, `Branch`, `BranchExecution`, `BranchOutcome`, `BranchFailure`, `Error`,
622
- `Effects`, and `Reduction`. Other constants under `Workflow::Composite` are
623
- implementation collaborators, not extension points or compatibility promises.
143
+ ## Agent outputs
624
144
 
625
- Every agent used by this durable composite boundary must declare a stable
626
- host-computed SHA-256 execution identity. The identity represents the exact
627
- sealed executable asset the host intends to dispatch; Smith records it in the
628
- branch descriptor and rejects registry replacement after planning. Identities
629
- are deliberately not inherited because a subclass is a distinct executable
630
- implementation.
145
+ An agent can declare the shape of its answer and what to do when a reply goes
146
+ wrong:
631
147
 
632
148
  ```ruby
633
- class Researcher < Smith::Agent
634
- execution_identity Digest::SHA256.hexdigest(generated_asset_manifest)
635
- register_as :researcher, publish: false
149
+ class Reviewer < Smith::Agent
150
+ register_as :reviewer
151
+ model "claude-opus-4-7", provider: :anthropic
152
+ output_schema ReviewSchema # the reply must be this JSON object
153
+ output_contract ReviewContract # and pass your own check
154
+ on_truncation retry_with: { max_tokens: 64_000 } # a reply cut off at its limit
155
+ on_refusal fallback: "anthropic/claude-sonnet-4-6" # a reply the model refused
636
156
  end
637
-
638
- Researcher.freeze
639
- Researcher.publish_registration!
640
157
  ```
641
158
 
642
- Hosts that generate agent classes dynamically may call
643
- `register_as :name, publish: false` while constructing the class. This records
644
- the class identity without exposing it through `Smith::Agent::Registry`. After
645
- the host has validated and sealed the class, it may publish the exact class with
646
- `agent_class.publish_registration!`. Publication always uses the staged,
647
- immutable identity; callers cannot provide a second key. Registry collision and
648
- mutation epoch guarantees remain unchanged.
649
-
650
- Hosts that persist or cache generated executable definitions should include
651
- `Smith::EXECUTION_SEMANTICS_VERSION` in their definition identity. The gem
652
- version describes the package release; the execution-semantics version changes
653
- when Smith's executable workflow contract changes and must participate in host
654
- cache invalidation.
655
-
656
- ## Tools and Guardrails
159
+ A reply that fails its schema or contract goes back to the model once with the
160
+ problems listed; one that still fails raises `Smith::OutputInvalid`. Every
161
+ reply is billed and counted, whatever happens to it.
162
+ [`docs/AGENTS.md`](docs/AGENTS.md) covers outputs, model fallbacks, how
163
+ requests are shaped per provider, streaming and batch mode.
657
164
 
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.
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.
165
+ ## Retries
664
166
 
665
167
  ```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
-
674
- class SearchAgent < Smith::Agent
675
- register_as :search_agent
676
- model "claude-opus-4-7"
677
- tools WebSearch, UrlFetcher # the host's subclasses
678
- budget tool_calls: 3
679
- tool_budget_exhaustion :complete
168
+ transition :charge, from: :approved, to: :charged do
169
+ compute { |step| Payments.charge(step.read_context(:order_id), idempotency_key: step.idempotency_key) }
170
+ retry_on Payments::Timeout, attempts: 5, backoff: 60, durable: true
171
+ on_failure :fail
680
172
  end
681
173
  ```
682
174
 
683
- Agent tool-call budgets default to raising `Smith::BudgetExceeded` when a tool
684
- tries to execute beyond the configured limit. Agents that declare
685
- `tool_budget_exhaustion :complete` must also declare a finite `tool_calls`
686
- budget. Smith then consumes each model-requested call before dispatch, executes
687
- only a complete batch admitted against both agent and workflow limits, and
688
- performs one tool-disabled completion when the allowance is exhausted or the
689
- provider returns an oversized batch. The bounded provider loop is iterative,
690
- not recursive. Calls to unavailable tools consume allowance as well, so a
691
- provider cannot create an unbounded correction loop. Workflow reservations are
692
- reconciled to actual Smith tool execution.
693
-
694
- Hosts may install `Smith::Tool::CallBudget` with exact per-tool limits through
695
- `Smith::Tool.with_call_budget`. Smith propagates that same root allowance into
696
- parallel and heterogeneous fan-out worker threads; agent budgets narrow the
697
- root and all branches share its atomic counters.
698
-
699
- Graceful exhaustion is an in-process protocol guarantee, not durable recovery
700
- for the model/tool loop. If the process exits after a tool begins and before the
701
- agent completes, the host must treat the outcome as uncertain unless it owns a
702
- durable per-invocation receipt and reconciliation contract. Smith also refuses
703
- to start a fresh fallback-model chat after tool execution has begun because that
704
- would discard the captured evidence and conversation protocol.
175
+ `retry_on` retries a step a bounded number of times. With `durable: true` the
176
+ run waits between attempts instead of sleeping in a worker. Each step gets
177
+ idempotency keys that stay the same across retries and restores, so a
178
+ receiver can drop a request it already handled. A response memo keeps what a
179
+ step's provider calls returned, so a step replayed after a crash doesn't pay
180
+ again. Errors you name as interrupting (your database out of reach) stop a
181
+ step as a crash would, instead of failing it.
182
+ [`docs/ERRORS_AND_RETRIES.md`](docs/ERRORS_AND_RETRIES.md)
705
183
 
706
- Within a host invocation scope, `Smith::Tool.current_invocation` exposes the
707
- normalized tool-call ID and deterministic execution/batch ordinals. The ID is
708
- RubyLLM correlation metadata and must not be treated as a provider idempotency
709
- key. Durable hosts can pass an explicitly seeded
710
- `Smith::Tool::InvocationSequence` to `with_invocation_context`; Smith itself does
711
- not persist or reconcile operation receipts.
712
-
713
- Guardrails run as input/output gates around agent calls. See [`docs/TOOLS_AND_GUARDRAILS.md`](docs/TOOLS_AND_GUARDRAILS.md).
714
-
715
- ## Budgets and Deadlines
184
+ ## Budgets
716
185
 
717
186
  ```ruby
718
- class BudgetedWorkflow < Smith::Workflow
719
- budget total_tokens: 10_000, total_cost: 0.50, wall_clock: 30
187
+ class Research < Smith::Workflow
188
+ budget total_tokens: 200_000, total_cost: 2.00, wall_clock: 300
189
+ budget_ledger(:account_id) { |scope| AccountLedger.new(scope[:account_id]) }
190
+ # states and transitions as usual
720
191
  end
721
192
  ```
722
193
 
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`,
732
- `token_limit`, `total_cost`, `tool_calls`, and `wall_clock`; an agent budget
733
- accepts `token_limit`, `cost`, `wall_clock`, `tool_calls`, `total_tokens`, and
734
- `total_cost`. Any other key raises `ArgumentError` naming the accepted keys. A
735
- restored run drops consumed amounts for keys the workflow no longer declares.
736
-
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.
738
-
739
- Direct ledger integrations settle the exact receipt returned by reservation:
740
-
741
- ```ruby
742
- reservation = ledger.reserve_many!(total_tokens: 1_000, total_cost: 0.10)
743
- ledger.reconcile_many!(reservation, actual: { total_tokens: 640, total_cost: 0.06 })
744
- ```
745
-
746
- `release!` / `release_many!` accept the same receipt when work never starts.
747
- Receipts are ledger-owned and one-shot: another ledger, a replay, or an
748
- amount-only settlement is rejected. `ledger.remaining(key)` never reads back
749
- above the exact remaining amount, and `ledger.remaining_share(key, parts)`
750
- returns one of `parts` equal shares of it such that reserving all `parts` fits.
194
+ `budget` bounds one run. `budget_ledger` names your own ledger (an account's
195
+ monthly spend, say): Smith asks it to hold each request's estimated cost before
196
+ sending it, and settles the real cost after. A refusal stops the step before
197
+ anything is sent, and a step can wait for budget instead.
198
+ [`docs/BUDGETS.md`](docs/BUDGETS.md)
751
199
 
752
- Budget amounts accept finite, non-negative `Integer` and `Float` values. Smith
753
- uses an exact `BigDecimal` representation derived from each value's canonical
754
- decimal text and converts ledger snapshots back to ordinary JSON-safe numerics,
755
- avoiding cumulative binary Float drift without changing persisted workflow
756
- state shapes. Ledger arithmetic temporarily uses unlimited decimal precision and
757
- restores the host's existing `BigDecimal` precision limit before returning.
200
+ ## Streaming and batch
758
201
 
759
- Budget operations are atomic for normal concurrent callers and synchronous
760
- exceptions. Smith uses cooperative cancellation and never terminates branch
761
- threads with `Thread#raise` or `Thread#kill`. Arbitrary asynchronous thread
762
- termination can make any in-process return value outcome-ambiguous; hosts must
763
- use cancellation signals, bounded I/O, and process isolation for hard stops.
202
+ `execute :writer, stream: :answer` sends the reply's text to a sink you
203
+ register as the model writes it. Treat it as a draft: Smith checks the reply
204
+ only once it is complete. `execute :triager, mode: :batch` sends the request
205
+ through Anthropic's batch API at half price; the run waits, and your app
206
+ delivers the result when the batch ends.
207
+ [`docs/AGENTS.md`](docs/AGENTS.md)
764
208
 
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`:
209
+ ## Untrusted content
770
210
 
771
211
  ```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
212
+ class ReviewContext < Smith::Context
213
+ persist :source, :fields
214
+ untrusted :source, :fields
780
215
  end
781
216
 
782
217
  class DocumentReview < Smith::Workflow
783
- budget_ledger(:account_id) { |scope| MonthlyLedger.new(scope[:account_id]) }
218
+ context_manager ReviewContext
219
+ session :isolated
220
+ initial_state :fetched
221
+ state :extracted
222
+
223
+ transition :extract, from: :fetched, to: :extracted do
224
+ execute :field_extractor,
225
+ input: ->(ctx) { [{ role: :user, content: Smith::Untrusted.wrap(ctx[:source], source: "document") }] },
226
+ output_to: :fields
227
+ end
784
228
  end
785
229
  ```
786
230
 
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.
231
+ With `session :isolated`, each agent step sees only the messages its `input:`
232
+ builds, not a shared transcript. Keys marked `untrusted` never reach
233
+ instructions, and `Smith::Untrusted.wrap` fences outside text off from
234
+ everything else. Messages can carry documents and images.
235
+ [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Step Isolation.
833
236
 
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.
237
+ ## Tools and guardrails
849
238
 
850
- ## Doctor
851
-
852
- After adding Smith, verify the integration:
853
-
854
- ```bash
855
- # Plain Ruby
856
- smith doctor # offline checks
857
- smith doctor --live # live provider call
858
- smith doctor --durability # persistence round-trip
859
- smith install # scaffold config/smith.rb
860
-
861
- # Rails
862
- bin/rails smith:doctor
863
- bin/rails smith:doctor:live
864
- bin/rails smith:doctor:durability
865
- bin/rails generate smith:install
866
- ```
867
-
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.
239
+ Agents can use `Smith::Tool` subclasses, with a budget on how many calls a
240
+ step may make. Guardrails check inputs, outputs and tool calls. Smith ships
241
+ `Tools::Think`; `Tools::WebSearch` and `Tools::UrlFetcher` are stubs that
242
+ declare the interface, which you subclass with your own search or fetch.
243
+ [`docs/TOOLS_AND_GUARDRAILS.md`](docs/TOOLS_AND_GUARDRAILS.md)
876
244
 
877
245
  ## Testing
878
246
 
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
247
  ```ruby
886
- # test/test_helper.rb
887
- require "smith/testing/minitest" # or "smith/testing/rspec" for matchers
248
+ require "smith/testing/minitest" # or "smith/testing/rspec"
888
249
 
889
- class NoticeTest < ActiveSupport::TestCase
250
+ class PublishTest < ActiveSupport::TestCase
890
251
  include Smith::Testing::Assertions
891
252
 
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: {})
253
+ test "drafts, then waits for approval" do
254
+ Smith::Testing.stub_agent(Writer, outputs: "Release notes for 2.0.")
255
+ run = Publish.new(context: { topic: "Release notes" })
897
256
 
898
257
  Smith::Testing.run_until_waiting(run)
899
258
  assert_waiting_for run, :approval
900
- Smith::Testing.resume_with(run, payload: { approved: true })
259
+ Smith::Testing.resume_with(run, payload: { ok: true })
901
260
  assert_transitioned_through run, :draft, :approve, :publish
902
- assert_usage_recorded run, agent_name: Writer, stop_reason: "end_turn", input_tokens: 30
903
261
  end
904
262
  end
905
263
  ```
906
264
 
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`.
936
-
937
- ## Capability-aware request shaping
938
-
939
- Smith ships a per-attempt normalizer that translates the request payload to whatever the resolved model's provider family expects:
940
-
941
- - Anthropic Opus 4.7+ adaptive thinking via `output_config[:effort]`
942
- - Anthropic 4.0–4.6 budget_tokens
943
- - OpenAI gpt-5 family reasoning_effort with `/v1/responses` routing when tools + thinking are combined
944
- - Gemini 2.5+ budget_tokens
945
-
946
- Override the inferred profile per-app via `Smith::Models.register(Profile.new(...))`.
947
- Profiles are keyed by both provider and model id. Pass `provider:` to
948
- `Smith::Models.find` or `find_or_infer` whenever an id exists under more than
949
- one provider; an ambiguous unqualified lookup fails closed. Smith never
950
- hardcodes model ids in the library.
951
-
952
- Provider-qualified fallback entries use the same exact identity:
953
-
954
- ```ruby
955
- fallback_models(
956
- { model: "claude-sonnet-4-6", provider: :anthropic },
957
- { model: "openai/gpt-5", provider: :openrouter }
958
- )
959
- ```
960
-
961
- Fallback entries must include an explicit provider. Smith does not infer a
962
- fallback provider from the primary model or from a model id because the same id
963
- may be registered by more than one provider. Unqualified fallback entries fail
964
- closed during agent configuration.
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
-
1023
- ## Errors and retry
1024
-
1025
- ```ruby
1026
- Smith::Errors.retryable?(error)
1027
- # AgentError, DeadlineExceeded => true (always)
1028
- # OutputTruncated, OutputRefused, OutputInvalid (OutputFailure) => false
1029
- # DeterministicStepFailure, ToolGuardrailFailed => honors error.retryable
1030
- # everything else => false
1031
-
1032
- Smith::Errors.retryable_classes
1033
- # => [Smith::AgentError, Smith::DeadlineExceeded] (for ActiveJob retry_on)
1034
-
1035
- Smith::Errors.retry_forbidden?(error)
1036
- # => true for terminal tool-evidence failures that must never be replayed
1037
- ```
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
-
1044
- Workflow transitions can also declare a bounded local retry policy:
1045
-
1046
- ```ruby
1047
- transition :draft, from: :idle, to: :done do
1048
- execute :writer
1049
- retry_on Smith::AgentError, attempts: 3, backoff: 0.1, max_delay: 1.0
1050
- end
1051
- ```
1052
-
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
- ```
265
+ A stubbed agent is answered where its request would go to the provider, so
266
+ everything else runs for real: tools, budgets, retries, output checks.
267
+ [`docs/TESTING.md`](docs/TESTING.md)
1085
268
 
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
269
+ ## Doctor
1102
270
 
1103
- Smith.config.response_memo = ResponseMemo.new
271
+ ```bash
272
+ bin/rails smith:doctor # configuration, models, the state table
273
+ bin/rails smith:doctor:durability # saves and restores a run
274
+ bin/rails smith:doctor:live # one real provider call
1104
275
  ```
1105
276
 
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.
277
+ Outside Rails: `smith doctor`, `smith doctor --durability`, `smith doctor --live`.
1133
278
 
1134
- ## Runtime Complexity
279
+ ## Documentation
1135
280
 
1136
- - Executable graph reachability is iterative `O(V + E)` time and `O(V)` space,
1137
- using indexed outgoing transitions and runtime-equivalent successor rules.
1138
- - Smith-managed tool arguments are copied iteratively in `O(N + B)` time and
1139
- `O(N + B)` bounded space for `N` expanded JSON value occurrences and `B`
1140
- expanded encoded bytes. Reused subgraphs are charged for every serialized
1141
- occurrence, native `Array` and `Hash` operations bypass hostile overrides, and
1142
- every mutable container's native size is admitted before one shallow copy is
1143
- made. Smith verifies the copy retained the admitted size before allocating or
1144
- traversing children. One immutable dispatch collection remains authoritative
1145
- from host admission through RubyLLM execution. The complete provider batch is
1146
- bounded to 100 calls and 1 MiB of normalized UTF-8 call metadata. The capture
1147
- boundary transfers temporary ownership of the provider collection and argument
1148
- graph to Smith: callers must not mutate them concurrently. Smith detects changed
1149
- cardinality and fails closed, but Ruby cannot prevent an unrelated writer from
1150
- growing a caller-owned container between native size admission and the native
1151
- copy without freezing that caller-owned object.
1152
- - Admitted tool-receipt settlement scans each batch once in `O(B)` time and
1153
- `O(B)` state for `B` admitted Smith calls, including when an earlier terminal
1154
- callback fails.
1155
- - Parallel execution is `O(B)` scheduling and result space for `B` branches,
1156
- with at most `C = parallel_concurrency` active worker threads per top-level
1157
- execution. Re-entrant fan-out inherits the same cancellation signal and uses
1158
- only idle workers from the top-level execution context, falling back to the
1159
- current worker when no capacity remains. Nested calls fail closed before the
1160
- configurable nesting bound can exhaust Ruby's stack. Cancellation prevents
1161
- queued branch bodies from starting;
1162
- already-running branches are drained before control returns so work is never
1163
- detached. Branch adapters must use bounded I/O and honor the cancellation
1164
- signal. Hosts continue to own process-level concurrency and hard deadlines.
1165
- - Reservation and settlement across `L` declared budget dimensions and `D`
1166
- operation dimensions is `O(L + D)` time and space. Receipt lookup is expected
1167
- `O(1)`; no operation scans or copies the active receipt registry. Integer and
1168
- Float amounts use exact decimal arithmetic internally.
1169
- - Retry delay calculation is `O(1)` time and space per attempt. A policy with
1170
- `A` attempts performs at most `A` guarded operations and never materializes an
1171
- exponential schedule.
1172
-
1173
- ## Development
1174
-
1175
- Smith changes must not use brute-force graph search, validation, scheduling, or
1176
- class-generation algorithms. Document the relevant time and space complexity,
1177
- prefer indexed, linear, logarithmic, or otherwise bounded approaches, and treat
1178
- an avoidable asymptotic regression as a release blocker.
1179
-
1180
- ```bash
1181
- bundle install
1182
- bundle exec rspec
1183
- bundle exec rubocop
1184
- ```
1185
-
1186
- 2,420 examples, MIT licensed. See [`CHANGELOG.md`](CHANGELOG.md) for the current
1187
- release surface.
281
+ | Topic | Read |
282
+ |---|---|
283
+ | Step patterns, with examples | [`docs/PATTERNS.md`](docs/PATTERNS.md) |
284
+ | Agents: outputs, models, streaming, batch | [`docs/AGENTS.md`](docs/AGENTS.md) |
285
+ | Runs: storage, waits, child runs, retries, isolation, artifacts | [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md) |
286
+ | Errors, retries, the response memo | [`docs/ERRORS_AND_RETRIES.md`](docs/ERRORS_AND_RETRIES.md) |
287
+ | Budgets and ledgers | [`docs/BUDGETS.md`](docs/BUDGETS.md) |
288
+ | Tools and guardrails | [`docs/TOOLS_AND_GUARDRAILS.md`](docs/TOOLS_AND_GUARDRAILS.md) |
289
+ | Every setting | [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) |
290
+ | Testing and the doctor | [`docs/TESTING.md`](docs/TESTING.md) |
291
+ | Inspecting a workflow before it runs | [`docs/INSPECTION.md`](docs/INSPECTION.md) |
292
+ | Working on Smith itself | [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) |
293
+ | What changed | [`CHANGELOG.md`](CHANGELOG.md) |
294
+
295
+ ## Versions
296
+
297
+ Pin an exact version. Apps that cache workflow definitions should include
298
+ `Smith::EXECUTION_SEMANTICS_VERSION` in their cache key: it changes when the
299
+ way Smith executes workflows changes, independently of the gem version.
300
+
301
+ ## License
302
+
303
+ MIT.