smith-agents 0.12.0 → 0.12.2

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.
data/README.md CHANGED
@@ -1,1186 +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.0", require: "smith"
33
+ gem "smith-agents", "0.12.2", 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` (1 MiB): a
35
- step whose result would take a stored run past it fails with
36
- `Smith::PersistedStateTooLarge` before the result is kept, as with any
37
- error (its usage recorded, its `on_failure` taken), so the run stays
38
- storable; any other state past it is refused before anything is written.
39
- The answer that did not fit is not kept, so set the bound above the largest
40
- output a step keeps, or keep large outputs in the artifact store. The CHECK
41
- sits a third above the bound, room for a column the host encrypts (Rails
42
- `encrypts` holds runs, but not split-step exact writes; the doctor warns).
43
- Options: `--state-model=NAME` (a namespaced model gets its table name set),
44
- `--max-state-bytes=N` (`0` for no bound and no CHECK), and
45
- `--skip-state-model` (the initializer only).
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`.)
46
46
 
47
- The Ruby module namespace stays `Smith::`; only the gem name is namespaced because `smith` on RubyGems is taken. The `require: "smith"` in the Gemfile tells bundler to load the actual file name.
48
-
49
- ## Quickstart
47
+ ## A first workflow
50
48
 
51
49
  ```ruby
52
- require "ruby_llm"
53
50
  require "smith"
54
51
 
55
- RubyLLM.configure do |config|
56
- config.openai_api_key = ENV.fetch("OPENAI_API_KEY")
57
- end
58
-
59
- class ReplyAgent < Smith::Agent
60
- register_as :reply_agent
61
- model "gpt-4.1-nano"
62
-
63
- instructions { "Write a concise, professional reply." }
64
- end
52
+ RubyLLM.configure { |config| config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY") }
65
53
 
66
- class ReplyContext < Smith::Context
67
- persist :user_message
68
- 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." }
69
58
  end
70
59
 
71
- class ReplyWorkflow < Smith::Workflow
72
- context_manager ReplyContext
60
+ class Summarize < Smith::Workflow
73
61
  initial_state :idle
74
62
  state :done
75
63
  state :failed
64
+ seed_messages { |context| [{ role: :user, content: context[:text] }] }
76
65
 
77
- transition :reply, from: :idle, to: :done do
78
- execute :reply_agent
66
+ transition :summarize, from: :idle, to: :done do
67
+ execute :summarizer, output_to: :summary
79
68
  on_failure :fail
80
69
  end
81
70
  end
82
71
 
83
- result = ReplyWorkflow.new(context: { user_message: "Charged twice." }).run!
84
- result.state # => :done
85
- result.output # => assistant reply
86
- 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..."
87
75
  ```
88
76
 
89
- ## Core Concepts
90
-
91
- | Concept | Purpose |
92
- |---|---|
93
- | `Smith::Agent` | A `RubyLLM` agent plus model, instructions, output schema, tools, budget, and fallback models. Identifies itself to the workflow via `register_as :name`. |
94
- | `Smith::Workflow` | A state machine of named transitions. Each transition calls an agent, runs deterministic code, routes, or composes a nested workflow. |
95
- | `Smith::Context` | Declares which workflow context keys persist across restore, how those keys become agent-visible input via `inject_state`, and which keys hold untrusted outside content (`untrusted`). |
96
- | `Smith::Tool` | A `RubyLLM` tool plus provider-compatibility metadata and guardrail hooks. |
97
- | Persistence adapters | Host-owned storage. Smith ships `Memory`, `RedisStore`, `CacheStore`, `RailsCache`, `ActiveRecordStore`. |
98
- | Trace adapters | Host-owned observability. Smith ships `Memory`, `Logger`, `OpenTelemetry`. |
99
-
100
- Agents register at class load. In Rails, register workflow-facing agents in a `to_prepare` hook so autoload doesn't drop them:
101
-
102
- ```ruby
103
- # config/initializers/smith_agents.rb
104
- Rails.application.config.to_prepare do
105
- ReplyAgent
106
- TriageAgent
107
- end
108
- ```
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.
109
81
 
110
- ## Patterns
82
+ ## Steps
111
83
 
112
- | Pattern | DSL | Use case |
84
+ | Step | Declared with | Does |
113
85
  |---|---|---|
114
- | Single execute | `execute :agent` | One agent call per transition. |
115
- | Pipeline | sequential transitions | Multi-step workflow with explicit success/failure routing. |
116
- | Router | `route :classifier, routes: {...}` | Branch on a classifier agent's output. |
117
- | Parallel fan-out | `execute :agent, parallel: true` | Concurrent agent calls under one ledger. |
118
- | Heterogeneous fan-out | `fan_out branches: {...}` | Concurrent calls to different agents with named branch results. |
119
- | Nested workflow | `workflow OtherWorkflow` | Reuse a subflow as one transition. |
120
- | Evaluator-Optimizer | `optimize generator:, evaluator:, ...` | Generate-then-critique refinement loops. |
121
- | Orchestrator-Worker | `orchestrate orchestrator:, worker:, ...` | Dynamic task fan-out with delegation rounds. |
122
- | Deterministic | `compute { |step| ... }` | Pure Ruby step inside the state machine. |
123
- | Wait | `await :event, into:, timeout:` / `delay seconds` | Durable wait for an event the host delivers, or until a time. |
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 |
124
95
 
125
- 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).
126
98
 
127
- ### Repair And Wait Boundaries
99
+ ## Durable runs
128
100
 
129
- Smith only owns repair and wait-style loop behavior when the bounds and stop
130
- conditions are explicit and enforceable inside the workflow step. Durable
131
- timers, queue delivery, and wake-up policy remain host-owned.
132
- For bounded dynamic delegation, use the separate Orchestrator-Worker pattern.
133
-
134
- | Contract | Status | Smith mapping |
135
- |---|---|---|
136
- | Retry loop | Executable | `retry_on`, bounded to one transition. |
137
- | Evaluator-Optimizer | Executable | `optimize`, bounded by `max_rounds` plus structured evaluator output. |
138
- | Deterministic repair | Not first-class yet | Can be handwritten with `compute` / `run` only when the workflow author owns the exact guard, repair, revalidation, and exit policy. Deterministic steps may declare inspectable `routes: [...]`, but that is not a native repair-loop contract. |
139
- | Guarded state re-entry | Not first-class yet | `compute` / `run` can declare and route to named transitions with `routes: [...]`, but Smith does not yet own persisted entry counts, mutation policy, or safe re-entry contracts. |
140
- | Wait for an event or a time | Executable | `await` and `delay`, bounded by a required timeout and timeout route. Smith records the wait and its deadline; delivering the event (`resume!`), scheduling the expiry (`expire_wait!`) and waking the run stay host-owned. Smith never polls for an event or sleeps until a deadline. |
141
-
142
- ### Isolation
143
-
144
- Each agent step can get its own messages and write its output to the context
145
- instead of a shared transcript:
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:
146
103
 
