ruby-pi 0.1.3 → 0.1.6

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.
@@ -30,7 +30,7 @@ module RubyPi
30
30
  # @param options [Hash] additional options passed to BaseProvider
31
31
  def initialize(model: nil, api_key: nil, **options)
32
32
  super(**options)
33
- config = RubyPi.configuration
33
+ config = @config
34
34
  @model = model || config.default_openai_model
35
35
  @api_key = api_key || config.openai_api_key
36
36
  end
@@ -84,6 +84,9 @@ module RubyPi
84
84
  # Structured content (Arrays, Hashes) is preserved for multimodal content
85
85
  # blocks (e.g., vision messages with image_url content parts).
86
86
  #
87
+ # Issue #21: When streaming, includes `stream_options: { include_usage: true }`
88
+ # so OpenAI returns usage data in the final SSE chunk.
89
+ #
87
90
  # @param messages [Array<Hash>] conversation messages
88
91
  # @param tools [Array<Hash>] tool definitions
89
92
  # @param stream [Boolean] whether streaming is enabled
@@ -94,7 +97,12 @@ module RubyPi
94
97
  messages: messages.map { |msg| format_message(msg) }
95
98
  }
96
99
 
97
- body[:stream] = true if stream
100
+ if stream
101
+ body[:stream] = true
102
+ # Issue #21: Request usage data in streaming mode. OpenAI supports
103
+ # returning token usage in the final SSE chunk when this option is set.
104
+ body[:stream_options] = { include_usage: true }
105
+ end
98
106
 
99
107
  unless tools.empty?
100
108
  body[:tools] = tools.map { |t| format_tool(t) }
@@ -121,9 +129,22 @@ module RubyPi
121
129
  # OpenAI accepts role "tool" with a required tool_call_id field
122
130
  # to match this result back to the assistant's tool_call.
123
131
  tool_call_id = message[:tool_call_id] || message["tool_call_id"]
132
+
133
+ # Fail fast with a descriptive error instead of sending "unknown" as
134
+ # the tool_call_id. OpenAI requires tool_call_id to match a preceding
135
+ # tool_calls entry; sending "unknown" causes an opaque HTTP 400.
136
+ if tool_call_id.nil? || tool_call_id.to_s.strip.empty?
137
+ raise RubyPi::ProviderError.new(
138
+ "Missing tool_call_id in tool result message. OpenAI requires " \
139
+ "tool_call_id to match a preceding tool_calls entry. Ensure every " \
140
+ "tool result message includes a valid :tool_call_id.",
141
+ provider: :openai
142
+ )
143
+ end
144
+
124
145
  {
125
146
  role: "tool",
126
- tool_call_id: tool_call_id || "unknown",
147
+ tool_call_id: tool_call_id,
127
148
  content: format_content(content)
128
149
  }
129
150
 
@@ -162,20 +183,50 @@ module RubyPi
162
183
  tc_name = tc[:name] || tc["name"]
163
184
  tc_args = tc[:arguments] || tc["arguments"] || {}
164
185
 
165
- # OpenAI requires arguments as a JSON string
166
- args_string = if tc_args.is_a?(String)
167
- tc_args
168
- elsif tc_args.is_a?(Hash)
186
+ # OpenAI requires arguments to be a JSON-encoded string. We
187
+ # validate up-front so a malformed string fails fast with a
188
+ # typed error here rather than as an opaque HTTP 400 from
189
+ # OpenAI. This mirrors Anthropic's input validation in
190
+ # build_assistant_message.
191
+ args_string = case tc_args
192
+ when Hash
169
193
  JSON.generate(tc_args)
194
+ when String
195
+ stripped = tc_args.strip
196
+ if stripped.empty?
197
+ "{}"
198
+ else
199
+ begin
200
+ JSON.parse(tc_args)
201
+ tc_args
202
+ rescue JSON::ParserError => e
203
+ raise RubyPi::ProviderError.new(
204
+ "Invalid JSON in assistant tool_call.arguments " \
205
+ "for tool '#{tc_name || "unknown"}': #{e.message} " \
206
+ "(raw: #{tc_args.inspect})",
207
+ provider: :openai
208
+ )
209
+ end
210
+ end
170
211
  else
171
212
  "{}"
172
213
  end
