ruby-pi 0.1.6 → 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: c78d37122ed67d80e61cf51b182dcd79a20a7efa77b503c8b0340963ad60b728
4
- data.tar.gz: e3b147cb2b01fe28ac15c2a65d6177156992be7560601886296b16941784ee08
3
+ metadata.gz: fbecc54fc18679e9f16a0a09c27944df82b0a98fff7ead1dee99ba1a659aad02
4
+ data.tar.gz: 298e084a7df3b6689e628477ddbae866057a81ac472838623ce6eefccd01a431
5
5
  SHA512:
6
- metadata.gz: cbc0c9abddf98885bf1a22352a9cd09475c324f9aff4bcdff66ce3a6a87e06eb677ab045c966038666744cf9819d5114714c66ba5b7c676de5958d5d964a6242
7
- data.tar.gz: 3f9c28b1a30d0e3ad0f1badd391c95065adea822927c1d334dc5fc5c9867e658b43e339e9307dd3eba8dd5a534043c9fae3ea8d0384bfae8eb35a1a09356f035
6
+ metadata.gz: cbb1ae3d3469f987b4ab7652cce23626e6e1a233c08636ab24dcda3c6151d0bc728d88658e575919840e393c84ce4efabbd5fa78f302c036993a0d1b3e79b354
7
+ data.tar.gz: 1132a860f026efc123813042eea18617242d036ec03ca425277cd2f9eba88a49f1cc2f461c6feee58fa021eeed8fbe42affee5c6f865ff6fad563809ede29479
data/CHANGELOG.md CHANGED
@@ -5,6 +5,69 @@ 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
+
31
+ ## [0.1.8] - 2026-06-09
32
+
33
+ ### Fixed (adversarial review round 6)
34
+
35
+ - **`Retry-After` header was parsed but never honored (High)**: On a 429, `handle_error_response` stored the server's `Retry-After` on `RateLimitError#retry_after`, but the retry loop in `BaseProvider#complete` always slept the local exponential backoff (capped at `retry_max_delay`) — hammering a server that asked for a longer cooldown until the retry budget burned out. The retry delay now prefers a positive `retry_after` (capped at `RETRY_AFTER_CEILING`, 60s); HTTP-date values (which parse to `0.0`) and absent headers fall through to the computed backoff
36
+ - **Parallel executor timeout/rejection Results reported `name: "unknown"` (High)**: `execute_parallel` hardcoded `"unknown"` in the timeout and rejected-future branches, so with several tools timing out concurrently, logs and `:tool_execution_end` subscribers could not tell which tool hung. Futures are now zipped with their originating calls and failure Results carry the real tool name; the timeout message matches sequential mode (`Tool 'x' timed out after Ns`). The rejected-future branch also no longer reports a misleading full-timeout `duration_ms` for what may have been an instant failure (now `0.0`)
37
+ - **Keyword-parameter tool blocks failed on every call (High)**: `Definition#call` passed a single positional Hash, so a block written `{ |content:, platform:| ... }` — the natural style given named schema parameters — raised `ArgumentError: missing keyword` on every invocation (surfacing as a confusing failed Result). `Definition` now detects keyword parameters at construction and splats the arguments hash to keywords; positional-Hash blocks are unchanged. Keyword blocks without `**rest` raise on unexpected keys — strict by design, since the keys come from the LLM
38
+ - **`:compaction` event was never emitted in production (Medium)**: `Compaction#emitter` defaults to nil and nothing ever assigned it, so the documented `agent.on(:compaction)` subscription silently never fired — the only place the emitter was set was the spec itself. `Loop#initialize` now wires its emitter into the compaction strategy (an explicitly preassigned emitter is left untouched)
39
+ - **Streaming chunks were never normalized to UTF-8 (Medium)**: Faraday delivers `on_data` chunks as ASCII-8BIT; appending a chunk to a UTF-8 SSE buffer already holding non-ASCII text raises `Encoding::CompatibilityError`, and yielded deltas could carry binary encoding into consumers' UTF-8 buffers. All three providers now buffer in BINARY and re-encode each complete SSE line to UTF-8 (with `scrub` guarding invalid bytes) before parsing, so `:text_delta` events are always valid UTF-8 — including multi-byte characters split across network chunks
40
+ - **Streaming fallback gave consumers no way to truncate partial primary output (Medium)**: If the primary streamed text and then died mid-stream, the fallback streamed a complete fresh response — a delta-appending consumer rendered `"<partial primary><full fallback>"` with no signal of how much to discard. The `:fallback_start` payload now includes `partial_output` (Boolean) and `partial_chars` (characters already yielded), so consumers can deterministically reset
41
+ - **Tool names were not validated against provider constraints (Medium)**: A tool named `send.email` registered fine and then 400'd on every Anthropic request with an opaque server error. `Definition` now validates names against `/\A[a-zA-Z0-9_-]{1,64}\z/` (the strictest provider constraint) and raises `ArgumentError` at definition time with a pointed message
42
+ - **`json` was used everywhere but never declared or required (Medium)**: `JSON.parse`/`JSON.generate` are called throughout the providers and agent loop, but the gem relied on Faraday's transitive `json` dependency and the entry point's single `require "json"` — loading `agent/loop.rb` in isolation raised `NameError`, contradicting the composability principle. The gemspec now declares `json >= 2.0` and every file referencing `JSON` requires it directly (pinned by a source-scan spec)
43
+ - **Configuration accepted negative retry/timeout values (Low)**: `max_retries = -1` silently disabled retries and a negative delay raised deep inside the retry loop's `sleep`. The numeric settings now have validated writers that raise `ArgumentError` at assignment time
44
+ - **Global configuration first-access race (Low)**: `@configuration ||= Configuration.new` was unsynchronized; two threads racing the first call could each construct a Configuration with one silently discarded. The configuration is now eagerly initialized at require time
45
+ - **`continue()` Result accounting documented (Docs)**: Each `run`/`continue` builds a fresh Loop, so the returned Result's `usage`/`tool_calls_made`/`turns` cover only that invocation while `messages` is cumulative — an undocumented asymmetry, now documented on `Core#continue`
46
+ - **Schema DSL documented as LLM-facing hints, not validation (Docs)**: Nothing validates model-supplied arguments against `tool.parameters` before invoking the block — `required`/`enum`/`minimum` constrain what the model is asked to produce, with no runtime enforcement or type coercion. This is deliberate (anti-framework), but the schema header now says so loudly and directs tool blocks to treat arguments as untrusted input
47
+ - **`State#add_message` unbounded growth documented (Docs)**: Long-lived agents calling `continue()` repeatedly accumulate messages linearly without compaction configured; documented on the method
48
+ - **CLAUDE.md module map corrected (Docs)**: The map referenced a nonexistent `agent/agent.rb`, omitted `core.rb`/`loop.rb`/`state.rb`/`events.rb`, hardcoded version `0.1.0`, and the extension example used the one-arg `|event|` block signature instead of the actual `|data, agent|`. All corrected
49
+
50
+ ### Release-history note
51
+
52
+ - **`[0.1.4]` below was never actually released**: `lib/ruby_pi/version.rb` went from `0.1.3` directly to `0.1.5` — the round-2 fixes documented under 0.1.4 shipped without a version bump and were first published as part of 0.1.5. There is intentionally no `v0.1.4` git tag or gem. (Discovered during round 6; the entry is kept for historical accuracy of *what* changed.)
53
+
54
+ ## [0.1.7] - 2026-05-28
55
+
56
+ ### Fixed (adversarial review round 5)
57
+
58
+ - **Compaction produced an Anthropic-invalid leading `:assistant` message (Critical)**: The 0.1.6 orphan-`:tool` strip fixed tool-result splitting but left the summary-role logic (`first_preserved == :assistant ? :user : :assistant`) intact. Whenever the first preserved message was `:user` (multi-turn reuse) or the preserved window emptied out (all tool results), the summary became an `:assistant` message at the head of the conversation — which Anthropic rejects with HTTP 400 "first message must use the 'user' role". The summary is now **always** a `:user` message (valid as the first message and never overwriting the system prompt). When the first preserved message is itself `:user`, the summary is merged into it to avoid consecutive same-role messages; an empty preserved window yields a lone `:user` summary. Extracted into `Compaction#build_compacted_history`
59
+ - **Compaction dead "mirror case" branch removed (Minor)**: The 0.1.6 `if droppable.last … && preserved.first[:role] == :tool` block was unreachable — the preceding `while` loop guarantees `preserved.first` is never `:tool`. Removed it (the originating assistant message is already in droppable alongside its now-moved tool results, so the pair is never split), eliminating misleading dead code
60
+ - **Deterministic `ProviderError` was retried with backoff (Minor)**: 0.1.6 added `RubyPi::ProviderError` to the retryable set in `BaseProvider#complete`, but provider errors are overwhelmingly deterministic request-construction failures (missing `tool_call_id`, invalid tool-argument JSON) raised before any HTTP call — retrying only burned the backoff schedule before re-raising the identical error. `ProviderError` is no longer retried. Fallback failover is unaffected (it rescues the `RubyPi::Error` superclass)
61
+ - **Lifecycle hooks saw string-keyed tool arguments while events saw symbols (Minor)**: `before_tool_call`/`after_tool_call` received the raw `ToolCall` (string-keyed `arguments`) while the `:tool_execution_start` event and `tool_calls_made` carried symbol keys — so a hook and an event subscriber disagreed on the key type for the same call. `Loop#act` now rebuilds each `ToolCall` with symbol-keyed arguments up front, so hooks, events, `tool_calls_made`, and the tool block all observe the identical shape
62
+ - **Anthropic streaming `finish_reason` could be clobbered to nil (Minor)**: A trailing `message_delta` event without a `stop_reason` overwrote the previously captured value, yielding a `Response` with no `finish_reason`. The assignment is now guarded (`finish_reason = delta["stop_reason"] if delta["stop_reason"]`), matching the OpenAI/Gemini guards
63
+ - **Gemini `finishReason` assumed a String (Minor)**: `finishReason.downcase` would raise `NoMethodError` on a non-String payload mid-stream. Both the streaming and standard paths now coerce via `to_s` before `downcase`, and remain consistent with each other
64
+ - **Dead streamed-content accumulator removed (Cleanup)**: `Loop#think` accumulated `streamed_content` that was never read (the recorded assistant message uses `Response#content`); the `.clear` on `:fallback_start` was a no-op and its comment was inaccurate. Removed the local; the `:provider_fallback` event still fires
65
+ - **`Fallback` class docstring corrected (Docs)**: The class-level docstring still described the removed happy-path buffering ("the Fallback now buffers deltas… buffered deltas are discarded"), contradicting the real-time direct-streaming implementation. Updated to describe direct streaming plus the `:fallback_start` signal
66
+
67
+ ### Investigated, no change
68
+
69
+ - **Streaming HTTP error bodies via `env.status`**: A prior review raised that streaming error responses might lose their body if Faraday's `on_data` callback received a nil `env.status`. Verified against the actual stack (faraday 2.14.1 / faraday-net_http 3.3.0): the net_http adapter calls `save_http_response` (which sets `env.status`) before `response.read_body` streams chunks, and `Env#stream_response` passes that same populated `env` to the user's `on_data` proc. `env.status` is therefore reliably available before the first chunk, so the existing `error_body` recovery works. No fix needed
70
+
8
71
  ## [0.1.6] - 2026-05-01
