smith-agents 0.12.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/README.md +197 -1081
  4. data/docs/AGENTS.md +241 -0
  5. data/docs/BUDGETS.md +134 -0
  6. data/docs/CONFIGURATION.md +29 -3
  7. data/docs/DEVELOPMENT.md +63 -0
  8. data/docs/ERRORS_AND_RETRIES.md +177 -0
  9. data/docs/INSPECTION.md +39 -0
  10. data/docs/PATTERNS.md +2 -2
  11. data/docs/PERSISTENCE.md +175 -4
  12. data/docs/TESTING.md +88 -0
  13. data/docs/TOOLS_AND_GUARDRAILS.md +59 -1
  14. data/lib/smith/agent/completion.rb +5 -3
  15. data/lib/smith/agent/provider_completion.rb +1 -1
  16. data/lib/smith/agent/provider_streaming.rb +15 -9
  17. data/lib/smith/agent/usage_tracking.rb +1 -2
  18. data/lib/smith/batch.rb +11 -9
  19. data/lib/smith/cache_markers.rb +86 -0
  20. data/lib/smith/interruptions.rb +50 -0
  21. data/lib/smith/launchers/inline.rb +1 -0
  22. data/lib/smith/providers/request_holds.rb +1 -1
  23. data/lib/smith/providers/stream_facts.rb +29 -11
  24. data/lib/smith/response_memo.rb +150 -45
  25. data/lib/smith/setting_constructors.rb +14 -0
  26. data/lib/smith/tool/chat_execution_callbacks.rb +1 -1
  27. data/lib/smith/tool/execution_batch.rb +5 -0
  28. data/lib/smith/tool/execution_failure_handling.rb +27 -9
  29. data/lib/smith/version.rb +1 -1
  30. data/lib/smith/workflow/deterministic_execution.rb +5 -1
  31. data/lib/smith/workflow/parallel.rb +5 -1
  32. data/lib/smith/workflow/persistence.rb +4 -1
  33. data/lib/smith/workflow/remembered_calls.rb +52 -19
  34. data/lib/smith/workflow/retry_execution.rb +1 -0
  35. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +1 -0
  36. data/lib/smith/workflow/step_context.rb +1 -0
  37. data/lib/smith/workflow.rb +4 -2
  38. data/lib/smith.rb +10 -0
  39. metadata +9 -1
