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 +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +31 -7
- data/lib/ruby_pi/agent/core.rb +6 -0
- 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 +60 -11
- data/lib/ruby_pi/agent/state.rb +29 -5
- data/lib/ruby_pi/configuration.rb +6 -3
- data/lib/ruby_pi/context/compaction.rb +27 -7
- data/lib/ruby_pi/errors.rb +16 -0
- data/lib/ruby_pi/extensions/base.rb +0 -8
- data/lib/ruby_pi/llm/anthropic.rb +34 -71
- data/lib/ruby_pi/llm/base_provider.rb +127 -9
- data/lib/ruby_pi/llm/fallback.rb +20 -5
- data/lib/ruby_pi/llm/gemini.rb +63 -81
- data/lib/ruby_pi/llm/openai.rb +70 -91
- data/lib/ruby_pi/llm/sse_parser.rb +125 -0
- data/lib/ruby_pi/llm/stream_event.rb +10 -3
- data/lib/ruby_pi/tools/executor.rb +162 -76
- data/lib/ruby_pi/tools/result.rb +17 -2
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae334e07e1a5788bfe62b1a1d721d565885ba0142dca6d9a238656b3835454a0
|
|
4
|
+
data.tar.gz: 3f68b1f787a4fa17fd6ea68ef1b4974c5bb625b76c3898786a9b23c0999c490b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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(
|
|
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),
|
data/lib/ruby_pi/agent/core.rb
CHANGED
|
@@ -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,
|
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,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
|
data/lib/ruby_pi/agent/state.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
145
|
+
if first_preserved && message_value(first_preserved, :role).to_s == "user"
|
|
143
146
|
merged = first_preserved.dup
|
|
144
|
-
|
|
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
|
|
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
|
|
203
|
-
content = msg
|
|
204
|
-
|
|
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
|
data/lib/ruby_pi/errors.rb
CHANGED
|
@@ -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
|