9
72
 
10
73
  ### Fixed (adversarial review round 4)
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),
@@ -140,12 +140,18 @@ module RubyPi
140
140
  # the existing conversation history and appends the new prompt before
141
141
  # resuming the loop.
142
142
  #
143
+ # NOTE on Result accounting: each run/continue builds a fresh Loop, so
144
+ # the returned Result's `usage`, `tool_calls_made`, and `turns` cover
145
+ # ONLY this invocation — while `messages` is cumulative across the whole
146
+ # conversation. Sum the per-call Results if you need session totals.
147
+ #
143
148
  # Issue #16: Uses the encapsulated reset_iteration! method instead of
144
149
  # the old approach that bypassed encapsulation
145
150
  # and was fragile.
146
151
  #
147
152
  # @param prompt [String] the follow-up user message
148
153
  # @return [RubyPi::Agent::Result] the outcome of the continued run
154
+ # (usage/tool_calls_made/turns are per-invocation; messages cumulative)
149
155
  def continue(prompt)
150
156
  @state.reset_iteration!
151
157
  @state.add_message(role: :user, content: prompt)
@@ -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
 
@@ -10,6 +10,8 @@
10
10
  # is reached. It handles streaming, lifecycle events, compaction, and all
11
11
  # pre/post tool call hooks.
