smith-agents 0.12.6 → 0.13.0

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/README.md +11 -4
  4. data/docs/AGENTS.md +9 -5
  5. data/docs/ERRORS_AND_RETRIES.md +20 -4
  6. data/docs/PATTERNS.md +5 -3
  7. data/docs/PERSISTENCE.md +41 -9
  8. data/docs/TOOLS_AND_GUARDRAILS.md +86 -6
  9. data/lib/smith/agent/attempt_captures.rb +62 -0
  10. data/lib/smith/agent/lifecycle.rb +7 -3
  11. data/lib/smith/agent/output_integrity.rb +7 -6
  12. data/lib/smith/agent/output_recovery.rb +6 -4
  13. data/lib/smith/agent/provider_attempt.rb +3 -0
  14. data/lib/smith/agent/provider_completion.rb +18 -16
  15. data/lib/smith/agent/provider_failure_handling.rb +5 -3
  16. data/lib/smith/agent/provider_streaming.rb +3 -1
  17. data/lib/smith/errors.rb +6 -3
  18. data/lib/smith/response_memo.rb +2 -1
  19. data/lib/smith/tool/budget_enforcement.rb +1 -1
  20. data/lib/smith/tool/capability_builder.rb +14 -1
  21. data/lib/smith/tool/capability_declaration.rb +45 -0
  22. data/lib/smith/tool/capture_configuration.rb +5 -2
  23. data/lib/smith/tool/declarations.rb +73 -0
  24. data/lib/smith/tool/execution_tracker.rb +13 -2
  25. data/lib/smith/tool.rb +8 -38
  26. data/lib/smith/version.rb +1 -1
  27. data/lib/smith/workflow/budget_pause.rb +8 -1
  28. data/lib/smith/workflow/child_run_execution.rb +2 -1
  29. data/lib/smith/workflow/dsl.rb +123 -0
  30. data/lib/smith/workflow/durability.rb +4 -3
  31. data/lib/smith/workflow/graph/rename_diagnostics.rb +69 -0
  32. data/lib/smith/workflow/graph/validator.rb +1 -0
  33. data/lib/smith/workflow/graph.rb +1 -0
  34. data/lib/smith/workflow/guarded_step_execution.rb +8 -8
  35. data/lib/smith/workflow/nested_execution.rb +3 -3
  36. data/lib/smith/workflow/persistence.rb +69 -1
  37. data/lib/smith/workflow/remembered_calls.rb +14 -2
  38. data/lib/smith/workflow/retry_execution.rb +24 -3
  39. data/lib/smith/workflow/retry_wait.rb +2 -0
  40. data/lib/smith/workflow/step_context.rb +2 -1
  41. data/lib/smith/workflow/wait_execution.rb +2 -1
  42. data/lib/smith/workflow.rb +11 -0
  43. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97813bcba79ce652b05eb860da067e3c566b8ba3450344d66940c9f65d23a1dc
4
- data.tar.gz: ea20e0a30b7517aab44a43b2668cb5e59fde6b98a829056e8e73ba585338f216
3
+ metadata.gz: 22c04af0f6062eb36758bb0fedc62663d68a27dce67f016942038ffc1ee3cc07
4
+ data.tar.gz: 0243fe30beb96d8bccc37869cb6ece9a3525ecd83004de98e18d8f97f4de8b8c
5
5
  SHA512:
6
- metadata.gz: 37d47a1f6268dc96cd05ce69f4138998d1c5a9649bbb41921882b6ae93a8f5d030586c273f37231501c121dd43492e1a37034cefe88981a5f89138cbf1ccd128
7
- data.tar.gz: 40eb2c1a4ac979411860e4971b4814e0c2cdaebc39f1a827e956f7fcb2a5ca33c3f34d18aad339edfa1460a8fce07188f505aa174a2e59233d735c50bab258d3
6
+ metadata.gz: 41b4452c51e7aa3e6946c192e0f6a9eb9bd89163c90886f013dfffd891da55a53f40ce30b00eb90ecebdc5adab3f7373687c7e0ae64be3e32085d6bfc05af47d
7
+ data.tar.gz: 4007dfee19e37843c993cf9abf5ea57a6e7450141594090fb0579ae013437de6e3053de16c382a88180bb5536b84fdbe951270e0e6707e7a0c73daec9036a569
data/CHANGELOG.md CHANGED
@@ -4,6 +4,111 @@ All notable changes to Smith are documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Smith is pre-1.0 and under active development; expect occasional contract tightening between minor versions until 1.0.
6
6
 
