ruby-pi 0.1.8 → 0.1.10

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: ae334e07e1a5788bfe62b1a1d721d565885ba0142dca6d9a238656b3835454a0
4
+ data.tar.gz: 3f68b1f787a4fa17fd6ea68ef1b4974c5bb625b76c3898786a9b23c0999c490b
5
5
  SHA512:
6
- metadata.gz: c130ada9b7ed93f5c9a0d16596c1176fec258204be26af15c61db3c18effee94bc7a8a1783620397780b0e3501e660b4e8ff48d8463e4089067edfcbf3bf9b60
7
- data.tar.gz: dc179fe40cb063c4321a1c7a1aff5abb7b441d5fd87ced19908f9875c1f5b26bf2e17555b44658f603b32627577fe99feb8f34d061ed7e53eaba3c28cecd8bbb
6
+ metadata.gz: cc0289504d07435aa8e681a102bcdc4e27d8f4f8033ab73ea73c227e345ab64be93cc0fd8d3dd27d50c640d4758508e14a91b290fed02fd089477f73e5ea3d35
7
+ data.tar.gz: 031ad8a0ca080a0024f596ec05e57857bd7cab68dc9236ae5ada606f09bbeedea5e59142e01e6bdea2de032008a1624bd9ad0db78fd4ed6fc1ddcddaab53331b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,50 @@ 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.10] - 2026-08-28
9
+
10
+ ### Security
11
+
12
+ - **Malformed or truncated HTTP-200 streams were accepted (High)**: Added a shared bounded SSE decoder with UTF-8 validation, standards-compliant event framing, final-buffer processing, and typed retryable protocol errors. OpenAI now requires `data: [DONE]`, Anthropic requires `message_stop`, and Gemini requires a finish reason or explicit prompt block before emitting `:done`
13
+ - **Model-controlled tool fan-out was unbounded (High)**: Parallel execution now uses a bounded pool with a default concurrency of four, rejects more than 32 calls per execution, and enforces a process-wide cap of 16 active or timed-out-but-still-running tools. Streaming output, tool arguments, tool-call count, SSE events, and retained error bodies also have hard memory limits
14
+ - **Malformed model arguments could enter logs (Medium)**: Provider errors no longer embed raw tool arguments, and fallback diagnostics remove control characters and cap message length
15
+
16
+ ### Fixed (adversarial review round 8)
17
+
18
+ - **Tool timeout results falsely implied failure was final (High)**: Timed-out work is now reported as `status: :timeout_unknown` with `completion_unknown?`. Sequential execution does not start later calls after an uncertain timeout; those calls return `status: :skipped`. Arbitrary in-process Ruby blocks are still cooperative and cannot be force-terminated safely
19
+ - **Immutable agent results could crash on valid tool values (High)**: Result snapshots are cycle-aware and never call `dup` on arbitrary application objects. Non-JSON objects become bounded frozen diagnostic strings instead of raising from `initialize_copy` or recursing indefinitely
20
+ - **Retry/fallback reset metadata ignored tool fragments (Medium)**: Reset events now report `partial_tool_calls` and set `partial_output` whenever either text or structured tool-call output was emitted
21
+ - **Compaction ignored structured tool metadata (Medium)**: Token estimates and summary prompts now account for assistant tool calls, IDs, names, and arguments
22
+ - **Invalid safety limits failed deep in execution (Medium)**: Agent iteration limits, tool timeouts, call limits, and concurrency limits now reject non-finite, zero, negative, or wrong-type values at construction
23
+ - **Release gates covered tests only (Low)**: CI now runs dependency auditing, Lint/Security checks, gem build/install, and an installed-artifact load/version smoke test alongside Ruby 3.2-3.4 tests
24
+
25
+ ### Known limitation
26
+
27
+ - Ruby cannot safely force-terminate an arbitrary in-process block. A timed-out tool may still finish and commit side effects, so callers must treat `completion_unknown?` as an indeterminate outcome and use idempotency keys. Enforceable cancellation requires isolated worker processes or an external job backend in a future execution API
28
+
29
+ ## [0.1.9] - 2026-08-28
30
+
31
+ ### Security
32
+
33
+ - **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
34
+ - **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
35
+
36
+ ### Fixed (adversarial review round 7)
37
+
38
+ - **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
39
+ - **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
40
+ - **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
41
+ - **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`
42
+ - **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
43
+ - **`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
44
+ - **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
45
+ - **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
46
+
47
+ ### Known limitations
48
+
49
+ - 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
50
+ - 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
51
+
8
52
  ## [0.1.8] - 2026-06-09
