smith-agents 0.12.1 → 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 +63 -0
- data/README.md +197 -1081
- data/docs/AGENTS.md +238 -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 +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/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,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 (
|
|
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 |
|
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
|
+
```
|
|
@@ -0,0 +1,175 @@
|
|
|
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). A replay
|
|
142
|
+
reaches the same key; a call whose request changed since (a deployed prompt or
|
|
143
|
+
tool, another model, a date in the prompt) misses and is sent again, as a miss
|
|
144
|
+
always may be.
|
|
145
|
+
|
|
146
|
+
Its record (`{"version" => 1 | 2, "messages" => [...], "complete" => true |
|
|
147
|
+
false}`, JSON) holds the messages the call added: the model's replies
|
|
148
|
+
(structured ones too) with their usage, stop reason and message id (a
|
|
149
|
+
stream's included), its tool calls and their results. It is stored when a
|
|
150
|
+
reply calling the tools its request offered comes, before they run; before
|
|
151
|
+
each later request, with their results; and when the call ends in the model's
|
|
152
|
+
reply (a call a tool halted keeps only its progress). A replayed call that
|
|
153
|
+
ended sends nothing: its reply is the step's (a streamed step's sink gets the
|
|
154
|
+
reply's text at once, not the earlier rounds'), its usage is recorded as if it
|
|
155
|
+
had just come, once, and its `:provider_call` trace says `memo: true`; a reply
|
|
156
|
+
that was cut off or refused fails the step as it did. One that had not ended
|
|
157
|
+
goes on from where it was kept. Kept after its tools' results, it continues
|
|
158
|
+
from them: kept tool results count as tools the step ran, so no retry or
|
|
159
|
+
fallback runs them again, and each is charged to the agent's `tool_calls`
|
|
160
|
+
allowance and the workflow's `tool_calls` budget as it was. Kept while its
|
|
161
|
+
tools ran (the step was cut inside a tool), the reply that called them is not
|
|
162
|
+
asked for again: its tools run again, under the same idempotency keys, and the
|
|
163
|
+
call goes on. Either way its live part goes on from the kept rounds (on a
|
|
164
|
+
budget ledger, a later round, held only when the step can pause for budget).
|
|
165
|
+
A call whose messages cannot be kept (an attachment, past 1 MiB) is not
|
|
166
|
+
stored, and is logged; a record Smith does not keep (its shape, or tool
|
|
167
|
+
results that do not answer the calls before them) is ignored, and an error
|
|
168
|
+
from `fetch` or `store` is logged by class: a memo never fails a step. Only a
|
|
169
|
+
record ending in a reply whose tools have not run is version 2, which a Smith
|
|
170
|
+
before 0.12.2 ignores (paying for that reply again and running its tools, as
|
|
171
|
+
it did); it reads every other record. Records for calls with tools that
|
|
172
|
+
0.12.1 kept are not found after the upgrade, nor these by 0.12.1, since the
|
|
173
|
+
digest now covers the tools' definitions. Keeping records and dropping them (a
|
|
174
|
+
TTL) are the host's; batch requests are not memoized, as their `custom_id`
|
|
175
|
+
already identifies them.
|