147
104
  ```ruby
148
- class ReviewContext < Smith::Context
149
- persist :source, :fields
150
- untrusted :source, :fields # outside text, and what an agent drew from it
151
- end
152
-
153
- class DocumentReview < Smith::Workflow
154
- context_manager ReviewContext
155
- session :isolated # no shared transcript; every agent step declares input:
156
- initial_state :fetched
157
- state :extracted
158
-
159
- transition :extract, from: :fetched, to: :extracted do
160
- execute :field_extractor,
161
- input: ->(ctx) { [{ role: :user, content: Smith::Untrusted.wrap(ctx[:source], source: "document") }] },
162
- output_to: :fields
163
- end
164
- end
165
- ```
166
-
167
- Context keys declared `untrusted` never reach `inject_state`, the
168
- `seed_messages` builder, or an agent's `inputs`, in the workflow or any
169
- workflow it nests. See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Step
170
- Isolation and Untrusted Content.
171
-
172
- A message in `input:` can carry content parts: text, and documents and images
173
- stored as artifacts of the run (a child run reads its ancestors' too), with
174
- `cache: true` marking a prefix Anthropic may cache; seed messages take text
175
- parts:
176
-
177
- ```ruby
178
- transition :store, from: :fetched, to: :stored do
179
- compute { |step| step.write_context(:contract_ref, Smith.artifacts.store(Contracts.pdf(step.read_context(:id)))) }
180
- end
181
-
182
- transition :extract, from: :stored, to: :extracted do
183
- execute :field_extractor,
184
- input: lambda { |ctx|
185
- [Smith::Message.user([Smith::Content.document(ctx[:contract_ref], media_type: "application/pdf"),
186
- Smith::Content.text("List the parties and the term.")])]
187
- },
188
- output_to: :fields
189
- end
190
- ```
191
-
192
- Anthropic gets native blocks; other providers get the parts in order as text,
193
- with the files attached where they stand. Documents are read once, checked
194
- against their media type and bounds, and only from runs allowed to read them.
195
- See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Content Parts.
196
-
197
- ### Waiting
198
-
199
- A step can wait, durably and bounded, for an event the host delivers or for a
200
- time to pass:
201
-
202
- ```ruby
203
- class DocumentReview < Smith::Workflow
204
- initial_state :requested
205
-
206
- transition :fetch_source, from: :requested, to: :fetched do
207
- await :source_ready, into: :source, timeout: 6 * 3600 do |step|
208
- SourceClient.request(ref: step.read_context(:source_ref), idempotency_key: step.await_token)
209
- end
210
- on_timeout :source_unavailable
211
- end
212
- transition :source_unavailable, from: :requested, to: :unavailable
213
-
214
- transition(:cool_off, from: :fetched, to: :reviewed) { delay 600 }
215
- end
216
-
217
- workflow.run_persisted!(key) # stops when the run waits
218
- workflow.waiting? # => true
219
- workflow.awaiting # => Smith::Workflow::Wait (event, token, deadline_at, ...)
220
-
221
- DocumentReview.resume!(key, event: :source_ready, token:, payload: reply) # => :resumed, :failed, :missing or :stale
222
- DocumentReview.expire_wait!(key, token:, now: Time.now.utc) # => :expired, :not_due, :missing or :stale
223
- ```
224
-
225
- The host indexes `awaiting` to route events and to schedule the expiry call.
226
- See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Waiting.
227
-
228
- ### Child runs
229
-
230
- A step can start other workflows as runs of their own, which the host launches,
231
- persists and runs, and wait for their results:
232
-
233
- ```ruby
234
- Smith.config.run_launcher = MyLauncher.new # launch(children:, parent_key:, await_token:)
235
-
236
- class DocumentReview < Smith::Workflow
237
- initial_state :split
238
- state :failed
239
-
240
- transition :review_sections, from: :split, to: :reviewed do
241
- map :section_ids, workflow: SectionReview, max_items: 500, timeout: 6 * 3600,
242
- input: ->(section_id, ctx) { { document_id: ctx[:document_id], section_id: } },
243
- into: :section_reviews, collect: :outputs, on_item_failure: :collect
244
- on_failure :fail
245
- end
246
-
247
- transition :summarize, from: :reviewed, to: :summarized do
248
- call Summarize, input: ->(ctx) { { document_id: ctx[:document_id] } }, timeout: 3600, into: :summary
249
- on_failure :fail
250
- end
251
-
252
- transition :notify, from: :summarized, to: :done do
253
- spawn :watcher_ids, workflow: NotifyWatcher, max_items: 1_000, input: ->(id, _ctx) { { watcher_id: id } }
254
- end
255
- end
256
- ```
257
-
258
- `map` launches a run per item (more than `max_items` fails the step before
259
- anything launches; the launcher is idempotent on each child's `key`) and waits,
260
- bounded by `timeout:`, for the host to resume the parent with
261
- `resume!(key, event: :children, token:, payload:)`, one `Smith::ChildRun.result`
262
- entry per child. `call` is a map of one whose `into:`
263
- gets the child's output; `spawn` launches and continues. A child starts with
264
- only what `input:` returns, and keeps any of its parent's untrusted keys passed
265
- under the same name. With `on_item_failure: :fail` (the default) a failed child
266
- fails the step with `Smith::ChildRunsFailed`, so the step declares `on_failure`;
267
- these steps take no `retry_on`. `Smith::Launchers::Inline` runs the children in
268
- process, for tests and scripts. See
269
- [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Child Runs.
270
-
271
- ### Streaming
272
-
273
- An agent step can send its reply's text to the host as the model writes it:
274
-
275
- ```ruby
276
- # Called when a streamed step starts; returns the step's sink, or nil to
277
- # stream nothing (nobody is watching). Each step execution gets its own.
278
- Smith.config.stream_sinks = {
279
- answer: ->(info) { AnswerStream.new(info[:execution_key]) if Screens.watching?(info[:execution_key]) }
280
- }
281
-
282
- class AnswerStream
283
- def initialize(key)
284
- @key = key
285
- @reply = Smith::Streaming::JsonField.new(:reply) # the output schema's reply field, as it grows
286
- end
287
-
288
- def delta(text) = (piece = @reply.push(text)) && Screens.append(@key, piece)
289
- def reset(_reason) = @reply.reset || Screens.clear(@key)
290
- def finish(output) = Screens.final(@key, output["reply"])
291
- def fail(_error_class) = Screens.failed(@key)
292
- end
293
-
294
- class Answer < Smith::Workflow
295
- initial_state :asked
105
+ class Publish < Smith::Workflow
106
+ initial_state :idle
107
+ state :drafted
108
+ state :approved
109
+ state :expired
110
+ state :done
296
111
  state :failed
297
112
 
298
- transition :answer, from: :asked, to: :answered do
299
- execute :answerer, stream: :answer, output_to: :answer
113
+ transition :draft, from: :idle, to: :drafted do
114
+ execute :writer, output_to: :draft
300
115
  on_failure :fail
301
116
  end
302
- end
303
- ```
304
-
305
- A sink defines any of `start(info)` (each provider call: the step's info with
306
- the attempt, model and provider), `delta(text)` (each text chunk; tool calls,
307
- thinking and `<think>` text are not sent), `reset(reason)` (Smith discarded the
308
- text sent so far: `:retry`, `:fallback`, `:truncation` or `:repair`; and
309
- `:retry_wait`, the last word, while a durable retry waits), `finish(output)`
310
- (the output the step accepted, after guardrails, the output contract and
311
- `output_to:`, once the step is stored) and `fail(error_class)`. Streamed text is
312
- a draft: guardrails and output checks run on the whole reply, so nothing should
313
- be saved or acted on before `finish`, and a step with an output schema streams
314
- JSON text (`Smith::Streaming::JsonField` gives the text each delta adds to one
315
- string field). A sink that raises is logged and the step goes on; a sink name
316
- that is not registered fails the step before its call. Stop reasons are read
317
- from the stream, so cut-off and refused replies are handled as without it, and a
318
- stream that ends without one counts as cut off (`stream_incomplete`). Smith's
319
- OpenAI Responses path does not stream: its sink gets each reply as one delta.
320
- Streaming changes nothing durable (each usage entry keeps its response's stop
321
- reason and message id, with `mode: :stream`), and a replayed step streams again
322
- (unless a response memo kept its reply: then its sink gets the text at once).
323
- Graph inspection shows the stream, and runtime readiness reports a missing sink
324
- (`:stream_sink_missing`) and an agent whose calls go to the Responses API
325
- (`:stream_whole_replies`).
326
-
327
- ### Batch mode
328
-
329
- An agent step, or a fan-out, can send its requests through a provider's batch
330
- API (half price, answered within 24 hours) instead of over the wire:
331
-
332
- ```ruby
333
- # The host keeps each request until a scheduled job submits it (a table, in
334
- # production); enqueue is idempotent on custom_id.
335
- class HostBatchQueue
336
- Entry = Struct.new(:custom_id, :await_token, :request, :run_key, :batch_id)
337
-
338
- def initialize = @entries = {}
339
- def enqueue(custom_id:, await_token:, request:, execution_key:)
340
- @entries[custom_id] ||= Entry.new(custom_id, await_token, request, execution_key)
341
- end
342
- def unsubmitted = @entries.values.reject(&:batch_id)
343
- def entry(custom_id) = @entries.fetch(custom_id)
344
- end
345
- Smith.config.batch_queue = HostBatchQueue.new
346
-
347
- class Triage < Smith::Workflow
348
- initial_state :queued
349
- state :failed
350
117
 
351
- transition :triage, from: :queued, to: :triaged do
352
- execute :triager, mode: ->(ctx) { ctx[:urgent] ? :sync : :batch }, output_to: :verdict
353
- on_failure :fail
118
+ transition :approve, from: :drafted, to: :approved do
119
+ await :approval, into: :approval, timeout: 2 * 86_400
120
+ on_timeout :expire
354
121
  end
355
- end
122
+ transition :expire, from: :drafted, to: :expired
356
123
 
357
- # One scheduled job submits what is queued; another collects an ended batch
358
- # and resumes each run once with every result of its wait.
359
- queue = Smith.config.batch_queue
360
- batches = Smith::Providers::Anthropic::Batches.new
361
- entries = queue.unsubmitted
362
- batch_id = batches.submit(entries.map { { custom_id: _1.custom_id, request: _1.request } })
363
- entries.each { _1.batch_id = batch_id }
364
- if batches.status(batch_id)[:status] == "ended"
365
- payloads = Hash.new { |hash, wait| hash[wait] = {} }
366
- batches.results(batch_id) do |custom_id, result|
367
- entry = queue.entry(custom_id)
368
- payloads[[entry.run_key, entry.await_token]][custom_id] = result
124
+ transition :publish, from: :approved, to: :done do
125
+ compute { |step| Posts.publish(step.read_context(:draft)) }
369
126
  end
370
- payloads.each { |(run_key, token), payload| Triage.resume!(run_key, event: :batch_result, token:, payload:) }
371
- end
372
- ```
373
-
374
- `mode:` is `:sync` (the default), `:batch`, or a callable of the context and
375
- the step attempt returning one, on `execute` and `fan_out`; a batch step
376
- declares `on_failure`, and cannot stream or be `parallel:`. Smith prepares
377
- the call as a synchronous one, admits it against the budget, and hands the
378
- exact request (the provider payload and the call's own headers, never the
379
- connection's credentials) to the queue under a `custom_id` (the wait's token;
380
- each fan-out branch its own) once the step's attempt has ended without error,
381
- then the run waits on `:batch_result`. Smith never submits or polls, and waits
382
- without sleeping: `Smith::Providers::Anthropic::Batches` gives the host
383
- `submit`, `status`, `results` and `cancel`. `resume!` with
384
- `{custom_id => result}` runs the step again, each call taking its result as
385
- the reply it would have got. A call that renders another request by then (a
386
- date in its prompt, data read afresh, a prompt changed in a deploy) does what
387
- its step's `on_request_change:` says: `:keep` (the default) takes the result,
388
- which answers the request as sent, and its `:provider_call` trace says
389
- `request_changed: true`; `:resend` sends the request as rendered now over the
390
- wire instead; `:fail` fails the step with `Smith::BatchRequestChanged` (an
391
- `AgentError`, so `retry_on` can send it again, synchronously if the mode
392
- callable says so). A result the call does not use is charged all the same.
393
- Then the output checks, a follow-up call (over the
394
- wire), guardrails and `output_to:` as ever, usage recorded with `mode: :batch`
395
- and priced with the catalog's `batch_discount`, and charged even past a
396
- budget, as the money is spent. A request the provider refused fails the step
397
- (`Smith::BatchRequestInvalid`); a failed, canceled or expired result, or none
398
- by the wait's deadline (`expire_wait!`, 25 hours), raises
399
- `Smith::BatchResultFailed`, which `retry_on` sends again (synchronously, if the
400
- mode callable says so at the next attempt; an in-process `retry_on` backoff
401
- sleeps as on any step, while `durable: true` waits). Only Anthropic's batch interface is
402
- built in, and a batch request is one turn, so a batching agent has no tools;
403
- runtime readiness reports a missing queue (`:batch_queue_missing`) and an
404
- agent that cannot batch (`:batch_unavailable`), and graph inspection shows the
405
- mode.
406
-
407
- ## Workflow Graph Inspection
408
-
409
- 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.
410
-
411
- ```ruby
412
- report = ReplyWorkflow.validate_graph
413
-
414
- report.valid? # => true
415
- report.transitions # => read-only transition snapshots
416
- report.diagnostics # => errors and warnings for missing states or routes
417
- report.metrics # => state, transition, reachability, and terminal-state counts
418
- ```
419
-
420
- Graph inspection is static and diagnostic-only. Runtime execution, persistence, progress projection, retries, and recovery remain host-owned concerns.
421
-
422
- Smith also exposes a static runtime-readiness report for checks that require
423
- declared runtime bindings but still do not execute the workflow:
424
-
425
- ```ruby
426
- readiness = ReplyWorkflow.runtime_readiness
427
-
428
- readiness.ready? # => true when no readiness/topology errors exist
429
- readiness.status # => :ready, :warning, or :not_ready
430
- readiness.diagnostics # => topology diagnostics + runtime binding diagnostics
431
- ```
432
-
433
- Runtime readiness checks graph topology, registered agent bindings, model
434
- requirements for structured runtime roles, lazy/uninspectable bindings, invalid
435
- non-agent bindings, nested workflow readiness, and fan-out branch binding counts.
436
- It does not call providers, resolve lazy container blocks, run tools, enqueue
437
- jobs, or verify host-owned durability.
438
-
439
- Readiness metrics include both direct graph counts and transitive counts folded
440
- in from nested workflows.
441
-
442
- Transition snapshots include runtime contracts for complex primitives where
443
- Smith owns executable semantics: heterogeneous fan-out, evaluator-optimizer, and
444
- orchestrator-worker transitions expose bounded settings, output shapes, and
445
- transition-level resume behavior for host renderers and compilers.
446
-
447
- ## Configuration
448
-
449
- ```ruby
450
- require "logger"
451
- require "smith"
452
-
453
- Smith.configure do |config|
454
- config.logger = Logger.new($stdout)
455
- config.trace_adapter = Smith::Trace::Memory.new
456
- config.artifact_store = :active_record # or :s3 (S3, R2), :file, :memory
457
- config.artifact_options = { model: "SmithArtifact" }
458
-
459
- # Persistence
460
- config.persistence_adapter = :rails_cache
461
- config.persistence_options = { namespace: "smith" }
462
- config.persistence_ttl = 1.day.to_i
463
- config.persistence_max_bytes = 1_048_576 # a stored state's bound (nil: none); see Installation
464
- config.persistence_retry_policy = { attempts: 3, base_delay: 0.1, max_delay: 1.0 }
465
-
466
- # Generic runtime resource bounds
467
- config.parallel_branch_limit = 1_000
468
- config.parallel_concurrency = 8
469
- config.parallel_nesting_limit = 64
470
- config.retry_attempt_limit = 100
471
-
472
- # OpenAI /v1/responses routing for gpt-5 + tools + thinking. :auto (default) or :off.
473
- config.openai_api_mode = :auto
474
-
475
- config.pricing = Smith::Pricing.validate_catalog!(
476
- %w[openai gpt-4.1-nano] => {
477
- input_cost_per_token: 1.0e-7, output_cost_per_token: 4.0e-7,
478
- cache_read_cost_per_token: 2.5e-8, # optional: prompt-cache reads (0 when absent)
479
- batch_discount: 0.5 # optional: off the cost of batch-mode calls (0 when absent)
480
- }
481
- )
482
127
  end
483
- ```
484
-
485
- All settings are optional for a first run, except that storing artifacts or
486
- attaching documents needs `artifact_store` (outside `test_mode`). See [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) for the full reference.
487
-
488
- ## Persistence and Resume
489
-
490
- ```ruby
491
- # Persist after every advance
492
- result = ReplyWorkflow.run_persisted!(
493
- context: { user_message: "..." },
494
- adapter: Smith.persistence_adapter
495
- )
496
-
497
- # Resume later
498
- result = ReplyWorkflow.run_persisted!(
499
- key: "ticket:T-1042",
500
- adapter: Smith.persistence_adapter
501
- )
502
-
503
- # Admit host-supplied conversation input at a stable workflow boundary.
504
- workflow = ReplyWorkflow.restore("ticket:T-1042", adapter: Smith.persistence_adapter)
505
- admission = workflow.append_session_messages!(role: :user, content: "Continue")
506
- ```
507
-
508
- `append_session_messages!` validates and owns a bounded canonical copy of the
509
- message batch, appends it under Smith's workflow lifecycle lock, and returns an
510
- immutable digest witness. The host still owns session identity, transaction
511
- coordination, persistence, idempotency, and resume policy.
512
-
513
- Built-in adapters (all except `ActiveRecord` support TTL; `ActiveRecord` has no
514
- expiry column, so its writes refuse a non-nil `persistence_ttl`, while reads and
515
- deletes keep working and `smith doctor --durability` flags the combination;
516
- `Redis`, `ActiveRecord`, and `Memory` support optimistic locking via
517
- `store_versioned` and exact dispatch claims via `replace_exact`; `ActiveRecord`
518
- participates in the caller's transaction for host-record coordination;
519
- `Redis` and `Memory` also support heartbeat probes via `record_heartbeat` /
520
- `last_heartbeat`):
521
-
522
- - `:memory` — in-process Hash, intended for tests and `test_mode = true`
523
- - `:redis` — Redis client; uses WATCH/MULTI/EXEC for CAS
524
- - `:rails_cache`, `:solid_cache` — Rails cache backends
525
- - `:cache_store` — any object responding to `write/read/delete`
526
- - `:active_record` — keyed ActiveRecord model with an enabled optimistic-locking
527
- column for row-level CAS; Smith's logical version remains in the JSON payload
528
-
529
- See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md) for schema versioning,
530
- seed-drift validation, the `idempotency_mode :strict` step-in-progress contract,
531
- host-coordinated message admission, and opt-in restart-safe prepared recovery.
532
- Hosts that need to commit a durable attempt before provider or tool work can
533
- separate verification from execution with
534
- `authorize_prepared_step_execution!`, commit their own attempt ledger, and then
535
- consume the exact process-local capability through
536
- `execute_authorized_prepared_step!`. Smith does not own the host lease, attempt
537
- record, scheduler, or unknown-outcome policy. Authorization discovers the exact
538
- agent bindings reachable by that transition with a bounded `O(V + E)` graph
539
- walk, then resolves the bounded binding set in one registry epoch. A concurrent
540
- registry writer therefore cannot produce a mixed authorization containing
541
- bindings from two registry states or replace a captured binding between
542
- authorization and work. Every public Dry::Container mutation path participates
543
- in that epoch. Execution authorization accepts only concrete registered
544
- `Smith::Agent` classes; lazy container callbacks are not executed while
545
- authority is captured.
546
- While the capability is active, a host may call `each_agent_binding` with a
547
- block to inspect the exact captured `(registry_name, agent_class)` pairs before
548
- external work. Enumeration is process-local, performs no dispatch, and is
549
- rejected after release or consumption; it deliberately does not return a lazy
550
- enumerator that could escape the authorization scope.
551
- Reachable nested workflow transition contracts are captured and revalidated
552
- before child execution. The typed result owns a bounded snapshot of JSON-like
553
- Ruby values; unsupported mutable output values fail closed.
554
- During authorized execution, Smith also seals its private execution path against
555
- workflow subclass method-name collisions, including nested child workflows.
556
- Ordinary non-authorized runs retain normal Ruby override behavior.
557
-
558
- For `parallel: true` and `fan_out` transitions, a host can split one confirmed
559
- dispatch into independently scheduled branch operations without invoking agent
560
- classes outside Smith:
561
128
 
562
- ```ruby
563
- preparation = workflow.prepare_composite_step!
564
-
565
- # Persist preparation.plan and preparation.input in host-owned records.
566
- # Each worker recovers the same confirmed dispatch and receives one descriptor.
567
- branch = preparation.plan.branches.fetch(0)
568
- execution = preparation.plan.execution_for(branch)
569
- outcome = branch_workflow.execute_prepared_composite_branch!(
570
- execution:,
571
- input: preparation.input
572
- )
129
+ Publish.new(context: { topic: "Release notes" }).run_persisted!("post-42") # stops at the wait
573
130
 
574
- # After every branch has a terminal outcome, use another fresh recovery to
575
- # complete the Smith transition exactly once.
576
- result = reducer_workflow.reduce_prepared_composite_step!(
577
- plan: preparation.plan,
578
- input: preparation.input,
579
- outcomes: ordered_or_unordered_complete_outcome_set,
580
- primary_failure: host_committed_failed_branch_key
581
- )
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
582
134
  ```
583
135
 
584
- Plans bind the exact prepared dispatch, host-supplied complete workflow
585
- `definition_digest`, execution-semantics version, prepared input digest, budget
586
- baseline, one shared artifact execution namespace, ordered branch identities,
587
- and explicit policies. Each plan, input, outcome, or effect payload is
588
- canonical, immutable, bounded to 1 MiB after JSON encoding, rejects missing or
589
- unknown fields, and redacts exception messages and backtraces.
590
- Reduction additionally bounds cumulative effects to 1 MiB and final aggregate
591
- output to 4 MiB before consuming execution authority. Reduction accepts either
592
- an `Array` or an `Enumerator`, validates each outcome incrementally, and stops
593
- as soon as a cumulative bound is crossed. The transport hard limit is 10,000
594
- branches; every recovered worker also enforces the current
595
- `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).
596
142
 
597
- Planning is `O(B * D)`, where `D` is the bounded number of budget dimensions.
598
- The coordinator derives a compact selected-branch execution envelope in
599
- `O(D)` without copying the full branch collection. The scoped branch execution
600
- entry point captures and validates one selected binding in `O(D)` and constant
601
- time with respect to total branch count; the reducer validates the complete plan
602
- once. Reduction is `O(B + P log P)` in
603
- the worst case because canonical nested Hash ownership sorts keys, and uses
604
- `O(B + P)` bounded space.
605
- Smith does not persist plans or outcomes, select scheduling slots, claim work,
606
- choose an uncertain-outcome policy, or infer the primary failure. The host
607
- durably owns those decisions, gives each branch and reduction a separately
608
- recovered workflow instance, and must pass a complete outcome set. Transport or
609
- encoding faults become typed branch failures when Smith can preserve accounting
610
- evidence; otherwise the host must treat the worker result as uncertain and must
611
- not infer safe retry. Resume policy is incomplete branches only; per-branch
612
- retry is intentionally unsupported in this contract version.
613
- Composite payload digests prove canonical content integrity, not worker
614
- authenticity. Before reduction, the host must authenticate every outcome against
615
- its own claimed operation identity and current fencing token; those durable
616
- receipts remain outside Smith.
617
-
618
- The supported composite API is the three scoped workflow methods above plus the
619
- immutable transport values they return or accept: `Preparation`, `Plan`,
620
- `Input`, `Branch`, `BranchExecution`, `BranchOutcome`, `BranchFailure`, `Error`,
621
- `Effects`, and `Reduction`. Other constants under `Workflow::Composite` are
622
- implementation collaborators, not extension points or compatibility promises.
143
+ ## Agent outputs
623
144
 
624
- Every agent used by this durable composite boundary must declare a stable
625
- host-computed SHA-256 execution identity. The identity represents the exact
626
- sealed executable asset the host intends to dispatch; Smith records it in the
627
- branch descriptor and rejects registry replacement after planning. Identities
628
- are deliberately not inherited because a subclass is a distinct executable
629
- implementation.
145
+ An agent can declare the shape of its answer and what to do when a reply goes
146
+ wrong:
630
147
 
631
148
  ```ruby
632
- class Researcher < Smith::Agent
633
- execution_identity Digest::SHA256.hexdigest(generated_asset_manifest)
634
- 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
635
156
  end
636
-
637
- Researcher.freeze
638
- Researcher.publish_registration!
639
157
  ```
640
158
 
641
- Hosts that generate agent classes dynamically may call
642
- `register_as :name, publish: false` while constructing the class. This records
643
- the class identity without exposing it through `Smith::Agent::Registry`. After
644
- the host has validated and sealed the class, it may publish the exact class with
645
- `agent_class.publish_registration!`. Publication always uses the staged,
646
- immutable identity; callers cannot provide a second key. Registry collision and
647
- mutation epoch guarantees remain unchanged.
648
-
649
- Hosts that persist or cache generated executable definitions should include
650
- `Smith::EXECUTION_SEMANTICS_VERSION` in their definition identity. The gem
651
- version describes the package release; the execution-semantics version changes
652
- when Smith's executable workflow contract changes and must participate in host
653
- cache invalidation.
654
-
655
- ## 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.
656
164
 
657
- Smith ships `Tools::WebSearch`, `Tools::UrlFetcher`, and `Tools::Think`. Tools declare provider compatibility via `compatible_with`; Smith's normalizer routes or drops them per-attempt.
658
-
659
- `Tools::WebSearch` and `Tools::UrlFetcher` are stubs: they declare each
660
- tool's parameters and description, and their `perform` raises
661
- `NotImplementedError`. A host subclasses them with its own search or fetch
662
- behind `perform`. `Tools::Think` works as shipped.
165
+ ## Retries
663
166
 
664
167
  ```ruby
665
- class WebSearch < Smith::Tools::WebSearch
666
- def perform(query:, max_results: 5) = SearchClient.search(query, limit: max_results)
667
- end
668
-
669
- class UrlFetcher < Smith::Tools::UrlFetcher
670
- def perform(url:) = PageClient.fetch(url)
671
- end
672
-
673
- class SearchAgent < Smith::Agent
674
- register_as :search_agent
675
- model "claude-opus-4-7"
676
- tools WebSearch, UrlFetcher # the host's subclasses
677
- budget tool_calls: 3
678
- 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
679
172
  end
680
173
  ```
681
174
 
682
- Agent tool-call budgets default to raising `Smith::BudgetExceeded` when a tool
683
- tries to execute beyond the configured limit. Agents that declare
684
- `tool_budget_exhaustion :complete` must also declare a finite `tool_calls`
685
- budget. Smith then consumes each model-requested call before dispatch, executes
686
- only a complete batch admitted against both agent and workflow limits, and
687
- performs one tool-disabled completion when the allowance is exhausted or the
688
- provider returns an oversized batch. The bounded provider loop is iterative,
689
- not recursive. Calls to unavailable tools consume allowance as well, so a
690
- provider cannot create an unbounded correction loop. Workflow reservations are
691
- reconciled to actual Smith tool execution.
692
-
693
- Hosts may install `Smith::Tool::CallBudget` with exact per-tool limits through
694
- `Smith::Tool.with_call_budget`. Smith propagates that same root allowance into
695
- parallel and heterogeneous fan-out worker threads; agent budgets narrow the
696
- root and all branches share its atomic counters.
697
-
698
- Graceful exhaustion is an in-process protocol guarantee, not durable recovery
699
- for the model/tool loop. If the process exits after a tool begins and before the
700
- agent completes, the host must treat the outcome as uncertain unless it owns a
701
- durable per-invocation receipt and reconciliation contract. Smith also refuses
702
- to start a fresh fallback-model chat after tool execution has begun because that
703
- 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)
704
183
 
705
- Within a host invocation scope, `Smith::Tool.current_invocation` exposes the
706
- normalized tool-call ID and deterministic execution/batch ordinals. The ID is
707
- RubyLLM correlation metadata and must not be treated as a provider idempotency
708
- key. Durable hosts can pass an explicitly seeded
709
- `Smith::Tool::InvocationSequence` to `with_invocation_context`; Smith itself does
710
- not persist or reconcile operation receipts.
711
-
712
- Guardrails run as input/output gates around agent calls. See [`docs/TOOLS_AND_GUARDRAILS.md`](docs/TOOLS_AND_GUARDRAILS.md).
713
-
714
- ## Budgets and Deadlines
184
+ ## Budgets
715
185
 
716
186
  ```ruby
717
- class BudgetedWorkflow < Smith::Workflow
718
- 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
719
191
  end
720
192
  ```
721
193
 
722
- `wall_clock` is in seconds. A workflow's `wall_clock` bounds the time its steps
723
- spend running, counted across restores in the persisted `active_seconds`, not
724
- the time since the run was created: time spent waiting, or between steps a host
725
- advances from a queue, does not count. A step that raised or whose process died
726
- is charged, on the next restore, the time from its start, at most what is left
727
- of the budget, so a run that keeps failing stays bounded across retries.
728
- Durable composite branches each start from what is left; their running time is
729
- not added back. An agent's `wall_clock` bounds each of
730
- its calls. A workflow budget accepts `total_tokens`,
731
- `token_limit`, `total_cost`, `tool_calls`, and `wall_clock`; an agent budget
732
- accepts `token_limit`, `cost`, `wall_clock`, `tool_calls`, `total_tokens`, and
733
- `total_cost`. Any other key raises `ArgumentError` naming the accepted keys. A
734
- restored run drops consumed amounts for keys the workflow no longer declares.
735
-
736
- Budgets reserve serially at each step and reconcile after the agent call. Parallel branches reserve scoped envelopes that release back to the parent ledger. The `Workflow::RunResult` carries `total_tokens`, `total_cost`, and per-call `usage_entries`. Token budgets and totals count prompt-cache reads and writes beside uncached input and output, and settle every billed response, including the completed rounds of a tool loop whose attempt then failed.
737
-
738
- Direct ledger integrations settle the exact receipt returned by reservation:
739
-
740
- ```ruby
741
- reservation = ledger.reserve_many!(total_tokens: 1_000, total_cost: 0.10)
742
- ledger.reconcile_many!(reservation, actual: { total_tokens: 640, total_cost: 0.06 })
743
- ```
744
-
745
- `release!` / `release_many!` accept the same receipt when work never starts.
746
- Receipts are ledger-owned and one-shot: another ledger, a replay, or an
747
- amount-only settlement is rejected. `ledger.remaining(key)` never reads back
748
- above the exact remaining amount, and `ledger.remaining_share(key, parts)`
749
- returns one of `parts` equal shares of it such that reserving all `parts` fits.
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)
750
199
 
751
- Budget amounts accept finite, non-negative `Integer` and `Float` values. Smith
752
- uses an exact `BigDecimal` representation derived from each value's canonical
753
- decimal text and converts ledger snapshots back to ordinary JSON-safe numerics,
754
- avoiding cumulative binary Float drift without changing persisted workflow
755
- state shapes. Ledger arithmetic temporarily uses unlimited decimal precision and
756
- restores the host's existing `BigDecimal` precision limit before returning.
200
+ ## Streaming and batch
757
201
 
758
- Budget operations are atomic for normal concurrent callers and synchronous
759
- exceptions. Smith uses cooperative cancellation and never terminates branch
760
- threads with `Thread#raise` or `Thread#kill`. Arbitrary asynchronous thread
761
- termination can make any in-process return value outcome-ambiguous; hosts must
762
- 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)
763
208
 
764
- ### Budget ledgers
765
-
766
- A run's `budget` bounds that run. A budget shared by many runs (an account's
767
- monthly spend, say) lives in the host, and a workflow names it with
768
- `budget_ledger`:
209
+ ## Untrusted content
769
210
 
