smith-agents 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +197 -1080
- data/docs/AGENTS.md +238 -0
- data/docs/BUDGETS.md +134 -0
- data/docs/CONFIGURATION.md +30 -4
- data/docs/DEVELOPMENT.md +63 -0
- data/docs/ERRORS_AND_RETRIES.md +175 -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/generators/smith/install/install_generator.rb +6 -2
- 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/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 +149 -44
- 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/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.rb +9 -0
- metadata +8 -1
data/docs/AGENTS.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
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) does what
|
|
220
|
+
its step's `on_request_change:` says: `:keep` (the default) takes the result,
|
|
221
|
+
which answers the request as sent, and its `:provider_call` trace says
|
|
222
|
+
`request_changed: true`; `:resend` sends the request as rendered now over the
|
|
223
|
+
wire instead; `:fail` fails the step with `Smith::BatchRequestChanged` (an
|
|
224
|
+
`AgentError`, so `retry_on` can send it again, synchronously if the mode
|
|
225
|
+
callable says so). A result the call does not use is charged all the same.
|
|
226
|
+
Then the output checks, a follow-up call (over the
|
|
227
|
+
wire), guardrails and `output_to:` as ever, usage recorded with `mode: :batch`
|
|
228
|
+
and priced with the catalog's `batch_discount`, and charged even past a
|
|
229
|
+
budget, as the money is spent. A request the provider refused fails the step
|
|
230
|
+
(`Smith::BatchRequestInvalid`); a failed, canceled or expired result, or none
|
|
231
|
+
by the wait's deadline (`expire_wait!`, 25 hours), raises
|
|
232
|
+
`Smith::BatchResultFailed`, which `retry_on` sends again (synchronously, if the
|
|
233
|
+
mode callable says so at the next attempt; an in-process `retry_on` backoff
|
|
234
|
+
sleeps as on any step, while `durable: true` waits). Only Anthropic's batch interface is
|
|
235
|
+
built in, and a batch request is one turn, so a batching agent has no tools;
|
|
236
|
+
runtime readiness reports a missing queue (`:batch_queue_missing`) and an
|
|
237
|
+
agent that cannot batch (`:batch_unavailable`), and graph inspection shows the
|
|
238
|
+
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.
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
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,14 +194,15 @@ 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 (
|
|
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 |
|
|
176
202
|
| `persistence_adapter` | Adapter for durable workflow state | `:redis`, `:rails_cache`, `:active_record`, `:memory`, or a custom object |
|
|
177
203
|
| `persistence_options` | Per-adapter options (client, namespace, model, columns) | See "Built-In Persistence Adapters" |
|
|
178
204
|
| `persistence_ttl` | Global TTL for persisted state (Integer/Float seconds; nil = no expiry). `:active_record` cannot expire state, so its writes refuse a non-nil TTL (reads and deletes still work, and `smith doctor --durability` flags the combination) | Set when long-tail abandoned workflows accumulate in storage |
|
|
179
|
-
| `persistence_max_bytes` | The most bytes a stored workflow state may take (nil, the default: no bound). A stored run's step whose result would take it past the bound fails with `Smith::PersistedStateTooLarge` (its `key`, `bytes` and `limit`) before the result is kept, as with any error: its usage is recorded and its `on_failure` taken, so the run stays storable, strict mode included. A tool loop refused for budget does not pause into a state past it (it goes on unheld). Any other state past it is refused before anything is written. The answer that did not fit is not kept. `rails g smith:install` sets it to 1 MiB, with a CHECK a third above it on the payload column | Set it above the largest output a step keeps, with a database CHECK on the payload, so an oversized state fails as a typed step error instead of a database one |
|
|
205
|
+
| `persistence_max_bytes` | The most bytes a stored workflow state may take (nil, the default: no bound). A stored run's step whose result would take it past the bound fails with `Smith::PersistedStateTooLarge` (its `key`, `bytes` and `limit`) before the result is kept, as with any error: its usage is recorded and its `on_failure` taken, so the run stays storable, strict mode included. A tool loop refused for budget does not pause into a state past it (it goes on unheld). Any other state past it is refused before anything is written. The answer that did not fit is not kept. `rails g smith:install` sets it to 16 MiB (well above what one step can add: a map's collected results are at most 4 MiB, a wait's payload and a paused tool loop 1 MiB each), with a CHECK a third above it on the payload column | Set it above the largest output a step keeps, with a database CHECK on the payload, so an oversized state fails as a typed step error instead of a database one |
|
|
180
206
|
| `persistence_retry_policy` | Exponential-backoff policy for transient adapter I/O failures | Defaults to `{ attempts: 3, base_delay: 0.1, max_delay: 1.0 }` |
|
|
181
207
|
| `retry_attempt_limit` | Maximum attempts accepted by workflow and persistence retry policies | Defaults to `100` |
|
|
182
208
|
| `parallel_branch_limit` | Maximum branches accepted by one homogeneous or heterogeneous fan-out | Defaults to `1_000` |
|
data/docs/DEVELOPMENT.md
ADDED
|
@@ -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
|
+
```
|