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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +13 -6
- data/lib/ruby_pi/agent/events.rb +3 -0
- data/lib/ruby_pi/agent/loop.rb +26 -2
- data/lib/ruby_pi/agent/result.rb +39 -11
- data/lib/ruby_pi/agent/state.rb +25 -5
- data/lib/ruby_pi/configuration.rb +6 -3
- data/lib/ruby_pi/context/compaction.rb +13 -6
- data/lib/ruby_pi/llm/anthropic.rb +1 -1
- data/lib/ruby_pi/llm/base_provider.rb +92 -3
- data/lib/ruby_pi/llm/fallback.rb +7 -1
- data/lib/ruby_pi/llm/gemini.rb +1 -1
- data/lib/ruby_pi/llm/openai.rb +1 -1
- data/lib/ruby_pi/llm/stream_event.rb +10 -3
- data/lib/ruby_pi/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbecc54fc18679e9f16a0a09c27944df82b0a98fff7ead1dee99ba1a659aad02
|
|
4
|
+
data.tar.gz: 298e084a7df3b6689e628477ddbae866057a81ac472838623ce6eefccd01a431
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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),
|
data/lib/ruby_pi/agent/events.rb
CHANGED
|
@@ -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
|
|
data/lib/ruby_pi/agent/loop.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
#
|
data/lib/ruby_pi/agent/result.rb
CHANGED
|
@@ -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
|
-
#
|
|
55
|
-
#
|
|
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)
|
|
75
|
-
@tool_calls_made = Array(tool_calls_made)
|
|
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
|
|
92
|
+
@error.nil? && @stop_reason == :complete
|
|
92
93
|
end
|
|
93
94
|
|
|
94
|
-
# Returns true if the agent was stopped by
|
|
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
|
|
101
|
+
# @return [Boolean] true if the run was truncated by an iteration/token limit
|
|
101
102
|
def truncated?
|
|
102
|
-
@stop_reason
|
|
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
|
data/lib/ruby_pi/agent/state.rb
CHANGED
|
@@ -80,7 +80,7 @@ module RubyPi
|
|
|
80
80
|
@system_prompt = system_prompt
|
|
81
81
|
@model = model
|
|
82
82
|
@tools = tools
|
|
83
|
-
@messages = Array(messages)
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
142
|
+
if first_preserved && message_value(first_preserved, :role).to_s == "user"
|
|
143
143
|
merged = first_preserved.dup
|
|
144
|
-
|
|
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
|
|
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
|
|
203
|
-
content = msg
|
|
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(
|
|
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, &
|
|
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"]
|
|
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
|
data/lib/ruby_pi/llm/fallback.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/ruby_pi/llm/gemini.rb
CHANGED
|
@@ -281,7 +281,7 @@ module RubyPi
|
|
|
281
281
|
end
|
|
282
282
|
|
|
283
283
|
handle_error_response(response) unless response.success?
|
|
284
|
-
parse_response(
|
|
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.
|
data/lib/ruby_pi/llm/openai.rb
CHANGED
|
@@ -291,7 +291,7 @@ module RubyPi
|
|
|
291
291
|
end
|
|
292
292
|
|
|
293
293
|
handle_error_response(response) unless response.success?
|
|
294
|
-
parse_response(
|
|
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,
|
|
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
|
data/lib/ruby_pi/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|