smith-agents 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +197 -1080
- data/docs/AGENTS.md +238 -0
- data/docs/BUDGETS.md +134 -0
- data/docs/CONFIGURATION.md +30 -4
- data/docs/DEVELOPMENT.md +63 -0
- data/docs/ERRORS_AND_RETRIES.md +175 -0
- data/docs/INSPECTION.md +39 -0
- data/docs/PATTERNS.md +2 -2
- data/docs/PERSISTENCE.md +175 -4
- data/docs/TESTING.md +88 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +59 -1
- data/lib/generators/smith/install/install_generator.rb +6 -2
- data/lib/smith/agent/completion.rb +5 -3
- data/lib/smith/agent/provider_completion.rb +1 -1
- data/lib/smith/agent/provider_streaming.rb +15 -9
- data/lib/smith/agent/usage_tracking.rb +1 -2
- data/lib/smith/interruptions.rb +50 -0
- data/lib/smith/launchers/inline.rb +1 -0
- data/lib/smith/providers/request_holds.rb +1 -1
- data/lib/smith/providers/stream_facts.rb +29 -11
- data/lib/smith/response_memo.rb +149 -44
- data/lib/smith/setting_constructors.rb +14 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +1 -1
- data/lib/smith/tool/execution_batch.rb +5 -0
- data/lib/smith/tool/execution_failure_handling.rb +27 -9
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +5 -1
- data/lib/smith/workflow/parallel.rb +5 -1
- data/lib/smith/workflow/remembered_calls.rb +52 -19
- data/lib/smith/workflow/retry_execution.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +1 -0
- data/lib/smith/workflow/step_context.rb +1 -0
- data/lib/smith.rb +9 -0
- metadata +8 -1
|
@@ -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.
|
data/docs/INSPECTION.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Graph Inspection and Runtime Readiness
|
|
2
|
+
|
|
3
|
+
Smith can inspect a workflow's declared graph without running agents or advancing state. This is useful for host apps that want to render, lint, or cache a workflow shape before execution.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
report = ReplyWorkflow.validate_graph
|
|
7
|
+
|
|
8
|
+
report.valid? # => true
|
|
9
|
+
report.transitions # => read-only transition snapshots
|
|
10
|
+
report.diagnostics # => errors and warnings for missing states or routes
|
|
11
|
+
report.metrics # => state, transition, reachability, and terminal-state counts
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Graph inspection is static and diagnostic-only. Runtime execution, persistence, progress projection, retries, and recovery remain host-owned concerns.
|
|
15
|
+
|
|
16
|
+
Smith also exposes a static runtime-readiness report for checks that require
|
|
17
|
+
declared runtime bindings but still do not execute the workflow:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
readiness = ReplyWorkflow.runtime_readiness
|
|
21
|
+
|
|
22
|
+
readiness.ready? # => true when no readiness/topology errors exist
|
|
23
|
+
readiness.status # => :ready, :warning, or :not_ready
|
|
24
|
+
readiness.diagnostics # => topology diagnostics + runtime binding diagnostics
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Runtime readiness checks graph topology, registered agent bindings, model
|
|
28
|
+
requirements for structured runtime roles, lazy/uninspectable bindings, invalid
|
|
29
|
+
non-agent bindings, nested workflow readiness, and fan-out branch binding counts.
|
|
30
|
+
It does not call providers, resolve lazy container blocks, run tools, enqueue
|
|
31
|
+
jobs, or verify host-owned durability.
|
|
32
|
+
|
|
33
|
+
Readiness metrics include both direct graph counts and transitive counts folded
|
|
34
|
+
in from nested workflows.
|
|
35
|
+
|
|
36
|
+
Transition snapshots include runtime contracts for complex primitives where
|
|
37
|
+
Smith owns executable semantics: heterogeneous fan-out, evaluator-optimizer, and
|
|
38
|
+
orchestrator-worker transitions expose bounded settings, output shapes, and
|
|
39
|
+
transition-level resume behavior for host renderers and compilers.
|
data/docs/PATTERNS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Patterns
|
|
2
2
|
|
|
3
|
-
Working examples for each Smith workflow pattern. The table in the [README](../README.md#
|
|
3
|
+
Working examples for each Smith workflow pattern. The steps table in the [README](../README.md#steps) is the quick selection rule; this file is the depth.
|
|
4
4
|
|
|
5
5
|
## Example 1: Single-Step Workflow
|
|
6
6
|
|
|
@@ -649,6 +649,6 @@ The yielded step object exposes a narrow, read-heavy surface:
|
|
|
649
649
|
- **No output**: Deterministic steps produce no session message output. `last_output` continues to mean the last agent output.
|
|
650
650
|
- **No budget**: No tokens or cost consumed.
|
|
651
651
|
- **Persistence**: Context writes (always to a persisted key, see above) and written outcomes survive `to_state`/`from_state`. The block itself (a Proc) lives on the class-level Transition and is never serialized.
|
|
652
|
-
- **Trace**: Emits `:deterministic_step` traces whose `outcome` is `:started`, `:success`, `:routed`, or `:
|
|
652
|
+
- **Trace**: Emits `:deterministic_step` traces whose `outcome` is `:started`, `:success`, `:routed`, `:failed`, or `:interrupted` (an interrupting error, `Smith.config.interrupting_errors`, with no error fields) (`outcome`, not `result`, which the content policy strips). A failure carries `error_class`, `error_family`, and `retryable` when known, never the error message. When a step writes an outcome, the trace includes `outcome_kind`.
|
|
653
653
|
- **Mutual exclusivity**: `compute` and `run` cannot be combined with `execute`, `route`, `workflow`, `optimize`, or `orchestrate`. A transition declares exactly one primary execution body.
|
|
654
654
|
- **No hidden scheduler**: `compute` and `run` execute synchronously inside the current runner. Use them for bounded deterministic logic, not for durable polling or wake-up loops.
|
data/docs/PERSISTENCE.md
CHANGED
|
@@ -940,7 +940,7 @@ end
|
|
|
940
940
|
|
|
941
941
|
### Batch Waits
|
|
942
942
|
|
|
943
|
-
A step whose mode is `:batch` (`execute` or `fan_out`,
|
|
943
|
+
A step whose mode is `:batch` (`execute` or `fan_out`, [AGENTS.md](AGENTS.md), Batch mode)
|
|
944
944
|
sends no request: it captures each call's request as a synchronous call would
|
|
945
945
|
post it, hands it to `Smith.config.batch_queue` and waits.
|
|
946
946
|
|
|
@@ -998,7 +998,7 @@ post it, hands it to `Smith.config.batch_queue` and waits.
|
|
|
998
998
|
|
|
999
999
|
## Budget Ledgers
|
|
1000
1000
|
|
|
1001
|
-
A workflow's `budget_ledger` (
|
|
1001
|
+
A workflow's `budget_ledger` ([BUDGETS.md](BUDGETS.md), Budget ledgers) is never persisted,
|
|
1002
1002
|
and restoring a run never builds it: Smith builds it at the run's first
|
|
1003
1003
|
request from the run's `budget_ledger_scope`, the values its scope keys had in
|
|
1004
1004
|
its trusted context when it was created, kept with the run (a run stored
|
|
@@ -1110,13 +1110,17 @@ Smith.config.run_launcher = QueueLauncher.new
|
|
|
1110
1110
|
## Replay Safety: Idempotency Keys
|
|
1111
1111
|
|
|
1112
1112
|
A replayed step's provider calls are paid for again unless
|
|
1113
|
-
`Smith.config.response_memo` keeps them (
|
|
1113
|
+
`Smith.config.response_memo` keeps them ([ERRORS_AND_RETRIES.md](ERRORS_AND_RETRIES.md), Response memo): each call
|
|
1114
1114
|
is keyed by its position's idempotency scope (so a settled failure's new key
|
|
1115
1115
|
generation reaches new records), its attempt, its model in the fallback chain
|
|
1116
1116
|
and its place among the attempt's calls. Nothing about a memo is persisted
|
|
1117
1117
|
with the run.
|
|
1118
1118
|
|
|
1119
1119
|
|
|
1120
|
+
A step that met one of the host's interrupting errors (`Smith.config.interrupting_errors`) stopped where
|
|
1121
|
+
it was, and is replayed as one whose process died ([ERRORS_AND_RETRIES.md](ERRORS_AND_RETRIES.md),
|
|
1122
|
+
Interrupting errors).
|
|
1123
|
+
|
|
1120
1124
|
A step that is replayed, by a retry or by a restore after a crash, sends its
|
|
1121
1125
|
outside requests again. Smith gives each request a key the step can pass along,
|
|
1122
1126
|
so a receiver that honours idempotency keys can drop the repeat. Passing the key
|
|
@@ -1237,7 +1241,7 @@ negative, or non-integer versions fail closed instead of being overwritten.
|
|
|
1237
1241
|
The default host model needs a unique key, a JSON/text payload, and Rails'
|
|
1238
1242
|
standard optimistic-locking column. `bin/rails generate smith:install`
|
|
1239
1243
|
writes its migration, model and initializer (`--state-model`,
|
|
1240
|
-
`--max-state-bytes
|
|
1244
|
+
`--max-state-bytes`), with a CHECK on the
|
|
1241
1245
|
payload's size a third above `persistence_max_bytes`. Smith keeps a run
|
|
1242
1246
|
within the bound: a stored run's step whose result would not fit fails with
|
|
1243
1247
|
`Smith::PersistedStateTooLarge` before the result is kept (its usage
|
|
@@ -1426,3 +1430,170 @@ Why this matters:
|
|
|
1426
1430
|
which ends with the step, so outside a run's steps `Smith.artifacts` is the
|
|
1427
1431
|
configured store itself; a child run reads its ancestors' refs too
|
|
1428
1432
|
- nested workflows inherit artifact scope correctly
|
|
1433
|
+
|
|
1434
|
+
## Adapters and Host-Coordinated Execution
|
|
1435
|
+
|
|
1436
|
+
```ruby
|
|
1437
|
+
# Persist after every advance
|
|
1438
|
+
result = ReplyWorkflow.run_persisted!(
|
|
1439
|
+
context: { user_message: "..." },
|
|
1440
|
+
adapter: Smith.persistence_adapter
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
# Resume later
|
|
1444
|
+
result = ReplyWorkflow.run_persisted!(
|
|
1445
|
+
key: "ticket:T-1042",
|
|
1446
|
+
adapter: Smith.persistence_adapter
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
# Admit host-supplied conversation input at a stable workflow boundary.
|
|
1450
|
+
workflow = ReplyWorkflow.restore("ticket:T-1042", adapter: Smith.persistence_adapter)
|
|
1451
|
+
admission = workflow.append_session_messages!(role: :user, content: "Continue")
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
`append_session_messages!` validates and owns a bounded canonical copy of the
|
|
1455
|
+
message batch, appends it under Smith's workflow lifecycle lock, and returns an
|
|
1456
|
+
immutable digest witness. The host still owns session identity, transaction
|
|
1457
|
+
coordination, persistence, idempotency, and resume policy.
|
|
1458
|
+
|
|
1459
|
+
Built-in adapters (all except `ActiveRecord` support TTL; `ActiveRecord` has no
|
|
1460
|
+
expiry column, so its writes refuse a non-nil `persistence_ttl`, while reads and
|
|
1461
|
+
deletes keep working and `smith doctor --durability` flags the combination;
|
|
1462
|
+
`Redis`, `ActiveRecord`, and `Memory` support optimistic locking via
|
|
1463
|
+
`store_versioned` and exact dispatch claims via `replace_exact`; `ActiveRecord`
|
|
1464
|
+
participates in the caller's transaction for host-record coordination;
|
|
1465
|
+
`Redis` and `Memory` also support heartbeat probes via `record_heartbeat` /
|
|
1466
|
+
`last_heartbeat`):
|
|
1467
|
+
|
|
1468
|
+
- `:memory` — in-process Hash, intended for tests and `test_mode = true`
|
|
1469
|
+
- `:redis` — Redis client; uses WATCH/MULTI/EXEC for CAS
|
|
1470
|
+
- `:rails_cache`, `:solid_cache` — Rails cache backends
|
|
1471
|
+
- `:cache_store` — any object responding to `write/read/delete`
|
|
1472
|
+
- `:active_record` — keyed ActiveRecord model with an enabled optimistic-locking
|
|
1473
|
+
column for row-level CAS; Smith's logical version remains in the JSON payload
|
|
1474
|
+
|
|
1475
|
+
The sections above cover schema versioning,
|
|
1476
|
+
seed-drift validation, the `idempotency_mode :strict` step-in-progress contract,
|
|
1477
|
+
host-coordinated message admission, and opt-in restart-safe prepared recovery.
|
|
1478
|
+
Hosts that need to commit a durable attempt before provider or tool work can
|
|
1479
|
+
separate verification from execution with
|
|
1480
|
+
`authorize_prepared_step_execution!`, commit their own attempt ledger, and then
|
|
1481
|
+
consume the exact process-local capability through
|
|
1482
|
+
`execute_authorized_prepared_step!`. Smith does not own the host lease, attempt
|
|
1483
|
+
record, scheduler, or unknown-outcome policy. Authorization discovers the exact
|
|
1484
|
+
agent bindings reachable by that transition with a bounded `O(V + E)` graph
|
|
1485
|
+
walk, then resolves the bounded binding set in one registry epoch. A concurrent
|
|
1486
|
+
registry writer therefore cannot produce a mixed authorization containing
|
|
1487
|
+
bindings from two registry states or replace a captured binding between
|
|
1488
|
+
authorization and work. Every public Dry::Container mutation path participates
|
|
1489
|
+
in that epoch. Execution authorization accepts only concrete registered
|
|
1490
|
+
`Smith::Agent` classes; lazy container callbacks are not executed while
|
|
1491
|
+
authority is captured.
|
|
1492
|
+
While the capability is active, a host may call `each_agent_binding` with a
|
|
1493
|
+
block to inspect the exact captured `(registry_name, agent_class)` pairs before
|
|
1494
|
+
external work. Enumeration is process-local, performs no dispatch, and is
|
|
1495
|
+
rejected after release or consumption; it deliberately does not return a lazy
|
|
1496
|
+
enumerator that could escape the authorization scope.
|
|
1497
|
+
Reachable nested workflow transition contracts are captured and revalidated
|
|
1498
|
+
before child execution. The typed result owns a bounded snapshot of JSON-like
|
|
1499
|
+
Ruby values; unsupported mutable output values fail closed.
|
|
1500
|
+
During authorized execution, Smith also seals its private execution path against
|
|
1501
|
+
workflow subclass method-name collisions, including nested child workflows.
|
|
1502
|
+
Ordinary non-authorized runs retain normal Ruby override behavior.
|
|
1503
|
+
|
|
1504
|
+
For `parallel: true` and `fan_out` transitions, a host can split one confirmed
|
|
1505
|
+
dispatch into independently scheduled branch operations without invoking agent
|
|
1506
|
+
classes outside Smith:
|
|
1507
|
+
|
|
1508
|
+
```ruby
|
|
1509
|
+
preparation = workflow.prepare_composite_step!
|
|
1510
|
+
|
|
1511
|
+
# Persist preparation.plan and preparation.input in host-owned records.
|
|
1512
|
+
# Each worker recovers the same confirmed dispatch and receives one descriptor.
|
|
1513
|
+
branch = preparation.plan.branches.fetch(0)
|
|
1514
|
+
execution = preparation.plan.execution_for(branch)
|
|
1515
|
+
outcome = branch_workflow.execute_prepared_composite_branch!(
|
|
1516
|
+
execution:,
|
|
1517
|
+
input: preparation.input
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
# After every branch has a terminal outcome, use another fresh recovery to
|
|
1521
|
+
# complete the Smith transition exactly once.
|
|
1522
|
+
result = reducer_workflow.reduce_prepared_composite_step!(
|
|
1523
|
+
plan: preparation.plan,
|
|
1524
|
+
input: preparation.input,
|
|
1525
|
+
outcomes: ordered_or_unordered_complete_outcome_set,
|
|
1526
|
+
primary_failure: host_committed_failed_branch_key
|
|
1527
|
+
)
|
|
1528
|
+
```
|
|
1529
|
+
|
|
1530
|
+
Plans bind the exact prepared dispatch, host-supplied complete workflow
|
|
1531
|
+
`definition_digest`, execution-semantics version, prepared input digest, budget
|
|
1532
|
+
baseline, one shared artifact execution namespace, ordered branch identities,
|
|
1533
|
+
and explicit policies. Each plan, input, outcome, or effect payload is
|
|
1534
|
+
canonical, immutable, bounded to 1 MiB after JSON encoding, rejects missing or
|
|
1535
|
+
unknown fields, and redacts exception messages and backtraces.
|
|
1536
|
+
Reduction additionally bounds cumulative effects to 1 MiB and final aggregate
|
|
1537
|
+
output to 4 MiB before consuming execution authority. Reduction accepts either
|
|
1538
|
+
an `Array` or an `Enumerator`, validates each outcome incrementally, and stops
|
|
1539
|
+
as soon as a cumulative bound is crossed. The transport hard limit is 10,000
|
|
1540
|
+
branches; every recovered worker also enforces the current
|
|
1541
|
+
`Smith.config.parallel_branch_limit` before work.
|
|
1542
|
+
|
|
1543
|
+
Planning is `O(B * D)`, where `D` is the bounded number of budget dimensions.
|
|
1544
|
+
The coordinator derives a compact selected-branch execution envelope in
|
|
1545
|
+
`O(D)` without copying the full branch collection. The scoped branch execution
|
|
1546
|
+
entry point captures and validates one selected binding in `O(D)` and constant
|
|
1547
|
+
time with respect to total branch count; the reducer validates the complete plan
|
|
1548
|
+
once. Reduction is `O(B + P log P)` in
|
|
1549
|
+
the worst case because canonical nested Hash ownership sorts keys, and uses
|
|
1550
|
+
`O(B + P)` bounded space.
|
|
1551
|
+
Smith does not persist plans or outcomes, select scheduling slots, claim work,
|
|
1552
|
+
choose an uncertain-outcome policy, or infer the primary failure. The host
|
|
1553
|
+
durably owns those decisions, gives each branch and reduction a separately
|
|
1554
|
+
recovered workflow instance, and must pass a complete outcome set. Transport or
|
|
1555
|
+
encoding faults become typed branch failures when Smith can preserve accounting
|
|
1556
|
+
evidence; otherwise the host must treat the worker result as uncertain and must
|
|
1557
|
+
not infer safe retry. Resume policy is incomplete branches only; per-branch
|
|
1558
|
+
retry is intentionally unsupported in this contract version.
|
|
1559
|
+
Composite payload digests prove canonical content integrity, not worker
|
|
1560
|
+
authenticity. Before reduction, the host must authenticate every outcome against
|
|
1561
|
+
its own claimed operation identity and current fencing token; those durable
|
|
1562
|
+
receipts remain outside Smith.
|
|
1563
|
+
|
|
1564
|
+
The supported composite API is the three scoped workflow methods above plus the
|
|
1565
|
+
immutable transport values they return or accept: `Preparation`, `Plan`,
|
|
1566
|
+
`Input`, `Branch`, `BranchExecution`, `BranchOutcome`, `BranchFailure`, `Error`,
|
|
1567
|
+
`Effects`, and `Reduction`. Other constants under `Workflow::Composite` are
|
|
1568
|
+
implementation collaborators, not extension points or compatibility promises.
|
|
1569
|
+
|
|
1570
|
+
Every agent used by this durable composite boundary must declare a stable
|
|
1571
|
+
host-computed SHA-256 execution identity. The identity represents the exact
|
|
1572
|
+
sealed executable asset the host intends to dispatch; Smith records it in the
|
|
1573
|
+
branch descriptor and rejects registry replacement after planning. Identities
|
|
1574
|
+
are deliberately not inherited because a subclass is a distinct executable
|
|
1575
|
+
implementation.
|
|
1576
|
+
|
|
1577
|
+
```ruby
|
|
1578
|
+
class Researcher < Smith::Agent
|
|
1579
|
+
execution_identity Digest::SHA256.hexdigest(generated_asset_manifest)
|
|
1580
|
+
register_as :researcher, publish: false
|
|
1581
|
+
end
|
|
1582
|
+
|
|
1583
|
+
Researcher.freeze
|
|
1584
|
+
Researcher.publish_registration!
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
Hosts that generate agent classes dynamically may call
|
|
1588
|
+
`register_as :name, publish: false` while constructing the class. This records
|
|
1589
|
+
the class identity without exposing it through `Smith::Agent::Registry`. After
|
|
1590
|
+
the host has validated and sealed the class, it may publish the exact class with
|
|
1591
|
+
`agent_class.publish_registration!`. Publication always uses the staged,
|
|
1592
|
+
immutable identity; callers cannot provide a second key. Registry collision and
|
|
1593
|
+
mutation epoch guarantees remain unchanged.
|
|
1594
|
+
|
|
1595
|
+
Hosts that persist or cache generated executable definitions should include
|
|
1596
|
+
`Smith::EXECUTION_SEMANTICS_VERSION` in their definition identity. The gem
|
|
1597
|
+
version describes the package release; the execution-semantics version changes
|
|
1598
|
+
when Smith's executable workflow contract changes and must participate in host
|
|
1599
|
+
cache invalidation.
|
data/docs/TESTING.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Testing and the Doctor
|
|
2
|
+
|
|
3
|
+
## Testing
|
|
4
|
+
|
|
5
|
+
`require "smith/testing"` runs a host's workflows in its tests without a
|
|
6
|
+
provider. A stubbed agent's requests are answered where they would go over
|
|
7
|
+
the wire, so everything above runs as for a provider's reply: the tool loop,
|
|
8
|
+
budget holds, the response memo, streaming, output checks and repairs, usage
|
|
9
|
+
entries and budgets.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# test/test_helper.rb
|
|
13
|
+
require "smith/testing/minitest" # or "smith/testing/rspec" for matchers
|
|
14
|
+
|
|
15
|
+
class NoticeTest < ActiveSupport::TestCase
|
|
16
|
+
include Smith::Testing::Assertions
|
|
17
|
+
|
|
18
|
+
test "drafts with the venue's hours, then waits for approval" do
|
|
19
|
+
Smith::Testing.stub_agent(Writer, outputs: [Smith::Testing.tool_call(Lookup, venue: "the hall"),
|
|
20
|
+
"The hall opens at 8."],
|
|
21
|
+
usage: { input_tokens: 30, output_tokens: 6 })
|
|
22
|
+
run = Notice.new(context: {})
|
|
23
|
+
|
|
24
|
+
Smith::Testing.run_until_waiting(run)
|
|
25
|
+
assert_waiting_for run, :approval
|
|
26
|
+
Smith::Testing.resume_with(run, payload: { approved: true })
|
|
27
|
+
assert_transitioned_through run, :draft, :approve, :publish
|
|
28
|
+
assert_usage_recorded run, agent_name: Writer, stop_reason: "end_turn", input_tokens: 30
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- `stub_agent(agent, outputs:, usage:, stop_reason:, stream:)` answers the
|
|
34
|
+
agent's requests (its class, or its registered name) with `outputs` in
|
|
35
|
+
turn, the last answering every request after it: a String reply, a Hash as
|
|
36
|
+
JSON text (for an output schema), or `tool_call(ToolClass, **arguments)`
|
|
37
|
+
(an Array of them for one round), which runs the agent's own tools before
|
|
38
|
+
its next request. A tool request naming a tool the agent does not have
|
|
39
|
+
fails the call. `stop_reason: "max_tokens"` tests a truncated reply;
|
|
40
|
+
`stream: n` sends a streamed step's reply in pieces of n characters. The
|
|
41
|
+
stub keeps the messages each request sent (`calls`). Only the agent's own
|
|
42
|
+
requests are answered, never a tool's own provider call. The chat is still
|
|
43
|
+
built, so configure the provider (any key). An agent with no stub goes over
|
|
44
|
+
the wire; `Smith::Testing.unstubbed = :refuse` fails its call with
|
|
45
|
+
`Smith::Testing::UnstubbedCall` instead, so a mistyped stub never reaches a
|
|
46
|
+
paid provider.
|
|
47
|
+
- `run_until_waiting(run)` runs until the run waits or ends and returns its
|
|
48
|
+
wait (a durable retry's backoff is ended at once unless `retries: false`);
|
|
49
|
+
`resume_with(run, payload:)` delivers the wait's event and runs on;
|
|
50
|
+
`expire(run)` ends the wait as if its deadline had passed and runs on.
|
|
51
|
+
- A batch step's request is captured as ever (the batch queue gets it);
|
|
52
|
+
`resume_with(run, payload: Smith::Testing.batch_payload(run, Smith::Testing.batch_result("text")))`
|
|
53
|
+
answers it, and a fan-out's `batch_payload` takes results by branch key.
|
|
54
|
+
- Minitest: `assert_waiting_for(run, :event)` (or `kind: :batch`),
|
|
55
|
+
`assert_transitioned_through(run, *transitions)` (in order; the steps the
|
|
56
|
+
instance completed, not a failed or timed-out one),
|
|
57
|
+
`assert_usage_recorded(run, count:, **usage_entry_attributes)`. RSpec:
|
|
58
|
+
`be_waiting_for`, `have_transitioned_through`, `have_recorded_usage`.
|
|
59
|
+
- Stubs belong to the process; both helpers drop them after each test. An
|
|
60
|
+
in-process `retry_on` backoff still sleeps: stub `sleep` on the workflow,
|
|
61
|
+
or declare the retry `durable: true`.
|
|
62
|
+
|
|
63
|
+
## Doctor
|
|
64
|
+
|
|
65
|
+
After adding Smith, verify the integration:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Plain Ruby
|
|
69
|
+
smith doctor # offline checks
|
|
70
|
+
smith doctor --live # live provider call
|
|
71
|
+
smith doctor --durability # persistence round-trip
|
|
72
|
+
smith install # scaffold config/smith.rb
|
|
73
|
+
|
|
74
|
+
# Rails
|
|
75
|
+
bin/rails smith:doctor
|
|
76
|
+
bin/rails smith:doctor:live
|
|
77
|
+
bin/rails smith:doctor:durability
|
|
78
|
+
bin/rails generate smith:install
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
An `:active_record` persistence adapter's model is checked on every run: its
|
|
83
|
+
`lock_version` (Rails' locking column), a text payload, and a string key the
|
|
84
|
+
database keeps unique, at least 64 characters wide, read from schema
|
|
85
|
+
metadata (skipped when the database cannot be reached; an encrypted payload
|
|
86
|
+
is a warning). The durability probe writes as runs write (`store_versioned`
|
|
87
|
+
where the adapter has it, and `persistence_max_bytes`), so it meets what a
|
|
88
|
+
run would.
|
|
@@ -387,7 +387,8 @@ receive an inspection-only argument Hash. The full admitted argument graph is
|
|
|
387
387
|
Smith-owned and frozen, so policy inspection cannot rewrite the values that
|
|
388
388
|
reach `perform`. Errors raised from inside `perform`, including
|
|
389
389
|
`Smith::DeadlineExceeded`, do not prove that external work was absent and retain
|
|
390
|
-
their original type. Process-fatal exceptions
|
|
390
|
+
their original type. Process-fatal exceptions, and errors the host names in
|
|
391
|
+
`Smith.config.interrupting_errors`, are never converted into receipt
|
|
391
392
|
outcomes or terminal callbacks: an admitted receipt remains unresolved and the
|
|
392
393
|
durable host must reconcile it before any resume decision. Smith never
|
|
393
394
|
automatically replays that uncertain work. Failure-handler errors propagate
|
|
@@ -450,3 +451,60 @@ class GuardedWorkflow < Smith::Workflow
|
|
|
450
451
|
end
|
|
451
452
|
end
|
|
452
453
|
```
|
|
454
|
+
|
|
455
|
+
## Shipped Tools and Tool Budgets
|
|
456
|
+
|
|
457
|
+
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.
|
|
458
|
+
|
|
459
|
+
`Tools::WebSearch` and `Tools::UrlFetcher` are stubs: they declare each
|
|
460
|
+
tool's parameters and description, and their `perform` raises
|
|
461
|
+
`NotImplementedError`. A host subclasses them with its own search or fetch
|
|
462
|
+
behind `perform`. `Tools::Think` works as shipped.
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
class WebSearch < Smith::Tools::WebSearch
|
|
466
|
+
def perform(query:, max_results: 5) = SearchClient.search(query, limit: max_results)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
class UrlFetcher < Smith::Tools::UrlFetcher
|
|
470
|
+
def perform(url:) = PageClient.fetch(url)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
class SearchAgent < Smith::Agent
|
|
474
|
+
register_as :search_agent
|
|
475
|
+
model "claude-opus-4-7"
|
|
476
|
+
tools WebSearch, UrlFetcher # the host's subclasses
|
|
477
|
+
budget tool_calls: 3
|
|
478
|
+
tool_budget_exhaustion :complete
|
|
479
|
+
end
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Agent tool-call budgets default to raising `Smith::BudgetExceeded` when a tool
|
|
483
|
+
tries to execute beyond the configured limit. Agents that declare
|
|
484
|
+
`tool_budget_exhaustion :complete` must also declare a finite `tool_calls`
|
|
485
|
+
budget. Smith then consumes each model-requested call before dispatch, executes
|
|
486
|
+
only a complete batch admitted against both agent and workflow limits, and
|
|
487
|
+
performs one tool-disabled completion when the allowance is exhausted or the
|
|
488
|
+
provider returns an oversized batch. The bounded provider loop is iterative,
|
|
489
|
+
not recursive. Calls to unavailable tools consume allowance as well, so a
|
|
490
|
+
provider cannot create an unbounded correction loop. Workflow reservations are
|
|
491
|
+
reconciled to actual Smith tool execution.
|
|
492
|
+
|
|
493
|
+
Hosts may install `Smith::Tool::CallBudget` with exact per-tool limits through
|
|
494
|
+
`Smith::Tool.with_call_budget`. Smith propagates that same root allowance into
|
|
495
|
+
parallel and heterogeneous fan-out worker threads; agent budgets narrow the
|
|
496
|
+
root and all branches share its atomic counters.
|
|
497
|
+
|
|
498
|
+
Graceful exhaustion is an in-process protocol guarantee, not durable recovery
|
|
499
|
+
for the model/tool loop. If the process exits after a tool begins and before the
|
|
500
|
+
agent completes, the host must treat the outcome as uncertain unless it owns a
|
|
501
|
+
durable per-invocation receipt and reconciliation contract. Smith also refuses
|
|
502
|
+
to start a fresh fallback-model chat after tool execution has begun because that
|
|
503
|
+
would discard the captured evidence and conversation protocol.
|
|
504
|
+
|
|
505
|
+
Within a host invocation scope, `Smith::Tool.current_invocation` exposes the
|
|
506
|
+
normalized tool-call ID and deterministic execution/batch ordinals. The ID is
|
|
507
|
+
RubyLLM correlation metadata and must not be treated as a provider idempotency
|
|
508
|
+
key. Durable hosts can pass an explicitly seeded
|
|
509
|
+
`Smith::Tool::InvocationSequence` to `with_invocation_context`; Smith itself does
|
|
510
|
+
not persist or reconcile operation receipts.
|
|
@@ -13,13 +13,17 @@ module Smith
|
|
|
13
13
|
# The most a PostgreSQL text column holds (1 GiB less a byte), the
|
|
14
14
|
# payload's limit when no bound is set.
|
|
15
15
|
UNBOUNDED_PAYLOAD = 1_073_741_823
|
|
16
|
+
# The default bound: well above what one step can add to a state (a
|
|
17
|
+
# map's collected results, 4 MiB; a wait's payload or a paused tool
|
|
18
|
+
# loop, 1 MiB each), so it refuses runaway growth, not a run Smith built.
|
|
19
|
+
MAX_STATE_BYTES = 16 * 1024 * 1024
|
|
16
20
|
|
|
17
21
|
source_root File.expand_path("templates", __dir__)
|
|
18
22
|
desc "Writes Smith's initializer, and a table and model for workflow state"
|
|
19
23
|
|
|
20
24
|
class_option :state_model, type: :string, default: "WorkflowState",
|
|
21
25
|
desc: "The model that keeps workflow state"
|
|
22
|
-
class_option :max_state_bytes, type: :numeric, default:
|
|
26
|
+
class_option :max_state_bytes, type: :numeric, default: MAX_STATE_BYTES,
|
|
23
27
|
desc: "persistence_max_bytes, and the payload CHECK above it (0: neither)"
|
|
24
28
|
class_option :skip_state_model, type: :boolean, default: false,
|
|
25
29
|
desc: "Write only the initializer"
|
|
@@ -77,7 +81,7 @@ module Smith
|
|
|
77
81
|
# ignores it, as SQLite does.
|
|
78
82
|
def payload_limit = [check_bytes || UNBOUNDED_PAYLOAD, UNBOUNDED_PAYLOAD].min
|
|
79
83
|
|
|
80
|
-
# An Integer as a Ruby literal:
|
|
84
|
+
# An Integer as a Ruby literal: 16_777_216.
|
|
81
85
|
def literal(number) = number.to_s.gsub(/\B(?=(\d{3})+(?!\d))/, "_")
|
|
82
86
|
end
|
|
83
87
|
end
|
|
@@ -15,8 +15,9 @@ module Smith
|
|
|
15
15
|
attribute :response_facts, Types::Hash.default({}.freeze)
|
|
16
16
|
|
|
17
17
|
# `stream` is set for a streamed call: the facts of each of its
|
|
18
|
-
# requests, read from the stream,
|
|
19
|
-
#
|
|
18
|
+
# requests, read from the stream, the request's output limit, and, for
|
|
19
|
+
# a call that failed, how many of its replies a response memo kept
|
|
20
|
+
# ahead of them (Providers::StreamFacts). Each response's usage carries its facts and
|
|
20
21
|
# `mode: :stream`. `mode` marks another call's usage (:batch).
|
|
21
22
|
def self.from_messages(response:, messages:, stream: nil, mode: nil)
|
|
22
23
|
assistant_messages = Array(messages).select do |message|
|
|
@@ -56,7 +57,8 @@ module Smith
|
|
|
56
57
|
def self.response_facts(messages, stream)
|
|
57
58
|
return messages.map { Providers::ResponseFacts.from_message(_1) } unless stream
|
|
58
59
|
|
|
59
|
-
Providers::StreamFacts.per_message(messages, stream.fetch(:rounds), stream.fetch(:output_limit)
|
|
60
|
+
Providers::StreamFacts.per_message(messages, stream.fetch(:rounds), stream.fetch(:output_limit),
|
|
61
|
+
kept: stream.fetch(:kept, 0))
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
def self.build_provider_usages(messages, streamed_facts, mode)
|