smith-agents 0.11.0 → 0.12.1
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 +712 -0
- data/README.md +622 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +67 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +70 -3
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -51
- data/lib/smith/agent.rb +5 -0
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +10 -5
- data/lib/smith/context.rb +28 -0
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +20 -18
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +249 -2
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +11 -5
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +36 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +113 -17
- data/lib/smith.rb +101 -45
- metadata +88 -1
data/README.md
CHANGED
|
@@ -17,13 +17,34 @@ environment before calling the slice complete.
|
|
|
17
17
|
|
|
18
18
|
```ruby
|
|
19
19
|
# Gemfile
|
|
20
|
-
gem "smith-agents", "0.
|
|
20
|
+
gem "smith-agents", "0.12.1", require: "smith"
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
bundle install
|
|
25
|
+
bin/rails generate smith:install # in a Rails app
|
|
26
|
+
bin/rails db:migrate
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
`smith:install` writes `config/initializers/smith.rb` and a table and model
|
|
30
|
+
for workflow state (`WorkflowState`, Smith's `:active_record` persistence
|
|
31
|
+
adapter): a unique string key (child runs are stored under 64-character
|
|
32
|
+
keys of their own, so the key is not a host row's id), the state as JSON
|
|
33
|
+
text, the `lock_version` Smith's versioned writes compare, and a CHECK on
|
|
34
|
+
the payload's size. The initializer sets `persistence_max_bytes` (16 MiB,
|
|
35
|
+
well above what one step can add, so it stops runaway growth only): a
|
|
36
|
+
step whose result would take a stored run past it fails with
|
|
37
|
+
`Smith::PersistedStateTooLarge` before the result is kept, as with any
|
|
38
|
+
error (its usage recorded, its `on_failure` taken), so the run stays
|
|
39
|
+
storable; any other state past it is refused before anything is written.
|
|
40
|
+
The answer that did not fit is not kept, so set the bound above the largest
|
|
41
|
+
output a step keeps, or keep large outputs in the artifact store. The CHECK
|
|
42
|
+
sits a third above the bound, room for a column the host encrypts (Rails
|
|
43
|
+
`encrypts` holds runs, but not split-step exact writes; the doctor warns).
|
|
44
|
+
Options: `--state-model=NAME` (a namespaced model gets its table name set),
|
|
45
|
+
`--max-state-bytes=N` (`0` for no bound and no CHECK), and
|
|
46
|
+
`--skip-state-model` (the initializer only).
|
|
47
|
+
|
|
27
48
|
The Ruby module namespace stays `Smith::`; only the gem name is namespaced because `smith` on RubyGems is taken. The `require: "smith"` in the Gemfile tells bundler to load the actual file name.
|
|
28
49
|
|
|
29
50
|
## Quickstart
|
|
@@ -72,7 +93,7 @@ result.steps # => [{ transition: :reply, from: :idle, to: :done, output: ...
|
|
|
72
93
|
|---|---|
|
|
73
94
|
| `Smith::Agent` | A `RubyLLM` agent plus model, instructions, output schema, tools, budget, and fallback models. Identifies itself to the workflow via `register_as :name`. |
|
|
74
95
|
| `Smith::Workflow` | A state machine of named transitions. Each transition calls an agent, runs deterministic code, routes, or composes a nested workflow. |
|
|
75
|
-
| `Smith::Context` | Declares which workflow context keys persist across restore,
|
|
96
|
+
| `Smith::Context` | Declares which workflow context keys persist across restore, how those keys become agent-visible input via `inject_state`, and which keys hold untrusted outside content (`untrusted`). |
|
|
76
97
|
| `Smith::Tool` | A `RubyLLM` tool plus provider-compatibility metadata and guardrail hooks. |
|
|
77
98
|
| Persistence adapters | Host-owned storage. Smith ships `Memory`, `RedisStore`, `CacheStore`, `RailsCache`, `ActiveRecordStore`. |
|
|
78
99
|
| Trace adapters | Host-owned observability. Smith ships `Memory`, `Logger`, `OpenTelemetry`. |
|
|
@@ -100,6 +121,7 @@ end
|
|
|
100
121
|
| Evaluator-Optimizer | `optimize generator:, evaluator:, ...` | Generate-then-critique refinement loops. |
|
|
101
122
|
| Orchestrator-Worker | `orchestrate orchestrator:, worker:, ...` | Dynamic task fan-out with delegation rounds. |
|
|
102
123
|
| Deterministic | `compute { |step| ... }` | Pure Ruby step inside the state machine. |
|
|
124
|
+
| Wait | `await :event, into:, timeout:` / `delay seconds` | Durable wait for an event the host delivers, or until a time. |
|
|
103
125
|
|
|
104
126
|
The full pattern guide with working examples for each lives in [`docs/PATTERNS.md`](docs/PATTERNS.md).
|
|
105
127
|
|
|
@@ -116,7 +138,272 @@ For bounded dynamic delegation, use the separate Orchestrator-Worker pattern.
|
|
|
116
138
|
| Evaluator-Optimizer | Executable | `optimize`, bounded by `max_rounds` plus structured evaluator output. |
|
|
117
139
|
| Deterministic repair | Not first-class yet | Can be handwritten with `compute` / `run` only when the workflow author owns the exact guard, repair, revalidation, and exit policy. Deterministic steps may declare inspectable `routes: [...]`, but that is not a native repair-loop contract. |
|
|
118
140
|
| Guarded state re-entry | Not first-class yet | `compute` / `run` can declare and route to named transitions with `routes: [...]`, but Smith does not yet own persisted entry counts, mutation policy, or safe re-entry contracts. |
|
|
119
|
-
|
|
|
141
|
+
| Wait for an event or a time | Executable | `await` and `delay`, bounded by a required timeout and timeout route. Smith records the wait and its deadline; delivering the event (`resume!`), scheduling the expiry (`expire_wait!`) and waking the run stay host-owned. Smith never polls for an event or sleeps until a deadline. |
|
|
142
|
+
|
|
143
|
+
### Isolation
|
|
144
|
+
|
|
145
|
+
Each agent step can get its own messages and write its output to the context
|
|
146
|
+
instead of a shared transcript:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class ReviewContext < Smith::Context
|
|
150
|
+
persist :source, :fields
|
|
151
|
+
untrusted :source, :fields # outside text, and what an agent drew from it
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class DocumentReview < Smith::Workflow
|
|
155
|
+
context_manager ReviewContext
|
|
156
|
+
session :isolated # no shared transcript; every agent step declares input:
|
|
157
|
+
initial_state :fetched
|
|
158
|
+
state :extracted
|
|
159
|
+
|
|
160
|
+
transition :extract, from: :fetched, to: :extracted do
|
|
161
|
+
execute :field_extractor,
|
|
162
|
+
input: ->(ctx) { [{ role: :user, content: Smith::Untrusted.wrap(ctx[:source], source: "document") }] },
|
|
163
|
+
output_to: :fields
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Context keys declared `untrusted` never reach `inject_state`, the
|
|
169
|
+
`seed_messages` builder, or an agent's `inputs`, in the workflow or any
|
|
170
|
+
workflow it nests. See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Step
|
|
171
|
+
Isolation and Untrusted Content.
|
|
172
|
+
|
|
173
|
+
A message in `input:` can carry content parts: text, and documents and images
|
|
174
|
+
stored as artifacts of the run (a child run reads its ancestors' too), with
|
|
175
|
+
`cache: true` marking a prefix Anthropic may cache; seed messages take text
|
|
176
|
+
parts:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
transition :store, from: :fetched, to: :stored do
|
|
180
|
+
compute { |step| step.write_context(:contract_ref, Smith.artifacts.store(Contracts.pdf(step.read_context(:id)))) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
transition :extract, from: :stored, to: :extracted do
|
|
184
|
+
execute :field_extractor,
|
|
185
|
+
input: lambda { |ctx|
|
|
186
|
+
[Smith::Message.user([Smith::Content.document(ctx[:contract_ref], media_type: "application/pdf"),
|
|
187
|
+
Smith::Content.text("List the parties and the term.")])]
|
|
188
|
+
},
|
|
189
|
+
output_to: :fields
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Anthropic gets native blocks; other providers get the parts in order as text,
|
|
194
|
+
with the files attached where they stand. Documents are read once, checked
|
|
195
|
+
against their media type and bounds, and only from runs allowed to read them.
|
|
196
|
+
See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Content Parts.
|
|
197
|
+
|
|
198
|
+
### Waiting
|
|
199
|
+
|
|
200
|
+
A step can wait, durably and bounded, for an event the host delivers or for a
|
|
201
|
+
time to pass:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
class DocumentReview < Smith::Workflow
|
|
205
|
+
initial_state :requested
|
|
206
|
+
|
|
207
|
+
transition :fetch_source, from: :requested, to: :fetched do
|
|
208
|
+
await :source_ready, into: :source, timeout: 6 * 3600 do |step|
|
|
209
|
+
SourceClient.request(ref: step.read_context(:source_ref), idempotency_key: step.await_token)
|
|
210
|
+
end
|
|
211
|
+
on_timeout :source_unavailable
|
|
212
|
+
end
|
|
213
|
+
transition :source_unavailable, from: :requested, to: :unavailable
|
|
214
|
+
|
|
215
|
+
transition(:cool_off, from: :fetched, to: :reviewed) { delay 600 }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
workflow.run_persisted!(key) # stops when the run waits
|
|
219
|
+
workflow.waiting? # => true
|
|
220
|
+
workflow.awaiting # => Smith::Workflow::Wait (event, token, deadline_at, ...)
|
|
221
|
+
|
|
222
|
+
DocumentReview.resume!(key, event: :source_ready, token:, payload: reply) # => :resumed, :failed, :missing or :stale
|
|
223
|
+
DocumentReview.expire_wait!(key, token:, now: Time.now.utc) # => :expired, :not_due, :missing or :stale
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The host indexes `awaiting` to route events and to schedule the expiry call.
|
|
227
|
+
See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Waiting.
|
|
228
|
+
|
|
229
|
+
### Child runs
|
|
230
|
+
|
|
231
|
+
A step can start other workflows as runs of their own, which the host launches,
|
|
232
|
+
persists and runs, and wait for their results:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
Smith.config.run_launcher = MyLauncher.new # launch(children:, parent_key:, await_token:)
|
|
236
|
+
|
|
237
|
+
class DocumentReview < Smith::Workflow
|
|
238
|
+
initial_state :split
|
|
239
|
+
state :failed
|
|
240
|
+
|
|
241
|
+
transition :review_sections, from: :split, to: :reviewed do
|
|
242
|
+
map :section_ids, workflow: SectionReview, max_items: 500, timeout: 6 * 3600,
|
|
243
|
+
input: ->(section_id, ctx) { { document_id: ctx[:document_id], section_id: } },
|
|
244
|
+
into: :section_reviews, collect: :outputs, on_item_failure: :collect
|
|
245
|
+
on_failure :fail
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
transition :summarize, from: :reviewed, to: :summarized do
|
|
249
|
+
call Summarize, input: ->(ctx) { { document_id: ctx[:document_id] } }, timeout: 3600, into: :summary
|
|
250
|
+
on_failure :fail
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
transition :notify, from: :summarized, to: :done do
|
|
254
|
+
spawn :watcher_ids, workflow: NotifyWatcher, max_items: 1_000, input: ->(id, _ctx) { { watcher_id: id } }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`map` launches a run per item (more than `max_items` fails the step before
|
|
260
|
+
anything launches; the launcher is idempotent on each child's `key`) and waits,
|
|
261
|
+
bounded by `timeout:`, for the host to resume the parent with
|
|
262
|
+
`resume!(key, event: :children, token:, payload:)`, one `Smith::ChildRun.result`
|
|
263
|
+
entry per child. `call` is a map of one whose `into:`
|
|
264
|
+
gets the child's output; `spawn` launches and continues. A child starts with
|
|
265
|
+
only what `input:` returns, and keeps any of its parent's untrusted keys passed
|
|
266
|
+
under the same name. With `on_item_failure: :fail` (the default) a failed child
|
|
267
|
+
fails the step with `Smith::ChildRunsFailed`, so the step declares `on_failure`;
|
|
268
|
+
these steps take no `retry_on`. `Smith::Launchers::Inline` runs the children in
|
|
269
|
+
process, for tests and scripts. See
|
|
270
|
+
[`docs/PERSISTENCE.md`](docs/PERSISTENCE.md), Child Runs.
|
|
271
|
+
|
|
272
|
+
### Streaming
|
|
273
|
+
|
|
274
|
+
An agent step can send its reply's text to the host as the model writes it:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
# Called when a streamed step starts; returns the step's sink, or nil to
|
|
278
|
+
# stream nothing (nobody is watching). Each step execution gets its own.
|
|
279
|
+
Smith.config.stream_sinks = {
|
|
280
|
+
answer: ->(info) { AnswerStream.new(info[:execution_key]) if Screens.watching?(info[:execution_key]) }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
class AnswerStream
|
|
284
|
+
def initialize(key)
|
|
285
|
+
@key = key
|
|
286
|
+
@reply = Smith::Streaming::JsonField.new(:reply) # the output schema's reply field, as it grows
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def delta(text) = (piece = @reply.push(text)) && Screens.append(@key, piece)
|
|
290
|
+
def reset(_reason) = @reply.reset || Screens.clear(@key)
|
|
291
|
+
def finish(output) = Screens.final(@key, output["reply"])
|
|
292
|
+
def fail(_error_class) = Screens.failed(@key)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
class Answer < Smith::Workflow
|
|
296
|
+
initial_state :asked
|
|
297
|
+
state :failed
|
|
298
|
+
|
|
299
|
+
transition :answer, from: :asked, to: :answered do
|
|
300
|
+
execute :answerer, stream: :answer, output_to: :answer
|
|
301
|
+
on_failure :fail
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
A sink defines any of `start(info)` (each provider call: the step's info with
|
|
307
|
+
the attempt, model and provider), `delta(text)` (each text chunk; tool calls,
|
|
308
|
+
thinking and `<think>` text are not sent), `reset(reason)` (Smith discarded the
|
|
309
|
+
text sent so far: `:retry`, `:fallback`, `:truncation` or `:repair`; and
|
|
310
|
+
`:retry_wait`, the last word, while a durable retry waits), `finish(output)`
|
|
311
|
+
(the output the step accepted, after guardrails, the output contract and
|
|
312
|
+
`output_to:`, once the step is stored) and `fail(error_class)`. Streamed text is
|
|
313
|
+
a draft: guardrails and output checks run on the whole reply, so nothing should
|
|
314
|
+
be saved or acted on before `finish`, and a step with an output schema streams
|
|
315
|
+
JSON text (`Smith::Streaming::JsonField` gives the text each delta adds to one
|
|
316
|
+
string field). A sink that raises is logged and the step goes on; a sink name
|
|
317
|
+
that is not registered fails the step before its call. Stop reasons are read
|
|
318
|
+
from the stream, so cut-off and refused replies are handled as without it, and a
|
|
319
|
+
stream that ends without one counts as cut off (`stream_incomplete`). Smith's
|
|
320
|
+
OpenAI Responses path does not stream: its sink gets each reply as one delta.
|
|
321
|
+
Streaming changes nothing durable (each usage entry keeps its response's stop
|
|
322
|
+
reason and message id, with `mode: :stream`), and a replayed step streams again
|
|
323
|
+
(unless a response memo kept its reply: then its sink gets the text at once).
|
|
324
|
+
Graph inspection shows the stream, and runtime readiness reports a missing sink
|
|
325
|
+
(`:stream_sink_missing`) and an agent whose calls go to the Responses API
|
|
326
|
+
(`:stream_whole_replies`).
|
|
327
|
+
|
|
328
|
+
### Batch mode
|
|
329
|
+
|
|
330
|
+
An agent step, or a fan-out, can send its requests through a provider's batch
|
|
331
|
+
API (half price, answered within 24 hours) instead of over the wire:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
# The host keeps each request until a scheduled job submits it (a table, in
|
|
335
|
+
# production); enqueue is idempotent on custom_id.
|
|
336
|
+
class HostBatchQueue
|
|
337
|
+
Entry = Struct.new(:custom_id, :await_token, :request, :run_key, :batch_id)
|
|
338
|
+
|
|
339
|
+
def initialize = @entries = {}
|
|
340
|
+
def enqueue(custom_id:, await_token:, request:, execution_key:)
|
|
341
|
+
@entries[custom_id] ||= Entry.new(custom_id, await_token, request, execution_key)
|
|
342
|
+
end
|
|
343
|
+
def unsubmitted = @entries.values.reject(&:batch_id)
|
|
344
|
+
def entry(custom_id) = @entries.fetch(custom_id)
|
|
345
|
+
end
|
|
346
|
+
Smith.config.batch_queue = HostBatchQueue.new
|
|
347
|
+
|
|
348
|
+
class Triage < Smith::Workflow
|
|
349
|
+
initial_state :queued
|
|
350
|
+
state :failed
|
|
351
|
+
|
|
352
|
+
transition :triage, from: :queued, to: :triaged do
|
|
353
|
+
execute :triager, mode: ->(ctx) { ctx[:urgent] ? :sync : :batch }, output_to: :verdict
|
|
354
|
+
on_failure :fail
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# One scheduled job submits what is queued; another collects an ended batch
|
|
359
|
+
# and resumes each run once with every result of its wait.
|
|
360
|
+
queue = Smith.config.batch_queue
|
|
361
|
+
batches = Smith::Providers::Anthropic::Batches.new
|
|
362
|
+
entries = queue.unsubmitted
|
|
363
|
+
batch_id = batches.submit(entries.map { { custom_id: _1.custom_id, request: _1.request } })
|
|
364
|
+
entries.each { _1.batch_id = batch_id }
|
|
365
|
+
if batches.status(batch_id)[:status] == "ended"
|
|
366
|
+
payloads = Hash.new { |hash, wait| hash[wait] = {} }
|
|
367
|
+
batches.results(batch_id) do |custom_id, result|
|
|
368
|
+
entry = queue.entry(custom_id)
|
|
369
|
+
payloads[[entry.run_key, entry.await_token]][custom_id] = result
|
|
370
|
+
end
|
|
371
|
+
payloads.each { |(run_key, token), payload| Triage.resume!(run_key, event: :batch_result, token:, payload:) }
|
|
372
|
+
end
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`mode:` is `:sync` (the default), `:batch`, or a callable of the context and
|
|
376
|
+
the step attempt returning one, on `execute` and `fan_out`; a batch step
|
|
377
|
+
declares `on_failure`, and cannot stream or be `parallel:`. Smith prepares
|
|
378
|
+
the call as a synchronous one, admits it against the budget, and hands the
|
|
379
|
+
exact request (the provider payload and the call's own headers, never the
|
|
380
|
+
connection's credentials) to the queue under a `custom_id` (the wait's token;
|
|
381
|
+
each fan-out branch its own) once the step's attempt has ended without error,
|
|
382
|
+
then the run waits on `:batch_result`. Smith never submits or polls, and waits
|
|
383
|
+
without sleeping: `Smith::Providers::Anthropic::Batches` gives the host
|
|
384
|
+
`submit`, `status`, `results` and `cancel`. `resume!` with
|
|
385
|
+
`{custom_id => result}` runs the step again, each call taking its result as
|
|
386
|
+
the reply it would have got. A call that renders another request by then (a
|
|
387
|
+
date in its prompt, data read afresh, a prompt changed in a deploy) does what
|
|
388
|
+
its step's `on_request_change:` says: `:keep` (the default) takes the result,
|
|
389
|
+
which answers the request as sent, and its `:provider_call` trace says
|
|
390
|
+
`request_changed: true`; `:resend` sends the request as rendered now over the
|
|
391
|
+
wire instead; `:fail` fails the step with `Smith::BatchRequestChanged` (an
|
|
392
|
+
`AgentError`, so `retry_on` can send it again, synchronously if the mode
|
|
393
|
+
callable says so). A result the call does not use is charged all the same.
|
|
394
|
+
Then the output checks, a follow-up call (over the
|
|
395
|
+
wire), guardrails and `output_to:` as ever, usage recorded with `mode: :batch`
|
|
396
|
+
and priced with the catalog's `batch_discount`, and charged even past a
|
|
397
|
+
budget, as the money is spent. A request the provider refused fails the step
|
|
398
|
+
(`Smith::BatchRequestInvalid`); a failed, canceled or expired result, or none
|
|
399
|
+
by the wait's deadline (`expire_wait!`, 25 hours), raises
|
|
400
|
+
`Smith::BatchResultFailed`, which `retry_on` sends again (synchronously, if the
|
|
401
|
+
mode callable says so at the next attempt; an in-process `retry_on` backoff
|
|
402
|
+
sleeps as on any step, while `durable: true` waits). Only Anthropic's batch interface is
|
|
403
|
+
built in, and a batch request is one turn, so a batching agent has no tools;
|
|
404
|
+
runtime readiness reports a missing queue (`:batch_queue_missing`) and an
|
|
405
|
+
agent that cannot batch (`:batch_unavailable`), and graph inspection shows the
|
|
406
|
+
mode.
|
|
120
407
|
|
|
121
408
|
## Workflow Graph Inspection
|
|
122
409
|
|
|
@@ -167,12 +454,14 @@ require "smith"
|
|
|
167
454
|
Smith.configure do |config|
|
|
168
455
|
config.logger = Logger.new($stdout)
|
|
169
456
|
config.trace_adapter = Smith::Trace::Memory.new
|
|
170
|
-
config.artifact_store =
|
|
457
|
+
config.artifact_store = :active_record # or :s3 (S3, R2), :file, :memory
|
|
458
|
+
config.artifact_options = { model: "SmithArtifact" }
|
|
171
459
|
|
|
172
460
|
# Persistence
|
|
173
461
|
config.persistence_adapter = :rails_cache
|
|
174
462
|
config.persistence_options = { namespace: "smith" }
|
|
175
463
|
config.persistence_ttl = 1.day.to_i
|
|
464
|
+
config.persistence_max_bytes = 16_777_216 # a stored state's bound (nil: none); see Installation
|
|
176
465
|
config.persistence_retry_policy = { attempts: 3, base_delay: 0.1, max_delay: 1.0 }
|
|
177
466
|
|
|
178
467
|
# Generic runtime resource bounds
|
|
@@ -184,13 +473,18 @@ Smith.configure do |config|
|
|
|
184
473
|
# OpenAI /v1/responses routing for gpt-5 + tools + thinking. :auto (default) or :off.
|
|
185
474
|
config.openai_api_mode = :auto
|
|
186
475
|
|
|
187
|
-
config.pricing =
|
|
188
|
-
|
|
189
|
-
|
|
476
|
+
config.pricing = Smith::Pricing.validate_catalog!(
|
|
477
|
+
%w[openai gpt-4.1-nano] => {
|
|
478
|
+
input_cost_per_token: 1.0e-7, output_cost_per_token: 4.0e-7,
|
|
479
|
+
cache_read_cost_per_token: 2.5e-8, # optional: prompt-cache reads (0 when absent)
|
|
480
|
+
batch_discount: 0.5 # optional: off the cost of batch-mode calls (0 when absent)
|
|
481
|
+
}
|
|
482
|
+
)
|
|
190
483
|
end
|
|
191
484
|
```
|
|
192
485
|
|
|
193
|
-
All settings are optional for a first run
|
|
486
|
+
All settings are optional for a first run, except that storing artifacts or
|
|
487
|
+
attaching documents needs `artifact_store` (outside `test_mode`). See [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) for the full reference.
|
|
194
488
|
|
|
195
489
|
## Persistence and Resume
|
|
196
490
|
|
|
@@ -363,11 +657,24 @@ cache invalidation.
|
|
|
363
657
|
|
|
364
658
|
Smith ships `Tools::WebSearch`, `Tools::UrlFetcher`, and `Tools::Think`. Tools declare provider compatibility via `compatible_with`; Smith's normalizer routes or drops them per-attempt.
|
|
365
659
|
|
|
660
|
+
`Tools::WebSearch` and `Tools::UrlFetcher` are stubs: they declare each
|
|
661
|
+
tool's parameters and description, and their `perform` raises
|
|
662
|
+
`NotImplementedError`. A host subclasses them with its own search or fetch
|
|
663
|
+
behind `perform`. `Tools::Think` works as shipped.
|
|
664
|
+
|
|
366
665
|
```ruby
|
|
666
|
+
class WebSearch < Smith::Tools::WebSearch
|
|
667
|
+
def perform(query:, max_results: 5) = SearchClient.search(query, limit: max_results)
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
class UrlFetcher < Smith::Tools::UrlFetcher
|
|
671
|
+
def perform(url:) = PageClient.fetch(url)
|
|
672
|
+
end
|
|
673
|
+
|
|
367
674
|
class SearchAgent < Smith::Agent
|
|
368
675
|
register_as :search_agent
|
|
369
676
|
model "claude-opus-4-7"
|
|
370
|
-
tools
|
|
677
|
+
tools WebSearch, UrlFetcher # the host's subclasses
|
|
371
678
|
budget tool_calls: 3
|
|
372
679
|
tool_budget_exhaustion :complete
|
|
373
680
|
end
|
|
@@ -413,13 +720,21 @@ class BudgetedWorkflow < Smith::Workflow
|
|
|
413
720
|
end
|
|
414
721
|
```
|
|
415
722
|
|
|
416
|
-
`wall_clock` is in seconds. A workflow
|
|
723
|
+
`wall_clock` is in seconds. A workflow's `wall_clock` bounds the time its steps
|
|
724
|
+
spend running, counted across restores in the persisted `active_seconds`, not
|
|
725
|
+
the time since the run was created: time spent waiting, or between steps a host
|
|
726
|
+
advances from a queue, does not count. A step that raised or whose process died
|
|
727
|
+
is charged, on the next restore, the time from its start, at most what is left
|
|
728
|
+
of the budget, so a run that keeps failing stays bounded across retries.
|
|
729
|
+
Durable composite branches each start from what is left; their running time is
|
|
730
|
+
not added back. An agent's `wall_clock` bounds each of
|
|
731
|
+
its calls. A workflow budget accepts `total_tokens`,
|
|
417
732
|
`token_limit`, `total_cost`, `tool_calls`, and `wall_clock`; an agent budget
|
|
418
733
|
accepts `token_limit`, `cost`, `wall_clock`, `tool_calls`, `total_tokens`, and
|
|
419
734
|
`total_cost`. Any other key raises `ArgumentError` naming the accepted keys. A
|
|
420
735
|
restored run drops consumed amounts for keys the workflow no longer declares.
|
|
421
736
|
|
|
422
|
-
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`.
|
|
737
|
+
Budgets reserve serially at each step and reconcile after the agent call. Parallel branches reserve scoped envelopes that release back to the parent ledger. The `Workflow::RunResult` carries `total_tokens`, `total_cost`, and per-call `usage_entries`. Token budgets and totals count prompt-cache reads and writes beside uncached input and output, and settle every billed response, including the completed rounds of a tool loop whose attempt then failed.
|
|
423
738
|
|
|
424
739
|
Direct ledger integrations settle the exact receipt returned by reservation:
|
|
425
740
|
|
|
@@ -447,6 +762,91 @@ threads with `Thread#raise` or `Thread#kill`. Arbitrary asynchronous thread
|
|
|
447
762
|
termination can make any in-process return value outcome-ambiguous; hosts must
|
|
448
763
|
use cancellation signals, bounded I/O, and process isolation for hard stops.
|
|
449
764
|
|
|
765
|
+
### Budget ledgers
|
|
766
|
+
|
|
767
|
+
A run's `budget` bounds that run. A budget shared by many runs (an account's
|
|
768
|
+
monthly spend, say) lives in the host, and a workflow names it with
|
|
769
|
+
`budget_ledger`:
|
|
770
|
+
|
|
771
|
+
```ruby
|
|
772
|
+
class MonthlyLedger
|
|
773
|
+
def initialize(account) = @account = account
|
|
774
|
+
|
|
775
|
+
# Admit the hold (true; anything else refuses it) and keep it under key
|
|
776
|
+
# until expires_at, after which it counts for nothing.
|
|
777
|
+
def reserve!(amount, key:, expires_at:) = ...
|
|
778
|
+
def reconcile!(key, amount) = ... # what the request spent, in place of its hold
|
|
779
|
+
def release!(key) = ... # it spent nothing
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
class DocumentReview < Smith::Workflow
|
|
783
|
+
budget_ledger(:account_id) { |scope| MonthlyLedger.new(scope[:account_id]) }
|
|
784
|
+
end
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
The block gets the run's scope: the values the named keys have in its trusted
|
|
788
|
+
context when the run is created (JSON scalars, kept with the run), so nothing a
|
|
789
|
+
step or a model writes later chooses another ledger, and an untrusted key is
|
|
790
|
+
refused. Smith builds the ledger at the first request a step makes, never
|
|
791
|
+
persists it, and never needs it to restore a run; nil means none. A nested
|
|
792
|
+
workflow without a `budget_ledger` of its own charges the enclosing run's; a
|
|
793
|
+
child run (`call`, `map`, `spawn`), inline or not, uses its own class's, so
|
|
794
|
+
give each child workflow its own (runtime readiness warns of one that has none,
|
|
795
|
+
`:child_run_unbudgeted`) and pass its scope keys in each child's input.
|
|
796
|
+
|
|
797
|
+
Before a call's first request over the wire (each fallback model, each
|
|
798
|
+
follow-up, streamed or not, OpenAI's Responses route included) Smith asks the
|
|
799
|
+
ledger to admit its hold. If the ledger refuses, the step fails with
|
|
800
|
+
`Smith::BudgetUnavailable` (a `BudgetExceeded`, not retried by default) before
|
|
801
|
+
anything is sent; `retry_on Smith::BudgetUnavailable, durable: true, ...` waits
|
|
802
|
+
for budget instead (the host ends the wait with `expire_wait!`, and ends it
|
|
803
|
+
early, once there is budget, with a `now:` at its `deadline_at`). After the request, Smith reconciles what it
|
|
804
|
+
spent: its usage, or its hold when it answered without usage or its stream
|
|
805
|
+
failed part way; a request that failed before anything came back releases its
|
|
806
|
+
hold. If building the ledger or `reserve!` raises, the step fails with
|
|
807
|
+
`Smith::BudgetLedgerError` (nothing was sent, no fallback model is tried); one
|
|
808
|
+
from `reconcile!` or `release!` is logged by class and the step goes on, as
|
|
809
|
+
the money is spent.
|
|
810
|
+
|
|
811
|
+
A tool loop's later rounds continue a call whose tools already ran, so
|
|
812
|
+
stopping one would leave the tools' effects without their answer. A step whose
|
|
813
|
+
`retry_on` names `BudgetUnavailable` holds them too: when the ledger refuses
|
|
814
|
+
one, the loop pauses, keeping its tool calls and their results (at most 1 MiB)
|
|
815
|
+
with the step's retry, and the retry continues the loop from them, running
|
|
816
|
+
none of its tools again. Any other step's later rounds, or a loop too large to
|
|
817
|
+
keep, go on unheld and are reconciled with what they spent, so give tool-using
|
|
818
|
+
agents a `tool_calls` budget.
|
|
819
|
+
|
|
820
|
+
A hold (`Smith::Budget::Amount`: `input_tokens`, `output_tokens`, `cost`, and
|
|
821
|
+
`tokens`, their sum) is the request's output cap (its `max_tokens`, or its
|
|
822
|
+
provider's name for it, else the model's) and an estimate of its input: text
|
|
823
|
+
at one token per 3 bytes, an image at 1,600 tokens, a PDF at 2,000 tokens a
|
|
824
|
+
page, 8 tokens a message for its framing, capped at the model's context window.
|
|
825
|
+
Images and documents count so as RubyLLM attachments and in raw content blocks
|
|
826
|
+
(base64 data or a data URL). `Smith.config.budget_hold_output_tokens`, when
|
|
827
|
+
set, caps the output part (an agent with no `max_tokens` is otherwise held for
|
|
828
|
+
the model's maximum, which can be tens of thousands of tokens, and a longer
|
|
829
|
+
reply is reconciled at what it cost). It is priced from the pricing catalog
|
|
830
|
+
under the id RubyLLM resolves the model to; `cost` is nil for an unpriced
|
|
831
|
+
model, which runtime readiness warns of (`:budget_ledger_unpriced`, for every
|
|
832
|
+
agent that charges the ledger), and its readiness metrics name the scope keys.
|
|
833
|
+
|
|
834
|
+
A key is the provider attempt's `attempt_id` (as on its `:provider_call` trace)
|
|
835
|
+
and the request's round, `"<attempt_id>:<n>"`, and for a batch request its
|
|
836
|
+
`custom_id`. A batch request is held when its step captures it, at the batch
|
|
837
|
+
discount, and settled when its result comes: a succeeded result, used or not,
|
|
838
|
+
is reconciled at what it cost; one that failed, or never came, is released.
|
|
839
|
+
A process that dies during a request never settles its hold, so every hold
|
|
840
|
+
carries `expires_at`: `Smith.config.budget_hold_ttl` from now (an hour by
|
|
841
|
+
default; it must outlast the longest request), and for a batch request its
|
|
842
|
+
25-hour wait longer. Smith runs no timer; the ledger leaves holds past
|
|
843
|
+
`expires_at` out of what it admits, and still records a reconcile that comes
|
|
844
|
+
later (a batch delivered late). A key may come again after a crash (a batch
|
|
845
|
+
request captured again, a delivery that ran twice): a reserve of it replaces
|
|
846
|
+
its hold, a release ends it, and its reconcile is final, recorded once.
|
|
847
|
+
Fan-out branches call the ledger from their own threads. RubyLLM's own retries
|
|
848
|
+
(`max_retries`) resend a request under its one hold.
|
|
849
|
+
|
|
450
850
|
## Doctor
|
|
451
851
|
|
|
452
852
|
After adding Smith, verify the integration:
|
|
@@ -466,6 +866,73 @@ bin/rails generate smith:install
|
|
|
466
866
|
```
|
|
467
867
|
|
|
468
868
|
Doctor verifies: Smith loads, RubyLLM loads, minimal workflow boots, configuration is non-empty, serialization round-trips, persistence adapter works, and (with `--live`) a real provider call succeeds.
|
|
869
|
+
An `:active_record` persistence adapter's model is checked on every run: its
|
|
870
|
+
`lock_version` (Rails' locking column), a text payload, and a string key the
|
|
871
|
+
database keeps unique, at least 64 characters wide, read from schema
|
|
872
|
+
metadata (skipped when the database cannot be reached; an encrypted payload
|
|
873
|
+
is a warning). The durability probe writes as runs write (`store_versioned`
|
|
874
|
+
where the adapter has it, and `persistence_max_bytes`), so it meets what a
|
|
875
|
+
run would.
|
|
876
|
+
|
|
877
|
+
## Testing
|
|
878
|
+
|
|
879
|
+
`require "smith/testing"` runs a host's workflows in its tests without a
|
|
880
|
+
provider. A stubbed agent's requests are answered where they would go over
|
|
881
|
+
the wire, so everything above runs as for a provider's reply: the tool loop,
|
|
882
|
+
budget holds, the response memo, streaming, output checks and repairs, usage
|
|
883
|
+
entries and budgets.
|
|
884
|
+
|
|
885
|
+
```ruby
|
|
886
|
+
# test/test_helper.rb
|
|
887
|
+
require "smith/testing/minitest" # or "smith/testing/rspec" for matchers
|
|
888
|
+
|
|
889
|
+
class NoticeTest < ActiveSupport::TestCase
|
|
890
|
+
include Smith::Testing::Assertions
|
|
891
|
+
|
|
892
|
+
test "drafts with the venue's hours, then waits for approval" do
|
|
893
|
+
Smith::Testing.stub_agent(Writer, outputs: [Smith::Testing.tool_call(Lookup, venue: "the hall"),
|
|
894
|
+
"The hall opens at 8."],
|
|
895
|
+
usage: { input_tokens: 30, output_tokens: 6 })
|
|
896
|
+
run = Notice.new(context: {})
|
|
897
|
+
|
|
898
|
+
Smith::Testing.run_until_waiting(run)
|
|
899
|
+
assert_waiting_for run, :approval
|
|
900
|
+
Smith::Testing.resume_with(run, payload: { approved: true })
|
|
901
|
+
assert_transitioned_through run, :draft, :approve, :publish
|
|
902
|
+
assert_usage_recorded run, agent_name: Writer, stop_reason: "end_turn", input_tokens: 30
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
- `stub_agent(agent, outputs:, usage:, stop_reason:, stream:)` answers the
|
|
908
|
+
agent's requests (its class, or its registered name) with `outputs` in
|
|
909
|
+
turn, the last answering every request after it: a String reply, a Hash as
|
|
910
|
+
JSON text (for an output schema), or `tool_call(ToolClass, **arguments)`
|
|
911
|
+
(an Array of them for one round), which runs the agent's own tools before
|
|
912
|
+
its next request. A tool request naming a tool the agent does not have
|
|
913
|
+
fails the call. `stop_reason: "max_tokens"` tests a truncated reply;
|
|
914
|
+
`stream: n` sends a streamed step's reply in pieces of n characters. The
|
|
915
|
+
stub keeps the messages each request sent (`calls`). Only the agent's own
|
|
916
|
+
requests are answered, never a tool's own provider call. The chat is still
|
|
917
|
+
built, so configure the provider (any key). An agent with no stub goes over
|
|
918
|
+
the wire; `Smith::Testing.unstubbed = :refuse` fails its call with
|
|
919
|
+
`Smith::Testing::UnstubbedCall` instead, so a mistyped stub never reaches a
|
|
920
|
+
paid provider.
|
|
921
|
+
- `run_until_waiting(run)` runs until the run waits or ends and returns its
|
|
922
|
+
wait (a durable retry's backoff is ended at once unless `retries: false`);
|
|
923
|
+
`resume_with(run, payload:)` delivers the wait's event and runs on;
|
|
924
|
+
`expire(run)` ends the wait as if its deadline had passed and runs on.
|
|
925
|
+
- A batch step's request is captured as ever (the batch queue gets it);
|
|
926
|
+
`resume_with(run, payload: Smith::Testing.batch_payload(run, Smith::Testing.batch_result("text")))`
|
|
927
|
+
answers it, and a fan-out's `batch_payload` takes results by branch key.
|
|
928
|
+
- Minitest: `assert_waiting_for(run, :event)` (or `kind: :batch`),
|
|
929
|
+
`assert_transitioned_through(run, *transitions)` (in order; the steps the
|
|
930
|
+
instance completed, not a failed or timed-out one),
|
|
931
|
+
`assert_usage_recorded(run, count:, **usage_entry_attributes)`. RSpec:
|
|
932
|
+
`be_waiting_for`, `have_transitioned_through`, `have_recorded_usage`.
|
|
933
|
+
- Stubs belong to the process; both helpers drop them after each test. An
|
|
934
|
+
in-process `retry_on` backoff still sleeps: stub `sleep` on the workflow,
|
|
935
|
+
or declare the retry `durable: true`.
|
|
469
936
|
|
|
470
937
|
## Capability-aware request shaping
|
|
471
938
|
|
|
@@ -496,11 +963,69 @@ fallback provider from the primary model or from a model id because the same id
|
|
|
496
963
|
may be registered by more than one provider. Unqualified fallback entries fail
|
|
497
964
|
closed during agent configuration.
|
|
498
965
|
|
|
966
|
+
## Agent outputs
|
|
967
|
+
|
|
968
|
+
Smith reads how each response ended from the provider's stop reason. A
|
|
969
|
+
response cut off at its output limit, a refused one, and a structured output
|
|
970
|
+
that is not what the agent declares are not accepted as the step's output:
|
|
971
|
+
|
|
972
|
+
```ruby
|
|
973
|
+
class Reviewer < Smith::Agent
|
|
974
|
+
model "claude-opus-5-5", provider: :anthropic
|
|
975
|
+
output_schema ReviewSchema # its root is an object
|
|
976
|
+
output_contract ReviewContract # call(hash) => true, false, or a result with success? and errors
|
|
977
|
+
output_repairs 1 # the default; 0 turns repair off
|
|
978
|
+
on_truncation retry_with: { max_tokens: 64_000 } # or :fail (the default), or :accept
|
|
979
|
+
on_refusal fallback: "openai/gpt-5" # or :fail (the default)
|
|
980
|
+
end
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
- A cut-off response raises `Smith::OutputTruncated`, unless the agent
|
|
984
|
+
accepts it or retries it once on the same model with a larger output limit,
|
|
985
|
+
mapped to the provider's own parameter (Anthropic, OpenAI, Gemini, Vertex AI
|
|
986
|
+
and Bedrock; another provider is not retried). There is no retry when the
|
|
987
|
+
response ran out of context rather than output, or when the new limit is no
|
|
988
|
+
larger than the request had (the agent's own parameter, or for Anthropic the
|
|
989
|
+
model's maximum, which RubyLLM already sends); a later repair keeps the
|
|
990
|
+
larger limit.
|
|
991
|
+
- A refused response (a Gemini prompt block included) raises
|
|
992
|
+
`Smith::OutputRefused`, unless the agent falls back once to another model.
|
|
993
|
+
- Every other response then goes through the agent's `after_completion`. A
|
|
994
|
+
structured output (an `output_schema`, or a static RubyLLM `schema`) that is
|
|
995
|
+
not a JSON object, breaks a schema constraint, or fails the output contract
|
|
996
|
+
goes back to the same model up to `output_repairs` times: the step's messages
|
|
997
|
+
as the provider got them, the rejected reply as the assistant's, and the
|
|
998
|
+
problems as "path: problem" lines. It then raises `Smith::OutputInvalid`,
|
|
999
|
+
whose `problems` lists at most 20 of them. A blank output still raises
|
|
1000
|
+
`Smith::BlankAgentOutputError`.
|
|
1001
|
+
- No follow-up call is made once a tool has run in the step (the tools would
|
|
1002
|
+
run again; the step fails with `Smith::ToolOutcomeUncertain` around the output
|
|
1003
|
+
error) or once the call has spent what its step reserved from a budget. A
|
|
1004
|
+
follow-up that fails raises the output error it was making up for, with the
|
|
1005
|
+
provider's error as its cause.
|
|
1006
|
+
- Anthropic's structured outputs reject numeric, string-length and most
|
|
1007
|
+
array-size constraints: Smith sends Anthropic the schema without them, with
|
|
1008
|
+
`additionalProperties: false` on every object, and checks them on the parsed
|
|
1009
|
+
output for every provider. An object that allows other properties or
|
|
1010
|
+
declares none cannot be sent to Anthropic; the step fails before the call
|
|
1011
|
+
with a `Smith::WorkflowError`, and runtime readiness reports
|
|
1012
|
+
`:output_schema_unsupported` for an agent declared for Anthropic.
|
|
1013
|
+
- An agent with no model and no fallback models fails its step with a
|
|
1014
|
+
`Smith::WorkflowError`; runtime readiness reports it (`:agent_without_model`).
|
|
1015
|
+
|
|
1016
|
+
Every response is billed and settles into budgets, whatever happens to its
|
|
1017
|
+
output, and each rejected one leaves an `:output_rejected` trace naming its
|
|
1018
|
+
attempt. The output errors share the failure family `output_failure`, persist
|
|
1019
|
+
with a failed step, and are never retried by `Smith::Errors.retryable?`, a job
|
|
1020
|
+
retry on `Smith::Errors.retryable_classes`, or a transition's
|
|
1021
|
+
`retry_on Smith::AgentError`: Smith's own retry, fallback and repairs have run.
|
|
1022
|
+
|
|
499
1023
|
## Errors and retry
|
|
500
1024
|
|
|
501
1025
|
```ruby
|
|
502
1026
|
Smith::Errors.retryable?(error)
|
|
503
1027
|
# AgentError, DeadlineExceeded => true (always)
|
|
1028
|
+
# OutputTruncated, OutputRefused, OutputInvalid (OutputFailure) => false
|
|
504
1029
|
# DeterministicStepFailure, ToolGuardrailFailed => honors error.retryable
|
|
505
1030
|
# everything else => false
|
|
506
1031
|
|
|
@@ -511,6 +1036,11 @@ Smith::Errors.retry_forbidden?(error)
|
|
|
511
1036
|
# => true for terminal tool-evidence failures that must never be replayed
|
|
512
1037
|
```
|
|
513
1038
|
|
|
1039
|
+
A nested workflow's step fails with the child's own error when the child
|
|
1040
|
+
raises it, so these answers carry through nesting. When a tool ran inside the
|
|
1041
|
+
child, the parent step's failure becomes `ToolOutcomeUncertain`, which is never
|
|
1042
|
+
retried.
|
|
1043
|
+
|
|
514
1044
|
Workflow transitions can also declare a bounded local retry policy:
|
|
515
1045
|
|
|
516
1046
|
```ruby
|
|
@@ -520,9 +1050,86 @@ transition :draft, from: :idle, to: :done do
|
|
|
520
1050
|
end
|
|
521
1051
|
```
|
|
522
1052
|
|
|
523
|
-
When no classes are passed, `retry_on` uses `Smith::Errors.retryable?`.
|
|
524
|
-
|
|
525
|
-
|
|
1053
|
+
When no classes are passed, `retry_on` uses `Smith::Errors.retryable?`. No
|
|
1054
|
+
attempt starts once the workflow's wall-clock deadline has passed.
|
|
1055
|
+
This is a bounded local transition retry policy. What a receiver does with an
|
|
1056
|
+
idempotency key remains host-owned.
|
|
1057
|
+
|
|
1058
|
+
For backoffs too long to sleep through in a worker, `durable: true` makes each
|
|
1059
|
+
one a wait of the run instead:
|
|
1060
|
+
|
|
1061
|
+
```ruby
|
|
1062
|
+
transition :charge, from: :approved, to: :charged do
|
|
1063
|
+
compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
|
|
1064
|
+
retry_on Payments::Timeout, attempts: 5, backoff: 60, max_delay: 3600, durable: true
|
|
1065
|
+
on_failure :charge_failed
|
|
1066
|
+
end
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
A failed attempt the policy retries stores the run waiting (`awaiting.kind ==
|
|
1070
|
+
:retry`, due when the backoff ends); the host schedules `expire_wait!` for that
|
|
1071
|
+
time and then continues the run, which makes the next attempt under the step's
|
|
1072
|
+
keys (new ones only after an attempt declared settled). See
|
|
1073
|
+
`docs/PERSISTENCE.md`, Durable Retry.
|
|
1074
|
+
|
|
1075
|
+
Steps and tools get idempotency keys for the requests they send outside the
|
|
1076
|
+
system, the same on every attempt and after a restore:
|
|
1077
|
+
|
|
1078
|
+
```ruby
|
|
1079
|
+
transition :charge, from: :approved, to: :charged do
|
|
1080
|
+
compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
|
|
1081
|
+
retry_on Payments::Timeout, Payments::Declined, attempts: 3,
|
|
1082
|
+
new_idempotency_key_on: [Payments::Declined]
|
|
1083
|
+
end
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
A retry keeps the keys, so a receiver that honours them drops a request that
|
|
1087
|
+
took effect before a timeout. An error named in `new_idempotency_key_on:` (or
|
|
1088
|
+
`step.fail!(..., new_idempotency_key: true)`) shows the attempt's own requests
|
|
1089
|
+
took no effect, and the attempts after it use new keys. See
|
|
1090
|
+
`docs/PERSISTENCE.md`, Replay Safety.
|
|
1091
|
+
|
|
1092
|
+
### Response memo
|
|
1093
|
+
|
|
1094
|
+
A step replayed after its process died makes its provider calls again, and
|
|
1095
|
+
pays for them again. A host's response memo keeps what they got:
|
|
1096
|
+
|
|
1097
|
+
```ruby
|
|
1098
|
+
class ResponseMemo
|
|
1099
|
+
def fetch(key) = Rails.cache.read("smith-memo/#{key}")
|
|
1100
|
+
def store(key, record) = Rails.cache.write("smith-memo/#{key}", record, expires_in: 2.days)
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
Smith.config.response_memo = ResponseMemo.new
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
Each agent call (its tool loop included) is kept under a key of its position
|
|
1107
|
+
(the run, the step, any branch or nested position, the key generation), the
|
|
1108
|
+
attempts it runs under (its step's, and each enclosing run's for a nested
|
|
1109
|
+
workflow, so a retry never takes the attempt before's answer), its attempt
|
|
1110
|
+
index (its model in the fallback chain, or a follow-up's place), its place
|
|
1111
|
+
among the attempt's calls, and a digest of its request (model, messages,
|
|
1112
|
+
tools, schema, params). A replay reaches the same key; a call whose request
|
|
1113
|
+
changed since (a deployed prompt, another model, a date in the prompt) misses
|
|
1114
|
+
and is sent again, as a miss always may be.
|
|
1115
|
+
|
|
1116
|
+
Its record (`{"version" => 1, "messages" => [...], "complete" => true | false}`,
|
|
1117
|
+
JSON) holds the messages the call added: the model's replies (structured ones
|
|
1118
|
+
too) with their usage, stop reason and message id (a stream's included), its
|
|
1119
|
+
tool calls and their results. It is stored after each round's tool results
|
|
1120
|
+
and when the call ends; a complete record ends with the model's reply. A
|
|
1121
|
+
replayed call that ended sends nothing: its reply is the step's (a streamed
|
|
1122
|
+
step's sink gets the reply's text at once, not the earlier rounds'), its usage
|
|
1123
|
+
is recorded as if it had just come, once, and its `:provider_call` trace says
|
|
1124
|
+
`memo: true`; a reply that was cut off or refused fails the step as it did.
|
|
1125
|
+
One that had not ended continues from its tools' results, its live part going
|
|
1126
|
+
on from the kept rounds (on a budget ledger, a later round). Kept tool results
|
|
1127
|
+
count as tools the step ran, so no retry or fallback runs them again. A call
|
|
1128
|
+
whose messages cannot be kept (an attachment, past 1 MiB) is not stored, and
|
|
1129
|
+
is logged; a record Smith does not keep is ignored, and an error from `fetch`
|
|
1130
|
+
or `store` is logged by class: a memo never fails a step. Keeping records and
|
|
1131
|
+
dropping them (a TTL) are the host's; batch requests are not memoized, as
|
|
1132
|
+
their `custom_id` already identifies them.
|
|
526
1133
|
|
|
527
1134
|
## Runtime Complexity
|
|
528
1135
|
|
|
@@ -576,5 +1183,5 @@ bundle exec rspec
|
|
|
576
1183
|
bundle exec rubocop
|
|
577
1184
|
```
|
|
578
1185
|
|
|
579
|
-
|
|
1186
|
+
2,420 examples, MIT licensed. See [`CHANGELOG.md`](CHANGELOG.md) for the current
|
|
580
1187
|
release surface.
|