173
214
 
215
+ # Fail fast if tool call id is missing — OpenAI requires it for
216
+ # conversation continuity and tool result matching.
217
+ if tc_id.nil? || tc_id.to_s.strip.empty?
218
+ raise RubyPi::ProviderError.new(
219
+ "Missing id in assistant tool_call. OpenAI requires each " \
220
+ "tool_call to have a valid id for result matching.",
221
+ provider: :openai
222
+ )
223
+ end
224
+
174
225
  {
175
- id: tc_id || "unknown",
226
+ id: tc_id,
176
227
  type: "function",
177
228
  function: {
178
- name: tc_name || "unknown",
229
+ name: tc_name || "unknown_function",
179
230
  arguments: args_string
180
231
  }
181
232
  }
@@ -230,9 +281,11 @@ module RubyPi
230
281
  headers: default_headers
231
282
  )
232
283
 
233
- response = conn.post("/v1/chat/completions") do |req|
234
- req.headers["Content-Type"] = "application/json"
235
- req.body = JSON.generate(body)
284
+ response = with_transport_errors do
285
+ conn.post("/v1/chat/completions") do |req|
286
+ req.headers["Content-Type"] = "application/json"
287
+ req.body = JSON.generate(body)
288
+ end
236
289
  end
237
290
 
238
291
  handle_error_response(response) unless response.success?
@@ -241,6 +294,10 @@ module RubyPi
241
294
 
242
295
  # Executes a streaming request to the OpenAI API, yielding events.
243
296
  #
297
+ # Issue #21: Parses usage data from the final SSE chunk (when
298
+ # stream_options: { include_usage: true } is set in the request).
299
+ # The final chunk contains the aggregated token usage.
300
+ #
244
301
  # @param body [Hash] the request body
245
302
  # @yield [event] StreamEvent objects
246
303
  # @return [RubyPi::LLM::Response] final aggregated response
@@ -253,62 +310,126 @@ module RubyPi
253
310
  accumulated_text = +""
254
311
  tool_call_accumulators = {}
255
312
  finish_reason = nil
313
+ # Issue #21: Accumulate usage data from the final SSE chunk
314
+ streaming_usage = {}
315
+
316
+ # Buffer for incomplete SSE lines across on_data chunks. Faraday's
317
+ # on_data callback delivers raw bytes as they arrive from the network,
318
+ # which may split SSE events mid-line. We accumulate a line buffer and
319
+ # process complete lines incrementally so that deltas reach the caller
320
+ # as soon as each SSE event is fully received.
321
+ sse_buffer = +""
322
+ response_status = nil
323
+ error_body = +""
324
+
325
+ response = with_transport_errors do
326
+ conn.post("/v1/chat/completions") do |req|
327
+ req.headers["Content-Type"] = "application/json"
328
+ req.body = JSON.generate(body)
329
+
330
+ # Use Faraday's on_data callback for real incremental streaming.
331
+ # Without this, Faraday buffers the entire response body before
332
+ # returning — no deltas reach the caller until the model finishes
333
+ # generating (fake streaming).
334
+ req.options.on_data = proc do |chunk, _overall_received_bytes, env|
335
+ response_status ||= env&.status
336
+
337
+ # If the HTTP status indicates an error, accumulate the body for
338
+ # the error handler instead of parsing it as SSE events.
339
+ if response_status && response_status >= 400
340
+ error_body << chunk
341
+ next
342
+ end
256
343
 
257
- response = conn.post("/v1/chat/completions") do |req|
258
- req.headers["Content-Type"] = "application/json"
259
- req.body = JSON.generate(body)
260
- end
344
+ sse_buffer << chunk
345
+ # Process all complete lines in the buffer
346
+ while (line_end = sse_buffer.index("\n"))
347
+ line = sse_buffer.slice!(0, line_end + 1).strip
348
+ next if line.empty?
349
+ next unless line.start_with?("data: ")
261
350
 
262
- handle_error_response(response) unless response.success?
351
+ data_str = line.sub(/\Adata: /, "")
352
+ next if data_str == "[DONE]"
263
353
 
264
- # Parse SSE events from the response body
265
- parse_sse_events(response.body) do |data|
266
- choices = data["choices"] || []
267
- choice = choices.first
268
- next unless choice
354
+ begin
355
+ data = JSON.parse(data_str)
356
+ rescue JSON::ParserError
357
+ next
358
+ end
269
359
 