770
211
  ```ruby
771
- class MonthlyLedger
772
- def initialize(account) = @account = account
773
-
774
- # Admit the hold (true; anything else refuses it) and keep it under key
775
- # until expires_at, after which it counts for nothing.
776
- def reserve!(amount, key:, expires_at:) = ...
777
- def reconcile!(key, amount) = ... # what the request spent, in place of its hold
778
- def release!(key) = ... # it spent nothing
212
+ class ReviewContext < Smith::Context
213
+ persist :source, :fields
214
+ untrusted :source, :fields
779
215
  end
780
216
 
781
217
  class DocumentReview < Smith::Workflow
782
- 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
783
228
  end
784
229
  ```
785
230
 
786
- The block gets the run's scope: the values the named keys have in its trusted
787
- context when the run is created (JSON scalars, kept with the run), so nothing a
788
- step or a model writes later chooses another ledger, and an untrusted key is
789
- refused. Smith builds the ledger at the first request a step makes, never
790
- persists it, and never needs it to restore a run; nil means none. A nested
791
- workflow without a `budget_ledger` of its own charges the enclosing run's; a
792
- child run (`call`, `map`, `spawn`), inline or not, uses its own class's, so
793
- give each child workflow its own (runtime readiness warns of one that has none,
794
- `:child_run_unbudgeted`) and pass its scope keys in each child's input.
795
-
796
- Before a call's first request over the wire (each fallback model, each
797
- follow-up, streamed or not, OpenAI's Responses route included) Smith asks the
798
- ledger to admit its hold. If the ledger refuses, the step fails with
799
- `Smith::BudgetUnavailable` (a `BudgetExceeded`, not retried by default) before
800
- anything is sent; `retry_on Smith::BudgetUnavailable, durable: true, ...` waits
801
- for budget instead (the host ends the wait with `expire_wait!`, and ends it
802
- early, once there is budget, with a `now:` at its `deadline_at`). After the request, Smith reconciles what it
803
- spent: its usage, or its hold when it answered without usage or its stream
804
- failed part way; a request that failed before anything came back releases its
805
- hold. If building the ledger or `reserve!` raises, the step fails with
806
- `Smith::BudgetLedgerError` (nothing was sent, no fallback model is tried); one
807
- from `reconcile!` or `release!` is logged by class and the step goes on, as
808
- the money is spent.
809
-
810
- A tool loop's later rounds continue a call whose tools already ran, so
811
- stopping one would leave the tools' effects without their answer. A step whose
812
- `retry_on` names `BudgetUnavailable` holds them too: when the ledger refuses
813
- one, the loop pauses, keeping its tool calls and their results (at most 1 MiB)
814
- with the step's retry, and the retry continues the loop from them, running
815
- none of its tools again. Any other step's later rounds, or a loop too large to
816
- keep, go on unheld and are reconciled with what they spent, so give tool-using
817
- agents a `tool_calls` budget.
818
-
819
- A hold (`Smith::Budget::Amount`: `input_tokens`, `output_tokens`, `cost`, and
820
- `tokens`, their sum) is the request's output cap (its `max_tokens`, or its
821
- provider's name for it, else the model's) and an estimate of its input: text
822
- at one token per 3 bytes, an image at 1,600 tokens, a PDF at 2,000 tokens a
823
- page, 8 tokens a message for its framing, capped at the model's context window.
824
- Images and documents count so as RubyLLM attachments and in raw content blocks
825
- (base64 data or a data URL). `Smith.config.budget_hold_output_tokens`, when
826
- set, caps the output part (an agent with no `max_tokens` is otherwise held for
827
- the model's maximum, which can be tens of thousands of tokens, and a longer
828
- reply is reconciled at what it cost). It is priced from the pricing catalog
829
- under the id RubyLLM resolves the model to; `cost` is nil for an unpriced
830
- model, which runtime readiness warns of (`:budget_ledger_unpriced`, for every
831
- agent that charges the ledger), and its readiness metrics name the scope keys.
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.
832
236
 
