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.
@@ -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.
@@ -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#patterns) is the quick selection rule; this file is the depth.
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 `:failed` (`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`.
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`, README "Batch mode")
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` (README, "Budget ledgers") is never persisted,
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 (README, "Response memo"): each call
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`; see the README's Installation), with a CHECK on the
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 are never converted into receipt
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: 1_048_576,
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: 1_048_576.
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, and the request's output limit
19
- # (Providers::StreamFacts). Each response's usage carries its facts and
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)