smith-agents 0.12.1 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/README.md +197 -1081
- data/docs/AGENTS.md +238 -0
- data/docs/BUDGETS.md +134 -0
- data/docs/CONFIGURATION.md +29 -3
- data/docs/DEVELOPMENT.md +63 -0
- data/docs/ERRORS_AND_RETRIES.md +175 -0
- data/docs/INSPECTION.md +39 -0
- data/docs/PATTERNS.md +2 -2
- data/docs/PERSISTENCE.md +175 -4
- data/docs/TESTING.md +88 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +59 -1
- data/lib/smith/agent/completion.rb +5 -3
- data/lib/smith/agent/provider_completion.rb +1 -1
- data/lib/smith/agent/provider_streaming.rb +15 -9
- data/lib/smith/agent/usage_tracking.rb +1 -2
- data/lib/smith/interruptions.rb +50 -0
- data/lib/smith/launchers/inline.rb +1 -0
- data/lib/smith/providers/request_holds.rb +1 -1
- data/lib/smith/providers/stream_facts.rb +29 -11
- data/lib/smith/response_memo.rb +149 -44
- data/lib/smith/setting_constructors.rb +14 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +1 -1
- data/lib/smith/tool/execution_batch.rb +5 -0
- data/lib/smith/tool/execution_failure_handling.rb +27 -9
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +5 -1
- data/lib/smith/workflow/parallel.rb +5 -1
- data/lib/smith/workflow/remembered_calls.rb +52 -19
- data/lib/smith/workflow/retry_execution.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +1 -0
- data/lib/smith/workflow/step_context.rb +1 -0
- data/lib/smith.rb +9 -0
- metadata +8 -1
|
@@ -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, chat.params]
|
|
55
153
|
Digest::SHA256.hexdigest(JSON.generate(shape))
|
|
56
154
|
rescue StandardError
|
|
57
155
|
nil
|
|
@@ -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
|
|
@@ -6,16 +6,20 @@ module Smith
|
|
|
6
6
|
# (Smith::ResponseMemo): each call takes its kept messages, if any, and
|
|
7
7
|
# sends nothing when they end it (a streamed step's sink gets the reply's
|
|
8
8
|
# text at once), else goes on from them, and is recorded as it goes.
|
|
9
|
-
# Kept tool results count as tools the step ran,
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# Kept tool results count as tools the step ran, charged to its
|
|
10
|
+
# tool-call allowances as they were before the cut; a kept reply whose
|
|
11
|
+
# tools had not run is the answer to the live part's first request, and
|
|
12
|
+
# its tools run. The live part goes on from the kept rounds (Resumed):
|
|
13
|
+
# its first request is a later round, a pause for budget keeps the kept
|
|
14
|
+
# rounds too, and a streamed one's facts pair with its live replies.
|
|
12
15
|
module RememberedCalls
|
|
13
16
|
CALLS_LOCK = Mutex.new
|
|
14
17
|
private_constant :CALLS_LOCK
|
|
15
18
|
|
|
16
|
-
# Where a call's live part starts: the call's first message,
|
|
17
|
-
# rounds its kept messages made
|
|
18
|
-
|
|
19
|
+
# Where a call's live part starts: the call's first message, the
|
|
20
|
+
# rounds its kept messages made, and its kept reply whose tools had
|
|
21
|
+
# not run (one of those rounds).
|
|
22
|
+
Resumed = Data.define(:start, :rounds, :pending)
|
|
19
23
|
|
|
20
24
|
private
|
|
21
25
|
|
|
@@ -26,39 +30,68 @@ module Smith
|
|
|
26
30
|
|
|
27
31
|
start = chat.messages.length
|
|
28
32
|
kept = ResponseMemo.fetch(memo, key)
|
|
29
|
-
keep_messages(chat, kept
|
|
33
|
+
keep_messages(chat, kept) if kept
|
|
30
34
|
return replayed_completion(chat, model_reference) if kept&.fetch(:complete)
|
|
31
35
|
|
|
32
|
-
recorded_completion(chat, memo, key,
|
|
36
|
+
recorded_completion(chat, memo, key, resumed(start, kept), &)
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
def
|
|
39
|
+
def resumed(start, kept)
|
|
40
|
+
return Resumed.new(start:, rounds: 0, pending: nil) unless kept
|
|
41
|
+
|
|
42
|
+
pending = kept.fetch(:pending)
|
|
43
|
+
Resumed.new(start:, rounds: kept.fetch(:messages).count { _1.role == :assistant } + (pending ? 1 : 0), pending:)
|
|
44
|
+
end
|
|
36
45
|
|
|
37
46
|
def memo_key(chat, facts)
|
|
38
47
|
ResponseMemo.key([*@memo_attempts, @step_attempt.to_i], facts[:attempt_index], next_memo_call, chat)
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
# The kept messages, after the call's own; their tool results are
|
|
42
|
-
# tools the step ran.
|
|
43
|
-
def keep_messages(chat,
|
|
51
|
+
# tools the step ran, each charged as it was.
|
|
52
|
+
def keep_messages(chat, kept)
|
|
53
|
+
messages = kept.fetch(:messages)
|
|
44
54
|
messages.each { chat.add_message(_1) }
|
|
45
|
-
|
|
55
|
+
ran = messages.filter_map { _1.tool_call_id if _1.role == :tool }
|
|
56
|
+
Tool.current_tool_execution_tracker&.mark_started! if ran.any?
|
|
57
|
+
charge_kept_calls(ran_calls(messages, ran))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ran_calls(messages, ran) = messages.flat_map { _1.tool_calls&.values.to_a }.select { ran.include?(_1.id) }
|
|
61
|
+
|
|
62
|
+
# Each kept call against the agent's tool-call allowance and the
|
|
63
|
+
# workflow's `tool_calls` budget, as a call that runs is, so a replay
|
|
64
|
+
# runs no more tools than the step may. A call past them (a budget
|
|
65
|
+
# lowered since, or a call rejected for budget) charges nothing more.
|
|
66
|
+
def charge_kept_calls(calls)
|
|
67
|
+
allowance = Tool.current_tool_call_allowance
|
|
68
|
+
ledger = Tool.current_ledger
|
|
69
|
+
commit = -> { ledger.reconcile!(ledger.reserve!(:tool_calls, 1), 1) if ledger&.limits&.key?(:tool_calls) }
|
|
70
|
+
calls.each { allowance.is_a?(Tool::CallAllowance) ? allowance.charge!(_1.name, &commit) : commit.call }
|
|
71
|
+
rescue BudgetExceeded
|
|
72
|
+
nil
|
|
46
73
|
end
|
|
47
74
|
|
|
75
|
+
# A call a tool halted ends in that tool's result, not a reply: it
|
|
76
|
+
# keeps its progress, which a replay can go on from.
|
|
48
77
|
def recorded_completion(chat, memo, key, resumed)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
78
|
+
result = ResponseMemo.recording(chat_provider(chat), memo, key, resumed.start, resumed.pending) do
|
|
79
|
+
yield(resumed)
|
|
80
|
+
end
|
|
81
|
+
messages = chat.messages.drop(resumed.start)
|
|
82
|
+
if ResponseMemo.answer?(messages.last)
|
|
83
|
+
ResponseMemo.store(memo, key, messages, complete: true, facts: streamed_facts(messages, result))
|
|
84
|
+
end
|
|
53
85
|
result
|
|
54
86
|
end
|
|
55
87
|
|
|
56
|
-
# A streamed call's replies' facts, read from its stream
|
|
57
|
-
|
|
88
|
+
# A streamed call's replies' facts, read from its stream (its kept
|
|
89
|
+
# replies, which no round of it answered, keep their own).
|
|
90
|
+
def streamed_facts(messages, result)
|
|
58
91
|
stream = result[1]
|
|
59
92
|
return {} unless stream.is_a?(Hash) && stream[:rounds]
|
|
60
93
|
|
|
61
|
-
replies =
|
|
94
|
+
replies = messages.select { _1.role == :assistant }
|
|
62
95
|
replies.zip(Providers::StreamFacts.per_message(replies, stream[:rounds], stream[:output_limit]))
|
|
63
96
|
.to_h.compare_by_identity
|
|
64
97
|
end
|
|
@@ -39,6 +39,7 @@ module Smith
|
|
|
39
39
|
attempt_scope = attempt_idempotency_scope(key_generation)
|
|
40
40
|
run_attempt_under_keys(transition, attempt_scope)
|
|
41
41
|
rescue StandardError => e
|
|
42
|
+
Interruptions.pass!(e)
|
|
42
43
|
raise unless retry_transition_error?(config, e, attempt)
|
|
43
44
|
|
|
44
45
|
key_generation += 1 if new_idempotency_key_after?(config, e, attempt_scope)
|
|
@@ -16,6 +16,7 @@ module Smith
|
|
|
16
16
|
install_step_idempotency_scope(transition)
|
|
17
17
|
Thread.handle_interrupt(Object => :immediate, &block)
|
|
18
18
|
rescue StandardError => e
|
|
19
|
+
Interruptions.pass!(e)
|
|
19
20
|
@outcome = nil
|
|
20
21
|
GuardrailIntegration.instance_method(:handle_step_failure).bind_call(self, transition, e)
|
|
21
22
|
ensure
|
data/lib/smith.rb
CHANGED
|
@@ -79,6 +79,14 @@ module Smith
|
|
|
79
79
|
:response_memo, { fetch: "fetch(key)", store: "store(key, record)" }
|
|
80
80
|
)
|
|
81
81
|
|
|
82
|
+
# Errors that interrupt a step instead of failing it, such as the host's
|
|
83
|
+
# database or queue out of reach (Smith::Interruptions). A step that meets
|
|
84
|
+
# one, raised in it or as the cause of an error that was, stops where it
|
|
85
|
+
# is, as if its worker had stopped: no failure route, no retry, no failure
|
|
86
|
+
# recorded, and the error reaches the caller as it was raised.
|
|
87
|
+
setting :interrupting_errors, default: [].freeze,
|
|
88
|
+
constructor: SettingConstructors.error_classes(:interrupting_errors)
|
|
89
|
+
|
|
82
90
|
# How long a provider request's hold on a host's budget ledger
|
|
83
91
|
# (`budget_ledger`) lasts unsettled, in seconds: past it the request's
|
|
84
92
|
# process is taken to have died, and the ledger drops the hold. It must
|
|
@@ -203,6 +211,7 @@ require_relative "smith/providers/schema_constraints"
|
|
|
203
211
|
require_relative "smith/tool_failure_notification_failed"
|
|
204
212
|
require_relative "smith/persistence_payload_conflict"
|
|
205
213
|
require_relative "smith/exponential_backoff"
|
|
214
|
+
require_relative "smith/interruptions"
|
|
206
215
|
|
|
207
216
|
# Event system (depends on Types)
|
|
208
217
|
require_relative "smith/event"
|