833
- A key is the provider attempt's `attempt_id` (as on its `:provider_call` trace)
834
- and the request's round, `"<attempt_id>:<n>"`, and for a batch request its
835
- `custom_id`. A batch request is held when its step captures it, at the batch
836
- discount, and settled when its result comes: a succeeded result, used or not,
837
- is reconciled at what it cost; one that failed, or never came, is released.
838
- A process that dies during a request never settles its hold, so every hold
839
- carries `expires_at`: `Smith.config.budget_hold_ttl` from now (an hour by
840
- default; it must outlast the longest request), and for a batch request its
841
- 25-hour wait longer. Smith runs no timer; the ledger leaves holds past
842
- `expires_at` out of what it admits, and still records a reconcile that comes
843
- later (a batch delivered late). A key may come again after a crash (a batch
844
- request captured again, a delivery that ran twice): a reserve of it replaces
845
- its hold, a release ends it, and its reconcile is final, recorded once.
846
- Fan-out branches call the ledger from their own threads. RubyLLM's own retries
847
- (`max_retries`) resend a request under its one hold.
237
+ ## Tools and guardrails
848
238
 
849
- ## Doctor
850
-
851
- After adding Smith, verify the integration:
852
-
853
- ```bash
854
- # Plain Ruby
855
- smith doctor # offline checks
856
- smith doctor --live # live provider call
857
- smith doctor --durability # persistence round-trip
858
- smith install # scaffold config/smith.rb
859
-
860
- # Rails
861
- bin/rails smith:doctor
862
- bin/rails smith:doctor:live
863
- bin/rails smith:doctor:durability
864
- bin/rails generate smith:install
865
- ```
866
-
867
- Doctor verifies: Smith loads, RubyLLM loads, minimal workflow boots, configuration is non-empty, serialization round-trips, persistence adapter works, and (with `--live`) a real provider call succeeds.
868
- An `:active_record` persistence adapter's model is checked on every run: its
869
- `lock_version` (Rails' locking column), a text payload, and a string key the
870
- database keeps unique, at least 64 characters wide, read from schema
871
- metadata (skipped when the database cannot be reached; an encrypted payload
872
- is a warning). The durability probe writes as runs write (`store_versioned`
873
- where the adapter has it, and `persistence_max_bytes`), so it meets what a
874
- run would.
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)
875
244
 