data/docs/AGENTS.md ADDED
@@ -0,0 +1,241 @@
1
+ # Agents
2
+
3
+ How an agent's replies are checked, how its requests are shaped for each
4
+ provider, and the two other ways a step can call it: streamed, and in batch.
5
+
6
+ ## Registering agents
7
+
8
+ Agents register at class load. In Rails, register workflow-facing agents in a `to_prepare` hook so autoload doesn't drop them:
9
+
10
+ ```ruby
11
+ # config/initializers/smith_agents.rb
12
+ Rails.application.config.to_prepare do
13
+ ReplyAgent
14
+ TriageAgent
15
+ end
16
+ ```
17
+
18
+ ## Agent outputs
19
+
20
+ Smith reads how each response ended from the provider's stop reason. A
21
+ response cut off at its output limit, a refused one, and a structured output
22
+ that is not what the agent declares are not accepted as the step's output:
23
+
24
+ ```ruby
25
+ class Reviewer < Smith::Agent
26
+ model "claude-opus-5-5", provider: :anthropic
27
+ output_schema ReviewSchema # its root is an object
28
+ output_contract ReviewContract # call(hash) => true, false, or a result with success? and errors
29
+ output_repairs 1 # the default; 0 turns repair off
30
+ on_truncation retry_with: { max_tokens: 64_000 } # or :fail (the default), or :accept
31
+ on_refusal fallback: "openai/gpt-5" # or :fail (the default)
32
+ end
33
+ ```
34
+
35
+ - A cut-off response raises `Smith::OutputTruncated`, unless the agent
36
+ accepts it or retries it once on the same model with a larger output limit,
37
+ mapped to the provider's own parameter (Anthropic, OpenAI, Gemini, Vertex AI
38
+ and Bedrock; another provider is not retried). There is no retry when the
39
+ response ran out of context rather than output, or when the new limit is no
40
+ larger than the request had (the agent's own parameter, or for Anthropic the
41
+ model's maximum, which RubyLLM already sends); a later repair keeps the
42
+ larger limit.
43
+ - A refused response (a Gemini prompt block included) raises
44
+ `Smith::OutputRefused`, unless the agent falls back once to another model.
45
+ - Every other response then goes through the agent's `after_completion`. A
46
+ structured output (an `output_schema`, or a static RubyLLM `schema`) that is
47
+ not a JSON object, breaks a schema constraint, or fails the output contract
48
+ goes back to the same model up to `output_repairs` times: the step's messages
49
+ as the provider got them, the rejected reply as the assistant's, and the
50
+ problems as "path: problem" lines. It then raises `Smith::OutputInvalid`,
51
+ whose `problems` lists at most 20 of them. A blank output still raises
52
+ `Smith::BlankAgentOutputError`.
53
+ - No follow-up call is made once a tool has run in the step (the tools would
54
+ run again; the step fails with `Smith::ToolOutcomeUncertain` around the output
55
+ error) or once the call has spent what its step reserved from a budget. A
56
+ follow-up that fails raises the output error it was making up for, with the
57
+ provider's error as its cause.
58
+ - Anthropic's structured outputs reject numeric, string-length and most
59
+ array-size constraints: Smith sends Anthropic the schema without them, with
60
+ `additionalProperties: false` on every object, and checks them on the parsed
61
+ output for every provider. An object that allows other properties or
62
+ declares none cannot be sent to Anthropic; the step fails before the call
63
+ with a `Smith::WorkflowError`, and runtime readiness reports
64
+ `:output_schema_unsupported` for an agent declared for Anthropic.
65
+ - An agent with no model and no fallback models fails its step with a
66
+ `Smith::WorkflowError`; runtime readiness reports it (`:agent_without_model`).
67
+
68
+ Every response is billed and settles into budgets, whatever happens to its
69
+ output, and each rejected one leaves an `:output_rejected` trace naming its
70
+ attempt. The output errors share the failure family `output_failure`, persist
71
+ with a failed step, and are never retried by `Smith::Errors.retryable?`, a job
72
+ retry on `Smith::Errors.retryable_classes`, or a transition's
73
+ `retry_on Smith::AgentError`: Smith's own retry, fallback and repairs have run.
74
+
75
+ ## Models and request shaping
76
+
77
+ Smith ships a per-attempt normalizer that translates the request payload to whatever the resolved model's provider family expects:
78
+
79
+ - Anthropic Opus 4.7+ adaptive thinking via `output_config[:effort]`
80
+ - Anthropic 4.0–4.6 budget_tokens
81
+ - OpenAI gpt-5 family reasoning_effort with `/v1/responses` routing when tools + thinking are combined
82
+ - Gemini 2.5+ budget_tokens
83
+
84
+ Override the inferred profile per-app via `Smith::Models.register(Profile.new(...))`.
85
+ Profiles are keyed by both provider and model id. Pass `provider:` to
86
+ `Smith::Models.find` or `find_or_infer` whenever an id exists under more than
87
+ one provider; an ambiguous unqualified lookup fails closed. Smith never
88
+ hardcodes model ids in the library.
89
+
90
+ Provider-qualified fallback entries use the same exact identity:
91
+
92
+ ```ruby
93
+ fallback_models(
94
+ { model: "claude-sonnet-4-6", provider: :anthropic },
95
+ { model: "openai/gpt-5", provider: :openrouter }
96
+ )
97
+ ```
98
+
99
+ Fallback entries must include an explicit provider. Smith does not infer a
100
+ fallback provider from the primary model or from a model id because the same id
101
+ may be registered by more than one provider. Unqualified fallback entries fail
102
+ closed during agent configuration.
103
+
104
+ ## Streaming
105
+
106
+ An agent step can send its reply's text to the host as the model writes it:
107
+
108
+ ```ruby
109
+ # Called when a streamed step starts; returns the step's sink, or nil to
110
+ # stream nothing (nobody is watching). Each step execution gets its own.
111
+ Smith.config.stream_sinks = {
112
+ answer: ->(info) { AnswerStream.new(info[:execution_key]) if Screens.watching?(info[:execution_key]) }
113
+ }
114
+
115
+ class AnswerStream
116
+ def initialize(key)
117
+ @key = key
118
+ @reply = Smith::Streaming::JsonField.new(:reply) # the output schema's reply field, as it grows
119
+ end
120
+
121
+ def delta(text) = (piece = @reply.push(text)) && Screens.append(@key, piece)
122
+ def reset(_reason) = @reply.reset || Screens.clear(@key)
123
+ def finish(output) = Screens.final(@key, output["reply"])
124
+ def fail(_error_class) = Screens.failed(@key)
125
+ end
126
+
127
+ class Answer < Smith::Workflow
128
+ initial_state :asked
129
+ state :failed
130
+
131
+ transition :answer, from: :asked, to: :answered do
132
+ execute :answerer, stream: :answer, output_to: :answer
133
+ on_failure :fail
134
+ end
135
+ end
136
+ ```
137
+
138
+ A sink defines any of `start(info)` (each provider call: the step's info with
139
+ the attempt, model and provider), `delta(text)` (each text chunk; tool calls,
140
+ thinking and `<think>` text are not sent), `reset(reason)` (Smith discarded the
141
+ text sent so far: `:retry`, `:fallback`, `:truncation` or `:repair`; and
142
+ `:retry_wait`, the last word, while a durable retry waits), `finish(output)`
143
+ (the output the step accepted, after guardrails, the output contract and
144
+ `output_to:`, once the step is stored) and `fail(error_class)`. Streamed text is
145
+ a draft: guardrails and output checks run on the whole reply, so nothing should
146
+ be saved or acted on before `finish`, and a step with an output schema streams
147
+ JSON text (`Smith::Streaming::JsonField` gives the text each delta adds to one
148
+ string field). A sink that raises is logged and the step goes on; a sink name
149
+ that is not registered fails the step before its call. Stop reasons are read
150
+ from the stream, so cut-off and refused replies are handled as without it, and a
151
+ stream that ends without one counts as cut off (`stream_incomplete`). Smith's
152
+ OpenAI Responses path does not stream: its sink gets each reply as one delta.
153
+ Streaming changes nothing durable (each usage entry keeps its response's stop
154
+ reason and message id, with `mode: :stream`), and a replayed step streams again
155
+ (unless a response memo kept its reply: then its sink gets the text at once).
156
+ Graph inspection shows the stream, and runtime readiness reports a missing sink
157
+ (`:stream_sink_missing`) and an agent whose calls go to the Responses API
158
+ (`:stream_whole_replies`).
159
+
160
+ ## Batch mode
161
+
162
+ An agent step, or a fan-out, can send its requests through a provider's batch
163
+ API (half price, answered within 24 hours) instead of over the wire:
164
+
165
+ ```ruby
166
+ # The host keeps each request until a scheduled job submits it (a table, in
167
+ # production); enqueue is idempotent on custom_id.
168
+ class HostBatchQueue
169
+ Entry = Struct.new(:custom_id, :await_token, :request, :run_key, :batch_id)
170
+
171
+ def initialize = @entries = {}
172
+ def enqueue(custom_id:, await_token:, request:, execution_key:)
173
+ @entries[custom_id] ||= Entry.new(custom_id, await_token, request, execution_key)
174
+ end
175
+ def unsubmitted = @entries.values.reject(&:batch_id)
176
+ def entry(custom_id) = @entries.fetch(custom_id)
177
+ end
178
+ Smith.config.batch_queue = HostBatchQueue.new
179
+
180
+ class Triage < Smith::Workflow
181
+ initial_state :queued
182
+ state :failed
183
+
184
+ transition :triage, from: :queued, to: :triaged do
185
+ execute :triager, mode: ->(ctx) { ctx[:urgent] ? :sync : :batch }, output_to: :verdict
186
+ on_failure :fail
187
+ end
188
+ end
189
+
190
+ # One scheduled job submits what is queued; another collects an ended batch
191
+ # and resumes each run once with every result of its wait.
192
+ queue = Smith.config.batch_queue
193
+ batches = Smith::Providers::Anthropic::Batches.new
194
+ entries = queue.unsubmitted
195
+ batch_id = batches.submit(entries.map { { custom_id: _1.custom_id, request: _1.request } })
196
+ entries.each { _1.batch_id = batch_id }
197
+ if batches.status(batch_id)[:status] == "ended"
198
+ payloads = Hash.new { |hash, wait| hash[wait] = {} }
199
+ batches.results(batch_id) do |custom_id, result|
200
+ entry = queue.entry(custom_id)
201
+ payloads[[entry.run_key, entry.await_token]][custom_id] = result
202
+ end
203
+ payloads.each { |(run_key, token), payload| Triage.resume!(run_key, event: :batch_result, token:, payload:) }
204
+ end
205
+ ```
206
+
207
+ `mode:` is `:sync` (the default), `:batch`, or a callable of the context and
208
+ the step attempt returning one, on `execute` and `fan_out`; a batch step
209
+ declares `on_failure`, and cannot stream or be `parallel:`. Smith prepares
210
+ the call as a synchronous one, admits it against the budget, and hands the
211
+ exact request (the provider payload and the call's own headers, never the
212
+ connection's credentials) to the queue under a `custom_id` (the wait's token;
213
+ each fan-out branch its own) once the step's attempt has ended without error,
214
+ then the run waits on `:batch_result`. Smith never submits or polls, and waits
215
+ without sleeping: `Smith::Providers::Anthropic::Batches` gives the host
216
+ `submit`, `status`, `results` and `cancel`. `resume!` with
217
+ `{custom_id => result}` runs the step again, each call taking its result as
218
+ the reply it would have got. A call that renders another request by then (a
219
+ date in its prompt, data read afresh, a prompt changed in a deploy; a prompt
220
+ cache marker added or dropped is no change, as it never changes what the
221
+ request asks, though a request captured by Smith 0.12.2 is matched as it was
222
+ sent, markers included) does what
223
+ its step's `on_request_change:` says: `:keep` (the default) takes the result,
224
+ which answers the request as sent, and its `:provider_call` trace says
225
+ `request_changed: true`; `:resend` sends the request as rendered now over the
226
+ wire instead; `:fail` fails the step with `Smith::BatchRequestChanged` (an
227
+ `AgentError`, so `retry_on` can send it again, synchronously if the mode
228
+ callable says so). A result the call does not use is charged all the same.
229
+ Then the output checks, a follow-up call (over the
230
+ wire), guardrails and `output_to:` as ever, usage recorded with `mode: :batch`
231
+ and priced with the catalog's `batch_discount`, and charged even past a
232
+ budget, as the money is spent. A request the provider refused fails the step
233
+ (`Smith::BatchRequestInvalid`); a failed, canceled or expired result, or none
234
+ by the wait's deadline (`expire_wait!`, 25 hours), raises
235
+ `Smith::BatchResultFailed`, which `retry_on` sends again (synchronously, if the
236
+ mode callable says so at the next attempt; an in-process `retry_on` backoff
237
+ sleeps as on any step, while `durable: true` waits). Only Anthropic's batch interface is
238
+ built in, and a batch request is one turn, so a batching agent has no tools;
239
+ runtime readiness reports a missing queue (`:batch_queue_missing`) and an
240
+ agent that cannot batch (`:batch_unavailable`), and graph inspection shows the
241
+ mode.
data/docs/BUDGETS.md ADDED
@@ -0,0 +1,134 @@
1
+ # Budgets and Deadlines
2
+
3
+ ```ruby
4
+ class BudgetedWorkflow < Smith::Workflow
5
+ budget total_tokens: 10_000, total_cost: 0.50, wall_clock: 30
6
+ end
7
+ ```
8
+
9
+ `wall_clock` is in seconds. A workflow's `wall_clock` bounds the time its steps
10
+ spend running, counted across restores in the persisted `active_seconds`, not
11
+ the time since the run was created: time spent waiting, or between steps a host
12
+ advances from a queue, does not count. A step that raised or whose process died
13
+ (or met an interrupting error) is charged, on the next restore, the time from its start, at most what is left
14
+ of the budget, so a run that keeps failing stays bounded across retries.
15
+ Durable composite branches each start from what is left; their running time is
16
+ not added back. An agent's `wall_clock` bounds each of
17
+ its calls. A workflow budget accepts `total_tokens`,
18
+ `token_limit`, `total_cost`, `tool_calls`, and `wall_clock`; an agent budget
19
+ accepts `token_limit`, `cost`, `wall_clock`, `tool_calls`, `total_tokens`, and
20
+ `total_cost`. Any other key raises `ArgumentError` naming the accepted keys. A
21
+ restored run drops consumed amounts for keys the workflow no longer declares.
22
+
23
+ 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.
24
+
25
+ Direct ledger integrations settle the exact receipt returned by reservation:
26
+
27
+ ```ruby
28
+ reservation = ledger.reserve_many!(total_tokens: 1_000, total_cost: 0.10)
29
+ ledger.reconcile_many!(reservation, actual: { total_tokens: 640, total_cost: 0.06 })
30
+ ```
31
+
32
+ `release!` / `release_many!` accept the same receipt when work never starts.
33
+ Receipts are ledger-owned and one-shot: another ledger, a replay, or an
34
+ amount-only settlement is rejected. `ledger.remaining(key)` never reads back
35
+ above the exact remaining amount, and `ledger.remaining_share(key, parts)`
36
+ returns one of `parts` equal shares of it such that reserving all `parts` fits.
37
+
38
+ Budget amounts accept finite, non-negative `Integer` and `Float` values. Smith
39
+ uses an exact `BigDecimal` representation derived from each value's canonical
40
+ decimal text and converts ledger snapshots back to ordinary JSON-safe numerics,
41
+ avoiding cumulative binary Float drift without changing persisted workflow
42
+ state shapes. Ledger arithmetic temporarily uses unlimited decimal precision and
43
+ restores the host's existing `BigDecimal` precision limit before returning.
44
+
45
+ Budget operations are atomic for normal concurrent callers and synchronous
46
+ exceptions. Smith uses cooperative cancellation and never terminates branch
47
+ threads with `Thread#raise` or `Thread#kill`. Arbitrary asynchronous thread
48
+ termination can make any in-process return value outcome-ambiguous; hosts must
49
+ use cancellation signals, bounded I/O, and process isolation for hard stops.
50
+
51
+ ## Budget ledgers
52
+
53
+ A run's `budget` bounds that run. A budget shared by many runs (an account's
54
+ monthly spend, say) lives in the host, and a workflow names it with
55
+ `budget_ledger`:
56
+
57
+ ```ruby
58
+ class MonthlyLedger
59
+ def initialize(account) = @account = account
60
+
61
+ # Admit the hold (true; anything else refuses it) and keep it under key
62
+ # until expires_at, after which it counts for nothing.
63
+ def reserve!(amount, key:, expires_at:) = ...
64
+ def reconcile!(key, amount) = ... # what the request spent, in place of its hold
65
+ def release!(key) = ... # it spent nothing
66
+ end
67
+
68
+ class DocumentReview < Smith::Workflow
69
+ budget_ledger(:account_id) { |scope| MonthlyLedger.new(scope[:account_id]) }
70
+ end
71
+ ```
72
+
73
+ The block gets the run's scope: the values the named keys have in its trusted
74
+ context when the run is created (JSON scalars, kept with the run), so nothing a
75
+ step or a model writes later chooses another ledger, and an untrusted key is
76
+ refused. Smith builds the ledger at the first request a step makes, never
77
+ persists it, and never needs it to restore a run; nil means none. A nested
78
+ workflow without a `budget_ledger` of its own charges the enclosing run's; a
79
+ child run (`call`, `map`, `spawn`), inline or not, uses its own class's, so
80
+ give each child workflow its own (runtime readiness warns of one that has none,
81
+ `:child_run_unbudgeted`) and pass its scope keys in each child's input.
82
+
83
+ Before a call's first request over the wire (each fallback model, each
84
+ follow-up, streamed or not, OpenAI's Responses route included) Smith asks the
85
+ ledger to admit its hold. If the ledger refuses, the step fails with
86
+ `Smith::BudgetUnavailable` (a `BudgetExceeded`, not retried by default) before
87
+ anything is sent; `retry_on Smith::BudgetUnavailable, durable: true, ...` waits
88
+ for budget instead (the host ends the wait with `expire_wait!`, and ends it
89
+ early, once there is budget, with a `now:` at its `deadline_at`). After the request, Smith reconciles what it
90
+ spent: its usage, or its hold when it answered without usage or its stream
91
+ failed part way; a request that failed before anything came back releases its
92
+ hold. If building the ledger or `reserve!` raises, the step fails with
93
+ `Smith::BudgetLedgerError` (nothing was sent, no fallback model is tried); one
94
+ from `reconcile!` or `release!` is logged by class and the step goes on, as
95
+ the money is spent.
96
+
97
+ A tool loop's later rounds continue a call whose tools already ran, so
98
+ stopping one would leave the tools' effects without their answer. A step whose
99
+ `retry_on` names `BudgetUnavailable` holds them too: when the ledger refuses
100
+ one, the loop pauses, keeping its tool calls and their results (at most 1 MiB)
101
+ with the step's retry, and the retry continues the loop from them, running
102
+ none of its tools again. Any other step's later rounds, or a loop too large to
103
+ keep, go on unheld and are reconciled with what they spent, so give tool-using
104
+ agents a `tool_calls` budget.
105
+
106
+ A hold (`Smith::Budget::Amount`: `input_tokens`, `output_tokens`, `cost`, and
107
+ `tokens`, their sum) is the request's output cap (its `max_tokens`, or its
108
+ provider's name for it, else the model's) and an estimate of its input: text
109
+ at one token per 3 bytes, an image at 1,600 tokens, a PDF at 2,000 tokens a
110
+ page, 8 tokens a message for its framing, capped at the model's context window.
111
+ Images and documents count so as RubyLLM attachments and in raw content blocks
112
+ (base64 data or a data URL). `Smith.config.budget_hold_output_tokens`, when
113
+ set, caps the output part (an agent with no `max_tokens` is otherwise held for
114
+ the model's maximum, which can be tens of thousands of tokens, and a longer
115
+ reply is reconciled at what it cost). It is priced from the pricing catalog
116
+ under the id RubyLLM resolves the model to; `cost` is nil for an unpriced
117
+ model, which runtime readiness warns of (`:budget_ledger_unpriced`, for every
118
+ agent that charges the ledger), and its readiness metrics name the scope keys.
119
+
120
+ A key is the provider attempt's `attempt_id` (as on its `:provider_call` trace)
121
+ and the request's round, `"<attempt_id>:<n>"`, and for a batch request its
122
+ `custom_id`. A batch request is held when its step captures it, at the batch
123
+ discount, and settled when its result comes: a succeeded result, used or not,
124
+ is reconciled at what it cost; one that failed, or never came, is released.
125
+ A process that dies during a request never settles its hold, so every hold
126
+ carries `expires_at`: `Smith.config.budget_hold_ttl` from now (an hour by
127
+ default; it must outlast the longest request), and for a batch request its
128
+ 25-hour wait longer. Smith runs no timer; the ledger leaves holds past
129
+ `expires_at` out of what it admits, and still records a reconcile that comes
130
+ later (a batch delivered late). A key may come again after a crash (a batch
131
+ request captured again, a delivery that ran twice): a reserve of it replaces
132
+ its hold, a release ends it, and its reconcile is final, recorded once.
133
+ Fan-out branches call the ledger from their own threads. RubyLLM's own retries
134
+ (`max_retries`) resend a request under its one hold.
@@ -30,7 +30,7 @@ Use agent classes for invocation behavior:
30
30
  - `fallback_models`
