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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/README.md +197 -1081
- data/docs/AGENTS.md +241 -0
- data/docs/BUDGETS.md +134 -0
- data/docs/CONFIGURATION.md +29 -3
- data/docs/DEVELOPMENT.md +63 -0
- data/docs/ERRORS_AND_RETRIES.md +177 -0
- data/docs/INSPECTION.md +39 -0
- data/docs/PATTERNS.md +2 -2
- data/docs/PERSISTENCE.md +175 -4
- data/docs/TESTING.md +88 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +59 -1
- data/lib/smith/agent/completion.rb +5 -3
- data/lib/smith/agent/provider_completion.rb +1 -1
- data/lib/smith/agent/provider_streaming.rb +15 -9
- data/lib/smith/agent/usage_tracking.rb +1 -2
- data/lib/smith/batch.rb +11 -9
- data/lib/smith/cache_markers.rb +86 -0
- data/lib/smith/interruptions.rb +50 -0
- data/lib/smith/launchers/inline.rb +1 -0
- data/lib/smith/providers/request_holds.rb +1 -1
- data/lib/smith/providers/stream_facts.rb +29 -11
- data/lib/smith/response_memo.rb +150 -45
- data/lib/smith/setting_constructors.rb +14 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +1 -1
- data/lib/smith/tool/execution_batch.rb +5 -0
- data/lib/smith/tool/execution_failure_handling.rb +27 -9
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +5 -1
- data/lib/smith/workflow/parallel.rb +5 -1
- data/lib/smith/workflow/persistence.rb +4 -1
- data/lib/smith/workflow/remembered_calls.rb +52 -19
- data/lib/smith/workflow/retry_execution.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +1 -0
- data/lib/smith/workflow/step_context.rb +1 -0
- data/lib/smith/workflow.rb +4 -2
- data/lib/smith.rb +10 -0
- metadata +9 -1
data/README.md
CHANGED
|
@@ -1,1187 +1,303 @@
|
|
|
1
1
|
# Smith
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
26
|
+
> Smith is pre-1.0: contracts can tighten between minor versions. Pin an exact
|
|
27
|
+
> version.
|
|
7
28
|
|
|
8
|
-
##
|
|
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.
|
|
33
|
+
gem "smith-agents", "0.12.3", require: "smith"
|
|
21
34
|
```
|
|
22
35
|
|
|
36
|
+
In a Rails app:
|
|
37
|
+
|
|
23
38
|
```bash
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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 :
|
|
79
|
-
execute :
|
|
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 =
|
|
85
|
-
result.state
|
|
86
|
-
result.
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
##
|
|
82
|
+
## Steps
|
|
112
83
|
|
|
113
|
-
|
|
|
84
|
+
| Step | Declared with | Does |
|
|
114
85
|
|---|---|---|
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
|
|
|
118
|
-
|
|
|
119
|
-
|
|
|
120
|
-
|
|
|
121
|
-
|
|
|
122
|
-
|
|
|
123
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
## Durable runs
|
|
129
100
|
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 :
|
|
300
|
-
execute :
|
|
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 :
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
122
|
+
transition :expire, from: :drafted, to: :expired
|
|
357
123
|
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
586
|
-
`
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
|
|
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
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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
|
|
719
|
-
budget total_tokens:
|
|
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
|
-
`
|
|
724
|
-
spend
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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
|
|
773
|
-
|
|
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
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
#
|
|
887
|
-
require "smith/testing/minitest" # or "smith/testing/rspec" for matchers
|
|
248
|
+
require "smith/testing/minitest" # or "smith/testing/rspec"
|
|
888
249
|
|
|
889
|
-
class
|
|
250
|
+
class PublishTest < ActiveSupport::TestCase
|
|
890
251
|
include Smith::Testing::Assertions
|
|
891
252
|
|
|
892
|
-
test "drafts
|
|
893
|
-
Smith::Testing.stub_agent(Writer, outputs:
|
|
894
|
-
|
|
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: {
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
279
|
+
## Documentation
|
|
1135
280
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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.
|