smith-agents 0.12.0 → 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.
@@ -123,7 +123,7 @@ module Smith
123
123
  observed_reference ||= observed_model_reference(chat, fallback: model_reference)
124
124
  prefix_usages = account_completed_prefix(
125
125
  agent_class, observed_reference, new_chat_messages(chat, message_count),
126
- attempt_id: facts[:attempt_id], stream_rounds: streamed_rounds(collected || [])
126
+ attempt_id: facts[:attempt_id], stream: streamed(collected || [])
127
127
  )
128
128
  attempt = failed_provider_attempt(e, observed_reference, timer, facts, prefix_usages)
129
129
  raise unless provider_failure?(e) || unsupported_content?(e)
@@ -39,12 +39,13 @@ module Smith
39
39
  # call a response memo kept part of goes on from.
40
40
  def live_completion(chat, model_reference, collected, facts, resumed)
41
41
  ledger = run_budget_ledger
42
- return wire_completion(chat, model_reference, collected) unless ledger
42
+ kept = resumed&.rounds || 0
43
+ return wire_completion(chat, model_reference, collected, kept) unless ledger
43
44
 
44
45
  start = resumed ? resumed.start : chat_message_count(chat)
45
- holding = { pausable: budget_pausable?, rounds: resumed&.rounds || 0 }
46
+ holding = { pausable: budget_pausable?, rounds: kept }
46
47
  Providers::RequestHolds.within(chat_provider(chat), ledger, facts[:attempt_id], **holding) do |holder|
47
- held_completion(holder, chat, start) { wire_completion(chat, model_reference, collected) }
48
+ held_completion(holder, chat, start) { wire_completion(chat, model_reference, collected, kept) }
48
49
  end
49
50
  end
50
51
 
@@ -82,21 +83,23 @@ module Smith
82
83
  false
83
84
  end
84
85
 
85
- def wire_completion(chat, model_reference, collected)
86
+ # `kept`: the replies a response memo kept for the call, ahead of
87
+ # those its stream answers (a failed call's facts pair after them).
88
+ def wire_completion(chat, model_reference, collected, kept = 0)
86
89
  stream = @step_stream
87
90
  return [chat.complete, nil] unless stream
88
91
 
89
92
  stream.start(attempt: @step_attempt, model: model_reference.model_id, provider: model_reference.provider&.to_s)
90
- response, rounds = streamed_completion(chat, stream, collected)
93
+ response, rounds = streamed_completion(chat, stream, collected, kept)
91
94
  [response, { rounds:, output_limit: request_output_limit(chat, model_reference) }]
92
95
  end
93
96
 
94
97
  # The chat's reply, its text sent as it comes (without <think> text),
95
98
  # and the facts of each of its requests.
96
- def streamed_completion(chat, stream, collected)
99
+ def streamed_completion(chat, stream, collected, kept)
97
100
  response = nil
98
101
  thinking = Streaming::ThinkTags.new
99
- rounds = Providers::StreamFacts.collect(chat_provider(chat)) do |collection|
102
+ rounds = Providers::StreamFacts.collect(chat_provider(chat), kept:) do |collection|
100
103
  collected << collection
101
104
  response = chat.complete { |chunk| stream.delta(thinking.strip(chunk.content, collection.rounds.length)) }
102
105
  end
@@ -138,8 +141,11 @@ module Smith
138
141
  tools.respond_to?(:empty?) && !tools.empty?
139
142
  end
140
143
 
141
- # The facts of a streamed call that failed, if it streamed.
142
- def streamed_rounds(collected) = collected.first&.facts
144
+ # What a streamed call that failed streamed, if it did.
145
+ def streamed(collected)
146
+ collection = collected.first
147
+ { rounds: collection.facts, output_limit: nil, kept: collection.kept } if collection
148
+ end
143
149
 
144
150
  def chat_provider(chat)
145
151
  llm_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
@@ -27,8 +27,7 @@ module Smith
27
27
  # billed: they are recorded and settled into the budget like a failed
28
28
  # attempt's usage. Returns the provider usages recorded, so the
29
29
  # attempt's trace carries the same totals its usage entries hold.
