ruby-pi 0.1.5 → 0.1.8
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 +51 -0
- data/lib/ruby_pi/agent/core.rb +6 -0
- data/lib/ruby_pi/agent/loop.rb +40 -25
- data/lib/ruby_pi/agent/state.rb +6 -0
- data/lib/ruby_pi/configuration.rb +50 -5
- data/lib/ruby_pi/context/compaction.rb +61 -24
- data/lib/ruby_pi/llm/anthropic.rb +38 -17
- data/lib/ruby_pi/llm/base_provider.rb +72 -1
- data/lib/ruby_pi/llm/fallback.rb +30 -9
- data/lib/ruby_pi/llm/gemini.rb +136 -37
- data/lib/ruby_pi/llm/openai.rb +53 -19
- 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 +24 -7
- 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 +15 -1
data/lib/ruby_pi/llm/fallback.rb
CHANGED
|
@@ -16,11 +16,14 @@ module RubyPi
|
|
|
16
16
|
# Authentication errors are NOT retried with the fallback since they
|
|
17
17
|
# indicate a configuration problem rather than a transient failure.
|
|
18
18
|
#
|
|
19
|
-
# Issue #23: When streaming,
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
19
|
+
# Issue #23 + Issue #12: When streaming, events flow from the primary
|
|
20
|
+
# provider directly to the consumer in real time (no buffering), preserving
|
|
21
|
+
# the streaming UX on the happy path. If the primary fails mid-stream, a
|
|
22
|
+
# :fallback_start StreamEvent is emitted before the fallback takes over, so
|
|
23
|
+
# the consumer can discard any partial output already rendered from the
|
|
24
|
+
# failed primary. (The agent loop translates :fallback_start into a
|
|
25
|
+
# :provider_fallback event; raw Fallback consumers should handle
|
|
26
|
+
# :fallback_start themselves.)
|
|
24
27
|
#
|
|
25
28
|
# @example Setting up a fallback chain
|
|
26
29
|
# primary = RubyPi::LLM.model(:gemini, "gemini-2.0-flash")
|
|
@@ -146,6 +149,19 @@ module RubyPi
|
|
|
146
149
|
# @yield [event] the consumer's streaming block
|
|
147
150
|
# @return [RubyPi::LLM::Response]
|
|
148
151
|
def perform_complete_with_streaming_fallback(messages:, tools:, &block)
|
|
152
|
+
# Count the characters of text already delivered to the consumer from
|
|
153
|
+
# the primary. If the primary fails mid-stream AFTER yielding text,
|
|
154
|
+
# the fallback streams a complete fresh response — a consumer that
|
|
155
|
+
# merely appends deltas would render the primary's partial text
|
|
156
|
+
# followed by the full fallback text. The :fallback_start payload
|
|
157
|
+
# carries partial_output/partial_chars so consumers can deterministically
|
|
158
|
+
# truncate what they already rendered.
|
|
159
|
+
partial_chars = 0
|
|
160
|
+
counting_block = proc do |event|
|
|
161
|
+
partial_chars += event.data.to_s.length if event.text_delta?
|
|
162
|
+
block.call(event)
|
|
163
|
+
end
|
|
164
|
+
|
|
149
165
|
begin
|
|
150
166
|
# Stream primary events directly to the consumer for real-time UX.
|
|
151
167
|
# No buffering — tokens appear immediately as they arrive.
|
|
@@ -153,7 +169,7 @@ module RubyPi
|
|
|
153
169
|
messages: messages,
|
|
154
170
|
tools: tools,
|
|
155
171
|
stream: true,
|
|
156
|
-
&
|
|
172
|
+
&counting_block
|
|
157
173
|
)
|
|
158
174
|
|
|
159
175
|
response
|
|
@@ -164,12 +180,17 @@ module RubyPi
|
|
|
164
180
|
log_fallback(e)
|
|
165
181
|
|
|
166
182
|
# Signal the consumer that the primary failed mid-stream and a
|
|
167
|
-
# fallback provider is taking over. Consumers
|
|
168
|
-
# to clear any partial output from the failed primary
|
|
183
|
+
# fallback provider is taking over. Consumers MUST use this event
|
|
184
|
+
# to clear any partial output from the failed primary:
|
|
185
|
+
# partial_output — true when the primary yielded any text deltas
|
|
186
|
+
# partial_chars — how many characters were yielded (truncate by
|
|
187
|
+
# this amount if appending to a shared buffer)
|
|
169
188
|
block.call(StreamEvent.new(type: :fallback_start, data: {
|
|
170
189
|
failed_provider: @primary.provider_name,
|
|
171
190
|
error: e.message,
|
|
172
|
-
fallback_provider: @fallback.provider_name
|
|
191
|
+
fallback_provider: @fallback.provider_name,
|
|
192
|
+
partial_output: partial_chars.positive?,
|
|
193
|
+
partial_chars: partial_chars
|
|
173
194
|
}))
|
|
174
195
|
|
|
175
196
|
# Stream directly from the fallback to the consumer's block.
|
data/lib/ruby_pi/llm/gemini.rb
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
# the Gemini REST API for both synchronous and streaming completions, including
|
|
7
7
|
# tool/function calling support.
|
|
8
8
|
|
|
9
|
+
require "json"
|
|
10
|
+
require "securerandom"
|
|
11
|
+
|
|
9
12
|
module RubyPi
|
|
10
13
|
module LLM
|
|
11
14
|
# Google Gemini provider implementation. Communicates with the Gemini
|
|
@@ -115,44 +118,116 @@ module RubyPi
|
|
|
115
118
|
|
|
116
119
|
# Converts a normalized message hash to Gemini's content format.
|
|
117
120
|
#
|
|
121
|
+
# Critically, an assistant message that carries `tool_calls` (set by
|
|
122
|
+
# the agent loop after a tool-using turn) must be rendered with one
|
|
123
|
+
# `functionCall` part per tool call. Without those parts, Gemini
|
|
124
|
+
# rejects any subsequent `functionResponse` on the next turn because
|
|
125
|
+
# the response has nothing to correlate against. Earlier versions
|
|
126
|
+
# dropped `tool_calls` here, breaking multi-turn tool use.
|
|
127
|
+
#
|
|
118
128
|
# @param message [Hash] a message with :role and :content keys
|
|
119
129
|
# @return [Hash] Gemini-formatted content object
|
|
120
130
|
def format_message(message)
|
|
121
131
|
role = message[:role]&.to_s || message["role"]&.to_s || "user"
|
|
122
|
-
content = message[:content] || message["content"]
|
|
123
|
-
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
# by build_request_body before reaching
|
|
128
|
-
|
|
129
|
-
when "assistant" then "model"
|
|
130
|
-
when "tool" then "user"
|
|
131
|
-
else role
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Tool-role messages carry function call results. When tool_call_id
|
|
135
|
-
# and name are present, send as a Gemini functionResponse so the
|
|
136
|
-
# model can correlate the result with its earlier functionCall.
|
|
132
|
+
content = message[:content] || message["content"]
|
|
133
|
+
|
|
134
|
+
# Tool-role messages carry function-call results. When the tool name
|
|
135
|
+
# is present, send as a Gemini functionResponse so the model can
|
|
136
|
+
# correlate the result with its earlier functionCall. System messages
|
|
137
|
+
# should have been extracted by build_request_body before reaching
|
|
138
|
+
# this method.
|
|
137
139
|
tool_name = message[:name] || message["name"]
|
|
138
140
|
if role == "tool" && tool_name
|
|
141
|
+
# Gemini's functionResponse expects a structured `response` object.
|
|
142
|
+
# Tool results are pre-serialized by the loop as either a JSON
|
|
143
|
+
# string (success) or an "Error: ..." string (failure). Try to
|
|
144
|
+
# parse JSON so the model receives structured data; fall back to
|
|
145
|
+
# wrapping the raw string under :result for plain-text content.
|
|
146
|
+
response_payload = parse_tool_response(content)
|
|
139
147
|
return {
|
|
140
148
|
role: "user",
|
|
141
149
|
parts: [{
|
|
142
150
|
functionResponse: {
|
|
143
151
|
name: tool_name.to_s,
|
|
144
|
-
response:
|
|
152
|
+
response: response_payload
|
|
145
153
|
}
|
|
146
154
|
}]
|
|
147
155
|
}
|
|
148
156
|
end
|
|
149
157
|
|
|
158
|
+
# Assistant messages may carry `tool_calls` from a prior turn. Each
|
|
159
|
+
# one must be emitted as a `functionCall` part on the model turn so
|
|
160
|
+
# that the next turn's `functionResponse` has something to bind to.
|
|
161
|
+
if role == "assistant"
|
|
162
|
+
parts = []
|
|
163
|
+
text = content.to_s
|
|
164
|
+
parts << { text: text } unless text.empty?
|
|
165
|
+
|
|
166
|
+
tool_calls = message[:tool_calls] || message["tool_calls"]
|
|
167
|
+
if tool_calls.is_a?(Array)
|
|
168
|
+
tool_calls.each do |tc|
|
|
169
|
+
tc_name = (tc[:name] || tc["name"]).to_s
|
|
170
|
+
tc_args = tc[:arguments] || tc["arguments"] || {}
|
|
171
|
+
tc_args = parse_tool_arguments(tc_args)
|
|
172
|
+
parts << { functionCall: { name: tc_name, args: tc_args } }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Gemini rejects an empty parts array on a model turn. If the
|
|
177
|
+
# assistant truly had no content and no tool_calls, fall back to
|
|
178
|
+
# an empty text part.
|
|
179
|
+
parts << { text: "" } if parts.empty?
|
|
180
|
+
|
|
181
|
+
return { role: "model", parts: parts }
|
|
182
|
+
end
|
|
183
|
+
|
|
150
184
|
{
|
|
151
|
-
role:
|
|
185
|
+
role: role,
|
|
152
186
|
parts: [{ text: content.to_s }]
|
|
153
187
|
}
|
|
154
188
|
end
|
|
155
189
|
|
|
190
|
+
# Best-effort parse of a tool-result string into a structured object
|
|
191
|
+
# for Gemini's `functionResponse.response`. JSON content is returned
|
|
192
|
+
# as-is (wrapped in a hash if it parsed to a non-hash); non-JSON
|
|
193
|
+
# content (e.g., "Error: ...") is wrapped under :result.
|
|
194
|
+
#
|
|
195
|
+
# @param content [String, Hash, nil]
|
|
196
|
+
# @return [Hash]
|
|
197
|
+
def parse_tool_response(content)
|
|
198
|
+
return { result: "" } if content.nil?
|
|
199
|
+
return content if content.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
str = content.to_s
|
|
202
|
+
return { result: str } if str.strip.empty?
|
|
203
|
+
|
|
204
|
+
begin
|
|
205
|
+
parsed = JSON.parse(str)
|
|
206
|
+
parsed.is_a?(Hash) ? parsed : { result: parsed }
|
|
207
|
+
rescue JSON::ParserError
|
|
208
|
+
{ result: str }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Coerce a tool_call.arguments value (Hash, JSON string, or other)
|
|
213
|
+
# into a Hash suitable for Gemini's `functionCall.args`. Malformed
|
|
214
|
+
# or non-Hash values become an empty hash so the request is still
|
|
215
|
+
# well-formed.
|
|
216
|
+
#
|
|
217
|
+
# @param args [Hash, String, nil]
|
|
218
|
+
# @return [Hash]
|
|
219
|
+
def parse_tool_arguments(args)
|
|
220
|
+
return args if args.is_a?(Hash)
|
|
221
|
+
return {} unless args.is_a?(String) && !args.strip.empty?
|
|
222
|
+
|
|
223
|
+
begin
|
|
224
|
+
parsed = JSON.parse(args)
|
|
225
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
226
|
+
rescue JSON::ParserError
|
|
227
|
+
{}
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
156
231
|
# Converts a tool definition to Gemini's function declaration format.
|
|
157
232
|
# Accepts either a RubyPi::Tools::Definition or a plain Hash.
|
|
158
233
|
#
|
|
@@ -198,9 +273,11 @@ module RubyPi
|
|
|
198
273
|
conn = build_connection(base_url: BASE_URL, headers: default_headers)
|
|
199
274
|
url = "/#{API_VERSION}/models/#{@model}:generateContent"
|
|
200
275
|
|
|
201
|
-
response =
|
|
202
|
-
|
|
203
|
-
|
|
276
|
+
response = with_transport_errors do
|
|
277
|
+
conn.post(url) do |req|
|
|
278
|
+
req.headers["Content-Type"] = "application/json"
|
|
279
|
+
req.body = JSON.generate(body)
|
|
280
|
+
end
|
|
204
281
|
end
|
|
205
282
|
|
|
206
283
|
handle_error_response(response) unless response.success?
|
|
@@ -229,15 +306,21 @@ module RubyPi
|
|
|
229
306
|
# which may split SSE events mid-line. We accumulate a line buffer and
|
|
230
307
|
# process complete lines incrementally so that deltas reach the caller
|
|
231
308
|
# as soon as each SSE event is fully received.
|
|
232
|
-
|
|
309
|
+
# BINARY buffer: chunks arrive as ASCII-8BIT and may end mid-way
|
|
310
|
+
# through a multi-byte UTF-8 character; appending such a chunk to a
|
|
311
|
+
# UTF-8 buffer holding non-ASCII text raises
|
|
312
|
+
# Encoding::CompatibilityError. Complete lines are re-encoded to
|
|
313
|
+
# UTF-8 (and scrubbed) before parsing.
|
|
314
|
+
sse_buffer = (+"").force_encoding(Encoding::BINARY)
|
|
233
315
|
response_status = nil
|
|
234
|
-
error_body = +""
|
|
316
|
+
error_body = (+"").force_encoding(Encoding::BINARY)
|
|
235
317
|
|
|
236
|
-
response =
|
|
237
|
-
|
|
238
|
-
|
|
318
|
+
response = with_transport_errors do
|
|
319
|
+
conn.post(url) do |req|
|
|
320
|
+
req.headers["Content-Type"] = "application/json"
|
|
321
|
+
req.body = JSON.generate(body)
|
|
239
322
|
|
|
240
|
-
|
|
323
|
+
# Use Faraday's on_data callback for real incremental streaming.
|
|
241
324
|
# Without this, Faraday buffers the entire response body before
|
|
242
325
|
# returning — no deltas reach the caller until the model finishes
|
|
243
326
|
# generating (fake streaming).
|
|
@@ -247,14 +330,17 @@ module RubyPi
|
|
|
247
330
|
# If the HTTP status indicates an error, accumulate the body for
|
|
248
331
|
# the error handler instead of parsing it as SSE events.
|
|
249
332
|
if response_status && response_status >= 400
|
|
250
|
-
error_body << chunk
|
|
333
|
+
error_body << chunk.b
|
|
251
334
|
next
|
|
252
335
|
end
|
|
253
336
|
|
|
254
|
-
sse_buffer << chunk
|
|
255
|
-
# Process all complete lines in the buffer
|
|
337
|
+
sse_buffer << chunk.b
|
|
338
|
+
# Process all complete lines in the buffer. A complete line holds
|
|
339
|
+
# complete UTF-8 sequences (multi-byte characters split across
|
|
340
|
+
# chunks are repaired by the buffering), so re-encode it to UTF-8
|
|
341
|
+
# here; scrub guards against a server sending invalid bytes.
|
|
256
342
|
while (line_end = sse_buffer.index("\n"))
|
|
257
|
-
line = sse_buffer.slice!(0, line_end + 1).strip
|
|
343
|
+
line = sse_buffer.slice!(0, line_end + 1).force_encoding(Encoding::UTF_8).scrub.strip
|
|
258
344
|
next if line.empty?
|
|
259
345
|
next unless line.start_with?("data: ")
|
|
260
346
|
|
|
@@ -281,7 +367,12 @@ module RubyPi
|
|
|
281
367
|
elsif part.key?("functionCall")
|
|
282
368
|
fc = part["functionCall"]
|
|
283
369
|
tool_call = ToolCall.new(
|
|
284
|
-
|
|
370
|
+
# Generate a globally-unique ID per tool call. A simple
|
|
371
|
+
# length-based counter ("gemini_0", "gemini_1") collides
|
|
372
|
+
# across turns since each response restarts numbering at
|
|
373
|
+
# 0, breaking any caller that uses ID as a hash key for
|
|
374
|
+
# observability or result correlation.
|
|
375
|
+
id: "gemini_#{SecureRandom.hex(8)}",
|
|
285
376
|
name: fc["name"],
|
|
286
377
|
arguments: fc["args"] || {}
|
|
287
378
|
)
|
|
@@ -293,8 +384,11 @@ module RubyPi
|
|
|
293
384
|
# Parse the actual finish reason from the streaming response
|
|
294
385
|
# instead of hardcoding "stop". Gemini sends finishReason in
|
|
295
386
|
# the candidate object (e.g., "STOP", "MAX_TOKENS", "SAFETY").
|
|
387
|
+
# Coerce via to_s before downcase so a non-String payload can
|
|
388
|
+
# never raise NoMethodError mid-stream (mirrors the &.to_s in
|
|
389
|
+
# the non-streaming parse path).
|
|
296
390
|
if candidate["finishReason"]
|
|
297
|
-
finish_reason = candidate["finishReason"].downcase
|
|
391
|
+
finish_reason = candidate["finishReason"].to_s.downcase
|
|
298
392
|
end
|
|
299
393
|
|
|
300
394
|
# Capture usage metadata if present
|
|
@@ -308,13 +402,14 @@ module RubyPi
|
|
|
308
402
|
end
|
|
309
403
|
end
|
|
310
404
|
end
|
|
311
|
-
|
|
405
|
+
end # conn.post
|
|
406
|
+
end # with_transport_errors
|
|
312
407
|
|
|
313
408
|
# When on_data is active, the response body was consumed by the
|
|
314
409
|
# callback. Pass the accumulated error_body so ApiError carries the
|
|
315
410
|
# full server message instead of an empty body.
|
|
316
411
|
unless response.success?
|
|
317
|
-
error_body_str = error_body.empty? ? response.body : error_body
|
|
412
|
+
error_body_str = error_body.empty? ? response.body : error_body.force_encoding(Encoding::UTF_8).scrub
|
|
318
413
|
handle_error_response(response, override_body: error_body_str)
|
|
319
414
|
end
|
|
320
415
|
|
|
@@ -347,7 +442,9 @@ module RubyPi
|
|
|
347
442
|
elsif part.key?("functionCall")
|
|
348
443
|
fc = part["functionCall"]
|
|
349
444
|
tool_calls << ToolCall.new(
|
|
350
|
-
|
|
445
|
+
# See note in perform_streaming_request: per-response counters
|
|
446
|
+
# collide across turns, so we generate a globally-unique ID.
|
|
447
|
+
id: "gemini_#{SecureRandom.hex(8)}",
|
|
351
448
|
name: fc["name"],
|
|
352
449
|
arguments: fc["args"] || {}
|
|
353
450
|
)
|
|
@@ -365,8 +462,10 @@ module RubyPi
|
|
|
365
462
|
}
|
|
366
463
|
end
|
|
367
464
|
|
|
368
|
-
# Map Gemini finish reason to normalized string
|
|
369
|
-
|
|
465
|
+
# Map Gemini finish reason to normalized string. to_s guards against
|
|
466
|
+
# a non-String payload (mirrors the streaming path); &. keeps a
|
|
467
|
+
# missing finishReason as nil.
|
|
468
|
+
finish_reason = candidate["finishReason"]&.to_s&.downcase
|
|
370
469
|
|
|
371
470
|
Response.new(
|
|
372
471
|
content: content,
|
data/lib/ruby_pi/llm/openai.rb
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
# OpenAI Chat Completions API for both synchronous and streaming completions,
|
|
7
7
|
# including function/tool calling support.
|
|
8
8
|
|
|
9
|
+
require "json"
|
|
10
|
+
|
|
9
11
|
module RubyPi
|
|
10
12
|
module LLM
|
|
11
13
|
# OpenAI provider implementation. Communicates with the OpenAI Chat
|
|
@@ -183,11 +185,31 @@ module RubyPi
|
|
|
183
185
|
tc_name = tc[:name] || tc["name"]
|
|
184
186
|
tc_args = tc[:arguments] || tc["arguments"] || {}
|
|
185
187
|
|
|
186
|
-
# OpenAI requires arguments
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
# OpenAI requires arguments to be a JSON-encoded string. We
|
|
189
|
+
# validate up-front so a malformed string fails fast with a
|
|
190
|
+
# typed error here rather than as an opaque HTTP 400 from
|
|
191
|
+
# OpenAI. This mirrors Anthropic's input validation in
|
|
192
|
+
# build_assistant_message.
|
|
193
|
+
args_string = case tc_args
|
|
194
|
+
when Hash
|
|
190
195
|
JSON.generate(tc_args)
|
|
196
|
+
when String
|
|
197
|
+
stripped = tc_args.strip
|
|
198
|
+
if stripped.empty?
|
|
199
|
+
"{}"
|
|
200
|
+
else
|
|
201
|
+
begin
|
|
202
|
+
JSON.parse(tc_args)
|
|
203
|
+
tc_args
|
|
204
|
+
rescue JSON::ParserError => e
|
|
205
|
+
raise RubyPi::ProviderError.new(
|
|
206
|
+
"Invalid JSON in assistant tool_call.arguments " \
|
|
207
|
+
"for tool '#{tc_name || "unknown"}': #{e.message} " \
|
|
208
|
+
"(raw: #{tc_args.inspect})",
|
|
209
|
+
provider: :openai
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
191
213
|
else
|
|
192
214
|
"{}"
|
|
193
215
|
end
|
|
@@ -261,9 +283,11 @@ module RubyPi
|
|
|
261
283
|
headers: default_headers
|
|
262
284
|
)
|
|
263
285
|
|
|
264
|
-
response =
|
|
265
|
-
|
|
266
|
-
|
|
286
|
+
response = with_transport_errors do
|
|
287
|
+
conn.post("/v1/chat/completions") do |req|
|
|
288
|
+
req.headers["Content-Type"] = "application/json"
|
|
289
|
+
req.body = JSON.generate(body)
|
|
290
|
+
end
|
|
267
291
|
end
|
|
268
292
|
|
|
269
293
|
handle_error_response(response) unless response.success?
|
|
@@ -296,15 +320,21 @@ module RubyPi
|
|
|
296
320
|
# which may split SSE events mid-line. We accumulate a line buffer and
|
|
297
321
|
# process complete lines incrementally so that deltas reach the caller
|
|
298
322
|
# as soon as each SSE event is fully received.
|
|
299
|
-
|
|
323
|
+
# BINARY buffer: chunks arrive as ASCII-8BIT and may end mid-way
|
|
324
|
+
# through a multi-byte UTF-8 character; appending such a chunk to a
|
|
325
|
+
# UTF-8 buffer holding non-ASCII text raises
|
|
326
|
+
# Encoding::CompatibilityError. Complete lines are re-encoded to
|
|
327
|
+
# UTF-8 (and scrubbed) before parsing.
|
|
328
|
+
sse_buffer = (+"").force_encoding(Encoding::BINARY)
|
|
300
329
|
response_status = nil
|
|
301
|
-
error_body = +""
|
|
330
|
+
error_body = (+"").force_encoding(Encoding::BINARY)
|
|
302
331
|
|
|
303
|
-
response =
|
|
304
|
-
|
|
305
|
-
|
|
332
|
+
response = with_transport_errors do
|
|
333
|
+
conn.post("/v1/chat/completions") do |req|
|
|
334
|
+
req.headers["Content-Type"] = "application/json"
|
|
335
|
+
req.body = JSON.generate(body)
|
|
306
336
|
|
|
307
|
-
|
|
337
|
+
# Use Faraday's on_data callback for real incremental streaming.
|
|
308
338
|
# Without this, Faraday buffers the entire response body before
|
|
309
339
|
# returning — no deltas reach the caller until the model finishes
|
|
310
340
|
# generating (fake streaming).
|
|
@@ -314,14 +344,17 @@ module RubyPi
|
|
|
314
344
|
# If the HTTP status indicates an error, accumulate the body for
|
|
315
345
|
# the error handler instead of parsing it as SSE events.
|
|
316
346
|
if response_status && response_status >= 400
|
|
317
|
-
error_body << chunk
|
|
347
|
+
error_body << chunk.b
|
|
318
348
|
next
|
|
319
349
|
end
|
|
320
350
|
|
|
321
|
-
sse_buffer << chunk
|
|
322
|
-
# Process all complete lines in the buffer
|
|
351
|
+
sse_buffer << chunk.b
|
|
352
|
+
# Process all complete lines in the buffer. A complete line holds
|
|
353
|
+
# complete UTF-8 sequences (multi-byte characters split across
|
|
354
|
+
# chunks are repaired by the buffering), so re-encode it to UTF-8
|
|
355
|
+
# here; scrub guards against a server sending invalid bytes.
|
|
323
356
|
while (line_end = sse_buffer.index("\n"))
|
|
324
|
-
line = sse_buffer.slice!(0, line_end + 1).strip
|
|
357
|
+
line = sse_buffer.slice!(0, line_end + 1).force_encoding(Encoding::UTF_8).scrub.strip
|
|
325
358
|
next if line.empty?
|
|
326
359
|
next unless line.start_with?("data: ")
|
|
327
360
|
|
|
@@ -389,13 +422,14 @@ module RubyPi
|
|
|
389
422
|
end
|
|
390
423
|
end
|
|
391
424
|
end
|
|
392
|
-
|
|
425
|
+
end # conn.post
|
|
426
|
+
end # with_transport_errors
|
|
393
427
|
|
|
394
428
|
# When on_data is active, the response body was consumed by the
|
|
395
429
|
# callback. Pass the accumulated error_body so ApiError carries the
|
|
396
430
|
# full server message instead of an empty body.
|
|
397
431
|
unless response.success?
|
|
398
|
-
error_body_str = error_body.empty? ? response.body : error_body
|
|
432
|
+
error_body_str = error_body.empty? ? response.body : error_body.force_encoding(Encoding::UTF_8).scrub
|
|
399
433
|
handle_error_response(response, override_body: error_body_str)
|
|
400
434
|
end
|
|
401
435
|
|
|
@@ -37,16 +37,32 @@ module RubyPi
|
|
|
37
37
|
# @return [Hash] A JSON Schema hash describing the tool's parameters.
|
|
38
38
|
attr_reader :parameters
|
|
39
39
|
|
|
40
|
+
# Tool names must satisfy the strictest provider constraint (Anthropic's
|
|
41
|
+
# ^[a-zA-Z0-9_-]{1,64}$). Without this guard, a name like "send.email"
|
|
42
|
+
# registers fine and then 400s on every API request with an opaque
|
|
43
|
+
# server-side validation error that doesn't point back to the tool.
|
|
44
|
+
NAME_FORMAT = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
45
|
+
|
|
40
46
|
# Creates a new tool definition.
|
|
41
47
|
#
|
|
42
|
-
# @param name [String, Symbol] Unique identifier for the tool.
|
|
48
|
+
# @param name [String, Symbol] Unique identifier for the tool. Must match
|
|
49
|
+
# NAME_FORMAT (letters, digits, underscore, hyphen; max 64 chars).
|
|
43
50
|
# @param description [String] What the tool does (shown to the LLM).
|
|
44
51
|
# @param category [Symbol, nil] Optional grouping category.
|
|
45
52
|
# @param parameters [Hash] JSON Schema hash for the tool's input parameters.
|
|
46
|
-
# @yield [Hash] Block that implements the tool logic. Receives a hash of
|
|
47
|
-
#
|
|
53
|
+
# @yield [Hash] Block that implements the tool logic. Receives a hash of
|
|
54
|
+
# symbol-keyed arguments, or keyword arguments if the block declares
|
|
55
|
+
# keyword parameters (see #call).
|
|
56
|
+
# @raise [ArgumentError] If name is missing or violates NAME_FORMAT,
|
|
57
|
+
# description is missing, or no block given.
|
|
48
58
|
def initialize(name:, description:, category: nil, parameters: {}, &block)
|
|
49
59
|
raise ArgumentError, "Tool name is required" if name.nil? || name.to_s.strip.empty?
|
|
60
|
+
unless name.to_s.match?(NAME_FORMAT)
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"Tool name #{name.to_s.inspect} is invalid — provider APIs require " \
|
|
63
|
+
"names matching #{NAME_FORMAT.inspect} (letters, digits, underscore, " \
|
|
64
|
+
"hyphen; 1-64 characters)"
|
|
65
|
+
end
|
|
50
66
|
raise ArgumentError, "Tool description is required" if description.nil? || description.strip.empty?
|
|
51
67
|
raise ArgumentError, "Tool implementation block is required" unless block_given?
|
|
52
68
|
|
|
@@ -55,14 +71,33 @@ module RubyPi
|
|
|
55
71
|
@category = category&.to_sym
|
|
56
72
|
@parameters = parameters
|
|
57
73
|
@implementation = block
|
|
74
|
+
# On Ruby 3.x a positional Hash is never auto-splatted to keywords, so
|
|
75
|
+
# a block written `{ |content:, platform:| ... }` — the natural style
|
|
76
|
+
# given named schema parameters — would fail every call with
|
|
77
|
+
# "missing keyword". Detect keyword parameters once here and splat in
|
|
78
|
+
# #call accordingly.
|
|
79
|
+
@expects_keywords = block.parameters.any? { |type, _| %i[key keyreq keyrest].include?(type) }
|
|
58
80
|
end
|
|
59
81
|
|
|
60
82
|
# Invokes the tool with the given arguments.
|
|
61
83
|
#
|
|
84
|
+
# Blocks may be written either style:
|
|
85
|
+
# { |args| args[:content] } # single positional Hash
|
|
86
|
+
# { |content:, platform: "x"| ... } # keyword parameters
|
|
87
|
+
#
|
|
88
|
+
# When the block declares keyword parameters, the arguments hash is
|
|
89
|
+
# splatted to keywords. Note that a keyword-style block without **rest
|
|
90
|
+
# raises ArgumentError on unexpected keys — strict by design, since the
|
|
91
|
+
# keys come from the LLM.
|
|
92
|
+
#
|
|
62
93
|
# @param args [Hash] The arguments to pass to the tool implementation.
|
|
63
94
|
# @return [Object] Whatever the implementation block returns.
|
|
64
95
|
def call(args = {})
|
|
65
|
-
@
|
|
96
|
+
if @expects_keywords
|
|
97
|
+
@implementation.call(**args)
|
|
98
|
+
else
|
|
99
|
+
@implementation.call(args)
|
|
100
|
+
end
|
|
66
101
|
end
|
|
67
102
|
|
|
68
103
|
# Converts this tool definition to Google Gemini function declaration format.
|
|
@@ -115,7 +115,12 @@ module RubyPi
|
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
# Collect results, respecting the configured timeout for each future.
|
|
118
|
-
|
|
118
|
+
# Zip each future with its originating call so failure Results carry
|
|
119
|
+
# the real tool name — with several tools timing out in parallel,
|
|
120
|
+
# "unknown" Results are indistinguishable in logs and extension events.
|
|
121
|
+
calls.zip(futures).map do |call, future|
|
|
122
|
+
tool_name = (call[:name] || call["name"]).to_s
|
|
123
|
+
|
|
119
124
|
# Issue #10: Wait for the future to complete, then check its state
|
|
120
125
|
# explicitly. Future#value returns nil both on timeout AND when the
|
|
121
126
|
# block legitimately returned nil, so we cannot use || to distinguish.
|
|
@@ -128,13 +133,16 @@ module RubyPi
|
|
|
128
133
|
else
|
|
129
134
|
# Future was rejected (raised an exception within the block).
|
|
130
135
|
# This shouldn't normally happen since execute_single rescues
|
|
131
|
-
# internally, but handle it defensively.
|
|
136
|
+
# internally, but handle it defensively. The actual run time is
|
|
137
|
+
# unknown here (the future failed at some point before the wait
|
|
138
|
+
# elapsed), so report 0.0 rather than a misleading full-timeout
|
|
139
|
+
# duration for what may have been an instant failure.
|
|
132
140
|
error = future.reason
|
|
133
141
|
Result.new(
|
|
134
|
-
name:
|
|
142
|
+
name: tool_name,
|
|
135
143
|
success: false,
|
|
136
144
|
error: "#{error.class}: #{error.message}",
|
|
137
|
-
duration_ms:
|
|
145
|
+
duration_ms: 0.0
|
|
138
146
|
)
|
|
139
147
|
end
|
|
140
148
|
else
|
|
@@ -147,9 +155,9 @@ module RubyPi
|
|
|
147
155
|
future.cancel if future.respond_to?(:cancel)
|
|
148
156
|
|
|
149
157
|
Result.new(
|
|
150
|
-
name:
|
|
158
|
+
name: tool_name,
|
|
151
159
|
success: false,
|
|
152
|
-
error: "Tool
|
|
160
|
+
error: "Tool '#{tool_name}' timed out after #{@timeout}s",
|
|
153
161
|
duration_ms: @timeout * 1000.0
|
|
154
162
|
)
|
|
155
163
|
end
|
|
@@ -195,9 +203,18 @@ module RubyPi
|
|
|
195
203
|
error = nil
|
|
196
204
|
|
|
197
205
|
worker = Thread.new do
|
|
206
|
+
# Don't spam stderr from the rescued worker thread.
|
|
207
|
+
Thread.current.report_on_exception = false
|
|
198
208
|
begin
|
|
199
209
|
value = tool.call(arguments)
|
|
200
|
-
rescue
|
|
210
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
211
|
+
# Rescue the full Exception hierarchy (not just StandardError).
|
|
212
|
+
# If a tool block raises Interrupt, SystemExit, or any other
|
|
213
|
+
# non-StandardError, rescuing only StandardError leaves both
|
|
214
|
+
# `value` and `error` nil; the join then reports a successful
|
|
215
|
+
# nil result — a panic in a tool silently becomes "returned nil".
|
|
216
|
+
# Capture the failure here; the main thread surfaces it as a
|
|
217
|
+
# failed Result. The worker thread itself does not propagate.
|
|
201
218
|
error = e
|
|
202
219
|
end
|
|
203
220
|
end
|
data/lib/ruby_pi/tools/schema.rb
CHANGED
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
# flag consumed by `.object` to populate the top-level "required" array.
|
|
14
14
|
# It is stripped from the property's own schema hash before inclusion.
|
|
15
15
|
#
|
|
16
|
+
# IMPORTANT: Schemas are LLM-facing hints, NOT runtime input validation.
|
|
17
|
+
# Nothing in the execution pipeline validates the model's arguments against
|
|
18
|
+
# the schema before invoking the tool block: `required`, `enum`, `minimum`,
|
|
19
|
+
# and type declarations constrain what the model is *asked* to produce, but a
|
|
20
|
+
# misbehaving model can still omit required fields, send extra keys, or pass
|
|
21
|
+
# a String where an Integer is declared — no coercion is performed. Tool
|
|
22
|
+
# blocks should treat their arguments as untrusted input and validate or
|
|
23
|
+
# coerce what they depend on. (This is deliberate, per the anti-framework
|
|
24
|
+
# philosophy: validation policy belongs to the tool, not the harness.)
|
|
25
|
+
#
|
|
16
26
|
# Usage:
|
|
17
27
|
# schema = RubyPi::Schema.object(
|
|
18
28
|
# name: RubyPi::Schema.string("User's name", required: true),
|
data/lib/ruby_pi/version.rb
CHANGED
data/lib/ruby_pi.rb
CHANGED
|
@@ -82,6 +82,13 @@ module RubyPi
|
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# Eagerly initialize the global configuration at load time. The lazy
|
|
86
|
+
# `@configuration ||= ...` in .configuration is not synchronized; two
|
|
87
|
+
# threads hitting it concurrently on first access could each construct a
|
|
88
|
+
# Configuration, with one silently discarded. Initializing here (requires
|
|
89
|
+
# run single-threaded) removes the race without adding a mutex to every read.
|
|
90
|
+
@configuration = Configuration.new
|
|
91
|
+
|
|
85
92
|
# Namespace for large language model providers and related abstractions.
|
|
86
93
|
module LLM
|
|
87
94
|
class << self
|