smith-agents 0.12.1 → 0.12.3
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 +90 -0
- data/README.md +197 -1081
- data/docs/AGENTS.md +241 -0
- data/docs/BUDGETS.md +134 -0
- data/docs/CONFIGURATION.md +29 -3
- data/docs/DEVELOPMENT.md +63 -0
- data/docs/ERRORS_AND_RETRIES.md +177 -0
- data/docs/INSPECTION.md +39 -0
- data/docs/PATTERNS.md +2 -2
- data/docs/PERSISTENCE.md +175 -4
- data/docs/TESTING.md +88 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +59 -1
- data/lib/smith/agent/completion.rb +5 -3
- data/lib/smith/agent/provider_completion.rb +1 -1
- data/lib/smith/agent/provider_streaming.rb +15 -9
- data/lib/smith/agent/usage_tracking.rb +1 -2
- data/lib/smith/batch.rb +11 -9
- data/lib/smith/cache_markers.rb +86 -0
- data/lib/smith/interruptions.rb +50 -0
- data/lib/smith/launchers/inline.rb +1 -0
- data/lib/smith/providers/request_holds.rb +1 -1
- data/lib/smith/providers/stream_facts.rb +29 -11
- data/lib/smith/response_memo.rb +150 -45
- data/lib/smith/setting_constructors.rb +14 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +1 -1
- data/lib/smith/tool/execution_batch.rb +5 -0
- data/lib/smith/tool/execution_failure_handling.rb +27 -9
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +5 -1
- data/lib/smith/workflow/parallel.rb +5 -1
- data/lib/smith/workflow/persistence.rb +4 -1
- data/lib/smith/workflow/remembered_calls.rb +52 -19
- data/lib/smith/workflow/retry_execution.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +1 -0
- data/lib/smith/workflow/step_context.rb +1 -0
- data/lib/smith/workflow.rb +4 -2
- data/lib/smith.rb +10 -0
- metadata +9 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
# The errors a host names in Smith.config.interrupting_errors: the host
|
|
5
|
+
# could not do its part (its database or queue out of reach), so the step
|
|
6
|
+
# did not go wrong, it was cut short. A step that meets one stops where it
|
|
7
|
+
# is, as if its worker had stopped. Smith takes no failure route, makes no
|
|
8
|
+
# retry, records no failure and notifies no tool failure handler; the
|
|
9
|
+
# error reaches the caller of advance!, run!, resume! or expire_wait! as it
|
|
10
|
+
# was raised, even when Smith or the host wrapped it in another error on
|
|
11
|
+
# the way (its `cause`). Smith does not count interruptions: a step that
|
|
12
|
+
# meets one every time is the host's to bound. A persisted run's state is as the step left it: a
|
|
13
|
+
# lax run takes the step again when it is next advanced, a strict run
|
|
14
|
+
# finds it under way.
|
|
15
|
+
module Interruptions
|
|
16
|
+
CAUSE_DEPTH = 16
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The interrupting error that `error` is, or was raised from, or nil:
|
|
21
|
+
# the error, or one of its first CAUSE_DEPTH - 1 causes (so a chain of
|
|
22
|
+
# causes that loops ends too). A provider's own failure (RubyLLM's, or
|
|
23
|
+
# its HTTP client's) is the provider's, whatever it was raised from: it
|
|
24
|
+
# interrupts only when its own class is named.
|
|
25
|
+
def find(error)
|
|
26
|
+
classes = Smith.config.interrupting_errors
|
|
27
|
+
return nil if classes.empty?
|
|
28
|
+
|
|
29
|
+
current = error
|
|
30
|
+
CAUSE_DEPTH.times do
|
|
31
|
+
return nil if current.nil?
|
|
32
|
+
return current if classes.any? { current.is_a?(_1) }
|
|
33
|
+
return nil if provider_failure?(current)
|
|
34
|
+
|
|
35
|
+
current = current.cause
|
|
36
|
+
end
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def provider_failure?(error)
|
|
41
|
+
error.is_a?(RubyLLM::Error) || (defined?(Faraday::Error) && error.is_a?(Faraday::Error))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Raises the interrupting error `error` is or was raised from, if any.
|
|
45
|
+
def pass!(error)
|
|
46
|
+
interruption = find(error)
|
|
47
|
+
raise interruption, cause: interruption.cause if interruption
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -57,7 +57,7 @@ module Smith
|
|
|
57
57
|
exchange = BatchExchange.exchange_for(provider)
|
|
58
58
|
return capture(provider, exchange, args, kwargs, block, &request) if exchange
|
|
59
59
|
|
|
60
|
-
ResponseMemo.round(provider, args.first) { send_held(provider, args, kwargs, block, &request) }
|
|
60
|
+
ResponseMemo.round(provider, args.first, kwargs) { send_held(provider, args, kwargs, block, &request) }
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def send_held(provider, args, kwargs, block, &request)
|
|
@@ -32,13 +32,15 @@ module Smith
|
|
|
32
32
|
MARK = :@smith_stream_facts_observer
|
|
33
33
|
private_constant :HELD, :MARK
|
|
34
34
|
|
|
35
|
-
# The streams of one call: its provider (nil reads any)
|
|
36
|
-
# of
|
|
35
|
+
# The streams of one call: its provider (nil reads any), the facts of
|
|
36
|
+
# each of its requests, and how many of its replies came kept, before
|
|
37
|
+
# them, from a response memo (no request of its own answered those).
|
|
37
38
|
class Collection
|
|
38
|
-
attr_reader :rounds
|
|
39
|
+
attr_reader :rounds, :kept
|
|
39
40
|
|
|
40
|
-
def initialize(provider)
|
|
41
|
+
def initialize(provider, kept = 0)
|
|
41
42
|
@provider = provider
|
|
43
|
+
@kept = kept
|
|
42
44
|
@rounds = []
|
|
43
45
|
end
|
|
44
46
|
|
|
@@ -64,20 +66,36 @@ module Smith
|
|
|
64
66
|
|
|
65
67
|
# Runs the block, given the collection, collecting the facts of the
|
|
66
68
|
# streams `provider` reads; returns them, one bounded Hash per round.
|
|
67
|
-
def collect(provider)
|
|
69
|
+
def collect(provider, kept: 0)
|
|
68
70
|
outer = Fiber[HELD]
|
|
69
|
-
collection = Fiber[HELD] = Collection.new(provider)
|
|
71
|
+
collection = Fiber[HELD] = Collection.new(provider, kept)
|
|
70
72
|
yield collection
|
|
71
73
|
collection.facts
|
|
72
74
|
ensure
|
|
73
75
|
Fiber[HELD] = outer
|
|
74
76
|
end
|
|
75
77
|
|
|
76
|
-
# The facts of
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
# The facts of the request `provider` last streamed in the collecting
|
|
79
|
+
# call, bounded; empty when it streamed none.
|
|
80
|
+
def last_round(provider)
|
|
81
|
+
collection = Fiber[HELD]
|
|
82
|
+
return {} unless collection&.owns?(provider) && !collection.rounds.empty?
|
|
83
|
+
|
|
84
|
+
collection.facts.last
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The facts of each response of a streamed call, in order: the first
|
|
88
|
+
# `kept` (a response memo's) their own; each live one its round's over
|
|
89
|
+
# its raw body's, the last settled. Rounds and live responses pair up
|
|
90
|
+
# in order; a round past the last response (a request that failed) has
|
|
91
|
+
# none.
|
|
92
|
+
def per_message(messages, rounds, output_limit, kept: 0)
|
|
93
|
+
live = messages.drop(kept)
|
|
94
|
+
own = messages.first(kept).map { ResponseFacts.from_message(_1) }
|
|
95
|
+
live.empty? ? own : [*own, *live_facts(live, rounds, output_limit)]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def live_facts(messages, rounds, output_limit)
|
|
81
99
|
missing = messages.length - rounds.length
|
|
82
100
|
aligned = missing.positive? ? [*Array.new(missing, {}), *rounds] : rounds.first(messages.length)
|
|
83
101
|
facts = messages.zip(aligned).map { |message, round| ResponseFacts.from_message(message).merge(round) }
|
data/lib/smith/response_memo.rb
CHANGED
|
@@ -14,29 +14,127 @@ module Smith
|
|
|
14
14
|
# position, the key generation), the attempts it runs under (the step's,
|
|
15
15
|
# and each enclosing run's for a nested workflow), its attempt index (its
|
|
16
16
|
# model in the fallback chain, or a follow-up's place), its place among
|
|
17
|
-
# the attempt's calls, and a digest of its request (model, messages,
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# prompt
|
|
17
|
+
# the attempt's calls, and a digest of its request (model, messages, tools
|
|
18
|
+
# with their descriptions and parameters, schema, params). A replay
|
|
19
|
+
# reaches the same key; a call whose request changed since (a deployed
|
|
20
|
+
# prompt or tool, another model, a date in the prompt) misses and is
|
|
21
|
+
# sent, as a miss always may be.
|
|
21
22
|
#
|
|
22
|
-
# Its record, `{"version" => 1, "messages" => [...], "complete" =>
|
|
23
|
-
# holds the messages the call added (Budget::Continuation, with
|
|
24
|
-
# response's usage and facts)
|
|
25
|
-
#
|
|
26
|
-
# a
|
|
27
|
-
# tools' results
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
23
|
+
# Its record, `{"version" => 1 | 2, "messages" => [...], "complete" =>
|
|
24
|
+
# bool}`, holds the messages the call added (Budget::Continuation, with
|
|
25
|
+
# each response's usage and facts). It is stored when a reply calling the
|
|
26
|
+
# tools its request offered comes (before they run; version 2, the only
|
|
27
|
+
# shape a Smith before 0.12.2 cannot read), before each later request (the
|
|
28
|
+
# tools' results included), and when the call ends in the model's reply (a
|
|
29
|
+
# call a tool halted keeps only its progress). Replayed, a complete call
|
|
30
|
+
# sends nothing; an unfinished one goes on from where it was kept: a reply
|
|
31
|
+
# whose tools had not run answers the call's first request without
|
|
32
|
+
# sending it, and its tools run again, under the same keys; kept tool
|
|
33
|
+
# results count as tools the step ran (charged to its tool-call
|
|
34
|
+
# allowances, and never run again by a retry or fallback). A call whose
|
|
35
|
+
# messages cannot be kept (an attachment, or too many bytes) is not
|
|
36
|
+
# stored, and is logged. A record Smith does not keep (its shape, or tool
|
|
37
|
+
# results that do not answer its replies' calls) is ignored, and an error
|
|
38
|
+
# from `fetch` or `store` is logged by class: a memo never fails a step.
|
|
39
|
+
# Keeping records and dropping them is the host's.
|
|
33
40
|
module ResponseMemo
|
|
34
|
-
VERSION =
|
|
41
|
+
VERSION = 2
|
|
35
42
|
PROGRESS = :smith_response_memo
|
|
36
43
|
INHERITED = :smith_inherited_memo_attempts
|
|
37
44
|
private_constant :PROGRESS, :INHERITED
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
# A provider call being recorded (`recording`): its messages from
|
|
47
|
+
# `start` are stored as its progress when a reply calling the tools its
|
|
48
|
+
# request offered comes (with the facts its stream gave), and before
|
|
49
|
+
# each later round (the round before's tool results included). A kept
|
|
50
|
+
# reply whose tools had not run (`pending`) answers its first request,
|
|
51
|
+
# and nothing is sent.
|
|
52
|
+
class Recorder
|
|
53
|
+
def initialize(provider, memo, key, start, pending)
|
|
54
|
+
@provider = provider
|
|
55
|
+
@memo = memo
|
|
56
|
+
@key = key
|
|
57
|
+
@start = start
|
|
58
|
+
@pending = pending
|
|
59
|
+
@rounds = 0
|
|
60
|
+
@busy = false
|
|
61
|
+
@facts = {}.compare_by_identity
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether a request `provider` is about to make is one of the call's
|
|
65
|
+
# rounds (not one made inside another, as OpenAI's hook inside the
|
|
66
|
+
# base class's).
|
|
67
|
+
def records?(provider) = provider.equal?(@provider) && !@busy
|
|
68
|
+
|
|
69
|
+
def round(messages, offered, &)
|
|
70
|
+
@rounds += 1
|
|
71
|
+
return @pending.tap { @pending = nil } if @pending
|
|
72
|
+
|
|
73
|
+
progress(messages.drop(@start)) if @rounds > 1
|
|
74
|
+
reply = busy(&)
|
|
75
|
+
return reply unless offered && ResponseMemo.calls_tools?(reply)
|
|
76
|
+
|
|
77
|
+
@facts[reply] = Providers::StreamFacts.last_round(@provider)
|
|
78
|
+
progress([*messages.drop(@start), reply])
|
|
79
|
+
reply
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def progress(messages) = ResponseMemo.store(@memo, @key, messages, complete: false, facts: @facts)
|
|
85
|
+
|
|
86
|
+
def busy
|
|
87
|
+
@busy = true
|
|
88
|
+
yield
|
|
89
|
+
ensure
|
|
90
|
+
@busy = false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The shape of a record Smith keeps, as JSON.
|
|
95
|
+
module Record
|
|
96
|
+
READ = [1, VERSION].freeze
|
|
97
|
+
private_constant :READ
|
|
98
|
+
|
|
99
|
+
module_function
|
|
100
|
+
|
|
101
|
+
def valid?(record)
|
|
102
|
+
record.is_a?(Hash) && record.keys.sort == %w[complete messages version] && READ.include?(record["version"]) &&
|
|
103
|
+
[true, false].include?(record["complete"]) && Budget::Continuation.valid?(record["messages"]) &&
|
|
104
|
+
paired?(record["messages"], record["complete"])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Each tool result answers a call of the reply before it, and a reply
|
|
108
|
+
# comes only once the one before has all its results. A complete
|
|
109
|
+
# record ends with a reply calling nothing; an unfinished one with all
|
|
110
|
+
# of the last reply's results, or with that reply (its tools not run).
|
|
111
|
+
def paired?(messages, complete)
|
|
112
|
+
open = []
|
|
113
|
+
messages.each do |message|
|
|
114
|
+
if message["role"] == "tool"
|
|
115
|
+
return false unless open.delete(message["tool_call_id"])
|
|
116
|
+
else
|
|
117
|
+
return false unless open.empty?
|
|
118
|
+
|
|
119
|
+
open = Array(message["tool_calls"]).map { _1["id"] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
ends?(messages.last, open, complete)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def ends?(last, open, complete)
|
|
126
|
+
return reply?(last) if complete
|
|
127
|
+
|
|
128
|
+
last["role"] == "tool" ? open.empty? : last["role"] == "assistant" && !open.empty?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reply?(entry) = entry["role"] == "assistant" && entry["tool_calls"].nil?
|
|
132
|
+
|
|
133
|
+
def version(entries, complete)
|
|
134
|
+
last = entries.last
|
|
135
|
+
!complete && last["tool_calls"] ? VERSION : 1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
40
138
|
|
|
41
139
|
module_function
|
|
42
140
|
|
|
@@ -51,7 +149,7 @@ module Smith
|
|
|
51
149
|
|
|
52
150
|
def request_digest(chat)
|
|
53
151
|
shape = [chat.model.id, chat.model.provider.to_s, chat.messages.map { message_shape(_1) },
|
|
54
|
-
chat.tools
|
|
152
|
+
tools_shape(chat.tools), chat.schema, CacheMarkers.strip_request(chat.params)]
|
|
55
153
|
Digest::SHA256.hexdigest(JSON.generate(shape))
|
|
56
154
|
rescue StandardError
|
|
57
155
|
nil
|
|
@@ -60,7 +158,7 @@ module Smith
|
|
|
60
158
|
def message_shape(message)
|
|
61
159
|
content = message.content
|
|
62
160
|
body = case content
|
|
63
|
-
when RubyLLM::Content::Raw then content.value
|
|
161
|
+
when RubyLLM::Content::Raw then CacheMarkers.strip_blocks(content.value)
|
|
64
162
|
when RubyLLM::Content
|
|
65
163
|
[content.text, content.attachments.map { [_1.filename, _1.mime_type, _1.content.to_s.bytesize] }]
|
|
66
164
|
else content
|
|
@@ -68,33 +166,44 @@ module Smith
|
|
|
68
166
|
[message.role.to_s, body, message.tool_calls&.keys, message.tool_call_id]
|
|
69
167
|
end
|
|
70
168
|
|
|
71
|
-
|
|
169
|
+
def tools_shape(tools)
|
|
170
|
+
tools.map { |name, tool| [name.to_s, tool.description, tool.params_schema] }.sort_by(&:first)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The call's kept messages, whether it ended, and its reply whose tools
|
|
174
|
+
# had not run (`pending`, taken off the messages), or nil.
|
|
72
175
|
def fetch(memo, key)
|
|
73
176
|
record = memo.fetch(key)
|
|
74
177
|
return unless record
|
|
75
178
|
|
|
76
179
|
record = JSON.parse(JSON.generate(record))
|
|
77
|
-
raise ArgumentError, "not a response memo record" unless
|
|
180
|
+
raise ArgumentError, "not a response memo record" unless Record.valid?(record)
|
|
78
181
|
|
|
79
|
-
|
|
182
|
+
kept(Budget::Continuation.load(record["messages"]), record["complete"])
|
|
80
183
|
rescue StandardError => e
|
|
81
184
|
Smith.config.logger&.error("Smith response memo fetch error: #{e.class}")
|
|
82
185
|
nil
|
|
83
186
|
end
|
|
84
187
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
(!record["complete"] || reply?(record["messages"].last))
|
|
188
|
+
def kept(messages, complete)
|
|
189
|
+
pending = messages.pop if !complete && calls_tools?(messages.last)
|
|
190
|
+
{ messages:, complete:, pending: }
|
|
89
191
|
end
|
|
90
192
|
|
|
91
|
-
def
|
|
193
|
+
def calls_tools?(message) = message.is_a?(RubyLLM::Message) && message.role == :assistant && message.tool_call?
|
|
194
|
+
|
|
195
|
+
# Whether the message is the model's reply calling nothing, as a call
|
|
196
|
+
# ends.
|
|
197
|
+
def answer?(message) = message.is_a?(RubyLLM::Message) && message.role == :assistant && !message.tool_call?
|
|
92
198
|
|
|
93
199
|
# Stores the call's messages (`facts`: a streamed reply's, by message),
|
|
94
200
|
# unless they cannot be kept.
|
|
95
201
|
def store(memo, key, messages, complete:, facts: {})
|
|
96
202
|
dumped = Budget::Continuation.dump(nil, messages, usage: true, facts:)
|
|
97
|
-
|
|
203
|
+
if dumped
|
|
204
|
+
return memo.store(key, { "version" => Record.version(dumped, complete), "messages" => dumped,
|
|
205
|
+
"complete" => complete })
|
|
206
|
+
end
|
|
98
207
|
|
|
99
208
|
Smith.config.logger&.info("Smith response memo: a call not kept (an attachment, or past its byte limit)")
|
|
100
209
|
nil
|
|
@@ -103,33 +212,29 @@ module Smith
|
|
|
103
212
|
nil
|
|
104
213
|
end
|
|
105
214
|
|
|
106
|
-
# Runs the block with the provider's call recorded
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
def recording(provider, memo, key, start)
|
|
215
|
+
# Runs the block with the provider's call recorded (Recorder) from
|
|
216
|
+
# `start`, its first request answered by `pending` if given.
|
|
217
|
+
def recording(provider, memo, key, start, pending = nil)
|
|
110
218
|
outer = Fiber[PROGRESS]
|
|
111
|
-
Fiber[PROGRESS] = Recorder.new(provider, memo, key, start,
|
|
219
|
+
Fiber[PROGRESS] = Recorder.new(provider, memo, key, start, pending)
|
|
112
220
|
yield
|
|
113
221
|
ensure
|
|
114
222
|
Fiber[PROGRESS] = outer
|
|
115
223
|
end
|
|
116
224
|
|
|
117
225
|
# A request the provider is about to make, from RubyLLM's
|
|
118
|
-
# `Provider#complete` (`messages`, its chat's
|
|
119
|
-
|
|
226
|
+
# `Provider#complete` (`messages`, its chat's, and the request's
|
|
227
|
+
# `options`), and its reply.
|
|
228
|
+
def round(provider, messages, options = {}, &)
|
|
120
229
|
recorder = Fiber[PROGRESS]
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
recorder.rounds += 1
|
|
124
|
-
store(recorder.memo, recorder.key, messages.drop(recorder.start), complete: false) if recorder.rounds > 1
|
|
125
|
-
busy(recorder, &)
|
|
230
|
+
recorder&.records?(provider) ? recorder.round(messages, tools_offered?(options), &) : yield
|
|
126
231
|
end
|
|
127
232
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
233
|
+
# Whether the request offered the model tools (a finalization that took
|
|
234
|
+
# them away did not, and its reply's calls never run).
|
|
235
|
+
def tools_offered?(options)
|
|
236
|
+
tools = options[:tools]
|
|
237
|
+
tools.respond_to?(:empty?) && !tools.empty?
|
|
133
238
|
end
|
|
134
239
|
|
|
135
240
|
# A nested workflow built in the block runs its calls under `attempts`
|
|
@@ -34,6 +34,20 @@ module Smith
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# A list of StandardError subclasses, frozen; nil is none. StandardError
|
|
38
|
+
# itself would match every error, and an error outside it never reaches
|
|
39
|
+
# a step's rescue.
|
|
40
|
+
def error_classes(name)
|
|
41
|
+
lambda do |value|
|
|
42
|
+
classes = Array(value)
|
|
43
|
+
unless classes.all? { _1.is_a?(Class) && _1 < StandardError }
|
|
44
|
+
raise ArgumentError, "Smith.config.#{name} must list subclasses of StandardError, got #{value.inspect}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
classes.uniq.freeze
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
37
51
|
# A host adapter: nil, or an object that answers `calls` (each named
|
|
38
52
|
# with its arguments, as the message says).
|
|
39
53
|
def adapter(name, calls)
|
|
@@ -39,7 +39,7 @@ module Smith
|
|
|
39
39
|
def handle_managed_callback_failure(batch, dispatch_call, claim, error)
|
|
40
40
|
record_batch_failure(batch, error)
|
|
41
41
|
raise error unless claim
|
|
42
|
-
raise error if error.is_a?(ToolFailureNotificationFailed)
|
|
42
|
+
raise error if error.is_a?(ToolFailureNotificationFailed) || Interruptions.find(error)
|
|
43
43
|
|
|
44
44
|
failure = invocation_dispatch_failure(batch, dispatch_call, error)
|
|
45
45
|
notify_invocation_failure(batch, dispatch_call, failure)
|
|
@@ -75,6 +75,11 @@ module Smith
|
|
|
75
75
|
|
|
76
76
|
def settle! = call_reservation&.settle!
|
|
77
77
|
|
|
78
|
+
# One of its calls met an interruption (Smith::Interruptions).
|
|
79
|
+
def interrupted! = @interrupted = true
|
|
80
|
+
|
|
81
|
+
def interrupted? = @interrupted == true
|
|
82
|
+
|
|
78
83
|
private
|
|
79
84
|
|
|
80
85
|
def initialize_failure_queues
|
|
@@ -16,14 +16,19 @@ module Smith
|
|
|
16
16
|
batch.complete_failure_notification!(tool_call)
|
|
17
17
|
rescue StandardError => e
|
|
18
18
|
batch.release_failure_notification!(tool_call, state)
|
|
19
|
+
pass_interruption!(batch, e)
|
|
19
20
|
raise ToolFailureNotificationFailed.new(
|
|
20
21
|
dispatch_error: error,
|
|
21
22
|
notification_error: e
|
|
22
23
|
), cause: e
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
# An interruption settles nothing, as a process that died settles
|
|
27
|
+
# nothing: the host reconciles the batch's unsettled requests, whichever
|
|
28
|
+
# of its calls' errors reached here first.
|
|
25
29
|
def notify_unsettled_batch_failures(batch, error)
|
|
26
30
|
return unless batch&.host_admitted?
|
|
31
|
+
return if Interruptions.find(error) || batch.interrupted?
|
|
27
32
|
|
|
28
33
|
handler = batch.context.fetch(:current_invocation_failure_handler)
|
|
29
34
|
handler_error = notify_unsettled_requests(batch, error, handler)
|
|
@@ -40,6 +45,7 @@ module Smith
|
|
|
40
45
|
batch.complete_failure_notification!(tool_call)
|
|
41
46
|
rescue StandardError => e
|
|
42
47
|
batch.release_failure_notification!(tool_call, state)
|
|
48
|
+
pass_interruption!(batch, e)
|
|
43
49
|
first_error ||= ToolFailureNotificationFailed.new(
|
|
44
50
|
dispatch_error: failure,
|
|
45
51
|
notification_error: e
|
|
@@ -49,6 +55,15 @@ module Smith
|
|
|
49
55
|
first_error
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
# A failure handler that met an interruption stops the batch with it, as
|
|
59
|
+
# it was raised; what it had not settled stays unsettled for the host.
|
|
60
|
+
def pass_interruption!(batch, error)
|
|
61
|
+
return unless Interruptions.find(error)
|
|
62
|
+
|
|
63
|
+
record_batch_failure(batch, error)
|
|
64
|
+
Interruptions.pass!(error)
|
|
65
|
+
end
|
|
66
|
+
|
|
52
67
|
def batch_dispatch_failure(error, state)
|
|
53
68
|
return error.dispatch_error if error.is_a?(ToolFailureNotificationFailed)
|
|
54
69
|
return error if state == :started || exact_dispatch_failure?(error)
|
|
@@ -68,15 +83,18 @@ module Smith
|
|
|
68
83
|
end
|
|
69
84
|
|
|
70
85
|
def record_batch_failure(batch, error)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
interrupted = Interruptions.find(error)
|
|
87
|
+
batch.interrupted! if interrupted
|
|
88
|
+
batch_failure_queue(batch, error, interrupted)&.push(error)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# An interruption counts as process-fatal: nothing else is raised over it.
|
|
92
|
+
def batch_failure_queue(batch, error, interrupted)
|
|
93
|
+
return batch.fatal_failures if interrupted || !error.is_a?(StandardError)
|
|
94
|
+
return batch.notification_failures if error.is_a?(ToolFailureNotificationFailed)
|
|
95
|
+
return batch.capture_failures if error.is_a?(ToolCaptureFailed)
|
|
96
|
+
|
|
97
|
+
batch.terminal_failures if Smith::Errors.retry_forbidden?(error)
|
|
80
98
|
end
|
|
81
99
|
|
|
82
100
|
def prioritized_batch_failure(batch)
|
data/lib/smith/version.rb
CHANGED
|
@@ -13,7 +13,11 @@ module Smith
|
|
|
13
13
|
finish_deterministic_step!(transition, step)
|
|
14
14
|
nil
|
|
15
15
|
rescue StandardError => e
|
|
16
|
-
|
|
16
|
+
if Interruptions.find(e)
|
|
17
|
+
emit_deterministic_trace(transition, outcome: :interrupted)
|
|
18
|
+
else
|
|
19
|
+
emit_deterministic_trace(transition, outcome: :failed, failure: deterministic_failure_fields(transition, e))
|
|
20
|
+
end
|
|
17
21
|
raise
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -39,6 +39,7 @@ module Smith
|
|
|
39
39
|
def self.preferred_error(reasons)
|
|
40
40
|
errors = Array(reasons).compact
|
|
41
41
|
process_fatal_error(errors) ||
|
|
42
|
+
interruption(errors) ||
|
|
42
43
|
notification_failure(errors) ||
|
|
43
44
|
terminal_execution_error(errors) ||
|
|
44
45
|
ordinary_error(errors) ||
|
|
@@ -47,13 +48,16 @@ module Smith
|
|
|
47
48
|
|
|
48
49
|
def self.process_fatal_error(errors) = errors.find { !_1.is_a?(StandardError) }
|
|
49
50
|
|
|
51
|
+
def self.interruption(errors) = errors.find { Interruptions.find(_1) }
|
|
52
|
+
|
|
50
53
|
def self.notification_failure(errors) = errors.find { _1.is_a?(ToolFailureNotificationFailed) }
|
|
51
54
|
|
|
52
55
|
def self.terminal_execution_error(errors) = errors.find { Smith::Errors.retry_forbidden?(_1) }
|
|
53
56
|
|
|
54
57
|
def self.ordinary_error(errors) = errors.find { !_1.is_a?(Cancellation) }
|
|
55
58
|
|
|
56
|
-
private_class_method :process_fatal_error, :notification_failure, :terminal_execution_error,
|
|
59
|
+
private_class_method :process_fatal_error, :interruption, :notification_failure, :terminal_execution_error,
|
|
60
|
+
:ordinary_error
|
|
57
61
|
end
|
|
58
62
|
end
|
|
59
63
|
end
|
|
@@ -228,13 +228,16 @@ module Smith
|
|
|
228
228
|
validate_step_in_progress!(fields.to_h { |field| [field, state[field] || state[field.to_s]] })
|
|
229
229
|
end
|
|
230
230
|
|
|
231
|
+
# A digest Smith before 0.12.3 took, its parts' cache flags included.
|
|
232
|
+
def flagged_seed_digest?(messages, stored) = compute_seed_digest(messages, cache_flags: true) == stored
|
|
233
|
+
|
|
231
234
|
def validate_seed_digest!(normalized)
|
|
232
235
|
stored_digest = normalized[:seed_digest]
|
|
233
236
|
return if stored_digest.nil?
|
|
234
237
|
|
|
235
238
|
current_messages = compute_seed_messages
|
|
236
239
|
current_digest = compute_seed_digest(current_messages)
|
|
237
|
-
return if current_digest == stored_digest
|
|
240
|
+
return if current_digest == stored_digest || flagged_seed_digest?(current_messages, stored_digest)
|
|
238
241
|
|
|
239
242
|
case self.class.seed_validation
|
|
240
243
|
when :strict
|