7
+ ## [0.13.0] - 2026-11-01
8
+
9
+ ### Added
10
+
11
+ - A tool can declare `capabilities { replay :safe }`: running it again under
12
+ the same keys does what running it once did (a lookup, a search, a read,
13
+ one that calls a paid API included). Once a tool had run in a step, Smith
14
+ made no output repair, refusal fallback or truncation retry, took no
15
+ fallback model after a provider failure, and turned the step's failure
16
+ into `Smith::ToolOutcomeUncertain`, which no retry takes. Now only a tool
17
+ that is not replay-safe does that (any tool without the declaration, a
18
+ plain RubyLLM tool included). When every tool that ran in a step is
19
+ replay-safe, the step keeps its follow-ups, its fallback models and its
20
+ `retry_on`, durable retries included; each runs the tools again, under the
21
+ same idempotency keys and within the same `tool_calls` allowances. A
22
+ provider attempt set aside for a follow-up or a fallback model takes its
23
+ tools' captures out of the step's `tool_results` once the call has its
24
+ output (by identity, so a concurrent fan-out branch's stay; a call that
25
+ fails after all keeps every capture), and so does a retried step attempt
26
+ once its retry is certain, unless the retry continues a tool loop the
27
+ step paused for budget. Tool evidence reaches a parent step from a nested
28
+ workflow as it was, and a call the response memo replays counts its kept
29
+ tool results by the tools its chat declares. `replay :unsafe` states the
30
+ default; any other value raises `ArgumentError`; `ToolClass.replay_safe?`
31
+ reads it. Each class declares it for itself: a subclass of a replay-safe
32
+ tool is unsafe until it says `replay :safe` too.
33
+
34
+ ### Changed
35
+
36
+ - A `Smith::Tool` subclass inherits its parent's declarations, each read
37
+ from the nearest class that made it, so a subclass overrides one by
38
+ declaring it again. Before, a subclass inherited none of them: it ran
39
+ without its parent's `authorize` (failing open), `privilege`, trace
40
+ `sensitivity` and `capture_result`, and reached the model without its
41
+ parent's description and declared parameters (a host subclass of the
42
+ shipped `Tools::WebSearch` or `Tools::UrlFetcher` stub sent no
43
+ description and parameters guessed from its `perform`, all strings).
44
+ `description`, `with_params`, `category`, `authorize`, `before_execute`,
45
+ `compatible_with` and `capture_result` (its block and strictness
46
+ together) are the nearest declared; `param`s add up by name, up to a
47
+ class that gave a `params` schema; a `params` schema is the nearest
48
+ unless the subclass declares `param`s of its own; `capabilities` merge
49
+ key by key, except `replay`, which is never inherited: a subclass of a
50
+ replay-safe tool is replay-safe only when it declares `replay :safe`
51
+ itself, as safety to run again depends on its own `perform`. A tool none
52
+ of whose classes declares `authorize` runs as an undeclared tool always
53
+ has, and a declaration made on a parent after its subclass was created
54
+ reaches the subclass (`compatible_with` was copied when the subclass was
55
+ made). Capture was documented as opt-in per concrete class; it is now
56
+ inherited too.
57
+
58
+ ### Fixed
59
+
60
+ - A budget refusal that carries a paused tool loop
61
+ (`Smith::BudgetUnavailable#continuation`) spares a step from
62
+ `Smith::ToolOutcomeUncertain`, and is continued by its retry, only in the
63
+ run whose step paused (`paused_by`). One raised from a nested workflow, or
64
+ made by host code, reached the parent step as if the parent could
65
+ continue it, so the parent's retry started the child over and ran its
66
+ tools again, and could take the child's paused messages as its own.
67
+
68
+ ### Upgrading
69
+
70
+ - A tool subclass that declared less than its parent now runs with its
71
+ parent's declarations: its parent's `authorize`, `privilege` and
72
+ `before_execute` apply (a call they deny raises `Smith::ToolPolicyDenied`),
73
+ its strict capture fails closed without a collector, its traces follow
74
+ its parent's `sensitivity`, and its requests carry its parent's
75
+ description and parameters. A subclass meant to differ declares its own
76
+ (`authorize { ... }`, `capabilities { privilege :none }`,
77
+ `capture_result(strict: false) { ... }`). As its
78
+ requests change, the response memo misses once for a call with such a
79
+ tool (its digest covers tools' descriptions and parameters), so a step
80
+ cut by the upgrade's deploy pays for that call again and runs its tools
81
+ again.
82
+ - `Smith::EXECUTION_SEMANTICS_VERSION` stays `"7"`: no persisted state,
83
+ wait, pending retry or durable plan changes shape.
84
+
85
+ ## [0.12.7] - 2026-10-13
86
+
87
+ ### Added
88
+
89
+ - A workflow can rename a state or a transition and keep its persisted runs:
90
+ `renamed_state :old, to: :new` and `renamed_transition :old, to: :new`.
91
+ On restore, a run persisted under an old name is mapped to the new one
92
+ before anything checks it: its state, the transition it takes next, the
93
+ transition its wait, pending retry or pending batch is in, and its last
94
+ failed step's names, each kept the type it was persisted as. A run waiting
95
+ in a renamed step resumes there under its wait's own token; one in a
96
+ renamed state carries on from the new one. A renamed transition keeps its
97
+ keys: its steps' idempotency keys and wait tokens are made under the first
98
+ name in its chain (`Workflow.transition_key_name`), so a step cut short, a
99
+ retry or an early delivery meets the keys its earlier attempt used. Renames
100
+ chain (a to b, then b to c); a name renamed twice or back to a name it had,
101
+ and two names renamed to one transition, are refused where declared. A name
102
+ the class still declares is never taken for an old one, a rename to a name
103
+ the class does not know is still refused on restore
104
+ (`Smith::UnknownStateOnRestore`, `UnknownTransitionOnRestore`), and
105
+ `validate_graph` reports both (`:rename_shadowed`,
106
+ `:renamed_to_unknown_state`, `:renamed_to_unknown_transition`). Subclasses
107
+ inherit their parent's renames, and `stuck_for?` follows them. Renames apply
108
+ after `migrate_from`; no `persistence_schema_version` is needed for one.
109
+ `Workflow.current_state_name` and `current_transition_name` give what a
110
+ persisted name stands for now, renames followed.
111
+
7
112
  ## [0.12.6] - 2026-10-13
8
113
 
9
114
  ### Fixed
data/README.md CHANGED
@@ -30,7 +30,7 @@ What you get:
30
30
 
31
31
  ```ruby
32
32
  # Gemfile
33
- gem "smith-agents", "0.12.6", require: "smith"
33
+ gem "smith-agents", "0.13.0", require: "smith"
34
34
  ```
35
35
 
36
36
  In a Rails app:
@@ -137,8 +137,11 @@ Your app delivers events (`resume!`), schedules the timeout
137
137
  (`expire_wait!` once `deadline_at` passes), and runs the next step from a
138
138
  job. Smith stores runs through an adapter (`:active_record`, `:redis`,
139
139
  `:rails_cache`, `:memory` or your own) and refuses a state past
140
- `persistence_max_bytes`. Waits, child runs, durable retries, replay safety
141
- and storage: [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md).
140
+ `persistence_max_bytes`. A later version can rename a state or a step
141
+ (`renamed_state :drafted, to: :written`, `renamed_transition :approve, to:
142
+ :review`), and runs persisted under the old names carry on under the new ones,
143
+ waits included, under the keys they had. Waits, child runs, durable retries, replay safety, renames and
144
+ storage: [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md).
142
145
 
143
146
  ## Agent outputs
144
147
 
@@ -237,7 +240,11 @@ everything else. Messages can carry documents and images.
237
240
  ## Tools and guardrails
238
241
 
239
242
  Agents can use `Smith::Tool` subclasses, with a budget on how many calls a
240
- step may make. Guardrails check inputs, outputs and tool calls. Smith ships
243
+ step may make. Once a tool has run, a step is not retried and takes no
244
+ fallback, as its tools would run again; a tool that is safe to run again (a
245
+ lookup) declares `capabilities { replay :safe }`, and a step whose tools were
246
+ all safe keeps its retries, fallbacks and output repair. Guardrails check
247
+ inputs, outputs and tool calls. Smith ships
241
248
  `Tools::Think`; `Tools::WebSearch` and `Tools::UrlFetcher` are stubs that
242
249
  declare the interface, which you subclass with your own search or fetch.
243
250
  [`docs/TOOLS_AND_GUARDRAILS.md`](docs/TOOLS_AND_GUARDRAILS.md)
data/docs/AGENTS.md CHANGED
@@ -50,11 +50,15 @@ end
50
50
  problems as "path: problem" lines. It then raises `Smith::OutputInvalid`,
51
51
  whose `problems` lists at most 20 of them. A blank output still raises
52
52
  `Smith::BlankAgentOutputError`.
53
- - No follow-up call is made once a tool has run in the step (the tools would
54
- run again; the step fails with `Smith::ToolOutcomeUncertain` around the output
55
- error) or once the call has spent what its step reserved from a budget. A
56
- follow-up that fails raises the output error it was making up for, with the
57
- provider's error as its cause.
53
+ - No follow-up call is made once a tool that is not replay-safe has run in
54
+ the step (the tools would run again; the step fails with
55
+ `Smith::ToolOutcomeUncertain` around the output error) or once the call has
56
+ spent what its step reserved from a budget. After replay-safe tools only
57
+ (`capabilities { replay :safe }`, docs/TOOLS_AND_GUARDRAILS.md), the
58
+ follow-up is made and its model may call them again; once there is an
59
+ output, the set-aside reply's tool captures leave `tool_results`. A
60
+ follow-up that fails raises the output error it was making up for, with
61
+ the provider's error as its cause.
58
62
  - Anthropic's structured outputs reject numeric, string-length and most
59
63
  array-size constraints: Smith sends Anthropic the schema without them, with
60
64
  `additionalProperties: false` on every object, and checks them on the parsed
@@ -15,9 +15,24 @@ Smith::Errors.retry_forbidden?(error)
15
15
  ```
16
16
 
17
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.
18
+ raises it, so these answers carry through nesting. When a tool that is not
19
+ replay-safe ran inside the child, the parent step's failure becomes
20
+ `ToolOutcomeUncertain`, which is never retried.
21
+
22
+ ## After tools ran
23
+
24
+ A step's failure after one of its tools ran is `ToolOutcomeUncertain`, which
25
+ no retry takes, and the step takes no fallback model and makes no follow-up
26
+ call for its output: running the tools again could repeat what they did. A
27
+ tool that declares `capabilities { replay :safe }` (a lookup, a search, a
28
+ read) says running it again does no harm. When every tool that ran in the
29
+ step is replay-safe, the step keeps its output repair, its refusal fallback
30
+ and truncation retry, its fallback models after a provider failure, and its
31
+ `retry_on`, durable retries included; each runs the tools again, under the
32
+ same idempotency keys and within the same `tool_calls` allowances, and the
33
+ captures of an attempt it sets aside leave `tool_results` once the step has
34
+ its output or its retry is certain. See
35
+ [TOOLS_AND_GUARDRAILS.md](TOOLS_AND_GUARDRAILS.md), Replay-Safe Tools.
21
36
 
22
37
  Workflow transitions can also declare a bounded local retry policy:
23
38
 
@@ -185,7 +200,8 @@ had just come, once, and its `:provider_call` trace says `memo: true`; a reply
185
200
  that was cut off or refused fails the step as it did. One that had not ended
186
201
  goes on from where it was kept. Kept after its tools' results, it continues
187
202
  from them: kept tool results count as tools the step ran, so no retry or
188
- fallback runs them again, and each is charged to the agent's `tool_calls`
203
+ fallback runs them again (unless each is the result of a tool the chat
204
+ declares replay-safe), and each is charged to the agent's `tool_calls`
189
205
  allowance and the workflow's `tool_calls` budget as it was. Kept while its
190
206
  tools ran (the step was cut inside a tool), the reply that called them is not
191
207
  asked for again: its tools run again, under the same idempotency keys, and the
data/docs/PATTERNS.md CHANGED
@@ -332,9 +332,11 @@ What you get:
332
332
  - a child that routes its failure through its own `on_failure` has handled it:
333
333
  the parent step fails with a non-retryable `Smith::WorkflowError`
334
334
  (`nested workflow failed: <child message>`, bounded to 1 KiB)
335
- - tool evidence travels up: when a tool ran in any of the child's steps, a
336
- failure of the parent step becomes `Smith::ToolOutcomeUncertain` and is never
337
- retried, exactly as when the tool runs in the parent step itself
335
+ - tool evidence travels up: when a tool that is not replay-safe ran in any of
336
+ the child's steps, a failure of the parent step becomes
337
+ `Smith::ToolOutcomeUncertain` and is never retried, exactly as when the tool
338
+ runs in the parent step itself (a child whose tools were all
339
+ `replay :safe` leaves the parent's retry)
338
340
  - parent step count stays parent-scoped
339
341
  - parent and child share the outer budget ledger
340
342
  - nested best-known token/cost totals roll up into the parent result
data/docs/PERSISTENCE.md CHANGED
@@ -72,12 +72,40 @@ What Smith does for you:
72
72
  `migrate_from` and after the `definition_digest` check, so a digest
73
73
  mismatch is reported as one and a migration can map an old name: bump
74
74
  `persistence_schema_version` and declare `migrate_from(<previous version>)`,
75
- since migrations run only when the stored version is behind. Finished runs
76
- are refused too, by `restore`, `restore_or_initialize`,
77
- `restorable_billing_state?` and the class-level `run_persisted!`: to read the
78
- outcome of a finished run whose terminal state was renamed, migrate it or
79
- read the stored JSON directly. `stuck_for?` reports a stored run in an
80
- unknown state as stuck, since it can neither advance nor restore
75
+ since migrations run only when the stored version is behind. A rename needs
76
+ neither: `renamed_state :old, to: :new` and `renamed_transition :old, to:
77
+ :new` map a run persisted under the old name to the new one after
78
+ `migrate_from` (a migration sees the names as they were persisted) and
79
+ before anything checks it (its state, the transition it takes next, the
80
+ transition its wait, pending retry or pending batch is in, and its last
81
+ failed step's names, each kept the type it was persisted as), so a run
82
+ waiting in a renamed step resumes there under its wait's own token. A
83
+ renamed transition keeps its keys: its steps' idempotency keys and wait
84
+ tokens are made under the first name in its chain
85
+ (`Workflow.transition_key_name`), so a step a deploy cut short runs again, a
86
+ retry makes its next attempt, and a delivery that came before its wait was
87
+ stored meets the block's token, all under the keys they had before the
88
+ rename. Renames chain (a to b, then b to c); a name is renamed at most once,
89
+ never back to a name it had, and at most one name is renamed to a given
90
+ transition, each refused with `ArgumentError` where it is declared. A name
91
+ the class still declares is never taken for an old one, and a rename to a
92
+ name the class does not know is refused as above when a run restores
93
+ through it; `validate_graph` reports both before then (`:rename_shadowed`,
94
+ a warning; `:renamed_to_unknown_state` and `:renamed_to_unknown_transition`,
95
+ errors). A subclass inherits its parent's renames. A state's rename stays
96
+ declared while runs persisted under its old name may remain; a
97
+ transition's, for good, since dropping it moves the transition's keys to
98
+ its new name.
99
+ `Workflow.current_state_name` and `current_transition_name` give what a
100
+ persisted name stands for now, renames followed. In strict mode a payload
101
+ caught mid-step is refused before renames apply, so
102
+ `Smith::StepInProgressOnRestore` names its transition as persisted. Finished
103
+ runs are refused too, by `restore`, `restore_or_initialize`,
104
+ `restorable_billing_state?` and the class-level `run_persisted!`: a finished
105
+ run whose terminal state was renamed restores while the rename is declared;
106
+ to read one whose terminal state was removed, migrate it or read the stored
107
+ JSON directly. `stuck_for?` reports a stored run in an unknown state
108
+ (renames followed) as stuck, since it can neither advance nor restore
81
109
 
82
110
  `preserve_seed: true` is intended for workflows whose seed is durable external
83
111
  conversation and whose later accepted outputs are internal stage handoffs. Smith
@@ -860,8 +888,9 @@ end
860
888
  transition raises `Smith::SerializationError`; one whose transition the
861
889
  class no longer declares exactly (kind, event, `into:`) or that does not
862
890
  start from the run's state raises `Smith::UnknownTransitionOnRestore`. A
863
- `migrate_from` that renames such things must rewrite `awaiting` too, and
864
- `pending_retry` for a retry wait.
891
+ rename declared with `renamed_transition` (or `renamed_state`) maps the
892
+ wait's transition first; a `migrate_from` that renames such things itself
893
+ must rewrite `awaiting` too, and `pending_retry` for a retry wait.
865
894
  - A wait that does not end within the store's persistence TTL is refused when
866
895
  it is entered, before the block runs. `stuck_for?` reports a waiting run as stuck
867
896
  only once its deadline has passed by the threshold.
@@ -906,7 +935,10 @@ end
906
935
  runs under the step-in-progress mark, so the restore of a run caught in it
907
936
  raises `Smith::StepInProgressOnRestore`.
908
937
  - No retry wait is entered once the run's wall-clock deadline has passed, after
909
- a tool ran (`Smith::ToolOutcomeUncertain` is never retried), or for a backoff
938
+ a tool that is not replay-safe ran (`Smith::ToolOutcomeUncertain` is never
939
+ retried; after replay-safe tools only, the wait is entered, the run stored
940
+ without the failed attempt's tool captures, and the next attempt runs them
941
+ again), or for a backoff
910
942
  that does not end within the store's persistence TTL (the step fails with a
911
943
  `Smith::WorkflowError` whose cause is the attempt's error); runtime readiness
912
944
  reports a policy whose longest backoff, jitter included, reaches the TTL
@@ -41,6 +41,85 @@ configuration, programming, authorization-integrity, or durability failures.
41
41
  Smith applies policy and budgeting around that host boundary; it does not
42
42
  interpret a host's input schema or failure envelope.
43
43
 
44
+ ### Subclasses
45
+
46
+ A subclass inherits every declaration of its parent's but `replay`, each
47
+ read from the nearest class that made it, so a subclass overrides one by
48
+ declaring it again:
49
+
50
+ - `description`, `with_params`, `category`, `authorize`, `before_execute`,
51
+ `compatible_with` and `capture_result` (its block and strictness
52
+ together): the nearest declared wins
53
+ - `param`: parameters add up by name, so a subclass adds parameters or
54
+ replaces one of its parent's; a schema given with `params` is the
55
+ nearest one, unless the subclass declares `param`s of its own
56
+ - `capabilities`: merged key by key, so a subclass's block sets or replaces
57
+ only the keys it names and keeps its parent's `privilege` and
58
+ `sensitivity`; `replay` is never inherited (Replay-Safe Tools)
59
+
60
+ An inherited `authorize` guards the subclass as it guards its parent; a
61
+ subclass's own replaces it (call `superclass.authorize` inside it to keep
62
+ both checks), and a tool none of whose classes declares one runs as an
63
+ undeclared tool does. A subclass made before its parent declares something
64
+ still reads it.
65
+
66
+ ### Replay-Safe Tools
67
+
68
+ A tool that does the same when it runs again under the same keys (a lookup
69
+ over the host's own data, a search, a read, even one that calls a paid API)
70
+ can say so:
71
+
72
+ ```ruby
73
+ class FindPlaces < Smith::Tool
74
+ description "Find places that match a description."
75
+ param :query, type: :string, required: true
76
+
77
+ capabilities do
78
+ replay :safe
79
+ end
80
+
81
+ def perform(query:) = PlaceIndex.search(query, limit: 5)
82
+ end
83
+ ```
84
+
85
+ Smith follows the tools each step attempt runs. Once a tool that is not
86
+ replay-safe has run (any tool without the declaration, a plain RubyLLM tool
87
+ included), the step makes no follow-up call for its output (the step fails
88
+ with `Smith::ToolOutcomeUncertain` around the output error), takes no
89
+ fallback model after a provider failure (`ToolOutcomeUncertain`), and a
90
+ failure of the step becomes `ToolOutcomeUncertain`, which no retry takes:
91
+ running the step's tools again could repeat what they did. When every tool
92
+ that ran is replay-safe, the step keeps all three:
93
+
94
+ - an output repair, a refusal fallback and a truncation retry are each a new
95
+ call from the step's messages (a repair adds the rejected reply and its
96
+ problems), so the model may call the tools again
97
+ - a provider failure takes the agent's fallback models, each with a tool
98
+ loop of its own
99
+ - a failure of the step is its own error, which `retry_on` retries (with
100
+ `durable: true`, a wait of the run), and the next attempt runs its tools
101
+ again
102
+
103
+ An attempt set aside for any of these takes its tools' captures
104
+ (`capture_result`, strict or not) out of the step's `tool_results` once the
105
+ call has its output, by identity, so a concurrent branch's are left; a call
106
+ that fails after all keeps every capture. A retried attempt's go too, once
107
+ the retry is certain (a durable retry's wait entered), unless the retry
108
+ continues a tool loop the step paused for budget, which keeps its tools'
109
+ results (one a nested workflow paused counts as any failure of its tools).
110
+ Tools that run again send the same idempotency keys, and count
111
+ against the agent's `tool_calls` allowance and the workflow's `tool_calls`
112
+ budget as any call does, so a step's tool calls stay bounded. Tool evidence
113
+ from a nested workflow reaches its parent step as it was: a child whose tools
114
+ were all replay-safe leaves the parent's retry. A call the response memo
115
+ replays counts its kept tool results the same way, by the tools its chat
116
+ declares. The declaration is never inherited, as whether a tool is safe to
117
+ run again depends on its own `perform`: a subclass of a replay-safe tool is
118
+ replay-safe only when it declares `replay :safe` itself (its other
119
+ capabilities, description, parameters and capture it inherits). `replay
120
+ :unsafe` states the default, any other value raises `ArgumentError`, and
121
+ `ToolClass.replay_safe?` reads the class's own declaration.
122
+
44
123
  ### Bounded Completion
45
124
 
46
125
  The default exhaustion policy remains fail-fast:
@@ -186,9 +265,9 @@ entry by raising an exception. A collector return value of `nil` or `false` is
186
265
  accepted; delivery is rejected only when the collector raises. Best-effort
187
266
  capture logs and continues as before.
188
267
 
189
- Capture policy is declared per concrete tool class and is not inherited. This
190
- preserves the historical opt-in contract and prevents a subclass from beginning
191
- to capture or fail strictly without its own declaration.
268
+ A subclass inherits its parent's capture, strictness included, and replaces it
269
+ with a `capture_result` of its own (Subclasses), so a subclass of a strictly
270
+ capturing tool fails closed as its parent does.
192
271
 
193
272
  Strict capture validates that a collector exists before `perform`, but the
194
273
  capture block and collector run after `perform` returns. It is therefore
@@ -459,7 +538,8 @@ Smith ships `Tools::WebSearch`, `Tools::UrlFetcher`, and `Tools::Think`. Tools d
459
538
  `Tools::WebSearch` and `Tools::UrlFetcher` are stubs: they declare each
460
539
  tool's parameters and description, and their `perform` raises
461
540
  `NotImplementedError`. A host subclasses them with its own search or fetch
462
- behind `perform`. `Tools::Think` works as shipped.
541
+ behind `perform`, and its subclass keeps their parameters, description and
542
+ compatibility (Subclasses). `Tools::Think` works as shipped.
463
543
 
464
544
  ```ruby
465
545
  class WebSearch < Smith::Tools::WebSearch
@@ -499,8 +579,8 @@ Graceful exhaustion is an in-process protocol guarantee, not durable recovery
499
579
  for the model/tool loop. If the process exits after a tool begins and before the
500
580
  agent completes, the host must treat the outcome as uncertain unless it owns a
501
581
  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.
582
+ to start a fresh fallback-model chat after a tool that is not replay-safe has
583
+ begun, because the new chat would run its tools again (Replay-Safe Tools).
504
584
 
505
585
  Within a host invocation scope, `Smith::Tool.current_invocation` exposes the
506
586
  normalized tool-call ID and deterministic execution/batch ordinals. The ID is
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ # The tool results one provider attempt captures, kept apart so an
6
+ # attempt the call sets aside (for a fallback model or a follow-up,
7
+ # possible only when its tools were replay-safe) takes them with it once
8
+ # the call has its output: the attempt that replaced it ran its tools
9
+ # again and captured anew. Each entry still reaches the step's collector
10
+ # as it comes.
11
+ module AttemptCaptures
12
+ # A collector that passes each entry on and remembers it.
13
+ class Scope
14
+ def initialize(collector)
15
+ @collector = collector
16
+ @entries = []
17
+ @lock = Mutex.new
18
+ end
19
+
20
+ def call(entry)
21
+ accepted = @collector.call(entry)
22
+ @lock.synchronize { @entries << entry }
23
+ accepted
24
+ end
25
+
26
+ def entries = @lock.synchronize { @entries.dup.freeze }
27
+ end
28
+ private_constant :Scope
29
+
30
+ private
31
+
32
+ # The chat's completion (ProviderStreaming#complete_chat), with what
33
+ # its tools capture recorded as `facts[:captures]`, the attempt's,
34
+ # however it ends. A step without a callable collector is left as it
35
+ # is, so strict capture still fails closed there.
36
+ def captured_completion(chat, model_reference, collected, facts)
37
+ collector = Tool.current_tool_result_collector
38
+ return complete_chat(chat, model_reference, collected, facts) unless collector.respond_to?(:call)
39
+
40
+ scope = Scope.new(collector)
41
+ Thread.handle_interrupt(Object => :never) do
42
+ Tool.current_tool_result_collector = scope
43
+ begin
44
+ Thread.handle_interrupt(Object => :immediate) { complete_chat(chat, model_reference, collected, facts) }
45
+ ensure
46
+ Tool.current_tool_result_collector = collector
47
+ facts[:captures] = scope.entries
48
+ end
49
+ end
50
+ end
51
+
52
+ # The accepted output; only now do the attempts set aside on the way
53
+ # take what their tools captured with them, so an invocation that
54
+ # fails after all keeps every capture, as before.
55
+ def accepted_output(agent_class, result, set_aside)
56
+ output = finalize_output(agent_class, result)
57
+ drop_tool_results(set_aside)
58
+ output
59
+ end
60
+ end
61
+ end
62
+ end
@@ -21,18 +21,22 @@ module Smith
21
21
  end
22
22
 
23
23
  # One invocation: the provider call, then whatever OutputIntegrity
24
- # asks for until the response can be the output.
24
+ # asks for until the response can be the output. The attempts set aside
25
+ # on the way (`set_aside`) take what their tools captured with them
26
+ # once there is an output (AttemptCaptures).
25
27
  def invoke_agent(agent_class, prepared_input, output_schema: effective_output_schema(agent_class))
26
28
  check_deadline!
27
29
  plan = output_plan(agent_class, prepared_input, output_schema)
28
- attempt = complete_with_provider(agent_class, prepared_input, shape: plan.shape)
30
+ set_aside = []
31
+ attempt = complete_with_provider(agent_class, prepared_input, shape: plan.shape, set_aside:)
29
32
  loop do
30
33
  result = account_attempt(agent_class, attempt.completion, attempt.model_reference,
31
34
  attempt_id: attempt.attempt_id)
32
35
  follow_up = output_follow_up(plan, attempt, result)
33
- return finalize_output(agent_class, result) unless follow_up
36
+ return accepted_output(agent_class, result, set_aside) unless follow_up
34
37
 
35
38
  settle_rejected(result)
39
+ set_aside.concat(attempt.captures)
36
40
  attempt = follow_up.call
37
41
  end
38
42
  end
@@ -16,12 +16,13 @@ module Smith
16
16
  # output contract is sent back for repair up to `output_repairs`, then
17
17
  # raises OutputInvalid.
18
18
  #
19
- # No follow-up call is made once a tool has run in the step (the tools
20
- # would run again), once the call has spent what its step reserved, or
21
- # past MAX_CALLS; a follow-up that fails raises the output failure it
22
- # was making up for, with the provider's error as its cause. Every
23
- # response is billed; one that is set aside settles with the
24
- # invocation's failed attempts, and an :output_rejected trace names it.
19
+ # No follow-up call is made once a tool that is not replay-safe has run
20
+ # in the step (the tools would run again), once the call has spent what
21
+ # its step reserved, or past MAX_CALLS; a follow-up that fails raises the
22
+ # output failure it was making up for, with the provider's error as its
23
+ # cause. Every response is billed; one that is set aside settles with
24
+ # the invocation's failed attempts, takes what its tools captured with
25
+ # it, and an :output_rejected trace names it.
25
26
  module OutputIntegrity
26
27
  include OutputRecovery
27
28
 
@@ -6,8 +6,10 @@ module Smith
6
6
  class Agent
7
7
  # The follow-up call an output failure may get (OutputIntegrity): a
8
8
  # refusal fallback, a truncation retry, or a repair, each within the
9
- # agent's settings and never once a tool has run in the step, once the
10
- # call has spent what its step reserved, or past MAX_CALLS calls.
9
+ # agent's settings and never once a tool that is not replay-safe has run
10
+ # in the step, once the call has spent what its step reserved, or past
11
+ # MAX_CALLS calls. A follow-up is a new chat from the step's messages,
12
+ # so the model may call replay-safe tools again.
11
13
  module OutputRecovery
12
14
  MAX_CALLS = 3 + OutputPolicy::MAX_REPAIRS
13
15
  REPAIR_NOTE = "Your previous reply could not be used as the required output:\n%s\n" \
@@ -20,7 +22,7 @@ module Smith
20
22
  private
21
23
 
22
24
  def recovery(plan, attempt, result, failure)
23
- return if tools_ran? || plan.calls >= MAX_CALLS || budget_spent?(result)
25
+ return if tools_uncertain? || plan.calls >= MAX_CALLS || budget_spent?(result)
24
26
 
25
27
  case failure
26
28
  when OutputRefused then refusal_recovery(plan, attempt, failure)
@@ -29,7 +31,7 @@ module Smith
29
31
  end
30
32
  end
31
33
 
32
- def tools_ran? = Tool.current_tool_execution_tracker&.started? || false
34
+ def tools_uncertain? = Tool.current_tool_execution_tracker&.uncertain? || false
33
35
 
34
36
  def refusal_recovery(plan, attempt, failure)
35
37
  fallback = plan.agent_class.on_refusal[:model]
@@ -30,6 +30,9 @@ module Smith
30
30
  # True for a response a host's response memo kept (ResponseMemo): the
31
31
  # call sent nothing.
32
32
  attribute :memo, Types::Bool.optional.default(nil)
33
+ # The tool results the attempt's tools captured (AttemptCaptures),
34
+ # which leave the step's with the attempt if it is set aside.
35
+ attribute :captures, Types::Array.default([].freeze)
33
36
 
34
37
  def self.success(completion:, model_reference:, **facts)
35
38
  new(completion:, model_reference:, error: nil, **facts)