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.
@@ -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.1"
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
@@ -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, and the live part goes
10
- # on from the kept rounds (Resumed): its first request is a later round,
11
- # and a pause for budget keeps the kept rounds too.
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, and the
17
- # rounds its kept messages made.
18
- Resumed = Data.define(:start, :rounds)
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.fetch(:messages)) if 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, Resumed.new(start:, rounds: kept_rounds(kept)), &)
36
+ recorded_completion(chat, memo, key, resumed(start, kept), &)
33
37
  end
34
38
 
35
- def kept_rounds(kept) = kept ? kept.fetch(:messages).count { _1.role == :assistant } : 0
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, messages)
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
- Tool.current_tool_execution_tracker&.mark_started! if messages.any? { _1.role == :tool }
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
- live = chat.messages.length
50
- result = ResponseMemo.recording(chat_provider(chat), memo, key, resumed.start) { yield(resumed) }
51
- ResponseMemo.store(memo, key, chat.messages.drop(resumed.start), complete: true,
52
- facts: streamed_facts(chat, live, result))
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
- def streamed_facts(chat, live, result)
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 = chat.messages.drop(live).select { _1.role == :assistant }
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)
@@ -54,6 +54,7 @@ module Smith
54
54
  end
55
55
  [output, nil]
56
56
  rescue StandardError => e
57
+ Interruptions.pass!(e)
57
58
  [nil, e]
58
59
  end
59
60
 
@@ -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"