31
31
  - `register_as`
32
32
 
33
- The output macros are described in the README, Agent outputs.
33
+ The output macros are described in [AGENTS.md](AGENTS.md), Agent outputs.
34
34
 
35
35
  ### 3. Workflow configuration: `Smith::Workflow`
36
36
 
@@ -115,8 +115,33 @@ Smith.configure do |config|
115
115
  # Batch steps (`execute Agent, mode: :batch`): the host's queue of requests.
116
116
  config.batch_queue = HostBatchQueue.new
117
117
 
118
- # How long a request's hold on a workflow's budget_ledger lasts unsettled.
118
+ # How long a request's hold on a workflow's budget_ledger lasts unsettled,
119
+ # and the most output tokens a hold counts (nil: the request's own cap).
119
120
  config.budget_hold_ttl = 3600
121
+ config.budget_hold_output_tokens = nil
122
+
123
+ # Persistence: where runs are stored, and a stored state's bound.
124
+ config.persistence_adapter = :active_record
125
+ config.persistence_options = { model: "WorkflowState" }
126
+ config.persistence_ttl = nil # :active_record cannot expire state
127
+ config.persistence_max_bytes = 16_777_216 # nil: no bound
128
+ config.persistence_retry_policy = { attempts: 3, base_delay: 0.1, max_delay: 1.0 }
129
+
130
+ # Child runs (`call`, `map`, `spawn`) and replays.
131
+ config.run_launcher = HostLauncher.new
132
+ config.response_memo = HostResponseMemo.new
133
+
134
+ # Errors that stop a step as if its worker had stopped, instead of failing it.
135
+ config.interrupting_errors = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::ConnectionFailed]
136
+
137
+ # Generic runtime resource bounds.
138
+ config.parallel_branch_limit = 1_000
139
+ config.parallel_concurrency = 8
140
+ config.parallel_nesting_limit = 64
141
+ config.retry_attempt_limit = 100
142
+
143
+ # OpenAI /v1/responses routing for gpt-5 + tools + thinking: :auto (default) or :off.
144
+ config.openai_api_mode = :auto
120
145
 
