ruby-pi 0.1.8 → 0.1.9

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: b0054adb6a0863a8f296917be736df0ebfd789aa7205589b82689199d4bf4c06
4
- data.tar.gz: fc79dcc61dbefce874e609807d989cf2293b0ecb45a6aa036069b11038ac5c9a
3
+ metadata.gz: fbecc54fc18679e9f16a0a09c27944df82b0a98fff7ead1dee99ba1a659aad02
4
+ data.tar.gz: 298e084a7df3b6689e628477ddbae866057a81ac472838623ce6eefccd01a431
5
5
  SHA512:
6
- metadata.gz: c130ada9b7ed93f5c9a0d16596c1176fec258204be26af15c61db3c18effee94bc7a8a1783620397780b0e3501e660b4e8ff48d8463e4089067edfcbf3bf9b60
7
- data.tar.gz: dc179fe40cb063c4321a1c7a1aff5abb7b441d5fd87ced19908f9875c1f5b26bf2e17555b44658f603b32627577fe99feb8f34d061ed7e53eaba3c28cecd8bbb
6
+ metadata.gz: cbb1ae3d3469f987b4ab7652cce23626e6e1a233c08636ab24dcda3c6151d0bc728d88658e575919840e393c84ce4efabbd5fa78f302c036993a0d1b3e79b354
7
+ data.tar.gz: 1132a860f026efc123813042eea18617242d036ec03ca425277cd2f9eba88a49f1cc2f461c6feee58fa021eeed8fbe42affee5c6f865ff6fad563809ede29479
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.9] - 2026-08-28
9
+
10
+ ### Security
11
+
12
+ - **Tool exception details crossed the LLM trust boundary (High)**: Tool failures remain available locally in execution Results and lifecycle events, but model-visible tool messages now contain a generic diagnostic notice rather than the raw exception. This prevents database errors, paths, signed URLs, tokens, and PII embedded in exception messages from being sent to an external model provider
13
+ - **Vulnerable locked dependencies (High)**: Updated `faraday` to 2.14.3, `concurrent-ruby` to 1.3.8, and `faraday-net_http` to 3.4.4. This resolves the published Faraday stack-exhaustion and host-scoping advisories plus concurrent-ruby lock/livelock advisories; `bundle audit check` now reports no known vulnerabilities
14
+
15
+ ### Fixed (adversarial review round 7)
16
+
17
+ - **Provider truncation and safety stops were reported as success (High)**: The agent loop previously treated every response without tool calls as a clean completion, ignoring `finish_reason`. Only an explicit `"stop"` now maps to `stop_reason: :complete`; token limits, safety/content filters, and unknown provider stops produce distinct unsuccessful Results. `truncated?` now covers both iteration and provider token limits
18
+ - **Streaming retries concatenated failed and successful attempts (High)**: If an attempt yielded deltas before a transient failure, the retry streamed a fresh response into the same consumer with no reset signal. Providers now emit `:retry_start` with the discarded character count, and the agent translates it to `:provider_retry`. Fallback accounting resets across intra-provider retries so later failover truncation remains accurate
19
+ - **Deterministic client errors were retried (Medium)**: HTTP 400/404/422 and other non-transient 4xx responses now fail immediately. Retries are limited to transport/protocol failures, timeouts, rate limits, selected transient 4xx statuses, and 5xx responses
20
+ - **Successful malformed JSON bypassed retry/fallback (Medium)**: All standard provider responses now parse through a typed protocol-error path. Truncated or malformed HTTP 200 JSON raises a retryable `ApiError`, allowing normal retry and fallback behavior instead of leaking `JSON::ParserError`
21
+ - **Infinite and fractional retry configuration (Medium)**: `max_retries` now requires a non-negative Integer, numeric retry/timeout settings must be finite, and provider constructor overrides receive the same validation. This closes the `Float::INFINITY` endless-retry path
22
+ - **`Retry-After` HTTP dates were ignored (Medium)**: Both delta-seconds and RFC HTTP-date forms are now parsed; invalid or expired values fall back to exponential backoff
23
+ - **String-keyed JSON histories broke compaction (Medium)**: Compaction now reads symbol- and string-keyed roles/content consistently, restoring token estimates, orphan-tool handling, and summary transcripts for JSON-round-tripped histories
24
+ - **Result and State immutability was shallow (Medium)**: `Agent::Result` now deep-copies and freezes its observable data. `State` deep-copies message inputs/outputs so callers cannot mutate internal conversation history through retained nested references or `add_message` return values
25
+
26
+ ### Known limitations
27
+
28
+ - In-process tool timeouts remain advisory: Ruby threads cannot be safely force-terminated, so timed-out side-effecting tools can continue running. Use idempotency and cooperative deadlines until tool isolation is redesigned
29
+ - Streaming providers do not yet enforce provider-specific terminal SSE events; a malformed/truncated stream can omit an event without a strict EOF protocol error
30
+
8
31
  ## [0.1.8] - 2026-06-09