876
245
  ## Testing
877
246
 
878
- `require "smith/testing"` runs a host's workflows in its tests without a
879
- provider. A stubbed agent's requests are answered where they would go over
880
- the wire, so everything above runs as for a provider's reply: the tool loop,
881
- budget holds, the response memo, streaming, output checks and repairs, usage
882
- entries and budgets.
883
-
884
247
  ```ruby
885
- # test/test_helper.rb
886
- require "smith/testing/minitest" # or "smith/testing/rspec" for matchers
248
+ require "smith/testing/minitest" # or "smith/testing/rspec"
887
249
 
888
- class NoticeTest < ActiveSupport::TestCase
250
+ class PublishTest < ActiveSupport::TestCase
889
251
  include Smith::Testing::Assertions
890
252
 
891
- test "drafts with the venue's hours, then waits for approval" do
892
- Smith::Testing.stub_agent(Writer, outputs: [Smith::Testing.tool_call(Lookup, venue: "the hall"),
893
- "The hall opens at 8."],
894
- usage: { input_tokens: 30, output_tokens: 6 })
895
- run = Notice.new(context: {})
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" })
896
256
 
897
257
  Smith::Testing.run_until_waiting(run)
898
258
  assert_waiting_for run, :approval
899
- Smith::Testing.resume_with(run, payload: { approved: true })
259
+ Smith::Testing.resume_with(run, payload: { ok: true })
900
260
  assert_transitioned_through run, :draft, :approve, :publish
901
- assert_usage_recorded run, agent_name: Writer, stop_reason: "end_turn", input_tokens: 30
902
261
  end
903
262
  end
904
263
  ```