121
146
  config.logger = Logger.new($stdout)
122
147
  end
@@ -169,7 +194,8 @@ from JSON or YAML has.
169
194
  | `pricing` | Best-known model-call cost catalog | Add once you care about `total_cost` |
170
195
  | `logger` | Smith's runtime logger | Usually the first setting to add |
171
196
  | `batch_queue` | Takes the requests `mode: :batch` steps send: `enqueue(custom_id:, await_token:, request:, execution_key:)`, idempotent on `custom_id`; the host submits them (`Smith::Providers::Anthropic::Batches`) and resumes each run with its results | Set before any workflow batches; readiness reports a missing one |
172
- | `response_memo` | Keeps what a step's provider calls got (`fetch(key)`, `store(key, record)`), so a step replayed after its process died reuses it instead of paying again (README, "Response memo") | Set it where replays happen: lax idempotency mode, or a host that runs a crashed step again |
197
+ | `response_memo` | Keeps what a step's provider calls got (`fetch(key)`, `store(key, record)`), so a step replayed after its process died reuses it instead of paying again ([ERRORS_AND_RETRIES.md](ERRORS_AND_RETRIES.md), Response memo) | Set it where replays happen: lax idempotency mode, or a host that runs a crashed step again |
198
+ | `interrupting_errors` | Subclasses of StandardError that interrupt a step instead of failing it, such as the host's database or queue out of reach: met in a step (or as the cause of an error that was, never through a provider's own failure), they take no route, no retry and no failure record, reach the caller as raised, and leave the run as the step left it. Smith does not count them ([ERRORS_AND_RETRIES.md](ERRORS_AND_RETRIES.md), Interrupting errors). None by default | Name the errors that mean your own systems failed, never ones a step can cause every time (a query timeout) |
173
199
  | `budget_hold_ttl` | Seconds a provider request's hold on a workflow's `budget_ledger` lasts unsettled (default 3600): each hold's `expires_at` is this from now, and a batch request's is its 25-hour wait longer. Past it the request's process is taken to have died | Raise it above the longest request (its retries and stream included) |
