smith-agents 0.12.4 → 0.12.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2c791c5165889ff1098cc5c92976b1affe2c3f82108815fda1c98fae56c0430
4
- data.tar.gz: 5b7d1d9f41e2949c335d6dc8b26a7f69aff73c2edf4ee39e100760ca3c4f4145
3
+ metadata.gz: 97813bcba79ce652b05eb860da067e3c566b8ba3450344d66940c9f65d23a1dc
4
+ data.tar.gz: ea20e0a30b7517aab44a43b2668cb5e59fde6b98a829056e8e73ba585338f216
5
5
  SHA512:
6
- metadata.gz: e76354065df47cf04703bc41cda18256df9f2118a9d714f046f3fe04c567256d969be828235b0a6da2ff0ee83fe8953ba8c381ed4209c4f49776cde1658ab5d0
7
- data.tar.gz: 3e73cddd45cd37191712cc2ed4d5cdc1fe62dbe07a2de73b86bd626f43eda0ba74efcbf8b40fabbbc15db21e6517495567ac346c4339f3c9864e316894170d3f
6
+ metadata.gz: 37d47a1f6268dc96cd05ce69f4138998d1c5a9649bbb41921882b6ae93a8f5d030586c273f37231501c121dd43492e1a37034cefe88981a5f89138cbf1ccd128
7
+ data.tar.gz: 40eb2c1a4ac979411860e4971b4814e0c2cdaebc39f1a827e956f7fcb2a5ca33c3f34d18aad339edfa1460a8fce07188f505aa174a2e59233d735c50bab258d3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ All notable changes to Smith are documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Smith is pre-1.0 and under active development; expect occasional contract tightening between minor versions until 1.0.
6
6
 
7
+ ## [0.12.6] - 2026-10-13
8
+
9
+ ### Fixed
10
+
11
+ - An error of a streamed request is what the provider said it was
12
+ (`Smith::ProviderErrors`). RubyLLM builds a streamed request's error
13
+ response itself, guessing its status from the error event's type:
14
+ Anthropic's adapter says 500 unless overloaded, OpenAI's 400 unless a
15
+ server error or a rate limit. With 0.12.5 sending a streamed request
16
+ once, an Anthropic bad request, wrong key or unknown model looked like a
17
+ passing outage (fallbacks and retries instead of failing or moving to
18
+ another account), and an OpenAI wrong key, unknown model, regional refusal
19
+ or rate limit like a bad request (no fallback). Before, RubyLLM's retry
20
+ hid this by garbling the error into one of the response's real status.
21
+ Smith reads the type the provider gave in the body (Anthropic's
22
+ `error.type`, OpenAI's `error.code`, else its `error.type`) and, when it
23
+ names another status, raises what RubyLLM raises for that status, through
24
+ RubyLLM's own mapping (context length included), with the provider's
25
+ message and the response's headers, its cause the error RubyLLM raised.
26
+ An error the provider sent whole keeps its own status.
27
+
28
+ ## [0.12.5] - 2026-10-13
29
+
30
+ ### Fixed
31
+
32
+ - A streamed step's requests are each sent once: its chat takes a copy of
33
+ the agent's config (its context, or RubyLLM's) with RubyLLM's HTTP retries
34
+ (`max_retries`) off, its model resolved again as RubyLLM resolves a chat
35
+ built with a context (the same id and provider, and the agent's own
36
+ `assume_model_exists`, so a model RubyLLM lists keeps its output limit and
37
+ thinking options). Retrying a stream read the next response's error into
38
+ the buffer the first had filled, so the provider's words were lost: a
39
+ refusal for want of credit reached Smith as a bare "Invalid request"
40
+ (`RubyLLM::BadRequestError`) and was never classified as
41
+ `RubyLLM::PaymentRequiredError`, and a spend-cap 429 as a plain rate limit.
42
+ A stream that failed part way was sent again, its text streamed twice.
43
+ Every provider failure of a streamed request, a transient one at connect
44
+ time included, now goes straight to the step's fallbacks and retries, so
45
+ a streamed step that should ride out an outage declares them. Calls that
46
+ do not stream, and requests OpenAI's Responses route sends whole, keep
47
+ RubyLLM's retries. The global config and the agent's are never changed.
48
+
7
49
  ## [0.12.4] - 2026-10-13