9
53
 
10
54
  ### Fixed (adversarial review round 6)
data/README.md CHANGED
@@ -130,6 +130,12 @@ 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 partial_chars and partial_tool_calls. Always discard
137
+ # all text and structured tool state from the failed attempt.
138
+ clear_partial_output
133
139
  when :fallback_start
134
140
  # Only emitted by RubyPi::LLM::Fallback when the primary provider
135
141
  # fails mid-stream. Discard any partial output rendered from the
@@ -142,11 +148,11 @@ model.complete(messages: messages, stream: true) do |event|
142
148
  end
143
149
  ```
144
150
 
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.
151
+ When using `RubyPi::Agent`, the loop translates `:retry_start` and
152
+ `:fallback_start` into agent-level `:provider_retry` and `:provider_fallback`
153
+ events. Subscribe to both to clear consumer-rendered partial output before a
154
+ fresh response begins. The recorded assistant response always comes from the
155
+ successful final attempt.
150
156
 
151
157
  #### Response & ToolCall
152
158
 
@@ -154,7 +160,7 @@ response reflects only the fallback's output.
154
160
  |---|---|
155
161
  | `RubyPi::LLM::Response` | `content`, `tool_calls`, `usage`, `finish_reason`, `tool_calls?` |
156
162
  | `RubyPi::LLM::ToolCall` | `id`, `name`, `arguments` |
157
- | `RubyPi::LLM::StreamEvent` | `type`, `data`, `text_delta?`, `tool_call_delta?`, `done?` |
163
+ | `RubyPi::LLM::StreamEvent` | `type`, `data`, `text_delta?`, `tool_call_delta?`, `retry_start?`, `fallback_start?`, `done?` |
158
164
 
159
165
  #### Fallback
160
166
 
@@ -235,7 +241,13 @@ registry.all # => [Definition, ...]
235
241
  Run tool calls in parallel or sequentially with automatic error handling and timeouts:
236
242
 
237
243
  ```ruby
238
- executor = RubyPi::Tools::Executor.new(registry, mode: :parallel, timeout: 30)
244
+ executor = RubyPi::Tools::Executor.new(
245
+ registry,
246
+ mode: :parallel,
247
+ timeout: 30,
248
+ max_calls: 32,
249
+ max_concurrency: 4
250
+ )
239
251
 