174
200
  | `budget_hold_output_tokens` | The most output tokens a request's hold on a workflow's `budget_ledger` counts (default nil: the request's own `max_tokens`, else the model's maximum). Only the hold changes, not the request; a longer reply is reconciled at what it cost | Set it when agents leave `max_tokens` unset and a small budget would refuse calls on their huge holds |
175
201
  | `stream_sinks` | The sinks `execute Agent, stream: :name` steps send text to, by name: callables that take a step's info (`stream`, `execution_key`, `transition`) and return that step's sink, or nil to stream nothing | Register each name a workflow streams to; readiness reports a missing one |
@@ -0,0 +1,63 @@
1
+ # Developing Smith
2
+
3
+ ## Verification discipline
4
+
5
+ Tests are required, but they are never enough for runtime primitive changes.
6
+ Every Smith workflow slice must also run practical gem-level execution probes.
7
+ When a host application consumes unreleased Smith changes, point that host app at
8
+ the local Smith repository and exercise the changed workflow paths in the host
9
+ environment before calling the slice complete.
10
+
11
+ ## Algorithms
12
+
13
+ Smith changes must not use brute-force graph search, validation, scheduling, or
14
+ class-generation algorithms. Document the relevant time and space complexity,
15
+ prefer indexed, linear, logarithmic, or otherwise bounded approaches, and treat
16
+ an avoidable asymptotic regression as a release blocker.
17
+
18
+ ## Runtime complexity
19
+
20
+ - Executable graph reachability is iterative `O(V + E)` time and `O(V)` space,
21
+ using indexed outgoing transitions and runtime-equivalent successor rules.
22
+ - Smith-managed tool arguments are copied iteratively in `O(N + B)` time and
23
+ `O(N + B)` bounded space for `N` expanded JSON value occurrences and `B`
24
+ expanded encoded bytes. Reused subgraphs are charged for every serialized
25
+ occurrence, native `Array` and `Hash` operations bypass hostile overrides, and
26
+ every mutable container's native size is admitted before one shallow copy is
27
+ made. Smith verifies the copy retained the admitted size before allocating or
28
+ traversing children. One immutable dispatch collection remains authoritative
29
+ from host admission through RubyLLM execution. The complete provider batch is
30
+ bounded to 100 calls and 1 MiB of normalized UTF-8 call metadata. The capture
31
+ boundary transfers temporary ownership of the provider collection and argument
32
+ graph to Smith: callers must not mutate them concurrently. Smith detects changed
33
+ cardinality and fails closed, but Ruby cannot prevent an unrelated writer from
34
+ growing a caller-owned container between native size admission and the native
35
+ copy without freezing that caller-owned object.
36
+ - Admitted tool-receipt settlement scans each batch once in `O(B)` time and
37
+ `O(B)` state for `B` admitted Smith calls, including when an earlier terminal
38
+ callback fails.
39
+ - Parallel execution is `O(B)` scheduling and result space for `B` branches,
40
+ with at most `C = parallel_concurrency` active worker threads per top-level
41
+ execution. Re-entrant fan-out inherits the same cancellation signal and uses
42
+ only idle workers from the top-level execution context, falling back to the
43
+ current worker when no capacity remains. Nested calls fail closed before the
44
+ configurable nesting bound can exhaust Ruby's stack. Cancellation prevents
45
+ queued branch bodies from starting;
46
+ already-running branches are drained before control returns so work is never
47
+ detached. Branch adapters must use bounded I/O and honor the cancellation
48
+ signal. Hosts continue to own process-level concurrency and hard deadlines.
49
+ - Reservation and settlement across `L` declared budget dimensions and `D`
50
+ operation dimensions is `O(L + D)` time and space. Receipt lookup is expected
51
+ `O(1)`; no operation scans or copies the active receipt registry. Integer and
52
+ Float amounts use exact decimal arithmetic internally.
53
+ - Retry delay calculation is `O(1)` time and space per attempt. A policy with
54
+ `A` attempts performs at most `A` guarded operations and never materializes an
55
+ exponential schedule.
56
+
57
+ ## Commands
58
+
59
+ ```bash
60
+ bundle install
61
+ bundle exec rspec
62
+ bundle exec rubocop
63
+ ```
@@ -0,0 +1,177 @@
1
+ # Errors and Retries
2
+
3
+ ```ruby
4
+ Smith::Errors.retryable?(error)
5
+ # AgentError, DeadlineExceeded => true (always)
6
+ # OutputTruncated, OutputRefused, OutputInvalid (OutputFailure) => false
7
+ # DeterministicStepFailure, ToolGuardrailFailed => honors error.retryable
8
+ # everything else => false
9
+
10
+ Smith::Errors.retryable_classes
11
+ # => [Smith::AgentError, Smith::DeadlineExceeded] (for ActiveJob retry_on)
12
+
13
+ Smith::Errors.retry_forbidden?(error)
14
+ # => true for terminal tool-evidence failures that must never be replayed
15
+ ```
16
+
17
+ A nested workflow's step fails with the child's own error when the child
18
+ raises it, so these answers carry through nesting. When a tool ran inside the
19
+ child, the parent step's failure becomes `ToolOutcomeUncertain`, which is never
20
+ retried.
21
+
22
+ Workflow transitions can also declare a bounded local retry policy:
23
+
24
+ ```ruby
25
+ transition :draft, from: :idle, to: :done do
26
+ execute :writer
27
+ retry_on Smith::AgentError, attempts: 3, backoff: 0.1, max_delay: 1.0
28
+ end
29
+ ```
30
+
31
+ When no classes are passed, `retry_on` uses `Smith::Errors.retryable?`. No
32
+ attempt starts once the workflow's wall-clock deadline has passed.
33
+ This is a bounded local transition retry policy. What a receiver does with an
34
+ idempotency key remains host-owned.
35
+
36
+ For backoffs too long to sleep through in a worker, `durable: true` makes each
37
+ one a wait of the run instead:
38
+
39
+ ```ruby
40
+ transition :charge, from: :approved, to: :charged do
41
+ compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
42
+ retry_on Payments::Timeout, attempts: 5, backoff: 60, max_delay: 3600, durable: true
43
+ on_failure :charge_failed
44
+ end
45
+ ```
46
+
47
+ A failed attempt the policy retries stores the run waiting (`awaiting.kind ==
48
+ :retry`, due when the backoff ends); the host schedules `expire_wait!` for that
49
+ time and then continues the run, which makes the next attempt under the step's
50
+ keys (new ones only after an attempt declared settled). See
51
+ [PERSISTENCE.md](PERSISTENCE.md), Durable Retry.
52
+
53
+ Steps and tools get idempotency keys for the requests they send outside the
54
+ system, the same on every attempt and after a restore:
55
+
56
+ ```ruby
57
+ transition :charge, from: :approved, to: :charged do
58
+ compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
59
+ retry_on Payments::Timeout, Payments::Declined, attempts: 3,
60
+ new_idempotency_key_on: [Payments::Declined]
61
+ end
62
+ ```
63
+
64
+ A retry keeps the keys, so a receiver that honours them drops a request that
65
+ took effect before a timeout. An error named in `new_idempotency_key_on:` (or
66
+ `step.fail!(..., new_idempotency_key: true)`) shows the attempt's own requests
67
+ took no effect, and the attempts after it use new keys. See
68
+ [PERSISTENCE.md](PERSISTENCE.md), Replay Safety.
69
+
70
+ ## Interrupting errors
71
+
72
+ Some errors say the host could not do its part, not that a step went wrong:
73
+ its database or its queue out of reach for a moment. Named as interrupting,
74
+ they stop a step where it is, as if its worker had stopped, instead of
75
+ failing it:
76
+
77
+ ```ruby
78
+ Smith.config.interrupting_errors = [
79
+ ActiveRecord::ConnectionNotEstablished,
80
+ ActiveRecord::ConnectionFailed,
81
+ Smith::PersistenceIOError
82
+ ]
83
+ ```
84
+
85
+ A step meets one when it is raised anywhere in the step (a compute block, a
86
+ tool, a tool failure handler, a guardrail, a budget ledger, a nested or
87
+ inline child run, a parallel branch) or is one of the first 15 causes of an
88
+ error that was. Smith then takes no failure route, makes no retry
89
+ (`retry_on`, durable ones included), records no failure (`last_failed_step`,
90
+ `StepFailed`; a deterministic step's trace says `outcome: :interrupted`),
91
+ notifies no tool failure handler and keeps no failed branch outcome. The
92
+ error itself, not one that wrapped it, reaches the caller of `advance!`,
93
+ `advance_persisted!`, `run!`, `run_persisted!`, or of `resume!` when the
94
+ delivery completes a batch step; among parallel branches and tools run
95
+ together it wins over every other error but a process-fatal one. The run's
96
+ stored state is as the step left it: a lax run takes the step again, under
97
+ the same idempotency keys and with the response memo's answers, when it is
98
+ next advanced; a strict run is found under way on restore
99
+ (`Smith::StepInProgressOnRestore`), as after a crash. The instance that
100
+ raised is not advanced again: restore the run.
101
+
102
+ Name only errors that mean your own systems failed. A provider's own failure
103
+ (`RubyLLM::Error`, or its HTTP client's `Faraday::Error`) is the provider's
104
+ whatever it was raised from, so naming a low-level class such as
105
+ `Errno::ECONNREFUSED` never turns a provider outage into an interruption;
106
+ name the provider's class itself for that. Smith does not count
107
+ interruptions: a step that meets one every time (a query of its own that
108
+ always times out, if `ActiveRecord::StatementTimeout` were named) is taken
109
+ again for as long as the host keeps advancing it, so the host bounds it, by
110
+ its job's retries or a count of its own. A `wall_clock` budget is charged as
111
+ after a crash: on restore, the time from the step's start, an outage
112
+ included, at most what is left.
113
+
114
+ A response memo, a trace adapter, a stream sink, an event handler, an
115
+ `on_step` callback, a `capture_result` block that is not strict, and a budget
116
+ ledger's `reconcile!` and `release!` log one they meet and go on, as they do
117
+ any error of their own. Only subclasses of StandardError can be named, never
118
+ StandardError itself. None are named by default, so every error takes its
119
+ step's route.
120
+
121
+ ## Response memo
122
+
123
+ A step replayed after its process died makes its provider calls again, and
124
+ pays for them again. A host's response memo keeps what they got:
125
+
126
+ ```ruby
127
+ class ResponseMemo
128
+ def fetch(key) = Rails.cache.read("smith-memo/#{key}")
129
+ def store(key, record) = Rails.cache.write("smith-memo/#{key}", record, expires_in: 2.days)
130
+ end
131
+
132
+ Smith.config.response_memo = ResponseMemo.new
133
+ ```
134
+
135
+ Each agent call (its tool loop included) is kept under a key of its position
136
+ (the run, the step, any branch or nested position, the key generation), the
137
+ attempts it runs under (its step's, and each enclosing run's for a nested
138
+ workflow, so a retry never takes the attempt before's answer), its attempt
139
+ index (its model in the fallback chain, or a follow-up's place), its place
140
+ among the attempt's calls, and a digest of its request (model, messages,
141
+ tools with their descriptions and parameters, schema, params), Anthropic's
142
+ prompt-cache markers aside: whether a part is cached never changes what the
143
+ call asks, so a host may decide it call by call, keeping the part's form (the
144
+ same text block, with or without its marker). A replay reaches the same key; a
145
+ call whose request changed since (a deployed prompt or tool, another model, a
146
+ date in the prompt) misses and is sent again, as a miss always may be.
147
+
148
+ Its record (`{"version" => 1 | 2, "messages" => [...], "complete" => true |
149
+ false}`, JSON) holds the messages the call added: the model's replies
150
+ (structured ones too) with their usage, stop reason and message id (a
151
+ stream's included), its tool calls and their results. It is stored when a
152
+ reply calling the tools its request offered comes, before they run; before
153
+ each later request, with their results; and when the call ends in the model's
154
+ reply (a call a tool halted keeps only its progress). A replayed call that
155
+ ended sends nothing: its reply is the step's (a streamed step's sink gets the
156
+ reply's text at once, not the earlier rounds'), its usage is recorded as if it
157
+ had just come, once, and its `:provider_call` trace says `memo: true`; a reply
158
+ that was cut off or refused fails the step as it did. One that had not ended
159
+ goes on from where it was kept. Kept after its tools' results, it continues
160
+ from them: kept tool results count as tools the step ran, so no retry or
161
+ fallback runs them again, and each is charged to the agent's `tool_calls`
162
+ allowance and the workflow's `tool_calls` budget as it was. Kept while its
163
+ tools ran (the step was cut inside a tool), the reply that called them is not
164
+ asked for again: its tools run again, under the same idempotency keys, and the
165
+ call goes on. Either way its live part goes on from the kept rounds (on a
166
+ budget ledger, a later round, held only when the step can pause for budget).
167
+ A call whose messages cannot be kept (an attachment, past 1 MiB) is not
168
+ stored, and is logged; a record Smith does not keep (its shape, or tool
169
+ results that do not answer the calls before them) is ignored, and an error
170
+ from `fetch` or `store` is logged by class: a memo never fails a step. Only a
171
+ record ending in a reply whose tools have not run is version 2, which a Smith
172
+ before 0.12.2 ignores (paying for that reply again and running its tools, as
173
+ it did); it reads every other record. Records for calls with tools that
174
+ 0.12.1 kept are not found after the upgrade, nor these by 0.12.1, since the
175
+ digest now covers the tools' definitions. Keeping records and dropping them (a
176
+ TTL) are the host's; batch requests are not memoized, as their `custom_id`
177
+ already identifies them.