8
50
 
9
51
  ### Changed
data/README.md CHANGED
@@ -30,7 +30,7 @@ What you get:
30
30
 
31
31
  ```ruby
32
32
  # Gemfile
33
- gem "smith-agents", "0.12.4", require: "smith"
33
+ gem "smith-agents", "0.12.6", require: "smith"
34
34
  ```
35
35
 
36
36
  In a Rails app:
data/docs/AGENTS.md CHANGED
@@ -155,7 +155,17 @@ reason and message id, with `mode: :stream`), and a replayed step streams again
155
155
  (unless a response memo kept its reply: then its sink gets the text at once).
156
156
  Graph inspection shows the stream, and runtime readiness reports a missing sink
157
157
  (`:stream_sink_missing`) and an agent whose calls go to the Responses API
158
- (`:stream_whole_replies`).
158
+ (`:stream_whole_replies`). A streamed step's requests are each sent once:
159
+ its chat takes a copy of the agent's config with RubyLLM's HTTP retries
160
+ (`max_retries`) off and its model resolved again as RubyLLM resolves a chat
161
+ built with a context (a model RubyLLM lists keeps its limits), since a
162
+ retried stream loses the first response's error body (a refusal for want of
163
+ credit would reach Smith as a bare "Invalid request") or streams its text
164
+ again. Every provider failure of a streamed request, a transient one at
165
+ connect time included, takes the step's fallbacks and retries directly, so
166
+ declare them (`fallback_models`, `retry_on Smith::AgentError`) on a streamed
167
+ step that should ride out an outage. A request OpenAI's Responses route sends
168
+ whole keeps the retries.
159
169
 
160
170
  ## Batch mode
161
171
 
@@ -113,8 +113,18 @@ naming `RubyLLM::PaymentRequiredError` makes a step that meets it wait for
113
113
  credit instead of failing, even when a fallback then fails some other way.
114
114
  `Smith::Providers::Anthropic::Batches#submit` raises it too. Any other error
115
115
  stays as it is: a rate limit whose message mentions a quota (Gemini's) is a
116
- rate limit. RubyLLM's own HTTP retries (`max_retries`) may repeat a 429 or a
117
- streamed refusal before Smith sees it; a refused request is not charged.
116
+ rate limit. RubyLLM's own HTTP retries (`max_retries`) may repeat a 429 of a
117
+ call that does not stream before Smith sees it (a refused request is not
118
+ charged); a streamed call is sent once, so its refusal reaches Smith whole.
119
+
120
+ RubyLLM guesses the status of a streamed request's error from the error
121
+ event's type (Anthropic's adapter says 500 unless overloaded, OpenAI's 400
122
+ unless a server error or a rate limit). Smith takes it as what the provider
123
+ said it was, from the type in the body (Anthropic's `error.type`, OpenAI's
124
+ `error.code`, else its `error.type`), through RubyLLM's own mapping, with the
125
+ response's headers: a streamed bad request fails, a wrong key is the
126
+ account's, an unknown model moves to the next candidate, an overload is a
127
+ passing outage. An error the provider sent whole keeps its own status.
118
128
 
119
129
  Name only errors that mean your own systems failed. A provider's own failure