905
264
 
906
- - `stub_agent(agent, outputs:, usage:, stop_reason:, stream:)` answers the
907
- agent's requests (its class, or its registered name) with `outputs` in
908
- turn, the last answering every request after it: a String reply, a Hash as
909
- JSON text (for an output schema), or `tool_call(ToolClass, **arguments)`
910
- (an Array of them for one round), which runs the agent's own tools before
911
- its next request. A tool request naming a tool the agent does not have
912
- fails the call. `stop_reason: "max_tokens"` tests a truncated reply;
913
- `stream: n` sends a streamed step's reply in pieces of n characters. The
914
- stub keeps the messages each request sent (`calls`). Only the agent's own
915
- requests are answered, never a tool's own provider call. The chat is still
916
- built, so configure the provider (any key). An agent with no stub goes over
917
- the wire; `Smith::Testing.unstubbed = :refuse` fails its call with
918
- `Smith::Testing::UnstubbedCall` instead, so a mistyped stub never reaches a
919
- paid provider.
920
- - `run_until_waiting(run)` runs until the run waits or ends and returns its
921
- wait (a durable retry's backoff is ended at once unless `retries: false`);
922
- `resume_with(run, payload:)` delivers the wait's event and runs on;
923
- `expire(run)` ends the wait as if its deadline had passed and runs on.
924
- - A batch step's request is captured as ever (the batch queue gets it);
925
- `resume_with(run, payload: Smith::Testing.batch_payload(run, Smith::Testing.batch_result("text")))`
926
- answers it, and a fan-out's `batch_payload` takes results by branch key.
927
- - Minitest: `assert_waiting_for(run, :event)` (or `kind: :batch`),
928
- `assert_transitioned_through(run, *transitions)` (in order; the steps the
929
- instance completed, not a failed or timed-out one),
930
- `assert_usage_recorded(run, count:, **usage_entry_attributes)`. RSpec:
931
- `be_waiting_for`, `have_transitioned_through`, `have_recorded_usage`.
932
- - Stubs belong to the process; both helpers drop them after each test. An
933
- in-process `retry_on` backoff still sleeps: stub `sleep` on the workflow,
934
- or declare the retry `durable: true`.
935
-
936
- ## Capability-aware request shaping
937
-
938
- Smith ships a per-attempt normalizer that translates the request payload to whatever the resolved model's provider family expects:
939
-
940
- - Anthropic Opus 4.7+ adaptive thinking via `output_config[:effort]`
941
- - Anthropic 4.0–4.6 budget_tokens
942
- - OpenAI gpt-5 family reasoning_effort with `/v1/responses` routing when tools + thinking are combined
943
- - Gemini 2.5+ budget_tokens
944
-
945
- Override the inferred profile per-app via `Smith::Models.register(Profile.new(...))`.
946
- Profiles are keyed by both provider and model id. Pass `provider:` to
947
- `Smith::Models.find` or `find_or_infer` whenever an id exists under more than
948
- one provider; an ambiguous unqualified lookup fails closed. Smith never
949
- hardcodes model ids in the library.
950
-
951
- Provider-qualified fallback entries use the same exact identity:
952
-
953
- ```ruby
954
- fallback_models(
955
- { model: "claude-sonnet-4-6", provider: :anthropic },
956
- { model: "openai/gpt-5", provider: :openrouter }
957
- )
958
- ```
959
-
960
- Fallback entries must include an explicit provider. Smith does not infer a
961
- fallback provider from the primary model or from a model id because the same id
962
- may be registered by more than one provider. Unqualified fallback entries fail
963
- closed during agent configuration.
964
-
965
- ## Agent outputs
966
-
967
- Smith reads how each response ended from the provider's stop reason. A
968
- response cut off at its output limit, a refused one, and a structured output
969
- that is not what the agent declares are not accepted as the step's output:
970
-
971
- ```ruby
972
- class Reviewer < Smith::Agent
973
- model "claude-opus-5-5", provider: :anthropic
974
- output_schema ReviewSchema # its root is an object
975
- output_contract ReviewContract # call(hash) => true, false, or a result with success? and errors
976
- output_repairs 1 # the default; 0 turns repair off
977
- on_truncation retry_with: { max_tokens: 64_000 } # or :fail (the default), or :accept
978
- on_refusal fallback: "openai/gpt-5" # or :fail (the default)
979
- end
980
- ```
981
-
982
- - A cut-off response raises `Smith::OutputTruncated`, unless the agent
983
- accepts it or retries it once on the same model with a larger output limit,
984
- mapped to the provider's own parameter (Anthropic, OpenAI, Gemini, Vertex AI
985
- and Bedrock; another provider is not retried). There is no retry when the
986
- response ran out of context rather than output, or when the new limit is no
987
- larger than the request had (the agent's own parameter, or for Anthropic the
988
- model's maximum, which RubyLLM already sends); a later repair keeps the
989
- larger limit.
990
- - A refused response (a Gemini prompt block included) raises
991
- `Smith::OutputRefused`, unless the agent falls back once to another model.
992
- - Every other response then goes through the agent's `after_completion`. A
993
- structured output (an `output_schema`, or a static RubyLLM `schema`) that is
994
- not a JSON object, breaks a schema constraint, or fails the output contract
995
- goes back to the same model up to `output_repairs` times: the step's messages
996
- as the provider got them, the rejected reply as the assistant's, and the
997
- problems as "path: problem" lines. It then raises `Smith::OutputInvalid`,
998
- whose `problems` lists at most 20 of them. A blank output still raises
999
- `Smith::BlankAgentOutputError`.
1000
- - No follow-up call is made once a tool has run in the step (the tools would
1001
- run again; the step fails with `Smith::ToolOutcomeUncertain` around the output
1002
- error) or once the call has spent what its step reserved from a budget. A
1003
- follow-up that fails raises the output error it was making up for, with the
1004
- provider's error as its cause.
1005
- - Anthropic's structured outputs reject numeric, string-length and most
1006
- array-size constraints: Smith sends Anthropic the schema without them, with
1007
- `additionalProperties: false` on every object, and checks them on the parsed
1008
- output for every provider. An object that allows other properties or
1009
- declares none cannot be sent to Anthropic; the step fails before the call
1010
- with a `Smith::WorkflowError`, and runtime readiness reports
1011
- `:output_schema_unsupported` for an agent declared for Anthropic.
1012
- - An agent with no model and no fallback models fails its step with a
1013
- `Smith::WorkflowError`; runtime readiness reports it (`:agent_without_model`).
1014
-
1015
- Every response is billed and settles into budgets, whatever happens to its
1016
- output, and each rejected one leaves an `:output_rejected` trace naming its
1017
- attempt. The output errors share the failure family `output_failure`, persist
1018
- with a failed step, and are never retried by `Smith::Errors.retryable?`, a job
1019
- retry on `Smith::Errors.retryable_classes`, or a transition's
1020
- `retry_on Smith::AgentError`: Smith's own retry, fallback and repairs have run.
1021
-
1022
- ## Errors and retry
1023
-
1024
- ```ruby
1025
- Smith::Errors.retryable?(error)
1026
- # AgentError, DeadlineExceeded => true (always)
1027
- # OutputTruncated, OutputRefused, OutputInvalid (OutputFailure) => false
1028
- # DeterministicStepFailure, ToolGuardrailFailed => honors error.retryable
1029
- # everything else => false
1030
-
1031
- Smith::Errors.retryable_classes
1032
- # => [Smith::AgentError, Smith::DeadlineExceeded] (for ActiveJob retry_on)
1033
-
1034
- Smith::Errors.retry_forbidden?(error)
1035
- # => true for terminal tool-evidence failures that must never be replayed
1036
- ```
1037
-
1038
- A nested workflow's step fails with the child's own error when the child
1039
- raises it, so these answers carry through nesting. When a tool ran inside the
1040
- child, the parent step's failure becomes `ToolOutcomeUncertain`, which is never
1041
- retried.
1042
-
1043
- Workflow transitions can also declare a bounded local retry policy:
1044
-
1045
- ```ruby
1046
- transition :draft, from: :idle, to: :done do
1047
- execute :writer
1048
- retry_on Smith::AgentError, attempts: 3, backoff: 0.1, max_delay: 1.0
1049
- end
1050
- ```
1051
-
1052
- When no classes are passed, `retry_on` uses `Smith::Errors.retryable?`. No
1053
- attempt starts once the workflow's wall-clock deadline has passed.
1054
- This is a bounded local transition retry policy. What a receiver does with an
1055
- idempotency key remains host-owned.
1056
-
1057
- For backoffs too long to sleep through in a worker, `durable: true` makes each
1058
- one a wait of the run instead:
1059
-
1060
- ```ruby
1061
- transition :charge, from: :approved, to: :charged do
1062
- compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
1063
- retry_on Payments::Timeout, attempts: 5, backoff: 60, max_delay: 3600, durable: true
1064
- on_failure :charge_failed
1065
- end
1066
- ```
1067
-
1068
- A failed attempt the policy retries stores the run waiting (`awaiting.kind ==
1069
- :retry`, due when the backoff ends); the host schedules `expire_wait!` for that
1070
- time and then continues the run, which makes the next attempt under the step's
1071
- keys (new ones only after an attempt declared settled). See
1072
- `docs/PERSISTENCE.md`, Durable Retry.
1073
-
1074
- Steps and tools get idempotency keys for the requests they send outside the
1075
- system, the same on every attempt and after a restore:
1076
-
1077
- ```ruby
1078
- transition :charge, from: :approved, to: :charged do
1079
- compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
1080
- retry_on Payments::Timeout, Payments::Declined, attempts: 3,
1081
- new_idempotency_key_on: [Payments::Declined]
1082
- end
1083
- ```
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)
1084
268
 