30
- def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil, stream_rounds: nil)
31
- stream = stream_rounds && { rounds: stream_rounds, output_limit: nil }
30
+ def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil, stream: nil)
32
31
  completion = Completion.from_messages(response: nil, messages:, stream:)
33
32
  record_billed_usages(agent_class, completion.provider_usages, :partial_attempt, model_reference, attempt_id:)
34
33
  completion.provider_usages
@@ -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
@@ -26,6 +26,7 @@ module Smith
26
26
 
27
27
  ChildRun.result(ref: child.ref, run_result: run)
28
28
  rescue StandardError => e
29
+ Interruptions.pass!(e)
29
30
  failed(child, nil, DiagnosticText.error_class_name(e))
30
31
  end
31
32
 
@@ -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) and the facts
36
- # of each of its requests.
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 each response of a streamed call, in order: each
77
- # round's over its raw body's, the last settled. Rounds and responses
78
- # pair up in order; a round past the last response (a request that
79
- # failed) has none.
80
- def per_message(messages, rounds, output_limit)
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) }
@@ -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
- # tools, schema, params). A replay reaches the same key; a call whose
19
- # request changed since (a deployed prompt, another model, a date in the
20
- # prompt) misses and is sent, as a miss always may be.
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" => bool}`,
23
- # holds the messages the call added (Budget::Continuation, with each
24
- # response's usage and facts), stored after each round's tool results and
25
- # when the call ends; a complete one ends with the model's reply. Replayed,
26
- # a complete call sends nothing; an unfinished one continues from its
27
- # tools' results. Either way the step counts its tools as run (a retry or
28
- # fallback never runs them again). A call whose messages cannot be kept
29
- # (an attachment, or too many bytes) is not stored, and is logged. A record
30
- # Smith does not keep is ignored, and an error from `fetch` or `store` is
31
- # logged by class: a memo never fails a step. Keeping records and dropping
32
- # them is the host's.
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 = 1
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
- Recorder = Struct.new(:provider, :memo, :key, :start, :rounds, :busy)
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.keys.map(&:to_s).sort, chat.schema, chat.params]
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
- # The call's kept messages and whether it ended, or nil.
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 record?(record)
180
+ raise ArgumentError, "not a response memo record" unless Record.valid?(record)
78
181
 
79
- { messages: Budget::Continuation.load(record["messages"]), complete: record["complete"] }
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 record?(record)
86
- record.is_a?(Hash) && record.keys.sort == %w[complete messages version] && record["version"] == VERSION &&
87
- [true, false].include?(record["complete"]) && Budget::Continuation.valid?(record["messages"]) &&
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 reply?(message) = message["role"] == "assistant" && message["tool_calls"].nil?
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
- return memo.store(key, { "version" => VERSION, "messages" => dumped, "complete" => complete }) if dumped
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: before each of its
107
- # later rounds, its messages from `start` (the round before's tool
108
- # results included) are stored as its progress.
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, 0, false)
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
- def round(provider, messages, &)
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
- return yield unless recorder&.provider.equal?(provider) && !recorder.busy
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
- def busy(recorder)
129
- recorder.busy = true
130
- yield
131
- ensure
132
- recorder.busy = false
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
- if !error.is_a?(StandardError)
72
- batch.fatal_failures.push(error)
73
- elsif error.is_a?(ToolFailureNotificationFailed)
74
- batch.notification_failures.push(error)
75
- elsif error.is_a?(ToolCaptureFailed)
76
- batch.capture_failures.push(error)
77
- elsif Smith::Errors.retry_forbidden?(error)
78
- batch.terminal_failures.push(error)
79
- end
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.12.0"
4
+ VERSION = "0.12.2"
5
5
  EXECUTION_SEMANTICS_VERSION = "7"
6
6
  end
@@ -13,7 +13,11 @@ module Smith
13
13
  finish_deterministic_step!(transition, step)
14
14
  nil
15
15
  rescue StandardError => e
16
- emit_deterministic_trace(transition, outcome: :failed, failure: deterministic_failure_fields(transition, e))
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, :ordinary_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