240
252
  results = executor.execute([
241
253
  { name: "create_post", arguments: { content: "Hello" } },
@@ -247,6 +259,7 @@ results.each do |r|
247
259
  puts "#{r.name}: #{r.value}"
248
260
  else
249
261
  puts "#{r.name} failed: #{r.error} (#{r.duration_ms}ms)"
262
+ reconcile_with_an_idempotency_key if r.completion_unknown?
250
263
  end
251
264
  end
252
265
  ```
@@ -258,6 +271,15 @@ end
258
271
  | `value` | Return value (on success) |
259
272
  | `error` | Error message (on failure) |
260
273
  | `duration_ms` | Execution time in milliseconds |
274
+ | `status` | `:success`, `:error`, `:timeout_unknown`, or `:skipped` |
275
+ | `completion_unknown?` | The deadline elapsed, but the in-process tool may still finish or commit side effects |
276
+ | `skipped?` | Sequential execution did not start this call after an uncertain timeout |
277
+
278
+ Parallel execution is bounded. A model response containing more than `max_calls`
279
+ tool calls raises `RubyPi::ToolCallLimitError`, and the process refuses new work
280
+ after 16 tools remain active. Ruby cannot safely kill an arbitrary
281
+ in-process block, so use idempotency keys for side-effecting tools and treat
282
+ `completion_unknown?` as indeterminate rather than as a confirmed rollback.
261
283
 
262
284
  ---
263
285
 
@@ -306,6 +328,8 @@ Subscribe to lifecycle events for logging, monitoring, or custom behavior:
306
328
  agent.on(:turn_start) { |e| puts "Turn #{e[:turn]} starting" }
307
329
  agent.on(:turn_end) { |e| puts "Turn #{e[:turn]} ended" }
308
330
  agent.on(:text_delta) { |e| print e[:content] }
331
+ agent.on(:provider_retry) { |_e| clear_partial_output }
332
+ agent.on(:provider_fallback) { |_e| clear_partial_output }
309
333
  agent.on(:tool_execution_start){ |e| puts "Calling #{e[:tool_name]}" }
310
334
  agent.on(:tool_execution_end) { |e| puts "#{e[:tool_name]} => #{e[:result].value}" }
311
335
  # Note: before_tool_call and after_tool_call are constructor hooks (Procs),
@@ -101,6 +101,12 @@ module RubyPi
101
101
  execution_mode: :parallel,
102
102
  tool_timeout: 30
103
103
  )
104
+ valid_timeout = tool_timeout.is_a?(Numeric) && !tool_timeout.is_a?(Complex) &&
105
+ tool_timeout.finite? && tool_timeout.positive?
106
+ unless valid_timeout
107
+ raise ArgumentError, "tool_timeout must be a finite positive number, got #{tool_timeout.inspect}"
108
+ end
109
+
104
110
  @state = State.new(
105
111
  system_prompt: system_prompt,
106
112
  model: model,
@@ -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,54 @@ 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
+ snapshot(value, {}.compare_by_identity).freeze
147
+ end
148
+
149
+ # Copies only JSON-like containers and immutable scalars. Arbitrary tool
150
+ # return objects are converted to a bounded diagnostic string instead of
151
+ # calling #dup, which may allocate resources, raise from initialize_copy,
152
+ # or recurse forever through cyclic structures.
153
+ def snapshot(value, memo)
154
+ if value.is_a?(Hash) || value.is_a?(Array)
155
+ return "[Circular]".freeze if memo.key?(value)
156
+
157
+ memo[value] = true
158
+ end
159
+
160
+ copy = case value
161
+ when Hash
162
+ value.each_with_object({}) do |(key, item), result|
163
+ result[snapshot(key, memo).freeze] = snapshot(item, memo).freeze
164
+ end
165
+ when Array
166
+ value.map { |item| snapshot(item, memo).freeze }
167
+ when String
168
+ value.dup
169
+ when NilClass, TrueClass, FalseClass, Symbol, Numeric
170
+ value
171
+ else
172
+ safe_object_snapshot(value)
173
+ end
174
+
175
+ memo.delete(value) if value.is_a?(Hash) || value.is_a?(Array)
176
+ copy
177
+ end
178
+
179
+ def safe_object_snapshot(value)
180
+ rendered = value.to_s
181
+ rendered = rendered.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�")
182
+ rendered = rendered.byteslice(0, 4096).scrub if rendered.bytesize > 4096
183
+ "#<#{value.class}: #{rendered}>"
184
+ rescue StandardError
185
+ "#<#{value.class}: uninspectable>"
186
+ end
138
187
  end
139
188
  end
140
189
  end
@@ -77,10 +77,14 @@ module RubyPi
77
77
  after_tool_call: nil,
78
78
  user_data: {}
79
79
  )
80
+ unless max_iterations.is_a?(Integer) && max_iterations.positive?
81
+ raise ArgumentError, "max_iterations must be a positive integer, got #{max_iterations.inspect}"
82
+ end
83
+
80
84
  @system_prompt = system_prompt
81
85
  @model = model
82
86
  @tools = tools
83
- @messages = Array(messages).dup
87
+ @messages = deep_dup(Array(messages))
84
88
  @max_iterations = max_iterations
85
89
  @transform_context = transform_context
86
90
  @before_tool_call = before_tool_call
@@ -103,8 +107,8 @@ module RubyPi
103
107
  # @return [Array<Hash>] the updated messages array
104
108
  def add_message(role:, content: nil, **options)
105
109
  message = { role: role.to_sym, content: content }.merge(options)
106
- @messages << message
107
- @messages
110
+ @messages << deep_dup(message)
111
+ messages
108
112
  end
109
113
 
110
114
  # Returns a frozen copy of the conversation history. Callers cannot
@@ -112,7 +116,7 @@ module RubyPi
112
116
  #
113
117
  # @return [Array<Hash>] the full conversation history
114
118
  def messages
115
- @messages.dup.freeze
119
+ deep_dup(@messages).freeze
116
120
  end
117
121
 
118
122
  # Replaces the entire conversation history. Used by compaction to swap
@@ -121,7 +125,7 @@ module RubyPi
121
125
  # @param new_messages [Array<Hash>] the replacement message array
122
126
  # @return [Array<Hash>] the new messages array
123
127
  def messages=(new_messages)
124
- @messages = Array(new_messages).dup
128
+ @messages = deep_dup(Array(new_messages))
125
129
  end
126
130
 
127
131
  # Returns the current iteration count (number of completed think-act-observe
@@ -168,6 +172,26 @@ module RubyPi
168
172
  "messages=#{@messages.size} " \
169
173
  "tools=#{@tools&.size || 0}>"
170
174
  end
175
+
176
+ private
177
+
178
+ # Copies the nested message structure so callers cannot mutate state by
179
+ # retaining an input reference or changing a Hash/String obtained from
180
+ # #messages. Message payloads are JSON-like, so recursive handling of
181
+ # Hash, Array, and String covers the supported shapes without attempting
182
+ # to duplicate arbitrary application objects.
183
+ def deep_dup(value)
184
+ case value
185
+ when Hash
186
+ value.each_with_object({}) { |(key, item), copy| copy[key] = deep_dup(item) }
187
+ when Array
188
+ value.map { |item| deep_dup(item) }
189
+ when String
190
+ value.dup
191
+ else
192
+ value
193
+ end
194
+ end
171
195
  end
172
196
  end
173
197
  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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  # lib/ruby_pi/context/compaction.rb
4
6
  #
5
7
  # RubyPi::Context::Compaction — Token estimation and context window management.
@@ -28,6 +30,7 @@ module RubyPi
28
30
  # Average characters per token — a rough heuristic that avoids the need
29
31
  # for provider-specific tokenizers. Errs on the conservative side.
30
32
  CHARS_PER_TOKEN = 4
33
+ SUMMARY_METADATA_LIMIT = 16_384
31
34
 
32
35
  # @return [Integer] the token threshold above which compaction triggers
33
36
  attr_reader :max_tokens
@@ -95,7 +98,7 @@ module RubyPi
95
98
  # it is already in droppable, so the pair stays together there — there
96
99
  # is no mirror case to handle (once a tool result is moved across, its
97
100
  # assistant is never left stranded on the preserved side).
98
- while preserved.first && preserved.first[:role] == :tool
101
+ while preserved.first && message_value(preserved.first, :role).to_s == "tool"
99
102
  droppable << preserved.shift
100
103
  end
101
104
 
@@ -139,9 +142,10 @@ module RubyPi
139
142
  summary_text = "[Conversation Summary]\n#{summary}"
140
143
  first_preserved = preserved.first
141
144
 
142
- if first_preserved && first_preserved[:role] == :user
145
+ if first_preserved && message_value(first_preserved, :role).to_s == "user"
143
146
  merged = first_preserved.dup
144
- merged[:content] = "#{summary_text}\n\n#{first_preserved[:content]}"
147
+ content_key = first_preserved.key?(:content) ? :content : "content"
148
+ merged[content_key] = "#{summary_text}\n\n#{message_value(first_preserved, :content)}"
145
149
  [merged] + preserved.drop(1)
146
150
  else
147
151
  [{ role: :user, content: summary_text }] + preserved
@@ -158,7 +162,9 @@ module RubyPi
158
162
  total_chars = system_prompt.to_s.length
159
163
 
160
164
  messages.each do |msg|
161
- total_chars += msg[:content].to_s.length
165
+ total_chars += message_value(msg, :content).to_s.length
166
+ metadata = msg.reject { |key, _| key == :role || key == "role" || key == :content || key == "content" }
167
+ total_chars += serialized_message(metadata).length unless metadata.empty?
162
168
  # Account for role and structural overhead (~10 tokens per message)
163
169
  total_chars += 40
164
170
  end
@@ -199,14 +205,28 @@ module RubyPi
199
205
  # @return [String] formatted prompt for summarization
200
206
  def build_summary_prompt(messages)
201
207
  transcript = messages.map do |msg|
202
- role = msg[:role].to_s.capitalize
203
- content = msg[:content].to_s
204
- "#{role}: #{content}"
208
+ role = message_value(msg, :role).to_s.capitalize
209
+ content = message_value(msg, :content).to_s
210
+ metadata = msg.reject { |key, _| key == :role || key == "role" || key == :content || key == "content" }
211
+ metadata_text = metadata.empty? ? nil : serialized_message(metadata).slice(0, SUMMARY_METADATA_LIMIT)
212
+ ["#{role}: #{content}", ("Structured metadata: #{metadata_text}" if metadata_text)].compact.join("\n")
205
213
  end.join("\n\n")
206
214
 
207
215
  "Summarize the following conversation, preserving all key facts, " \
208
216
  "decisions, and tool call results:\n\n#{transcript}"
209
217
  end
218
+
219
+ # Reads normalized symbol-keyed messages and histories loaded from JSON,
220
+ # whose top-level keys are strings.
221
+ def message_value(message, key)
222
+ message[key] || message[key.to_s]
223
+ end
224
+
225
+ def serialized_message(message)
226
+ JSON.generate(message)
227
+ rescue JSON::GeneratorError, TypeError
228
+ message.to_s
229
+ end
210
230
  end
211
231
  end
212
232
  end
@@ -39,6 +39,14 @@ module RubyPi
39
39
  end
40
40
  end
41
41
 
42
+ # Raised when an HTTP streaming response violates the provider's protocol.
43
+ # This includes malformed SSE data, invalid UTF-8, oversized events, and a
44
+ # connection that closes before the provider's terminal event arrives.
45
+ #
46
+ # It inherits from ApiError with a nil status code so BaseProvider treats a
47
+ # broken HTTP-200 stream like a transient transport failure and can retry it.
48
+ class StreamingProtocolError < ApiError; end
49
+
42
50
  # Raised when authentication fails (HTTP 401 or 403). Typically indicates
43
51
  # an invalid, expired, or missing API key.
44
52
  class AuthenticationError < ApiError
@@ -112,4 +120,12 @@ module RubyPi
112
120
  super(message || "Model returned tool calls but no tools are registered")
113
121
  end
114
122
  end
123
+
124
+ # Raised when a model asks the executor to exceed its configured per-turn
125
+ # tool-call safety limit.
126
+ class ToolCallLimitError < Error; end
127
+
128
+ # Raised when too many previously timed-out in-process tools are still
129
+ # running and accepting more work would risk thread exhaustion.
130
+ class ToolExecutionCapacityError < Error; end
115
131
  end
@@ -72,14 +72,6 @@ module RubyPi
72
72
  end
73
73
  end
74
74
 
75
- # Returns the extension name. Override in subclasses to provide
76
- # a human-readable identifier.
77
- #
78
- # @return [String] the extension name
79
- def name
80
- super
81
- end
82
-
83
75
  private
84
76
 
85
77
  # Returns the hooks hash defined directly on this class (not