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.
@@ -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, the Fallback now buffers deltas from the
20
- # primary provider. If the primary fails mid-stream, the buffered deltas
21
- # are discarded and the fallback provider streams fresh from the start.
22
- # This prevents the consumer from seeing partial output from the primary
23
- # concatenated with the complete output from the fallback.
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
- &block
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 should use this event
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.
@@ -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
- # Gemini uses "user" and "model" roles. Map tool results to "user"
125
- # role with a functionResponse part when we have the metadata, or
126
- # plain text otherwise. System messages should have been extracted
127
- # by build_request_body before reaching this method.
128
- gemini_role = case role
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: { result: content.to_s }
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: gemini_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 = conn.post(url) do |req|
202
- req.headers["Content-Type"] = "application/json"
203
- req.body = JSON.generate(body)
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
- sse_buffer = +""
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 = conn.post(url) do |req|
237
- req.headers["Content-Type"] = "application/json"
238
- req.body = JSON.generate(body)
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
- # Use Faraday's on_data callback for real incremental streaming.
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
- id: "gemini_#{accumulated_tool_calls.length}",
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
- end
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
- id: "gemini_#{tool_calls.length}",
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
- finish_reason = candidate["finishReason"]&.downcase
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,
@@ -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 as a JSON string
187
- args_string = if tc_args.is_a?(String)
188
- tc_args
189
- elsif tc_args.is_a?(Hash)
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 = conn.post("/v1/chat/completions") do |req|
265
- req.headers["Content-Type"] = "application/json"
266
- req.body = JSON.generate(body)
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
- sse_buffer = +""
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 = conn.post("/v1/chat/completions") do |req|
304
- req.headers["Content-Type"] = "application/json"
305
- req.body = JSON.generate(body)
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
- # Use Faraday's on_data callback for real incremental streaming.
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
- end
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
 
@@ -6,6 +6,8 @@
6
6
  # decides to invoke a tool, it returns one or more ToolCall objects describing
7
7
  # which function to call and with what arguments.
8
8
 
9
+ require "json"
10
+
9
11
  module RubyPi
10
12
  module LLM
11
13
  # A tool call extracted from an LLM response. Contains the unique call ID,
@@ -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 arguments.
47
- # @raise [ArgumentError] If name or description is missing, or no block given.
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
- @implementation.call(args)
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
- futures.map do |future|
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: "unknown",
142
+ name: tool_name,
135
143
  success: false,
136
144
  error: "#{error.class}: #{error.message}",
137
- duration_ms: @timeout * 1000.0
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: "unknown",
158
+ name: tool_name,
151
159
  success: false,
152
- error: "Tool execution timed out after #{@timeout}s",
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 StandardError => e
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
@@ -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),
@@ -7,5 +7,5 @@
7
7
 
8
8
  module RubyPi
9
9
  # The current version of the RubyPi gem, following Semantic Versioning.
10
- VERSION = "0.1.5"
10
+ VERSION = "0.1.8"
11
11
  end
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