smith-agents 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -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)
@@ -123,7 +123,7 @@ module Smith
123
123
  observed_reference ||= observed_model_reference(chat, fallback: model_reference)
124
124
  prefix_usages = account_completed_prefix(
125
125
  agent_class, observed_reference, new_chat_messages(chat, message_count),
126
- attempt_id: facts[:attempt_id], stream_rounds: streamed_rounds(collected || [])
126
+ attempt_id: facts[:attempt_id], stream: streamed(collected || [])
127
127
  )
128
128
  attempt = failed_provider_attempt(e, observed_reference, timer, facts, prefix_usages)
129
129
  raise unless provider_failure?(e) || unsupported_content?(e)
@@ -39,12 +39,13 @@ module Smith
39
39
  # call a response memo kept part of goes on from.
40
40
  def live_completion(chat, model_reference, collected, facts, resumed)
41
41
  ledger = run_budget_ledger
42
- return wire_completion(chat, model_reference, collected) unless ledger
42
+ kept = resumed&.rounds || 0
43
+ return wire_completion(chat, model_reference, collected, kept) unless ledger
43
44
 
44
45
  start = resumed ? resumed.start : chat_message_count(chat)
45
- holding = { pausable: budget_pausable?, rounds: resumed&.rounds || 0 }
46
+ holding = { pausable: budget_pausable?, rounds: kept }
46
47
  Providers::RequestHolds.within(chat_provider(chat), ledger, facts[:attempt_id], **holding) do |holder|
47
- held_completion(holder, chat, start) { wire_completion(chat, model_reference, collected) }
48
+ held_completion(holder, chat, start) { wire_completion(chat, model_reference, collected, kept) }
48
49
  end
49
50
  end
50
51
 
@@ -82,21 +83,23 @@ module Smith
82
83
  false
83
84
  end
84
85
 
85
- def wire_completion(chat, model_reference, collected)
86
+ # `kept`: the replies a response memo kept for the call, ahead of
87
+ # those its stream answers (a failed call's facts pair after them).
88
+ def wire_completion(chat, model_reference, collected, kept = 0)
86
89
  stream = @step_stream
87
90
  return [chat.complete, nil] unless stream
88
91
 
89
92
  stream.start(attempt: @step_attempt, model: model_reference.model_id, provider: model_reference.provider&.to_s)
90
- response, rounds = streamed_completion(chat, stream, collected)
93
+ response, rounds = streamed_completion(chat, stream, collected, kept)
91
94
  [response, { rounds:, output_limit: request_output_limit(chat, model_reference) }]
92
95
  end
93
96
 
94
97
  # The chat's reply, its text sent as it comes (without <think> text),
95
98
  # and the facts of each of its requests.
96
- def streamed_completion(chat, stream, collected)
99
+ def streamed_completion(chat, stream, collected, kept)
97
100
  response = nil
98
101
  thinking = Streaming::ThinkTags.new
99
- rounds = Providers::StreamFacts.collect(chat_provider(chat)) do |collection|
102
+ rounds = Providers::StreamFacts.collect(chat_provider(chat), kept:) do |collection|
100
103
  collected << collection
101
104
  response = chat.complete { |chunk| stream.delta(thinking.strip(chunk.content, collection.rounds.length)) }
102
105
  end
@@ -138,8 +141,11 @@ module Smith
138
141
  tools.respond_to?(:empty?) && !tools.empty?
139
142
  end
140
143
 
141
- # The facts of a streamed call that failed, if it streamed.
142
- def streamed_rounds(collected) = collected.first&.facts
144
+ # What a streamed call that failed streamed, if it did.
145
+ def streamed(collected)
146
+ collection = collected.first
147
+ { rounds: collection.facts, output_limit: nil, kept: collection.kept } if collection
148
+ end
143
149
 
144
150
  def chat_provider(chat)
145
151
  llm_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
@@ -27,8 +27,7 @@ module Smith
27
27
  # billed: they are recorded and settled into the budget like a failed
28
28
  # attempt's usage. Returns the provider usages recorded, so the
29
29
  # attempt's trace carries the same totals its usage entries hold.
30
- def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil, stream_rounds: nil)
31
- stream = stream_rounds && { rounds: stream_rounds, output_limit: nil }
30
+ def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil, stream: nil)
32
31
  completion = Completion.from_messages(response: nil, messages:, stream:)
33
32
  record_billed_usages(agent_class, completion.provider_usages, :partial_attempt, model_reference, attempt_id:)
34
33
  completion.provider_usages
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ # The errors a host names in Smith.config.interrupting_errors: the host
5
+ # could not do its part (its database or queue out of reach), so the step
6
+ # did not go wrong, it was cut short. A step that meets one stops where it
7
+ # is, as if its worker had stopped. Smith takes no failure route, makes no
8
+ # retry, records no failure and notifies no tool failure handler; the
9
+ # error reaches the caller of advance!, run!, resume! or expire_wait! as it
10
+ # was raised, even when Smith or the host wrapped it in another error on
11
+ # the way (its `cause`). Smith does not count interruptions: a step that
12
+ # meets one every time is the host's to bound. A persisted run's state is as the step left it: a
13
+ # lax run takes the step again when it is next advanced, a strict run
14
+ # finds it under way.
15
+ module Interruptions
16
+ CAUSE_DEPTH = 16
17
+
18
+ module_function
19
+
20
+ # The interrupting error that `error` is, or was raised from, or nil:
21
+ # the error, or one of its first CAUSE_DEPTH - 1 causes (so a chain of
22
+ # causes that loops ends too). A provider's own failure (RubyLLM's, or
23
+ # its HTTP client's) is the provider's, whatever it was raised from: it
24
+ # interrupts only when its own class is named.
25
+ def find(error)
26
+ classes = Smith.config.interrupting_errors
27
+ return nil if classes.empty?
28
+
29
+ current = error
30
+ CAUSE_DEPTH.times do
31
+ return nil if current.nil?
32
+ return current if classes.any? { current.is_a?(_1) }
33
+ return nil if provider_failure?(current)
34
+
35
+ current = current.cause
36
+ end
37
+ nil
38
+ end
39
+
40
+ def provider_failure?(error)
41
+ error.is_a?(RubyLLM::Error) || (defined?(Faraday::Error) && error.is_a?(Faraday::Error))
42
+ end
43
+
44
+ # Raises the interrupting error `error` is or was raised from, if any.
45
+ def pass!(error)
46
+ interruption = find(error)
47
+ raise interruption, cause: interruption.cause if interruption
48
+ end
49
+ end
50
+ end
@@ -26,6 +26,7 @@ module Smith
26
26
 
27
27
  ChildRun.result(ref: child.ref, run_result: run)
28
28
  rescue StandardError => e
29
+ Interruptions.pass!(e)
29
30
  failed(child, nil, DiagnosticText.error_class_name(e))
30
31
  end
31
32
 
@@ -57,7 +57,7 @@ module Smith
57
57
  exchange = BatchExchange.exchange_for(provider)
58
58
  return capture(provider, exchange, args, kwargs, block, &request) if exchange
59
59
 
60
- ResponseMemo.round(provider, args.first) { send_held(provider, args, kwargs, block, &request) }
60
+ ResponseMemo.round(provider, args.first, kwargs) { send_held(provider, args, kwargs, block, &request) }
61
61
  end
62
62
 
63
63
  def send_held(provider, args, kwargs, block, &request)