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 +4 -4
- data/CHANGELOG.md +63 -0
- data/README.md +13 -6
- 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 +66 -27
- data/lib/ruby_pi/agent/result.rb +39 -11
- data/lib/ruby_pi/agent/state.rb +31 -5
- data/lib/ruby_pi/configuration.rb +53 -5
- data/lib/ruby_pi/context/compaction.rb +59 -50
- data/lib/ruby_pi/llm/anthropic.rb +27 -10
- data/lib/ruby_pi/llm/base_provider.rb +126 -5
- data/lib/ruby_pi/llm/fallback.rb +36 -9
- data/lib/ruby_pi/llm/gemini.rb +25 -11
- data/lib/ruby_pi/llm/openai.rb +18 -8
- data/lib/ruby_pi/llm/stream_event.rb +10 -3
- data/lib/ruby_pi/llm/tool_call.rb +2 -0
- data/lib/ruby_pi/tools/definition.rb +39 -4
- data/lib/ruby_pi/tools/executor.rb +14 -6
- data/lib/ruby_pi/tools/schema.rb +10 -0
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +7 -0
- metadata +17 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbecc54fc18679e9f16a0a09c27944df82b0a98fff7ead1dee99ba1a659aad02
|
|
4
|
+
data.tar.gz: 298e084a7df3b6689e628477ddbae866057a81ac472838623ce6eefccd01a431
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cbb1ae3d3469f987b4ab7652cce23626e6e1a233c08636ab24dcda3c6151d0bc728d88658e575919840e393c84ce4efabbd5fa78f302c036993a0d1b3e79b354
|
|
7
|
+
data.tar.gz: 1132a860f026efc123813042eea18617242d036ec03ca425277cd2f9eba88a49f1cc2f461c6feee58fa021eeed8fbe42affee5c6f865ff6fad563809ede29479
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
+
When using `RubyPi::Agent`, the loop translates `:retry_start` and
|
|
151
|
+
`:fallback_start` into agent-level `:provider_retry` and `:provider_fallback`
|
|
152
|
+
events. Subscribe to both to clear consumer-rendered partial output before a
|
|
153
|
+
fresh response begins. The recorded assistant response always comes from the
|
|
154
|
+
successful final attempt.
|
|
150
155
|
|
|
151
156
|
#### Response & ToolCall
|
|
152
157
|
|
|
@@ -154,7 +159,7 @@ response reflects only the fallback's output.
|
|
|
154
159
|
|---|---|
|
|
155
160
|
| `RubyPi::LLM::Response` | `content`, `tool_calls`, `usage`, `finish_reason`, `tool_calls?` |
|
|
156
161
|
| `RubyPi::LLM::ToolCall` | `id`, `name`, `arguments` |
|
|
157
|
-
| `RubyPi::LLM::StreamEvent` | `type`, `data`, `text_delta?`, `tool_call_delta?`, `done?` |
|
|
162
|
+
| `RubyPi::LLM::StreamEvent` | `type`, `data`, `text_delta?`, `tool_call_delta?`, `retry_start?`, `fallback_start?`, `done?` |
|
|
158
163
|
|
|
159
164
|
#### Fallback
|
|
160
165
|
|
|
@@ -306,6 +311,8 @@ Subscribe to lifecycle events for logging, monitoring, or custom behavior:
|
|
|
306
311
|
agent.on(:turn_start) { |e| puts "Turn #{e[:turn]} starting" }
|
|
307
312
|
agent.on(:turn_end) { |e| puts "Turn #{e[:turn]} ended" }
|
|
308
313
|
agent.on(:text_delta) { |e| print e[:content] }
|
|
314
|
+
agent.on(:provider_retry) { |_e| clear_partial_output }
|
|
315
|
+
agent.on(:provider_fallback) { |_e| clear_partial_output }
|
|
309
316
|
agent.on(:tool_execution_start){ |e| puts "Calling #{e[:tool_name]}" }
|
|
310
317
|
agent.on(:tool_execution_end) { |e| puts "#{e[:tool_name]} => #{e[:result].value}" }
|
|
311
318
|
# Note: before_tool_call and after_tool_call are constructor hooks (Procs),
|
data/lib/ruby_pi/agent/core.rb
CHANGED
|
@@ -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)
|
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
|
@@ -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(
|
|
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
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
#
|
|
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.
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
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
|
-
#
|
|
212
|
-
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
symbolized
|
|
218
|
-
|
|
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 =
|
|
223
|
-
{ name: tc.name, arguments:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
#
|
data/lib/ruby_pi/agent/result.rb
CHANGED
|
@@ -51,8 +51,9 @@ module RubyPi
|
|
|
51
51
|
attr_reader :error
|
|
52
52
|
|
|
53
53
|
# @return [Symbol] the reason the agent stopped — :complete, :max_iterations,
|
|
54
|
-
#
|
|
55
|
-
#
|
|
54
|
+
# :max_tokens, :safety, :content_filter, :unknown_provider_stop, or
|
|
55
|
+
# :error. Allows callers to distinguish a clean finish from truncation
|
|
56
|
+
# or provider-enforced termination.
|
|
56
57
|
#
|
|
57
58
|
# Issue #19: Added stop_reason to distinguish between a natural stop
|
|
58
59
|
# (LLM signaled completion) and hitting the max iteration limit. Previously,
|
|
@@ -70,10 +71,10 @@ module RubyPi
|
|
|
70
71
|
# @param error [Exception, nil] error if the run failed
|
|
71
72
|
# @param stop_reason [Symbol] why the agent stopped (:complete, :max_iterations, :error)
|
|
72
73
|
def initialize(content: nil, messages: [], tool_calls_made: [], usage: {}, turns: 0, error: nil, stop_reason: :complete)
|
|
73
|
-
@content = content
|
|
74
|
-
@messages = Array(messages)
|
|
75
|
-
@tool_calls_made = Array(tool_calls_made)
|
|
76
|
-
@usage = usage
|
|
74
|
+
@content = deep_frozen_copy(content)
|
|
75
|
+
@messages = deep_frozen_copy(Array(messages))
|
|
76
|
+
@tool_calls_made = deep_frozen_copy(Array(tool_calls_made))
|
|
77
|
+
@usage = deep_frozen_copy(usage)
|
|
77
78
|
@turns = turns
|
|
78
79
|
@error = error
|
|
79
80
|
@stop_reason = stop_reason
|
|
@@ -88,18 +89,18 @@ module RubyPi
|
|
|
88
89
|
#
|
|
89
90
|
# @return [Boolean] true only if the run completed naturally without error
|
|
90
91
|
def success?
|
|
91
|
-
@error.nil? && @stop_reason
|
|
92
|
+
@error.nil? && @stop_reason == :complete
|
|
92
93
|
end
|
|
93
94
|
|
|
94
|
-
# Returns true if the agent was stopped by
|
|
95
|
-
# limit rather than completing naturally.
|
|
95
|
+
# Returns true if the agent was stopped by an agent iteration limit or
|
|
96
|
+
# a provider token limit rather than completing naturally.
|
|
96
97
|
#
|
|
97
98
|
# Issue #19: Provides a convenient predicate for checking truncation
|
|
98
99
|
# without inspecting stop_reason directly.
|
|
99
100
|
#
|
|
100
|
-
# @return [Boolean] true if the run was truncated by
|
|
101
|
+
# @return [Boolean] true if the run was truncated by an iteration/token limit
|
|
101
102
|
def truncated?
|
|
102
|
-
@stop_reason
|
|
103
|
+
%i[max_iterations max_tokens].include?(@stop_reason)
|
|
103
104
|
end
|
|
104
105
|
|
|
105
106
|
# Returns a hash representation of the result for serialization.
|
|
@@ -135,6 +136,33 @@ module RubyPi
|
|
|
135
136
|
end
|
|
136
137
|
|
|
137
138
|
alias_method :inspect, :to_s
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Agent::Result is documented as immutable. Freezing only the outer
|
|
143
|
+
# arrays still allowed callers (or later State mutations) to rewrite
|
|
144
|
+
# nested messages, tool arguments, usage, and content in place.
|
|
145
|
+
def deep_frozen_copy(value)
|
|
146
|
+
copy = case value
|
|
147
|
+
when Hash
|
|
148
|
+
value.each_with_object({}) do |(key, item), result|
|
|
149
|
+
result[deep_frozen_copy(key)] = deep_frozen_copy(item)
|
|
150
|
+
end
|
|
151
|
+
when Array
|
|
152
|
+
value.map { |item| deep_frozen_copy(item) }
|
|
153
|
+
when String
|
|
154
|
+
value.dup
|
|
155
|
+
else
|
|
156
|
+
begin
|
|
157
|
+
value.dup
|
|
158
|
+
rescue TypeError
|
|
159
|
+
# Immutable scalar values (nil, symbols, numerics, booleans)
|
|
160
|
+
# cannot be duplicated and are already safe to share.
|
|
161
|
+
value
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
copy.freeze
|
|
165
|
+
end
|
|
138
166
|
end
|
|
139
167
|
end
|
|
140
168
|
end
|
data/lib/ruby_pi/agent/state.rb
CHANGED
|
@@ -80,7 +80,7 @@ module RubyPi
|
|
|
80
80
|
@system_prompt = system_prompt
|
|
81
81
|
@model = model
|
|
82
82
|
@tools = tools
|
|
83
|
-
@messages = Array(messages)
|
|
83
|
+
@messages = deep_dup(Array(messages))
|
|
84
84
|
@max_iterations = max_iterations
|
|
85
85
|
@transform_context = transform_context
|
|
86
86
|
@before_tool_call = before_tool_call
|
|
@@ -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
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
40
|
+
attr_reader :max_retries
|
|
41
41
|
|
|
42
42
|
# @return [Float] Base delay in seconds for exponential backoff (default: 1.0)
|
|
43
|
-
|
|
43
|
+
attr_reader :retry_base_delay
|
|
44
44
|
|
|
45
45
|
# @return [Float] Maximum delay in seconds between retries (default: 30.0)
|
|
46
|
-
|
|
46
|
+
attr_reader :retry_max_delay
|
|
47
47
|
|
|
48
48
|
# @return [Integer] HTTP request timeout in seconds (default: 120)
|
|
49
|
-
|
|
49
|
+
attr_reader :request_timeout
|
|
50
50
|
|
|
51
51
|
# @return [Integer] HTTP connection open timeout in seconds (default: 10)
|
|
52
|
-
|
|
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!.
|