270
- delta = choice["delta"] || {}
271
- finish_reason = choice["finish_reason"] if choice["finish_reason"]
360
+ # Issue #21: Capture usage data from the final SSE chunk.
361
+ # OpenAI sends usage in a dedicated chunk when include_usage is true.
362
+ if data.key?("usage") && data["usage"]
363
+ usage_info = data["usage"]
364
+ streaming_usage = {
365
+ prompt_tokens: usage_info["prompt_tokens"],
366
+ completion_tokens: usage_info["completion_tokens"],
367
+ total_tokens: usage_info["total_tokens"]
368
+ }
369
+ end
272
370
 
273
- # Handle text content deltas
274
- if delta.key?("content") && delta["content"]
275
- text = delta["content"]
276
- accumulated_text << text
277
- block.call(StreamEvent.new(type: :text_delta, data: text))
278
- end
371
+ # Process this SSE event
372
+ choices = data["choices"] || []
373
+ choice = choices.first
374
+ next unless choice
279
375
 
280
- # Handle tool call deltas
281
- if delta.key?("tool_calls")
282
- delta["tool_calls"].each do |tc_delta|
283
- index = tc_delta["index"] || 0
376
+ delta = choice["delta"] || {}
377
+ finish_reason = choice["finish_reason"] if choice["finish_reason"]
284
378
 
285
- # Initialize accumulator for this tool call
286
- tool_call_accumulators[index] ||= { id: nil, name: +"", arguments: +"" }
287
- acc = tool_call_accumulators[index]
379
+ # Handle text content deltas
380
+ if delta.key?("content") && delta["content"]
381
+ text = delta["content"]
382
+ accumulated_text << text
383
+ block.call(StreamEvent.new(type: :text_delta, data: text))
384
+ end
288
385
 
289
- acc[:id] = tc_delta["id"] if tc_delta["id"]
386
+ # Handle tool call deltas
387
+ if delta.key?("tool_calls")
388
+ delta["tool_calls"].each do |tc_delta|
389
+ index = tc_delta["index"] || 0
290
390
 
291
- if tc_delta.dig("function", "name")
292
- acc[:name] << tc_delta["function"]["name"]
293
- end
391
+ # Initialize accumulator for this tool call
392
+ tool_call_accumulators[index] ||= { id: nil, name: +"", arguments: +"" }
393
+ acc = tool_call_accumulators[index]
294
394
 
295
- if tc_delta.dig("function", "arguments")
296
- acc[:arguments] << tc_delta["function"]["arguments"]
297
- end
395
+ acc[:id] = tc_delta["id"] if tc_delta["id"]
298
396
 
299
- block.call(StreamEvent.new(type: :tool_call_delta, data: {
300
- index: index,
301
- id: acc[:id],
302
- name: acc[:name],
303
- arguments_fragment: tc_delta.dig("function", "arguments") || ""
304
- }))
397
+ if tc_delta.dig("function", "name")
398
+ acc[:name] << tc_delta["function"]["name"]
399
+ end
400
+
401
+ if tc_delta.dig("function", "arguments")
402
+ acc[:arguments] << tc_delta["function"]["arguments"]
403
+ end
404
+
405
+ block.call(StreamEvent.new(type: :tool_call_delta, data: {
406
+ index: index,
407
+ id: acc[:id],
408
+ name: acc[:name],
409
+ arguments_fragment: tc_delta.dig("function", "arguments") || ""
410
+ }))
411
+ end
412
+ end
305
413
  end
306
414
  end
415
+ end # conn.post
416
+ end # with_transport_errors
417
+
418
+ # When on_data is active, the response body was consumed by the
419
+ # callback. Pass the accumulated error_body so ApiError carries the
420
+ # full server message instead of an empty body.
421
+ unless response.success?
422
+ error_body_str = error_body.empty? ? response.body : error_body
423
+ handle_error_response(response, override_body: error_body_str)
307
424
  end
308
425
 
309
426
  # Build final tool calls from accumulators
