smith-agents 0.12.7 → 0.13.1

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +6 -2
  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 +14 -1
  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/failure_record_restore.rb +7 -1
  29. data/lib/smith/workflow/failure_record_text.rb +20 -12
  30. data/lib/smith/workflow/guarded_step_execution.rb +8 -8
  31. data/lib/smith/workflow/nested_execution.rb +3 -3
  32. data/lib/smith/workflow/remembered_calls.rb +14 -2
  33. data/lib/smith/workflow/retry_execution.rb +24 -3
  34. data/lib/smith/workflow/retry_wait.rb +2 -0
  35. data/lib/smith/workflow/split_step_persistence/preparation.rb +1 -3
  36. data/lib/smith/workflow/split_step_persistence/unsupported_steps.rb +29 -0
  37. data/lib/smith/workflow/split_step_persistence.rb +2 -0
  38. data/lib/smith/workflow.rb +11 -0
  39. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8158004e28c8bec8f58a83727cbfe9d91a7fff89dd24cc3435aefd06f87d826f
4
- data.tar.gz: 6b7088fd850554492cbe98edb93c76b2d1958024206eec9a497fd423f7d2b794
3
+ metadata.gz: 7c3bc332fe08b2ff077b3039603ddc8356ac65d5034f47abcf4253c3e167f8c6
4
+ data.tar.gz: 9eaf0e8548b473da25838206b63e2a5639d7ee4391f7cb6e30980a5b5aa46e67
5
5
  SHA512:
6
- metadata.gz: 442ebb9375a3e912f6d98c08da04e67d3aeec1afa8aa1b0f1a45f936e4e33ef9b9dd477d847b113510545a34327451566bd264b3ca2970a26762d61291c5d5f4
7
- data.tar.gz: dc3fb7433a59f12a2e947e8eeec20670f4430e5c86b654ad5e1795c4f6e75da753450905de141b409e19162c2f034f070685bf40ef83aa3a93b370abd21fcab6
6
+ metadata.gz: 1ff7f020acb2efd11f7143a074d3c65093137be5bc2646e10a668e1e5242b37255db15909f0c99aa3b6073ad73e31a2557f61fa3d2a35d2de716ba122d7deb4e
7
+ data.tar.gz: fab4fd19cd33c21b6800a8d75740bc62533aa4100a2d9ae5e26bad31ad5a1fa57b4869cce9efc9a351b41366ee5ee5a8cb24c0d21dc8369effdbe5b8fd8e703c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,132 @@ 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.1] - 2026-11-08
8
+
9
+ ### Changed
10
+
11
+ - Split-step persistence refuses a step declared `from: nil` when it is
12
+ prepared: `prepare_persisted_step!` raises `Smith::WorkflowError` naming
13
+ the step and saying to run it with `advance_persisted!`, before anything
14
+ is written, as a prepared step is checked against the state its step is
15
+ declared from. It is not supported yet. Under 0.13.0 such a step was
16
+ prepared, which stores its run as a step in progress, and then failed:
17
+ its work ran first, a parallel or fan-out step's branches included, and
18
+ then "prepared execution did not return the claimed transition", so a
19
+ retry ran the work again. Only a parallel or fan-out step whose branches
20
+ the host ran through `prepare_composite_step!` failed in that call,
21
+ before any branch ran, its run left in progress all the same. A run
22
+ stored that way still raises `Smith::StepInProgressOnRestore` in strict
23
+ mode.
24
+
25
+ ### Fixed
26
+
27
+ - A failure in a step declared `from: nil` (one that runs from whichever
28
+ state routes to it) restores. Smith records the failure with the step's
29
+ declared origin, `from: nil`, as its step result, its `StepFailed` event
30
+ and its trace have it, but restore refused a record without a `from`
31
+ state (`Smith::PersistedFailureInvalid`), so a stored run could not go on
32
+ to the step's `on_failure`, and a run that ended failed there could not
33
+ be read back. Restore now takes a nil `from`, in lax and strict modes and
34
+ through every store adapter, and a run stored by 0.13.0 restores as it
35
+ is. Any other `from`, and its `transition` and `to`, must still be text
36
+ of 1 to 256 bytes, and a record without the field is still refused.
37
+ - A run whose last failed step was renamed (`renamed_transition`, or
38
+ `renamed_state` for the state it failed from or was headed to) restores.
39
+ The names a rename leads to are US-ASCII text, as a Symbol's name is,
40
+ and restore refused any failure record name not marked UTF-8
41
+ (`Smith::PersistedFailureInvalid`, "transition is invalid"), so such a
42
+ run could not go on to the step's `on_failure`, or be read back once it
43
+ ended failed. A failure record restored from `to_state` itself, without a
44
+ JSON round trip, was refused the same way. US-ASCII text is UTF-8 as it
45
+ stands and is now taken as such; text in any other encoding, or with
46
+ bytes outside its encoding, is still refused. Runs stored by 0.12.7 and
47
+ later restore as they are.
48
+
49
+ ### Upgrading
50
+
51
+ - `Smith::EXECUTION_SEMANTICS_VERSION` stays `"7"`: no persisted state,
52
+ wait, pending retry or durable plan changes shape, and a run stored by
53
+ 0.13.0 restores as it is.
54
+
55
+ ## [0.13.0] - 2026-11-01
56
+
57
+ ### Added
58
+
59
+ - A tool can declare `capabilities { replay :safe }`: running it again under
60
+ the same keys does what running it once did (a lookup, a search, a read,
61
+ one that calls a paid API included). Once a tool had run in a step, Smith
62
+ made no output repair, refusal fallback or truncation retry, took no
63
+ fallback model after a provider failure, and turned the step's failure
64
+ into `Smith::ToolOutcomeUncertain`, which no retry takes. Now only a tool
65
+ that is not replay-safe does that (any tool without the declaration, a
66
+ plain RubyLLM tool included). When every tool that ran in a step is
67
+ replay-safe, the step keeps its follow-ups, its fallback models and its
68
+ `retry_on`, durable retries included; each runs the tools again, under the
69
+ same idempotency keys and within the same `tool_calls` allowances. A
70
+ provider attempt set aside for a follow-up or a fallback model takes its
71
+ tools' captures out of the step's `tool_results` once the call has its
72
+ output (by identity, so a concurrent fan-out branch's stay; a call that
73
+ fails after all keeps every capture), and so does a retried step attempt
74
+ once its retry is certain, unless the retry continues a tool loop the
75
+ step paused for budget. Tool evidence reaches a parent step from a nested
76
+ workflow as it was, and a call the response memo replays counts its kept
77
+ tool results by the tools its chat declares. `replay :unsafe` states the
78
+ default; any other value raises `ArgumentError`; `ToolClass.replay_safe?`
79
+ reads it. Each class declares it for itself: a subclass of a replay-safe
80
+ tool is unsafe until it says `replay :safe` too.
81
+
82
+ ### Changed
83
+
84
+ - A `Smith::Tool` subclass inherits its parent's declarations, each read
85
+ from the nearest class that made it, so a subclass overrides one by
86
+ declaring it again. Before, a subclass inherited none of them: it ran
87
+ without its parent's `authorize` (failing open), `privilege`, trace
88
+ `sensitivity` and `capture_result`, and reached the model without its
89
+ parent's description and declared parameters (a host subclass of the
90
+ shipped `Tools::WebSearch` or `Tools::UrlFetcher` stub sent no
91
+ description and parameters guessed from its `perform`, all strings).
92
+ `description`, `with_params`, `category`, `authorize`, `before_execute`,
93
+ `compatible_with` and `capture_result` (its block and strictness
94
+ together) are the nearest declared; `param`s add up by name, up to a
95
+ class that gave a `params` schema; a `params` schema is the nearest
96
+ unless the subclass declares `param`s of its own; `capabilities` merge
97
+ key by key, except `replay`, which is never inherited: a subclass of a
98
+ replay-safe tool is replay-safe only when it declares `replay :safe`
99
+ itself, as safety to run again depends on its own `perform`. A tool none
100
+ of whose classes declares `authorize` runs as an undeclared tool always
101
+ has, and a declaration made on a parent after its subclass was created
102
+ reaches the subclass (`compatible_with` was copied when the subclass was
103
+ made). Capture was documented as opt-in per concrete class; it is now
104
+ inherited too.
105
+
106
+ ### Fixed
107
+
108
+ - A budget refusal that carries a paused tool loop
109
+ (`Smith::BudgetUnavailable#continuation`) spares a step from
110
+ `Smith::ToolOutcomeUncertain`, and is continued by its retry, only in the
111
+ run whose step paused (`paused_by`). One raised from a nested workflow, or
112
+ made by host code, reached the parent step as if the parent could
113
+ continue it, so the parent's retry started the child over and ran its
114
+ tools again, and could take the child's paused messages as its own.
115
+
116
+ ### Upgrading
117
+
118
+ - A tool subclass that declared less than its parent now runs with its
119
+ parent's declarations: its parent's `authorize`, `privilege` and
120
+ `before_execute` apply (a call they deny raises `Smith::ToolPolicyDenied`),
121
+ its strict capture fails closed without a collector, its traces follow
122
+ its parent's `sensitivity`, and its requests carry its parent's
123
+ description and parameters. A subclass meant to differ declares its own
124
+ (`authorize { ... }`, `capabilities { privilege :none }`,
125
+ `capture_result(strict: false) { ... }`). As its
126
+ requests change, the response memo misses once for a call with such a
127
+ tool (its digest covers tools' descriptions and parameters), so a step
128
+ cut by the upgrade's deploy pays for that call again and runs its tools
129
+ again.
130
+ - `Smith::EXECUTION_SEMANTICS_VERSION` stays `"7"`: no persisted state,
131
+ wait, pending retry or durable plan changes shape.
132
+
7
133
  ## [0.12.7] - 2026-10-13
8
134
 
9
135
  ### Added
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.7", require: "smith"
33
+ gem "smith-agents", "0.13.1", require: "smith"
34
34
  ```
35
35
 
36
36
  In a Rails app:
@@ -240,7 +240,11 @@ everything else. Messages can carry documents and images.
240
240
  ## Tools and guardrails
241
241
 
242
242
  Agents can use `Smith::Tool` subclasses, with a budget on how many calls a
243
- 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
244
248
  `Tools::Think`; `Tools::WebSearch` and `Tools::UrlFetcher` are stubs that
245
249
  declare the interface, which you subclass with your own search or fetch.
246
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
@@ -60,6 +60,11 @@ What Smith does for you:
60
60
  not the caller's Hash
61
61
  - persists accepted session history
62
62
  - preserves chosen next transitions across persistence
63
+ - keeps a step's failure across persistence: a restored run takes the step's
64
+ `on_failure`, and `RunResult#last_error` and `#failure_detail` read as they
65
+ did in the run that failed. A step declared `from: nil`, which runs from
66
+ whichever state routes to it, keeps `from: nil` there, as its step result
67
+ and its `StepFailed` event do
63
68
  - supports JSON host round-trips through `to_state` and `.from_state`
64
69
  - refuses to restore a payload whose state, or whose pending transition, the
65
70
  workflow class does not know (a deploy renamed or removed it, or the payload
@@ -307,6 +312,11 @@ a versioned persistence adapter whose `store_versioned` method accepts `ttl:`,
307
312
  and non-expiring workflow persistence. Smith explicitly pins `ttl: nil` for
308
313
  every boundary write, so a later global configuration change cannot turn an
309
314
  accepted checkpoint into expiring state.
315
+ It does not run a step declared `from: nil` yet: `prepare_persisted_step!`
316
+ refuses one with a `Smith::WorkflowError` naming the step, before anything is
317
+ written, since a prepared step is checked against the state its step is
318
+ declared from. The run itself is unchanged: run that step with
319
+ `advance_persisted!`.
310
320
  `prepare_persisted_step!` writes the normal `step_in_progress` marker and returns
311
321
  the pending transition name without consuming it. Duplicate preparation on the
312
322
  same workflow object is rejected. The boundary is pinned to the exact
@@ -935,7 +945,10 @@ end
935
945
  runs under the step-in-progress mark, so the restore of a run caught in it
936
946
  raises `Smith::StepInProgressOnRestore`.
937
947
  - No retry wait is entered once the run's wall-clock deadline has passed, after
938
- a tool ran (`Smith::ToolOutcomeUncertain` is never retried), or for a backoff
948
+ a tool that is not replay-safe ran (`Smith::ToolOutcomeUncertain` is never
949
+ retried; after replay-safe tools only, the wait is entered, the run stored
950
+ without the failed attempt's tool captures, and the next attempt runs them
951
+ again), or for a backoff
939
952
  that does not end within the store's persistence TTL (the step fails with a
940
953
  `Smith::WorkflowError` whose cause is the attempt's error); runtime readiness
941
954
  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)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "provider_failure_handling"
4
+ require_relative "attempt_captures"
4
5
  require_relative "content_bytes"
5
6
  require_relative "content_parts"
6
7
  require_relative "invocation_preparation"
@@ -15,6 +16,7 @@ module Smith
15
16
  class Agent
16
17
  module ProviderCompletion
17
18
  include ProviderFailureHandling
19
+ include AttemptCaptures
18
20
  include InvocationPreparation
19
21
  include ContentParts
20
22
  include ProviderCallTiming
@@ -26,13 +28,14 @@ module Smith
26
28
 
27
29
  # The input's documents and images are read once (ContentBytes), and
28
30
  # its cached parts checked against every Anthropic model in the chain,
29
- # before the first attempt.
30
- def complete_with_provider(agent_class, prepared_input, shape:)
31
+ # before the first attempt. A candidate that fails over to the next
32
+ # joins `set_aside` (AttemptCaptures).
33
+ def complete_with_provider(agent_class, prepared_input, shape:, set_aside: [])
31
34
  chain = build_model_chain(agent_class).tap { check_chain_cache_breakpoints!(_1, prepared_input) }
32
- ContentBytes.with(prepared_input) { attempt_candidates(agent_class, prepared_input, chain, shape) }
35
+ ContentBytes.with(prepared_input) { attempt_candidates(agent_class, prepared_input, chain, shape, set_aside) }
33
36
  end
34
37
 
35
- def attempt_candidates(agent_class, prepared_input, chain, shape)
38
+ def attempt_candidates(agent_class, prepared_input, chain, shape, set_aside)
36
39
  candidates = ProviderCandidateSequence.new(chain)
37
40
  interrupted = nil
38
41
  candidates.each do |model_reference, index|
@@ -43,7 +46,7 @@ module Smith
43
46
 
44
47
  interrupted ||= attempt if Interruptions.find(attempt.error)
45
48
  candidates.suppress(account_failed_provider(attempt, model_reference))
46
- fail_over!(attempt, agent_class, candidates, interrupted)
49
+ fail_over!(attempt, agent_class, candidates, interrupted, set_aside)
47
50
  end
48
51
 
49
52
  # Fail closed: an empty candidate chain (agent without a model)
@@ -53,16 +56,15 @@ module Smith
53
56
  "no executable model candidate for #{agent_class}; declare a model or fallback_models"
54
57
  end
55
58
 
56
- # Returns when another candidate is left to try. A candidate that met
57
- # an interrupting error (an account out of credit) outlasts how a later
58
- # one fails: the step was cut short, not failed, and is taken again
59
- # from its first candidate once the host can do its part.
60
- def fail_over!(attempt, agent_class, candidates, interrupted)
61
- handle_provider_failure!(
62
- attempt.error, attempt.model_reference, agent_class,
63
- fallback_available: candidates.fallback_available?,
64
- attempt_id: attempt.attempt_id
65
- )
59
+ # Returns when another candidate is left to try, the failed attempt's
60
+ # captures set aside. A candidate that met an interrupting error (an
61
+ # account out of credit) outlasts how a later one fails: the step was
62
+ # cut short, not failed, and is taken again from its first candidate
63
+ # once the host can do its part.
64
+ def fail_over!(attempt, agent_class, candidates, interrupted, set_aside)
65
+ handle_provider_failure!(attempt.error, attempt.model_reference, agent_class,
66
+ fallback_available: candidates.fallback_available?, attempt_id: attempt.attempt_id)
67
+ set_aside.concat(attempt.captures)
66
68
  rescue StandardError => e
67
69
  raise if interrupted.nil? || Interruptions.find(e)
68
70
 
@@ -126,7 +128,7 @@ module Smith
126
128
  message_count = chat_message_count(chat)
127
129
  observed_reference = observed_model_reference(chat, fallback: model_reference)
128
130
  timer = ProviderCallTiming::Timer.start
129
- response, stream, mode, call_facts = complete_chat(chat, observed_reference, collected = [], facts)
131
+ response, stream, mode, call_facts = captured_completion(chat, observed_reference, collected = [], facts)
130
132
  timer.stop
131
133
  completion = Completion.from_messages(response:, messages: new_chat_messages(chat, message_count), stream:,
132
134
  mode:)