120
130
  (`RubyLLM::Error`, or its HTTP client's `Faraday::Error`) is the provider's
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module AttemptChat
6
+ private
7
+
8
+ # An attempt's chat. A streamed step's sends each request it streams
9
+ # over the wire once: RubyLLM's HTTP retries would send it again on an
10
+ # error, its error body already read into the first request's buffer
11
+ # (so a refusal for want of credit reaches Smith as a bare "Invalid
12
+ # request", never classified) or its text already streamed (the
13
+ # retry's text would join it). Smith's fallbacks and the step's
14
+ # retries take their place. Its chat takes a copy of the agent's config
15
+ # (its context, or RubyLLM's) without the retries, and resolves its
16
+ # model again as RubyLLM builds a chat with a context (the same id and
17
+ # provider, and the agent's own assume_model_exists, so a model RubyLLM
18
+ # knows keeps its limits), before any hold attaches to its provider. A
19
+ # request OpenAI's Responses route sends answers whole, unstreamed,
20
+ # and keeps the retries.
21
+ def attempt_chat(agent_class, model_reference)
22
+ chat = agent_class.chat(**model_reference.chat_options, **bridge_workflow_inputs(agent_class))
23
+ return chat unless @step_stream && streams_over_the_wire?(chat)
24
+
25
+ sent_once(chat, agent_class)
26
+ end
27
+
28
+ def sent_once(chat, agent_class)
29
+ model = chat.model
30
+ config = (agent_class.context&.config || RubyLLM.config).dup
31
+ config.max_retries = 0
32
+ chat.with_context(RubyLLM::Context.new(config))
33
+ chat.with_model(model.id, provider: model.provider,
34
+ assume_exists: agent_class.chat_kwargs.fetch(:assume_model_exists, false))
35
+ end
36
+
37
+ def streams_over_the_wire?(chat)
38
+ return false unless chat.respond_to?(:with_context) && chat.respond_to?(:with_model)
39
+
40
+ (chat.params[:openai_api_mode] || chat.params["openai_api_mode"]).to_s != "responses"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -123,9 +123,11 @@ module Smith
123
123
  # have their join target; the error then propagates unchanged. A
124
124
  # provider failure's own reported usage is accounted beside the
125
125
  # completed prefix, so its trace totals both.
126
- # A billing refusal is recorded as the provider account's (BillingRefusals).
126
+ # An error is recorded as what the provider said it was
127
+ # (ProviderErrors), a billing refusal as the provider account's
128
+ # (BillingRefusals).
127
129
  def failed_provider_attempt(error, observed_reference, timer, facts, prefix_usages)
128
- error = BillingRefusals.classified(error)
130
+ error = BillingRefusals.classified(ProviderErrors.classified(error))
129
131
  aborted = !provider_failure?(error)
130
132
  usages = aborted ? prefix_usages : [*prefix_usages, ProviderUsage.from_message(error)].compact
131
133
  attempt = ProviderAttempt.failure(
@@ -147,7 +147,7 @@ module Smith
147
147
  # rubocop:enable Metrics/AbcSize
148
148
 
149
149
  def prepared_attempt_chat(agent_class, prepared_input, model_reference, shape)
150
- chat = agent_class.chat(**model_reference.chat_options, **bridge_workflow_inputs(agent_class))
150
+ chat = attempt_chat(agent_class, model_reference)
151
151
  add_prepared_input(chat, prepared_input, observed_model_reference(chat, fallback: model_reference).provider)
152
152
  continue_paused_loop(chat)
153
153
  shaped_chat(chat, model_reference, shape)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "attempt_chat"
4
+
3
5
  module Smith
4
6
  class Agent
5
7
  # The provider calls of a streamed step (Smith::Streaming): before a
@@ -8,6 +10,8 @@ module Smith
8
10
  # reason is read from the stream (Providers::StreamFacts), as a
9
11
  # streamed reply keeps no raw body.
10
12
  module ProviderStreaming
13
+ include AttemptChat
14
+
11
15
  # Room for what a pause adds besides its messages (its retry, the
12
16
  # attempt's usage).
13
17
  PAUSE_MARGIN = 4096
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ruby_llm"
5
+
6
+ module Smith
7
+ # An error of a streamed request as the provider said it was. RubyLLM
8
+ # builds a streamed request's error response itself, its status a guess
9
+ # from the error event's type (Anthropic's adapter says 500 unless
10
+ # overloaded, OpenAI's 400 unless a server error or a rate limit), so a
11
+ # wrong key, a model the provider does not serve or a refusal by region
12
+ # could look like a passing outage or a bad request. Smith reads the type
13
+ # the provider gave in the body (Anthropic's `error.type`, OpenAI's
14
+ # `error.code`, else its `error.type`) and, when it names another status,
15
+ # raises what RubyLLM raises for that status (its own mapping, context
16
+ # length included), with the provider's message, the response's headers
17
+ # kept, its cause the error RubyLLM raised. An error from a response the
18
+ # provider sent whole (its status the provider's own), or whose body
19
+ # names nothing it knows, stays as it is.
20
+ module ProviderErrors
21
+ STATUSES = {
22
+ "invalid_request_error" => 400,
23
+ "authentication_error" => 401,
24
+ "invalid_api_key" => 401,
25
+ "billing_error" => 402,
26
+ "permission_error" => 403,
27
+ "unsupported_country_region_territory" => 403,
28
+ "not_found_error" => 404,
29
+ "model_not_found" => 404,
30
+ "request_too_large" => 413,
31
+ "rate_limit_error" => 429,
32
+ "rate_limit_exceeded" => 429,
33
+ "timeout_error" => 504
34
+ }.freeze
35
+ private_constant :STATUSES
36
+
37
+ Response = Struct.new(:status, :body)
38
+ Said = Struct.new(:message) do
39
+ def parse_error(_response) = message
40
+ end
41
+ private_constant :Response, :Said
42
+
43
+ module_function
44
+
45
+ # The error as Smith takes it: a streamed request's error whose body
46
+ # names another status as the error RubyLLM raises for that status,
47
+ # anything else unchanged.
48
+ def classified(error)
49
+ return error unless error.is_a?(RubyLLM::Error) && streamed?(error)
50
+
51
+ said = said_of(body_of(error))
52
+ status = named_status(said)
53
+ return error if status.nil? || status == error.response.status.to_i
54
+
55
+ reclassify(error, with_status(error.response, status), said["message"])
56
+ end
57
+
58
+ def named_status(said) = said && (STATUSES[said["code"].to_s] || STATUSES[said["type"].to_s])
59
+
60
+ # A response RubyLLM built for a streamed request's error, never one the
61
+ # provider sent whole (a Faraday::Response).
62
+ def streamed?(error)
63
+ response = error.response
64
+ response.respond_to?(:status) && response.respond_to?(:body) && !response.is_a?(Faraday::Response)
65
+ end
66
+
67
+ def reclassify(error, response, message)
68
+ named = begin
69
+ RubyLLM::ErrorMiddleware.parse_error(provider: Said.new(message.is_a?(String) ? message : nil), response:)
70
+ rescue RubyLLM::Error => e
71
+ e
72
+ end
73
+ return error unless named.is_a?(RubyLLM::Error)
74
+
75
+ begin
76
+ raise named.class.new(response, named.message), cause: error
77
+ rescue RubyLLM::Error => e
78
+ e
79
+ end
80
+ end
81
+
82
+ def with_status(response, status)
83
+ response.respond_to?(:merge) ? response.merge(status:) : Response.new(status, response.body)
84
+ end
85
+
86
+ def said_of(body)
87
+ said = body.is_a?(Hash) ? body["error"] : nil
88
+ said.is_a?(Hash) ? said : nil
89
+ end
90
+
91
+ def body_of(error)
92
+ body = error.response.body
93
+ body.is_a?(String) ? JSON.parse(body) : body
94
+ rescue JSON::ParserError
95
+ nil
96
+ end
97
+ end
98
+ end
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.4"
4
+ VERSION = "0.12.6"
5
5
  EXECUTION_SEMANTICS_VERSION = "7"
6
6
  end
data/lib/smith.rb CHANGED
@@ -212,6 +212,7 @@ require_relative "smith/tool_failure_notification_failed"
212
212
  require_relative "smith/persistence_payload_conflict"
213
213
  require_relative "smith/exponential_backoff"
214
214
  require_relative "smith/interruptions"
215
+ require_relative "smith/provider_errors"
215
216
  require_relative "smith/billing_refusals"
216
217
 
217
218
  # Event system (depends on Types)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.4
4
+ version: 0.12.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -161,6 +161,7 @@ files:
161
161
  - lib/generators/smith/install/templates/workflow_state.rb.tt
162
162
  - lib/smith.rb
163
163
  - lib/smith/agent.rb
164
+ - lib/smith/agent/attempt_chat.rb
164
165
  - lib/smith/agent/chat_construction.rb
165
166
  - lib/smith/agent/completion.rb
166
167
  - lib/smith/agent/completion_usage_recording.rb
@@ -297,6 +298,7 @@ files:
297
298
  - lib/smith/pricing.rb
298
299
  - lib/smith/pricing/catalog_validation.rb
299
300
  - lib/smith/pricing_configuration_error.rb
301
+ - lib/smith/provider_errors.rb
300
302
  - lib/smith/provider_permanent_failure.rb
301
303
  - lib/smith/providers/anthropic/batches.rb
302
304
  - lib/smith/providers/batch_exchange.rb