427
+ # Issue #12: Guard JSON.parse against empty strings. An empty string
428
+ # is truthy in Ruby, so the previous `empty? ? {} : JSON.parse(...)` check
429
+ # was correct for empty strings, but we also guard against malformed JSON
430
+ # from truncated streams.
310
431
  tool_calls = tool_call_accumulators.sort_by { |k, _| k }.map do |_, acc|
311
- arguments = acc[:arguments].empty? ? {} : JSON.parse(acc[:arguments])
432
+ arguments = safe_parse_arguments(acc[:arguments])
312
433
  ToolCall.new(id: acc[:id], name: acc[:name], arguments: arguments)
313
434
  end
314
435
 
@@ -318,7 +439,7 @@ module RubyPi
318
439
  Response.new(
319
440
  content: accumulated_text.empty? ? nil : accumulated_text,
320
441
  tool_calls: tool_calls,
321
- usage: {},
442
+ usage: streaming_usage,
322
443
  finish_reason: normalize_finish_reason(finish_reason)
323
444
  )
324
445
  end
@@ -334,6 +455,11 @@ module RubyPi
334
455
 
335
456
  # Parses an OpenAI Chat Completions response into a normalized Response.
336
457
  #
458
+ # Issue #12: Guards JSON.parse(func["arguments"]) against empty strings.
459
+ # An empty string is truthy in Ruby but causes JSON::ParserError. We now
460
+ # check that arguments is a non-empty string before parsing, and rescue
461
+ # JSON::ParserError to wrap in a ProviderError.
462
+ #
337
463
  # @param data [Hash] parsed JSON response from OpenAI
338
464
  # @return [RubyPi::LLM::Response]
339
465
  def parse_response(data)
@@ -345,7 +471,7 @@ module RubyPi
345
471
 
346
472
  (message["tool_calls"] || []).each do |tc|
347
473
  func = tc["function"] || {}