12
12
 
13
+ require "json"
14
+
13
15
  module RubyPi
14
16
  module Agent
15
17
  # Executes the think-act-observe cycle against a given State, emitting
@@ -55,6 +57,14 @@ module RubyPi
55
57
  @state = state
56
58
  @emitter = emitter
57
59
  @compaction = compaction
60
+ # Wire the loop's emitter into the compaction strategy so the
61
+ # documented :compaction event actually reaches agent subscribers.
62
+ # Compaction#emitter defaults to nil and nothing else ever sets it —
63
+ # without this, `agent.on(:compaction)` never fires. An emitter that
64
+ # was already assigned explicitly is left untouched.
65
+ if @compaction.respond_to?(:emitter=) && @compaction.respond_to?(:emitter) && @compaction.emitter.nil?
66
+ @compaction.emitter = emitter
67
+ end
58
68
  @execution_mode = execution_mode
59
69
  @tool_timeout = tool_timeout
60
70
  @tool_calls_made = []
@@ -106,7 +116,10 @@ module RubyPi
106
116
  else
107
117
  # No tool calls — the LLM is done
108
118
  @emitter.emit(:turn_end, turn: @state.iteration, has_tool_calls: false)
109
- 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
+ )
110
123
  end
111
124
  end
112
125
  rescue *PROGRAMMING_ERRORS