1085
- A retry keeps the keys, so a receiver that honours them drops a request that
1086
- took effect before a timeout. An error named in `new_idempotency_key_on:` (or
1087
- `step.fail!(..., new_idempotency_key: true)`) shows the attempt's own requests
1088
- took no effect, and the attempts after it use new keys. See
1089
- `docs/PERSISTENCE.md`, Replay Safety.
1090
-
1091
- ### Response memo
1092
-
1093
- A step replayed after its process died makes its provider calls again, and
1094
- pays for them again. A host's response memo keeps what they got:
1095
-
1096
- ```ruby
1097
- class ResponseMemo
1098
- def fetch(key) = Rails.cache.read("smith-memo/#{key}")
1099
- def store(key, record) = Rails.cache.write("smith-memo/#{key}", record, expires_in: 2.days)
1100
- end
269
+ ## Doctor
1101
270
 
1102
- 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
1103
275
  ```
1104
276
 
1105
- Each agent call (its tool loop included) is kept under a key of its position
1106
- (the run, the step, any branch or nested position, the key generation), the
1107
- attempts it runs under (its step's, and each enclosing run's for a nested
1108
- workflow, so a retry never takes the attempt before's answer), its attempt
1109
- index (its model in the fallback chain, or a follow-up's place), its place
1110
- among the attempt's calls, and a digest of its request (model, messages,
1111
- tools, schema, params). A replay reaches the same key; a call whose request
1112
- changed since (a deployed prompt, another model, a date in the prompt) misses
1113
- and is sent again, as a miss always may be.
1114
-
1115
- Its record (`{"version" => 1, "messages" => [...], "complete" => true | false}`,
1116
- JSON) holds the messages the call added: the model's replies (structured ones
1117
- too) with their usage, stop reason and message id (a stream's included), its
1118
- tool calls and their results. It is stored after each round's tool results
1119
- and when the call ends; a complete record ends with the model's reply. A
1120
- replayed call that ended sends nothing: its reply is the step's (a streamed
1121
- step's sink gets the reply's text at once, not the earlier rounds'), its usage
1122
- is recorded as if it had just come, once, and its `:provider_call` trace says
1123
- `memo: true`; a reply that was cut off or refused fails the step as it did.
1124
- One that had not ended continues from its tools' results, its live part going
1125
- on from the kept rounds (on a budget ledger, a later round). Kept tool results
1126
- count as tools the step ran, so no retry or fallback runs them again. A call
1127
- whose messages cannot be kept (an attachment, past 1 MiB) is not stored, and
1128
- is logged; a record Smith does not keep is ignored, and an error from `fetch`
1129
- or `store` is logged by class: a memo never fails a step. Keeping records and
1130
- dropping them (a TTL) are the host's; batch requests are not memoized, as
1131
- their `custom_id` already identifies them.
277
+ Outside Rails: `smith doctor`, `smith doctor --durability`, `smith doctor --live`.
1132
278
 
1133
- ## Runtime Complexity
279
+ ## Documentation
1134
280
 
1135
- - Executable graph reachability is iterative `O(V + E)` time and `O(V)` space,
1136
- using indexed outgoing transitions and runtime-equivalent successor rules.
1137
- - Smith-managed tool arguments are copied iteratively in `O(N + B)` time and
1138
- `O(N + B)` bounded space for `N` expanded JSON value occurrences and `B`
1139
- expanded encoded bytes. Reused subgraphs are charged for every serialized
1140
- occurrence, native `Array` and `Hash` operations bypass hostile overrides, and
1141
- every mutable container's native size is admitted before one shallow copy is
1142
- made. Smith verifies the copy retained the admitted size before allocating or
1143
- traversing children. One immutable dispatch collection remains authoritative
1144
- from host admission through RubyLLM execution. The complete provider batch is
1145
- bounded to 100 calls and 1 MiB of normalized UTF-8 call metadata. The capture
1146
- boundary transfers temporary ownership of the provider collection and argument
1147
- graph to Smith: callers must not mutate them concurrently. Smith detects changed
1148
- cardinality and fails closed, but Ruby cannot prevent an unrelated writer from
1149
- growing a caller-owned container between native size admission and the native
1150
- copy without freezing that caller-owned object.
1151
- - Admitted tool-receipt settlement scans each batch once in `O(B)` time and
1152
- `O(B)` state for `B` admitted Smith calls, including when an earlier terminal
1153
- callback fails.
1154
- - Parallel execution is `O(B)` scheduling and result space for `B` branches,
1155
- with at most `C = parallel_concurrency` active worker threads per top-level
1156
- execution. Re-entrant fan-out inherits the same cancellation signal and uses
1157
- only idle workers from the top-level execution context, falling back to the
1158
- current worker when no capacity remains. Nested calls fail closed before the
1159
- configurable nesting bound can exhaust Ruby's stack. Cancellation prevents
1160
- queued branch bodies from starting;
1161
- already-running branches are drained before control returns so work is never
1162
- detached. Branch adapters must use bounded I/O and honor the cancellation
1163
- signal. Hosts continue to own process-level concurrency and hard deadlines.
1164
- - Reservation and settlement across `L` declared budget dimensions and `D`
1165
- operation dimensions is `O(L + D)` time and space. Receipt lookup is expected
1166
- `O(1)`; no operation scans or copies the active receipt registry. Integer and
1167
- Float amounts use exact decimal arithmetic internally.
1168
- - Retry delay calculation is `O(1)` time and space per attempt. A policy with
1169
- `A` attempts performs at most `A` guarded operations and never materializes an
1170
- exponential schedule.
1171
-
1172
- ## Development
1173
-
1174
- Smith changes must not use brute-force graph search, validation, scheduling, or
1175
- class-generation algorithms. Document the relevant time and space complexity,
1176
- prefer indexed, linear, logarithmic, or otherwise bounded approaches, and treat
1177
- an avoidable asymptotic regression as a release blocker.
1178
-
1179
- ```bash
1180
- bundle install
1181
- bundle exec rspec
1182
- bundle exec rubocop
1183
- ```
1184
-
1185
- 2,420 examples, MIT licensed. See [`CHANGELOG.md`](CHANGELOG.md) for the current
1186
- 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.