348
- arguments = func["arguments"] ? JSON.parse(func["arguments"]) : {}
474
+ arguments = safe_parse_arguments(func["arguments"])
349
475
  tool_calls << ToolCall.new(
350
476
  id: tc["id"],
351
477
  name: func["name"],
@@ -384,6 +510,35 @@ module RubyPi
384
510
  else reason
385
511
  end
386
512
  end
513
+
514
+ # Safely parses a JSON arguments string into a Hash.
515
+ #
516
+ # Issue #12: Handles nil, empty strings, and malformed JSON gracefully.
517
+ # Previously, `func["arguments"] ? JSON.parse(func["arguments"]) : {}`
518
+ # would crash on empty strings (truthy but unparseable). Now we validate
519
+ # the input and rescue parse errors with a typed ProviderError.
520
+ #
521
+ # @param raw_args [String, Hash, nil] raw arguments from the API
522
+ # @return [Hash] parsed arguments hash
523
+ # @raise [RubyPi::ProviderError] if JSON parsing fails on non-empty input
524
+ def safe_parse_arguments(raw_args)
525
+ # Already a Hash — pass through
526
+ return raw_args if raw_args.is_a?(Hash)
527
+
528
+ # nil or non-string — return empty hash
529
+ return {} unless raw_args.is_a?(String)
530
+
531
+ # Empty or whitespace-only string — return empty hash
532
+ return {} if raw_args.strip.empty?
533
+
534
+ # Attempt to parse the JSON string
535
+ JSON.parse(raw_args)
536
+ rescue JSON::ParserError => e
537
+ raise RubyPi::ProviderError.new(
538
+ "Failed to parse tool call arguments from OpenAI: #{e.message} (raw: #{raw_args.inspect})",
539
+ provider: :openai
540
+ )
541
+ end
387
542
  end
388
543
  end
389
544
  end
@@ -25,10 +25,10 @@ module RubyPi
25
25
  # end
26
26
  class StreamEvent
27
27
  # Valid event types for stream events.
28
- VALID_TYPES = %i[text_delta tool_call_delta done].freeze
28
+ VALID_TYPES = %i[text_delta tool_call_delta done fallback_start].freeze
29
29
 
30
30
  # @return [Symbol] the type of stream event — one of :text_delta,
31
- # :tool_call_delta, or :done
31
+ # :tool_call_delta, :done, or :fallback_start
32
32
  attr_reader :type
33
33
 
34
34
  # @return [Object] the event payload. For :text_delta this is a String
@@ -38,7 +38,7 @@ module RubyPi
38
38
 
39
39
  # Creates a new StreamEvent instance.
40
40
  #
41
- # @param type [Symbol] event type (:text_delta, :tool_call_delta, :done)
41
+ # @param type [Symbol] event type (:text_delta, :tool_call_delta, :done, :fallback_start)
42
42
  # @param data [Object] event payload
43
43
  # @raise [ArgumentError] if the type is not recognized
44
44
  def initialize(type:, data: nil)
@@ -71,6 +71,16 @@ module RubyPi
71
71
  @type == :done
72
72
  end
73
73
 
74
+ # Returns true if this is a fallback_start event, signaling that the
75
+ # primary provider failed mid-stream and the fallback provider is
76
+ # taking over. Consumers should clear any partial output rendered
77
+ # from the failed primary.
78
+ #
79
+ # @return [Boolean]
80
+ def fallback_start?
81
+ @type == :fallback_start
82
+ end
83
+
74
84
  # Returns a hash representation of the stream event.
75
85
  #
76
86
  # @return [Hash]
@@ -64,13 +64,36 @@ module RubyPi
64
64
  # Attempts to parse a JSON string into a Hash. Falls back to wrapping
65
65
  # the raw value in a hash if parsing fails.
66
66
  #
67
+ # Issue #15: Guards against non-string, non-hash inputs (e.g., Integer,
68
+ # nil, or any object that doesn't respond to `empty?`). Previously,
69
+ # calling `raw.empty?` on an Integer would raise NoMethodError.
70
+ # Now we check `raw.is_a?(String)` before calling string methods,
71
+ # and handle nil/non-string types gracefully.
72
+ #
67
73
  # @param raw [String, Object] raw arguments data
68
74
  # @return [Hash] parsed arguments
69
75
  def parse_arguments(raw)
70
- return {} if raw.nil? || raw.empty?
76
+ # Handle nil explicitly
77
+ return {} if raw.nil?
78
+
79
+ # If it's a String, attempt JSON parse (guard empty strings)
80
+ if raw.is_a?(String)
81
+ return {} if raw.strip.empty?
82
+
83
+ begin
84
+ parsed = JSON.parse(raw)
85
+ return parsed if parsed.is_a?(Hash)
86
+
87
+ # JSON.parse succeeded but didn't return a Hash (e.g., an array
88
+ # or scalar) — wrap it so callers always get a Hash.
89
+ return { "_raw" => parsed }
90
+ rescue JSON::ParserError
91
+ return { "_raw" => raw }
92
+ end
93
+ end
71
94
 
72
- JSON.parse(raw.to_s)
73
- rescue JSON::ParserError
95
+ # For any other type (Integer, Float, Array, etc.) that isn't a Hash,
96
+ # wrap it in a hash to maintain the Hash return type contract.
74
97
  { "_raw" => raw.to_s }
75
98
  end
76
99
  end
@@ -57,9 +57,21 @@ module RubyPi
57
57
  # Tools are looked up in the registry; if a tool is not found, a failure
58
58
  # Result is returned for that call.
59
59
  #
60
+ # Issue #17: Raises NoToolsRegisteredError if the registry is nil and
61
+ # tool calls are attempted, preventing a confusing NoMethodError.
62
+ #
60
63
  # @param calls [Array<Hash>] Tool call requests, each with :name and :arguments.
61
64
  # @return [Array<RubyPi::Tools::Result>] Results in the same order as the calls.
65
+ # @raise [RubyPi::NoToolsRegisteredError] if registry is nil
62
66
  def execute(calls)
67
+ # Issue #17: Guard against nil registry — if the LLM hallucinated tool
68
+ # calls but no tools are registered, raise a typed error immediately
69
+ # rather than crashing with NoMethodError on nil.find.
70
+ if @registry.nil?
71
+ raise RubyPi::NoToolsRegisteredError,
72
+ "Model returned #{calls.size} tool call(s) but no tools are registered"
73
+ end
74
+
63
75
  case @mode
64
76
  when :parallel
65
77
  execute_parallel(calls)
@@ -83,6 +95,16 @@ module RubyPi
83
95
  # Each call is dispatched as a Future on the global I/O thread pool.
84
96
  # Results are collected in order, respecting the per-tool timeout.
85
97
  #
98
+ # Issue #10: Uses future.wait(@timeout) + future.complete? to distinguish
99
+ # a legitimate nil return value from a timeout. Previously, the || operator
100
+ # treated nil return values as timeouts.
101
+ #
102
+ # Issue #11: After detecting a timeout, attempts to cancel the future.
103
+ # Note: Ruby threads cannot be forcibly killed safely; we use the future's
104
+ # cancellation mechanism which sets a flag. The underlying thread may
105
+ # continue running until it reaches a natural exit point. This is a known
106
+ # tradeoff — hard cancellation in Ruby risks corrupted state.
107
+ #
86
108
  # @param calls [Array<Hash>] The tool call requests.
87
109
  # @return [Array<RubyPi::Tools::Result>] Ordered results.
88
110
  def execute_parallel(calls)
@@ -94,22 +116,62 @@ module RubyPi
94
116
 
95
117
  # Collect results, respecting the configured timeout for each future.
96
118
  futures.map do |future|
97
- future.value(@timeout) || Result.new(
98
- name: "unknown",
99
- success: false,
100
- error: "Tool execution timed out after #{@timeout}s",
101
- duration_ms: @timeout * 1000.0
102
- )
119
+ # Issue #10: Wait for the future to complete, then check its state
120
+ # explicitly. Future#value returns nil both on timeout AND when the
121
+ # block legitimately returned nil, so we cannot use || to distinguish.
122
+ future.wait(@timeout)
123
+
124
+ if future.complete?
125
+ if future.fulfilled?
126
+ # Future completed successfully — return its value (which may be nil)
127
+ future.value
128
+ else
129
+ # Future was rejected (raised an exception within the block).
130
+ # This shouldn't normally happen since execute_single rescues
131
+ # internally, but handle it defensively.
132
+ error = future.reason
133
+ Result.new(
134
+ name: "unknown",
135
+ success: false,
136
+ error: "#{error.class}: #{error.message}",
137
+ duration_ms: @timeout * 1000.0
138
+ )
139
+ end
140
+ else
141
+ # Issue #11: Future did not complete within the timeout window.
142
+ # Attempt to cancel the future to signal the thread to stop.
143
+ # Concurrent::Future does not support hard cancellation — the
144
+ # underlying thread will continue until it naturally exits.
145
+ # This is the safest approach in Ruby since Thread#raise/Thread#kill
146
+ # can interrupt mid-mutation and corrupt shared state.
147
+ future.cancel if future.respond_to?(:cancel)
148
+
149
+ Result.new(
150
+ name: "unknown",
151
+ success: false,
152
+ error: "Tool execution timed out after #{@timeout}s",
153
+ duration_ms: @timeout * 1000.0
154
+ )
155
+ end
103
156
  end
104
157
  end
105
158
 
106
159
  # Executes a single tool call with error handling and timing.
107
160
  #
161
+ # Issue #9: Replaced the stdlib timeout mechanism with a thread+join approach for
162
+ # sequential mode. The stdlib timeout uses Thread#raise internally, which
163
+ # is unsafe — it can interrupt code mid-mutation, leak file handles,
164
+ # and corrupt state. The thread+join approach runs the tool in a
165
+ # separate thread and waits with a timeout; if the thread doesn't
166
+ # finish in time, we report a timeout error. The worker thread is
167
+ # left running (it cannot be safely killed in Ruby) but its result
168
+ # is discarded.
169
+ #
108
170
  # @param call [Hash] A tool call with :name and :arguments keys.
109
171
  # @return [RubyPi::Tools::Result] The execution result.
110
172
  def execute_single(call)
111
173
  tool_name = (call[:name] || call["name"]).to_s
112
- arguments = call[:arguments] || call["arguments"] || {}
174
+ arguments = deep_symbolize_keys(call[:arguments] || call["arguments"] || {})
113
175
 
114
176
  tool = @registry.find(tool_name)
115
177
 
@@ -123,34 +185,59 @@ module RubyPi
123
185
  )