@@ -145,17 +158,16 @@ module RubyPi
145
158
  # Build tools array for the LLM
146
159
  tools = build_tools_array
147
160
 
148
- # Accumulate streamed content
149
- streamed_content = +""
150
-
151
- # Call the LLM with streaming
161
+ # Call the LLM with streaming. The recorded assistant message uses
162
+ # the returned Response#content (already the final, authoritative
163
+ # text), so there is no need to accumulate deltas here — we only
164
+ # re-emit them for subscribers.
152
165
  response = @state.model.complete(
153
166
  messages: messages,
154
167
  tools: tools,
155
168
  stream: true
156
169
  ) do |event|
157
170
  if event.text_delta?
158
- streamed_content << event.data.to_s
159
171
  @emitter.emit(:text_delta, content: event.data)
160
172
  elsif event.tool_call_delta?
161
173
  # Emit tool call delta events so subscribers can observe partial
@@ -164,13 +176,14 @@ module RubyPi
164
176
  @emitter.emit(:tool_call_delta, data: event.data)
165
177
  elsif event.fallback_start?
166
178
  # The primary LLM provider failed mid-stream and a Fallback
167
- # provider is now taking over. Discard the partial text we
168
- # accumulated from the failed primary so the agent's recorded
169
- # response reflects only the fallback's output, and surface a
170
- # :provider_fallback event so subscribers can clear any UI
171
- # state they rendered from the discarded primary deltas.
172
- streamed_content.clear
179
+ # provider is now taking over. Surface a :provider_fallback event
180
+ # so subscribers can clear any UI state they rendered from the
181
+ # discarded primary deltas. The recorded response is unaffected:
182
+ # it comes from the fallback provider's returned Response#content,
183
+ # never from the failed primary's partial text.
173
184
  @emitter.emit(:provider_fallback, **event.data)
185
+ elsif event.retry_start?
186
+ @emitter.emit(:provider_retry, **event.data)
174
187
  end
175
188
  end
176
189
 
@@ -208,32 +221,39 @@ module RubyPi
208
221
  timeout: @tool_timeout
209
222
  )
210
223
 
