smith-agents 0.12.1 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/README.md +197 -1081
- data/docs/AGENTS.md +238 -0
- data/docs/BUDGETS.md +134 -0
- data/docs/CONFIGURATION.md +29 -3
- data/docs/DEVELOPMENT.md +63 -0
- data/docs/ERRORS_AND_RETRIES.md +175 -0
- data/docs/INSPECTION.md +39 -0
- data/docs/PATTERNS.md +2 -2
- data/docs/PERSISTENCE.md +175 -4
- data/docs/TESTING.md +88 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +59 -1
- data/lib/smith/agent/completion.rb +5 -3
- data/lib/smith/agent/provider_completion.rb +1 -1
- data/lib/smith/agent/provider_streaming.rb +15 -9
- data/lib/smith/agent/usage_tracking.rb +1 -2
- data/lib/smith/interruptions.rb +50 -0
- data/lib/smith/launchers/inline.rb +1 -0
- data/lib/smith/providers/request_holds.rb +1 -1
- data/lib/smith/providers/stream_facts.rb +29 -11
- data/lib/smith/response_memo.rb +149 -44
- data/lib/smith/setting_constructors.rb +14 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +1 -1
- data/lib/smith/tool/execution_batch.rb +5 -0
- data/lib/smith/tool/execution_failure_handling.rb +27 -9
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +5 -1
- data/lib/smith/workflow/parallel.rb +5 -1
- data/lib/smith/workflow/remembered_calls.rb +52 -19
- data/lib/smith/workflow/retry_execution.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +1 -0
- data/lib/smith/workflow/step_context.rb +1 -0
- data/lib/smith.rb +9 -0
- metadata +8 -1
data/docs/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.
|
|
@@ -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)
|
|
@@ -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],
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
142
|
-
def
|
|
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,
|
|
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
|
|
@@ -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)
|