124
186
  end
125
187
 
126
- # Execute the tool with timeout and error handling
188
+ # Execute the tool with a safe timeout mechanism.
189
+ # Instead of the stdlib timeout (which uses Thread#raise and is unsafe),
190
+ # we spawn a worker thread and join with a timeout.
127
191
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
128
- begin
129
- value = Timeout.timeout(@timeout) do
130
- tool.call(arguments)
192
+
193
+ # Container for the worker thread's result/error
194
+ value = nil
195
+ error = nil
196
+
197
+ worker = Thread.new do
198
+ # Don't spam stderr from the rescued worker thread.
199
+ Thread.current.report_on_exception = false
200
+ begin
201
+ value = tool.call(arguments)
202
+ rescue Exception => e # rubocop:disable Lint/RescueException
203
+ # Rescue the full Exception hierarchy (not just StandardError).
204
+ # If a tool block raises Interrupt, SystemExit, or any other
205
+ # non-StandardError, rescuing only StandardError leaves both
206
+ # `value` and `error` nil; the join then reports a successful
207
+ # nil result — a panic in a tool silently becomes "returned nil".
208
+ # Capture the failure here; the main thread surfaces it as a
209
+ # failed Result. The worker thread itself does not propagate.
210
+ error = e
131
211
  end
132
- elapsed_ms = elapsed_since(start_time)
212
+ end
213
+
214
+ # Join with timeout — returns nil if the thread didn't finish in time
215
+ finished = worker.join(@timeout)
216
+
217
+ elapsed_ms = elapsed_since(start_time)
133
218
 