211
- # Symbolize the JSON-parsed (string-keyed) tool_call arguments once,
212
- # up front. Both the executor (which actually invokes the tool block)
213
- # and the recorded `tool_calls_made` payload use this symbol-keyed
214
- # form, keeping a single consistent shape across the pipeline rather
215
- # than mixing string keys (raw from JSON) and symbol keys (post-
216
- # symbolize) in different places.
217
- symbolized = response.tool_calls.map do |tc|
218
- RubyPi::Tools::Executor.deep_symbolize_keys(tc.arguments)
224
+ # Normalize each tool call's arguments to symbol keys ONCE, up front,
225
+ # by rebuilding the ToolCall objects. Every downstream consumer the
226
+ # executor (which invokes the tool block), the before/after_tool_call
227
+ # hooks (which receive the ToolCall directly), the emitted
228
+ # :tool_execution_start event, and the recorded `tool_calls_made`
229
+ # payload then observes the identical symbol-keyed shape. Carrying
230
+ # the symbolized form on the ToolCall itself (rather than in a side
231
+ # array) is what keeps the hooks consistent with everything else;
232
+ # previously hooks saw raw string keys while events/records saw symbols.
233
+ tool_calls = response.tool_calls.map do |tc|
234
+ RubyPi::LLM::ToolCall.new(
235
+ id: tc.id,
236
+ name: tc.name,
237
+ arguments: RubyPi::Tools::Executor.deep_symbolize_keys(tc.arguments)
238
+ )
219
239
  end
220
240
 
221
241
  # Prepare call hashes for the executor
222
- calls = response.tool_calls.each_with_index.map do |tc, idx|
223
- { name: tc.name, arguments: symbolized[idx] }
242
+ calls = tool_calls.map do |tc|
243
+ { name: tc.name, arguments: tc.arguments }
224
244
  end
225
245
 
226
246
  # Fire before_tool_call hooks and emit start events
227
- response.tool_calls.each_with_index do |tc, idx|
247
+ tool_calls.each do |tc|
228
248
  @state.before_tool_call&.call(tc)
229
- @emitter.emit(:tool_execution_start, tool_name: tc.name, arguments: symbolized[idx])
249
+ @emitter.emit(:tool_execution_start, tool_name: tc.name, arguments: tc.arguments)
230
250
  end
231
251
 
232
252
  # Execute all tool calls
233
253
  results = executor.execute(calls)
234
254
 
235
255
  # Fire after_tool_call hooks, emit end events, and add results to messages
236
- response.tool_calls.each_with_index do |tc, idx|
256
+ tool_calls.each_with_index do |tc, idx|
237
257
  result = results[idx]
238
258
 
239
259
  @state.after_tool_call&.call(tc, result)
@@ -247,7 +267,7 @@ module RubyPi
247
267
  # arguments so callers see the same shape the tool itself received.
248
268
  @tool_calls_made << {
249
269
  tool_name: tc.name,
250
- arguments: symbolized[idx],
270
+ arguments: tc.arguments,
251
271
  result: result.to_h
252
272
  }
253
273
 
@@ -263,7 +283,12 @@ module RubyPi
263
283
  result.value.to_s
264
284
  end
265
285
  else
266
- "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."
267
292
  end