9
32
 
10
33
  ### Fixed (adversarial review round 6)
data/README.md CHANGED
@@ -130,6 +130,11 @@ model.complete(messages: messages, stream: true) do |event|
130
130
  print event.data # incremental text chunk
131
131
  when :tool_call_delta
132
132
  handle_fragment(event.data) # partial tool call JSON
133
+ when :retry_start
134
+ # A transient failure occurred after streaming may have begun. Discard
135
+ # the previous attempt before the provider retries from the beginning.
136
+ # Payload includes: { provider:, attempt:, partial_output:, partial_chars: }
137
+ clear_partial_output
133
138
  when :fallback_start
134
139
  # Only emitted by RubyPi::LLM::Fallback when the primary provider
135
140
  # fails mid-stream. Discard any partial output rendered from the
@@ -142,11 +147,11 @@ model.complete(messages: messages, stream: true) do |event|
142
147
  end
143
148
  ```
144
149
 
145
- When using `RubyPi::Agent`, the loop translates a `:fallback_start` stream
146
- event into an agent-level `:provider_fallback` event you can subscribe to
147
- with `agent.on(:provider_fallback) { |e| ... }`. The agent also discards
148
- any partial text it accumulated from the failed primary so the recorded
149
- response reflects only the fallback's output.
150
+ When using `RubyPi::Agent`, the loop translates `:retry_start` and
151
+ `:fallback_start` into agent-level `:provider_retry` and `:provider_fallback`
152
+ events. Subscribe to both to clear consumer-rendered partial output before a
153
+ fresh response begins. The recorded assistant response always comes from the
154
+ successful final attempt.
150
155
 
151
156
  #### Response & ToolCall
152
157
 
@@ -154,7 +159,7 @@ response reflects only the fallback's output.
154
159
  |---|---|
155
160
  | `RubyPi::LLM::Response` | `content`, `tool_calls`, `usage`, `finish_reason`, `tool_calls?` |
156
161
  | `RubyPi::LLM::ToolCall` | `id`, `name`, `arguments` |
157
- | `RubyPi::LLM::StreamEvent` | `type`, `data`, `text_delta?`, `tool_call_delta?`, `done?` |
162
+ | `RubyPi::LLM::StreamEvent` | `type`, `data`, `text_delta?`, `tool_call_delta?`, `retry_start?`, `fallback_start?`, `done?` |
158
163
 
159
164
  #### Fallback
160
165
 
@@ -306,6 +311,8 @@ Subscribe to lifecycle events for logging, monitoring, or custom behavior:
306
311
  agent.on(:turn_start) { |e| puts "Turn #{e[:turn]} starting" }
307
312
  agent.on(:turn_end) { |e| puts "Turn #{e[:turn]} ended" }
308
313
  agent.on(:text_delta) { |e| print e[:content] }
314
+ agent.on(:provider_retry) { |_e| clear_partial_output }
315
+ agent.on(:provider_fallback) { |_e| clear_partial_output }
309
316
  agent.on(:tool_execution_start){ |e| puts "Calling #{e[:tool_name]}" }
310
317
  agent.on(:tool_execution_end) { |e| puts "#{e[:tool_name]} => #{e[:result].value}" }
311
318
  # Note: before_tool_call and after_tool_call are constructor hooks (Procs),
@@ -26,6 +26,8 @@ module RubyPi
26
26
  # to backup mid-stream. Subscribers should
27
27
  # discard any partial text_delta output that
28
28
  # arrived before this event.
29
+ # - :provider_retry — A provider discarded a failed streaming
30
+ # attempt and is retrying from the beginning.
29
31
  EVENTS = %i[
30
32
  text_delta
31
33
  tool_call_delta
@@ -36,6 +38,7 @@ module RubyPi
36
38
  agent_end
37
39
  error
38
40
  compaction
41
+ provider_retry
39
42
  provider_fallback
40
43
  ].freeze
41
44
 
@@ -116,7 +116,10 @@ module RubyPi
116
116
  else
117
117
  # No tool calls — the LLM is done
118
118
  @emitter.emit(:turn_end, turn: @state.iteration, has_tool_calls: false)
119
- return build_result(content: response.content, stop_reason: :complete)
119
+ return build_result(
120
+ content: response.content,
121
+ stop_reason: agent_stop_reason(response.finish_reason)
122
+ )
120
123
  end
121
124
  end
122
125
  rescue *PROGRAMMING_ERRORS
@@ -179,6 +182,8 @@ module RubyPi
179
182
  # it comes from the fallback provider's returned Response#content,
180
183
  # never from the failed primary's partial text.
181
184
  @emitter.emit(:provider_fallback, **event.data)
185
+ elsif event.retry_start?
186
+ @emitter.emit(:provider_retry, **event.data)
182
187
  end
183
188
  end
184
189
 
@@ -278,7 +283,12 @@ module RubyPi
278
283
  result.value.to_s
279
284
  end
280
285
  else
281
- "Error: #{result.error}"
286
+ # Exception messages frequently contain database
287
+ # details, paths, signed URLs, tokens, or PII. The
288
+ # full error remains available locally through the
289
+ # Result and lifecycle event, but it must not be
290
+ # copied into the next request to the LLM provider.
291
+ "Error: Tool '#{tc.name}' failed. See local diagnostics."
282
292
  end
283
293
  @state.add_message(
284
294
  role: :tool,
@@ -321,6 +331,20 @@ module RubyPi
321
331
  @total_usage[:output_tokens] += (usage[:completion_tokens] || usage[:output_tokens] || 0)
322
332
  end
323
333
 
334
+ # Converts provider finish reasons into agent-level outcome semantics.
335
+ # Only an explicit normal stop is a successful completion; accepting a
336
+ # max-token, safety, content-filter, or unknown stop as success risks
337
+ # downstream publication or action on incomplete output.
338
+ def agent_stop_reason(finish_reason)
339
+ case finish_reason.to_s
340
+ when "stop" then :complete
341
+ when "max_tokens", "length" then :max_tokens
342
+ when "safety" then :safety
343
+ when "content_filter", "recitation", "prohibited_content" then :content_filter
344
+ else :unknown_provider_stop
345
+ end
346
+ end
347
+
324
348
  # Triggers context compaction if a compaction strategy is configured
325
349
  # and the estimated token count exceeds the threshold.
326
350
  #
@@ -51,8 +51,9 @@ module RubyPi
51
51
  attr_reader :error
52
52
 
53
53
  # @return [Symbol] the reason the agent stopped — :complete, :max_iterations,
54
- # or :error. Allows callers to distinguish between a clean finish and
55
- # being guillotined by the iteration limit.
54
+ # :max_tokens, :safety, :content_filter, :unknown_provider_stop, or
55
+ # :error. Allows callers to distinguish a clean finish from truncation
56
+ # or provider-enforced termination.
56
57
  #
57
58
  # Issue #19: Added stop_reason to distinguish between a natural stop
58
59
  # (LLM signaled completion) and hitting the max iteration limit. Previously,
@@ -70,10 +71,10 @@ module RubyPi
70
71
  # @param error [Exception, nil] error if the run failed
71
72
  # @param stop_reason [Symbol] why the agent stopped (:complete, :max_iterations, :error)
72
73
  def initialize(content: nil, messages: [], tool_calls_made: [], usage: {}, turns: 0, error: nil, stop_reason: :complete)
73
- @content = content
74
- @messages = Array(messages).freeze
75
- @tool_calls_made = Array(tool_calls_made).freeze
76
- @usage = usage
74
+ @content = deep_frozen_copy(content)
75
+ @messages = deep_frozen_copy(Array(messages))
76
+ @tool_calls_made = deep_frozen_copy(Array(tool_calls_made))
77
+ @usage = deep_frozen_copy(usage)
77
78
  @turns = turns
78
79
  @error = error
79
80
  @stop_reason = stop_reason
@@ -88,18 +89,18 @@ module RubyPi
88
89
  #
89
90
  # @return [Boolean] true only if the run completed naturally without error
90
91
  def success?
91
- @error.nil? && @stop_reason != :max_iterations
92
+ @error.nil? && @stop_reason == :complete
92
93
  end
93
94
 
94
- # Returns true if the agent was stopped by hitting the max iteration
95
- # limit rather than completing naturally.
95
+ # Returns true if the agent was stopped by an agent iteration limit or
96
+ # a provider token limit rather than completing naturally.
96
97
  #
97
98
  # Issue #19: Provides a convenient predicate for checking truncation
98
99
  # without inspecting stop_reason directly.
99
100
  #
100
- # @return [Boolean] true if the run was truncated by max_iterations
101
+ # @return [Boolean] true if the run was truncated by an iteration/token limit
101
102
  def truncated?
102
- @stop_reason == :max_iterations
103
+ %i[max_iterations max_tokens].include?(@stop_reason)
103
104
  end
104
105
 
105
106
  # Returns a hash representation of the result for serialization.
@@ -135,6 +136,33 @@ module RubyPi
135
136
  end
136
137
 
137
138
  alias_method :inspect, :to_s
139
+
140
+ private
141
+
142
+ # Agent::Result is documented as immutable. Freezing only the outer
143
+ # arrays still allowed callers (or later State mutations) to rewrite
144
+ # nested messages, tool arguments, usage, and content in place.
145
+ def deep_frozen_copy(value)
146
+ copy = case value
147
+ when Hash
148
+ value.each_with_object({}) do |(key, item), result|
149
+ result[deep_frozen_copy(key)] = deep_frozen_copy(item)
150
+ end
151
+ when Array
152
+ value.map { |item| deep_frozen_copy(item) }
153
+ when String
154
+ value.dup
155
+ else
156
+ begin
157
+ value.dup
158
+ rescue TypeError
159
+ # Immutable scalar values (nil, symbols, numerics, booleans)
160
+ # cannot be duplicated and are already safe to share.
161
+ value
162
+ end
163
+ end
164
+ copy.freeze
165
+ end
138
166
  end
139
167
  end
140
168
  end
@@ -80,7 +80,7 @@ module RubyPi
80
80
  @system_prompt = system_prompt
81
81
  @model = model
82
82
  @tools = tools
83
- @messages = Array(messages).dup
83
+ @messages = deep_dup(Array(messages))
84
84
  @max_iterations = max_iterations
85
85
  @transform_context = transform_context
86
86
  @before_tool_call = before_tool_call
@@ -103,8 +103,8 @@ module RubyPi
103
103
  # @return [Array<Hash>] the updated messages array
104
104
  def add_message(role:, content: nil, **options)
105
105
  message = { role: role.to_sym, content: content }.merge(options)
106
- @messages << message
107
- @messages
106
+ @messages << deep_dup(message)
107
+ messages
108
108
  end
109
109
 
110
110
  # Returns a frozen copy of the conversation history. Callers cannot
@@ -112,7 +112,7 @@ module RubyPi
112
112
  #
113
113
  # @return [Array<Hash>] the full conversation history
114
114
  def messages
115
- @messages.dup.freeze
115
+ deep_dup(@messages).freeze
116
116
  end
117
117
 
118
118
  # Replaces the entire conversation history. Used by compaction to swap
@@ -121,7 +121,7 @@ module RubyPi
121
121
  # @param new_messages [Array<Hash>] the replacement message array
122
122
  # @return [Array<Hash>] the new messages array
123
123
  def messages=(new_messages)
124
- @messages = Array(new_messages).dup
124
+ @messages = deep_dup(Array(new_messages))
125
125
  end
126
126
 
127
127
  # Returns the current iteration count (number of completed think-act-observe
@@ -168,6 +168,26 @@ module RubyPi
168
168
  "messages=#{@messages.size} " \
169
169
  "tools=#{@tools&.size || 0}>"
170
170
  end
171
+
172
+ private
173
+
174
+ # Copies the nested message structure so callers cannot mutate state by
175
+ # retaining an input reference or changing a Hash/String obtained from
176
+ # #messages. Message payloads are JSON-like, so recursive handling of
177
+ # Hash, Array, and String covers the supported shapes without attempting
178
+ # to duplicate arbitrary application objects.
179
+ def deep_dup(value)
180
+ case value
181
+ when Hash
182
+ value.each_with_object({}) { |(key, item), copy| copy[key] = deep_dup(item) }
183
+ when Array
184
+ value.map { |item| deep_dup(item) }
185
+ when String
186
+ value.dup
187
+ else
188
+ value
189
+ end
190
+ end
171
191
  end
172
192
  end
173
193
  end
@@ -57,7 +57,10 @@ module RubyPi
57
57
 
58
58
  # @param value [Integer] must be a non-negative integer
59
59
  def max_retries=(value)
60
- validate_numeric!(:max_retries, value)
60
+ unless value.is_a?(Integer) && value >= 0
61
+ raise ArgumentError, "max_retries must be a non-negative integer, got #{value.inspect}"
62
+ end
63
+
61
64
  @max_retries = value
62
65
  end
63
66
 
@@ -118,9 +121,9 @@ module RubyPi
118
121
  # @param value [Object] the value being assigned
119
122
  # @raise [ArgumentError] if value is not a Numeric or is negative
120
123
  def validate_numeric!(name, value)
121
- return if value.is_a?(Numeric) && value >= 0
124
+ return if value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value >= 0
122
125
 
123
- raise ArgumentError, "#{name} must be a non-negative number, got #{value.inspect}"
126
+ raise ArgumentError, "#{name} must be a finite non-negative number, got #{value.inspect}"
124
127
  end
125
128
 
126
129
  # Sets all configuration ivars to their default values. Called by both
@@ -95,7 +95,7 @@ module RubyPi
95
95
  # it is already in droppable, so the pair stays together there — there
96
96
  # is no mirror case to handle (once a tool result is moved across, its
97
97
  # assistant is never left stranded on the preserved side).
98
- while preserved.first && preserved.first[:role] == :tool
98
+ while preserved.first && message_value(preserved.first, :role).to_s == "tool"
99
99
  droppable << preserved.shift
100
100
  end
101
101
 
@@ -139,9 +139,10 @@ module RubyPi
139
139
  summary_text = "[Conversation Summary]\n#{summary}"
140
140
  first_preserved = preserved.first
141
141
 
142
- if first_preserved && first_preserved[:role] == :user
142
+ if first_preserved && message_value(first_preserved, :role).to_s == "user"
143
143
  merged = first_preserved.dup
144
- merged[:content] = "#{summary_text}\n\n#{first_preserved[:content]}"
144
+ content_key = first_preserved.key?(:content) ? :content : "content"
145
+ merged[content_key] = "#{summary_text}\n\n#{message_value(first_preserved, :content)}"
145
146
  [merged] + preserved.drop(1)
146
147
  else
147
148
  [{ role: :user, content: summary_text }] + preserved
@@ -158,7 +159,7 @@ module RubyPi
158
159
  total_chars = system_prompt.to_s.length
159
160
 
160
161
  messages.each do |msg|
161
- total_chars += msg[:content].to_s.length
162
+ total_chars += message_value(msg, :content).to_s.length
162
163
  # Account for role and structural overhead (~10 tokens per message)
163
164
  total_chars += 40
164
165
  end
@@ -199,14 +200,20 @@ module RubyPi
199
200
  # @return [String] formatted prompt for summarization
200
201
  def build_summary_prompt(messages)
201
202
  transcript = messages.map do |msg|
202
- role = msg[:role].to_s.capitalize
203
- content = msg[:content].to_s
203
+ role = message_value(msg, :role).to_s.capitalize
204
+ content = message_value(msg, :content).to_s
204
205
  "#{role}: #{content}"
205
206
  end.join("\n\n")
206
207
 
207
208
  "Summarize the following conversation, preserving all key facts, " \
208
209
  "decisions, and tool call results:\n\n#{transcript}"
209
210
  end
211
+
212
+ # Reads normalized symbol-keyed messages and histories loaded from JSON,
213
+ # whose top-level keys are strings.
214
+ def message_value(message, key)
215
+ message[key] || message[key.to_s]
216
+ end
210
217
  end
211
218
  end
212
219
  end
@@ -340,7 +340,7 @@ module RubyPi
340
340
  end
341
341
 
342
342
  handle_error_response(response) unless response.success?
343
- parse_response(JSON.parse(response.body))
343
+ parse_response(parse_json_response(response.body))
344
344
  end
345
345
 
346
346
  # Executes a streaming request to the Anthropic API, yielding events.
@@ -7,6 +7,9 @@
7
7
  # providers (Gemini, Anthropic, OpenAI) must subclass this and implement the
8
8
  # abstract methods.
9
9
 
10
+ require "json"
11
+ require "time"
12
+
10
13
  module RubyPi
11
14
  module LLM
12
15
  # Abstract base class that defines the contract every LLM provider must
@@ -53,6 +56,8 @@ module RubyPi
53
56
  @max_retries = max_retries || @config.max_retries
54
57
  @retry_base_delay = retry_base_delay || @config.retry_base_delay
55
58
  @retry_max_delay = retry_max_delay || @config.retry_max_delay
59
+
60
+ validate_retry_configuration!
56
61
  end
57
62
 
58
63
  # Sends a completion request to the LLM provider with automatic retry
@@ -71,10 +76,20 @@ module RubyPi
71
76
  # @raise [RubyPi::TimeoutError] on request timeouts
72
77
  def complete(messages:, tools: [], stream: false, &block)
73
78
  attempt = 0
79
+ partial_chars = 0
80
+
81
+ attempt_block = if stream && block
82
+ proc do |event|
83
+ partial_chars += event.data.to_s.length if event.text_delta?
84
+ block.call(event)
85
+ end
86
+ else
87
+ block
88
+ end
74
89
 
75
90
  begin
76
91
  attempt += 1
77
- perform_complete(messages: messages, tools: tools, stream: stream, &block)
92
+ perform_complete(messages: messages, tools: tools, stream: stream, &attempt_block)
78
93
  rescue RubyPi::AuthenticationError
79
94
  # Authentication errors are not retryable — raise immediately
80
95
  raise
@@ -93,9 +108,21 @@ module RubyPi
93
108
  # `attempt <= @max_retries` allows retries on attempts 1..3, so we get
94
109
  # 3 retries + 1 initial = 4 total attempts. Previously used `< @max_retries`
95
110
  # which was off-by-one (only 2 retries with max_retries: 3).
96
- if attempt <= @max_retries
111
+ if retryable_error?(e) && attempt <= @max_retries
97
112
  delay = retry_delay_for(e, attempt)
98
113
  log_retry(attempt, delay, e)
114
+
115
+ if stream && block
116
+ block.call(StreamEvent.new(type: :retry_start, data: {
117
+ provider: provider_name,
118
+ error: e.message,
119
+ attempt: attempt + 1,
120
+ partial_output: partial_chars.positive?,
121
+ partial_chars: partial_chars
122
+ }))
123
+ partial_chars = 0
124
+ end
125
+
99
126
  sleep(delay)
100
127
  retry
101
128
  else
@@ -159,6 +186,17 @@ module RubyPi
159
186
  end
160
187
  end
161
188
 
189
+ # Only transient failures should be retried. Deterministic client errors
190
+ # (400, 404, 422, etc.) cannot improve on an identical retry and merely
191
+ # waste quota and add the full backoff delay.
192
+ def retryable_error?(error)
193
+ return true if error.is_a?(RubyPi::RateLimitError) || error.is_a?(RubyPi::TimeoutError)
194
+ return false unless error.is_a?(RubyPi::ApiError)
195
+
196
+ status = error.status_code
197
+ status.nil? || status == 408 || status == 409 || status == 425 || status >= 500
198
+ end
199
+
162
200
  # Calculates the backoff delay for a given retry attempt using
163
201
  # exponential backoff with jitter.
164
202
  #
@@ -170,6 +208,27 @@ module RubyPi
170
208
  [base + jitter, @retry_max_delay].min
171
209
  end
172
210
 
211
+ # Validates constructor-level overrides. Configuration's writers perform
212
+ # the same checks, but provider keyword overrides bypass those writers.
213
+ # In particular, an infinite max_retries value makes the retry loop run
214
+ # forever because every finite attempt is <= Infinity.
215
+ def validate_retry_configuration!
216
+ unless @max_retries.is_a?(Integer) && @max_retries >= 0
217
+ raise ArgumentError,
218
+ "max_retries must be a non-negative integer, got #{@max_retries.inspect}"
219
+ end
220
+
221
+ {
222
+ retry_base_delay: @retry_base_delay,
223
+ retry_max_delay: @retry_max_delay
224
+ }.each do |name, value|
225
+ next if value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value >= 0
226
+
227
+ raise ArgumentError,
228
+ "#{name} must be a finite non-negative number, got #{value.inspect}"
229
+ end
230
+ end
231
+
173
232
  # Logs a retry attempt if a logger is configured.
174
233
  #
175
234
  # @param attempt [Integer] current attempt number
@@ -269,7 +328,7 @@ module RubyPi
269
328
  response_body: body
270
329
  )
271
330
  when 429
272
- retry_after = response.headers["retry-after"]&.to_f
331
+ retry_after = parse_retry_after(response.headers["retry-after"])
273
332
  raise RubyPi::RateLimitError.new(
274
333
  "#{provider_name} rate limit exceeded (HTTP 429)",
275
334
  retry_after: retry_after,
@@ -284,6 +343,36 @@ module RubyPi
284
343
  end
285
344
  end
286
345
 
346
+ # Parses a successful JSON response into a Hash while preserving the
347
+ # provider error contract. A proxy can truncate a nominal HTTP 200 body;
348
+ # treating that as a transient ApiError allows retry/fallback instead of
349
+ # leaking JSON::ParserError or accepting an empty response.
350
+ def parse_json_response(body)
351
+ JSON.parse(body)
352
+ rescue JSON::ParserError => e
353
+ raise RubyPi::ApiError.new(
354
+ "#{provider_name} returned malformed JSON: #{e.message}",
355
+ status_code: nil,
356
+ response_body: body
357
+ )
358
+ end
359
+
360
+ # Parses both Retry-After forms allowed by HTTP: delta-seconds and an
361
+ # HTTP-date. Fractional seconds are retained for compatibility with API
362
+ # providers and fast test environments. Invalid or past dates return nil
363
+ # so the normal exponential backoff policy applies.
364
+ def parse_retry_after(value)
365
+ return nil if value.nil? || value.to_s.strip.empty?
366
+
367
+ seconds = Float(value, exception: false)
368
+ return seconds if seconds&.finite? && seconds.positive?
369
+
370
+ delay = Time.httpdate(value.to_s) - Time.now
371
+ delay.positive? ? delay : nil
372
+ rescue ArgumentError
373
+ nil
374
+ end
375
+
287
376
  end
288
377
  end
289
378
  end
@@ -158,7 +158,13 @@ module RubyPi
158
158
  # truncate what they already rendered.
159
159
  partial_chars = 0
160
160
  counting_block = proc do |event|
161
- partial_chars += event.data.to_s.length if event.text_delta?
161
+ if event.retry_start?
162
+ # The provider tells consumers to discard its previous attempt,
163
+ # so only count text from the current attempt for a later fallback.
164
+ partial_chars = 0
165
+ elsif event.text_delta?
166
+ partial_chars += event.data.to_s.length
167
+ end
162
168
  block.call(event)
163
169
  end
164
170
 
@@ -281,7 +281,7 @@ module RubyPi
281
281
  end
282
282
 
283
283
  handle_error_response(response) unless response.success?
284
- parse_response(JSON.parse(response.body))
284
+ parse_response(parse_json_response(response.body))
285
285
  end
286
286
 
287
287
  # Executes a streaming request to the Gemini API, yielding events.
@@ -291,7 +291,7 @@ module RubyPi
291
291
  end
292
292
 
293
293
  handle_error_response(response) unless response.success?
294
- parse_response(JSON.parse(response.body))
294
+ parse_response(parse_json_response(response.body))
295
295
  end
296
296
 
297
297
  # Executes a streaming request to the OpenAI API, yielding events.
@@ -25,10 +25,10 @@ module RubyPi
25
25
  # end
26
26
  class StreamEvent
27
27
  # Valid event types for stream events.
28
- VALID_TYPES = %i[text_delta tool_call_delta done fallback_start].freeze
28
+ VALID_TYPES = %i[text_delta tool_call_delta done retry_start fallback_start].freeze
29
29
 
30
30
  # @return [Symbol] the type of stream event — one of :text_delta,
31
- # :tool_call_delta, :done, or :fallback_start
31
+ # :tool_call_delta, :done, :retry_start, or :fallback_start
32
32
  attr_reader :type
33
33
 
34
34
  # @return [Object] the event payload. For :text_delta this is a String
@@ -38,7 +38,8 @@ module RubyPi
38
38
 
39
39
  # Creates a new StreamEvent instance.
40
40
  #
41
- # @param type [Symbol] event type (:text_delta, :tool_call_delta, :done, :fallback_start)
41
+ # @param type [Symbol] event type (:text_delta, :tool_call_delta, :done,
42
+ # :retry_start, :fallback_start)
42
43
  # @param data [Object] event payload
43
44
  # @raise [ArgumentError] if the type is not recognized
44
45
  def initialize(type:, data: nil)
@@ -71,6 +72,12 @@ module RubyPi
71
72
  @type == :done
72
73
  end
73
74
 
75
+ # Returns true when a failed streaming attempt is being discarded and
76
+ # the same provider is about to retry from the beginning.
77
+ def retry_start?
78
+ @type == :retry_start
79
+ end
80
+
74
81
  # Returns true if this is a fallback_start event, signaling that the
75
82
  # primary provider failed mid-stream and the fallback provider is
76
83
  # taking over. Consumers should clear any partial output rendered
@@ -7,5 +7,5 @@
7
7
 
8
8
  module RubyPi
9
9
  # The current version of the RubyPi gem, following Semantic Versioning.
10
- VERSION = "0.1.8"
10
+ VERSION = "0.1.9"
11
11
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-pi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - RubyPi Contributors
@@ -171,7 +171,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
171
171
  - !ruby/object:Gem::Version
172
172
  version: '0'
173
173
  requirements: []
174
- rubygems_version: 3.6.9
174
+ rubygems_version: 4.0.16
175
175
  specification_version: 4
176
176
  summary: AI agent harness for Ruby — build LLM agents with tool calling, streaming,
177
177
  and a unified interface to OpenAI, Anthropic Claude, and Google Gemini.