219
+ if finished.nil?
220
+ # Thread did not finish within the timeout. We cannot safely kill it
221
+ # (Thread#kill can corrupt state), so we leave it running and report
222
+ # the timeout. This matches the tradeoff documented for parallel mode.
134
223
  Result.new(
135
224
  name: tool_name,
136
- success: true,
137
- value: value,
225
+ success: false,
226
+ error: "Tool '#{tool_name}' timed out after #{@timeout}s",
138
227
  duration_ms: elapsed_ms
139
228
  )
140
- rescue Timeout::Error
141
- elapsed_ms = elapsed_since(start_time)
229
+ elsif error
142
230
  Result.new(
143
231
  name: tool_name,
144
232
  success: false,
145
- error: "Tool '#{tool_name}' timed out after #{@timeout}s",
233
+ error: "#{error.class}: #{error.message}",
146
234
  duration_ms: elapsed_ms
147
235
  )
148
- rescue StandardError => e
149
- elapsed_ms = elapsed_since(start_time)
236
+ else
150
237
  Result.new(
151
238
  name: tool_name,
152
- success: false,
153
- error: "#{e.class}: #{e.message}",
239
+ success: true,
240
+ value: value,
154
241
  duration_ms: elapsed_ms
155
242
  )
156
243
  end
@@ -163,6 +250,37 @@ module RubyPi
163
250
  def elapsed_since(start_time)
164
251
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
165
252
  end
253
+
254
+ # Recursively converts all string keys in a hash to symbols so that
255
+ # tool implementations can use idiomatic Ruby symbol-key access
256
+ # (e.g. `args[:field]`) regardless of whether the LLM provider
257
+ # returned string-keyed JSON. Exposed as a class method so the agent
258
+ # loop can apply the same transformation to tool_call arguments
259
+ # before recording them in `tool_calls_made`, keeping the agent's
260
+ # observable arguments shape consistent with what tool blocks see.
261
+ #
262
+ # @param obj [Object] the object to transform (Hash, Array, or scalar)
263
+ # @return [Object] the transformed object with symbolized keys
264
+ def self.deep_symbolize_keys(obj)
265
+ case obj
266
+ when Hash
267
+ obj.each_with_object({}) do |(key, value), result|
268
+ result[key.to_sym] = deep_symbolize_keys(value)
269
+ end
270
+ when Array
271
+ obj.map { |item| deep_symbolize_keys(item) }
272
+ else
273
+ obj
274
+ end
275
+ end
276
+
277
+ # Instance-method delegate so existing internal callers keep working.
278
+ #
279
+ # @param obj [Object] the object to transform (Hash, Array, or scalar)
280
+ # @return [Object] the transformed object with symbolized keys
281
+ def deep_symbolize_keys(obj)
282
+ self.class.deep_symbolize_keys(obj)
283
+ end
166
284
  end
167
285
  end
168
286
  end