268
293
  @state.add_message(
269
294
  role: :tool,
@@ -306,6 +331,20 @@ module RubyPi
306
331
  @total_usage[:output_tokens] += (usage[:completion_tokens] || usage[:output_tokens] || 0)
307
332
  end
308
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
+
309
348
  # Triggers context compaction if a compaction strategy is configured
310
349
  # and the estimated token count exceeds the threshold.
311
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
@@ -91,14 +91,20 @@ module RubyPi
91
91
 
92
92
  # Appends a message to the conversation history.
93
93
  #
94
+ # NOTE: history grows without bound — there is no built-in cap. Growth
95
+ # per run is limited by max_iterations, but long-lived agents that call
96
+ # continue() repeatedly (or use a high max_iterations with large tool
97
+ # outputs) accumulate messages linearly. Configure
98
+ # Agent.new(compaction: ...) to keep the context bounded.
99
+ #
94
100
  # @param role [Symbol, String] the message role (:user, :assistant, :system, :tool)
95
101
  # @param content [String, nil] the text content of the message
96
102
  # @param options [Hash] additional fields (e.g., :tool_call_id, :tool_calls)
97
103
  # @return [Array<Hash>] the updated messages array
98
104
  def add_message(role:, content: nil, **options)
99
105
  message = { role: role.to_sym, content: content }.merge(options)
100
- @messages << message
101
- @messages
106
+ @messages << deep_dup(message)
107
+ messages
102
108
  end
103
109
 
104
110
  # Returns a frozen copy of the conversation history. Callers cannot
@@ -106,7 +112,7 @@ module RubyPi
106
112
  #
107
113
  # @return [Array<Hash>] the full conversation history
108
114
  def messages
109
- @messages.dup.freeze
115
+ deep_dup(@messages).freeze
110
116
  end
111
117
 
112
118
  # Replaces the entire conversation history. Used by compaction to swap
@@ -115,7 +121,7 @@ module RubyPi
115
121
  # @param new_messages [Array<Hash>] the replacement message array
116
122
  # @return [Array<Hash>] the new messages array
117
123
  def messages=(new_messages)
118
- @messages = Array(new_messages).dup
124
+ @messages = deep_dup(Array(new_messages))
119
125
  end
120
126
 
121
127
  # Returns the current iteration count (number of completed think-act-observe
@@ -162,6 +168,26 @@ module RubyPi
162
168
  "messages=#{@messages.size} " \
163
169
  "tools=#{@tools&.size || 0}>"
164
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
165
191
  end
166
192
  end
167
193
  end
@@ -37,19 +37,56 @@ module RubyPi
37
37
  attr_accessor :openai_api_key
38
38
 
39
39
  # @return [Integer] Maximum number of retry attempts for transient errors (default: 3)
40
- attr_accessor :max_retries
40
+ attr_reader :max_retries
41
41
 
42
42
  # @return [Float] Base delay in seconds for exponential backoff (default: 1.0)
43
- attr_accessor :retry_base_delay
43
+ attr_reader :retry_base_delay
44
44
 
45
45
  # @return [Float] Maximum delay in seconds between retries (default: 30.0)
46
- attr_accessor :retry_max_delay
46
+ attr_reader :retry_max_delay
47
47
 
48
48
  # @return [Integer] HTTP request timeout in seconds (default: 120)
49
- attr_accessor :request_timeout
49
+ attr_reader :request_timeout
50
50
 
51
51
  # @return [Integer] HTTP connection open timeout in seconds (default: 10)
52
- attr_accessor :open_timeout
52
+ attr_reader :open_timeout
53
+
54
+ # Validated writers for numeric settings. A negative max_retries silently
55
+ # disables retries and a negative delay raises deep inside the retry
56
+ # loop's sleep — fail fast at assignment time instead, where the typo is.
57
+
58
+ # @param value [Integer] must be a non-negative integer
59
+ def 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
+
64
+ @max_retries = value
65
+ end
66
+
67
+ # @param value [Numeric] must be non-negative
68
+ def retry_base_delay=(value)
69
+ validate_numeric!(:retry_base_delay, value)
70
+ @retry_base_delay = value
71
+ end
72
+
73
+ # @param value [Numeric] must be non-negative
74
+ def retry_max_delay=(value)
75
+ validate_numeric!(:retry_max_delay, value)
76
+ @retry_max_delay = value
77
+ end
78
+
79
+ # @param value [Numeric] must be non-negative
80
+ def request_timeout=(value)
81
+ validate_numeric!(:request_timeout, value)
82
+ @request_timeout = value
83
+ end
84
+
85
+ # @param value [Numeric] must be non-negative
86
+ def open_timeout=(value)
87
+ validate_numeric!(:open_timeout, value)
88
+ @open_timeout = value
89
+ end
53
90
 
54
91
  # @return [String] Default model name for Gemini provider
55
92
  attr_accessor :default_gemini_model
@@ -78,6 +115,17 @@ module RubyPi
78
115
 
79
116
  private
80
117
 
118
+ # Raises unless the value is a non-negative Numeric.
119
+ #
120
+ # @param name [Symbol] the setting name (for the error message)
121
+ # @param value [Object] the value being assigned
122
+ # @raise [ArgumentError] if value is not a Numeric or is negative
123
+ def validate_numeric!(name, value)
124
+ return if value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value >= 0
125
+
126
+ raise ArgumentError, "#{name} must be a finite non-negative number, got #{value.inspect}"
127
+ end
128
+
81
129
  # Sets all configuration ivars to their default values. Called by both
82
130
  # initialize and reset! to ensure consistent defaults without the
83
131
  # anti-pattern of calling initialize from reset!.