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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -0
- data/README.md +6 -2
- data/docs/AGENTS.md +9 -5
- data/docs/ERRORS_AND_RETRIES.md +20 -4
- data/docs/PATTERNS.md +5 -3
- data/docs/PERSISTENCE.md +14 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +86 -6
- data/lib/smith/agent/attempt_captures.rb +62 -0
- data/lib/smith/agent/lifecycle.rb +7 -3
- data/lib/smith/agent/output_integrity.rb +7 -6
- data/lib/smith/agent/output_recovery.rb +6 -4
- data/lib/smith/agent/provider_attempt.rb +3 -0
- data/lib/smith/agent/provider_completion.rb +18 -16
- data/lib/smith/agent/provider_failure_handling.rb +5 -3
- data/lib/smith/agent/provider_streaming.rb +3 -1
- data/lib/smith/errors.rb +6 -3
- data/lib/smith/response_memo.rb +2 -1
- data/lib/smith/tool/budget_enforcement.rb +1 -1
- data/lib/smith/tool/capability_builder.rb +14 -1
- data/lib/smith/tool/capability_declaration.rb +45 -0
- data/lib/smith/tool/capture_configuration.rb +5 -2
- data/lib/smith/tool/declarations.rb +73 -0
- data/lib/smith/tool/execution_tracker.rb +13 -2
- data/lib/smith/tool.rb +8 -38
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/budget_pause.rb +8 -1
- data/lib/smith/workflow/failure_record_restore.rb +7 -1
- data/lib/smith/workflow/failure_record_text.rb +20 -12
- data/lib/smith/workflow/guarded_step_execution.rb +8 -8
- data/lib/smith/workflow/nested_execution.rb +3 -3
- data/lib/smith/workflow/remembered_calls.rb +14 -2
- data/lib/smith/workflow/retry_execution.rb +24 -3
- data/lib/smith/workflow/retry_wait.rb +2 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +1 -3
- data/lib/smith/workflow/split_step_persistence/unsupported_steps.rb +29 -0
- data/lib/smith/workflow/split_step_persistence.rb +2 -0
- data/lib/smith/workflow.rb +11 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c3bc332fe08b2ff077b3039603ddc8356ac65d5034f47abcf4253c3e167f8c6
|
|
4
|
+
data.tar.gz: 9eaf0e8548b473da25838206b63e2a5639d7ee4391f7cb6e30980a5b5aa46e67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
|
54
|
-
run again; the step fails with
|
|
55
|
-
error) or once the call has
|
|
56
|
-
|
|
57
|
-
|
|
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
|
data/docs/ERRORS_AND_RETRIES.md
CHANGED
|
@@ -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
|
|
19
|
-
child, the parent step's failure becomes
|
|
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
|
|
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
|
|
336
|
-
failure of the parent step becomes
|
|
337
|
-
retried, exactly as when the tool
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
|
503
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
20
|
-
# would run again), once the call has spent what
|
|
21
|
-
# past MAX_CALLS; a follow-up that fails raises the
|
|
22
|
-
# was making up for, with the provider's error as its
|
|
23
|
-
# response is billed; one that is set aside settles with
|
|
24
|
-
# invocation's failed attempts,
|
|
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
|
|
10
|
-
# call has spent what its step reserved, or past
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
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:)
|