ruby-pi 0.1.3 → 0.1.5

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
 
@@ -171,11 +192,21 @@ module RubyPi
171
192
  "{}"
172
193
  end
173
194
 
195
+ # Fail fast if tool call id is missing — OpenAI requires it for
196
+ # conversation continuity and tool result matching.
197
+ if tc_id.nil? || tc_id.to_s.strip.empty?
198
+ raise RubyPi::ProviderError.new(
199
+ "Missing id in assistant tool_call. OpenAI requires each " \
200
+ "tool_call to have a valid id for result matching.",
201
+ provider: :openai
202
+ )
203
+ end
204
+
174
205
  {
175
- id: tc_id || "unknown",
206
+ id: tc_id,
176
207
  type: "function",
177
208
  function: {
178
- name: tc_name || "unknown",
209
+ name: tc_name || "unknown_function",
179
210
  arguments: args_string
180
211
  }
181
212
  }
@@ -241,6 +272,10 @@ module RubyPi
241
272
 
242
273
  # Executes a streaming request to the OpenAI API, yielding events.
243
274
  #
275
+ # Issue #21: Parses usage data from the final SSE chunk (when
276
+ # stream_options: { include_usage: true } is set in the request).
277
+ # The final chunk contains the aggregated token usage.
278
+ #
244
279
  # @param body [Hash] the request body
245
280
  # @yield [event] StreamEvent objects
246
281
  # @return [RubyPi::LLM::Response] final aggregated response
@@ -253,62 +288,124 @@ module RubyPi
253
288
  accumulated_text = +""
254
289
  tool_call_accumulators = {}
255
290
  finish_reason = nil
291
+ # Issue #21: Accumulate usage data from the final SSE chunk
292
+ streaming_usage = {}
293
+
294
+ # Buffer for incomplete SSE lines across on_data chunks. Faraday's
295
+ # on_data callback delivers raw bytes as they arrive from the network,
296
+ # which may split SSE events mid-line. We accumulate a line buffer and
297
+ # process complete lines incrementally so that deltas reach the caller
298
+ # as soon as each SSE event is fully received.
299
+ sse_buffer = +""
300
+ response_status = nil
301
+ error_body = +""
256
302
 
257
303
  response = conn.post("/v1/chat/completions") do |req|
258
304
  req.headers["Content-Type"] = "application/json"
259
305
  req.body = JSON.generate(body)
260
- end
261
306
 
262
- handle_error_response(response) unless response.success?
307
+ # Use Faraday's on_data callback for real incremental streaming.
308
+ # Without this, Faraday buffers the entire response body before
309
+ # returning — no deltas reach the caller until the model finishes
310
+ # generating (fake streaming).
311
+ req.options.on_data = proc do |chunk, _overall_received_bytes, env|
312
+ response_status ||= env&.status
313
+
314
+ # If the HTTP status indicates an error, accumulate the body for
315
+ # the error handler instead of parsing it as SSE events.
316
+ if response_status && response_status >= 400
317
+ error_body << chunk
318
+ next
319
+ end
263
320
 
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
321
+ sse_buffer << chunk
322
+ # Process all complete lines in the buffer
323
+ while (line_end = sse_buffer.index("\n"))
324
+ line = sse_buffer.slice!(0, line_end + 1).strip
325
+ next if line.empty?
326
+ next unless line.start_with?("data: ")
269
327
 
270
- delta = choice["delta"] || {}
271
- finish_reason = choice["finish_reason"] if choice["finish_reason"]
328
+ data_str = line.sub(/\Adata: /, "")
329
+ next if data_str == "[DONE]"
272
330
 
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
331
+ begin
332
+ data = JSON.parse(data_str)
333
+ rescue JSON::ParserError
334
+ next
335
+ end
279
336
 
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
337
+ # Issue #21: Capture usage data from the final SSE chunk.
338
+ # OpenAI sends usage in a dedicated chunk when include_usage is true.
339
+ if data.key?("usage") && data["usage"]
340
+ usage_info = data["usage"]
341
+ streaming_usage = {
342
+ prompt_tokens: usage_info["prompt_tokens"],
343
+ completion_tokens: usage_info["completion_tokens"],
344
+ total_tokens: usage_info["total_tokens"]
345
+ }
346
+ end
284
347
 
285
- # Initialize accumulator for this tool call
286
- tool_call_accumulators[index] ||= { id: nil, name: +"", arguments: +"" }
287
- acc = tool_call_accumulators[index]
348
+ # Process this SSE event
349
+ choices = data["choices"] || []
350
+ choice = choices.first
351
+ next unless choice
288
352
 
289
- acc[:id] = tc_delta["id"] if tc_delta["id"]
353
+ delta = choice["delta"] || {}
354
+ finish_reason = choice["finish_reason"] if choice["finish_reason"]
290
355
 
291
- if tc_delta.dig("function", "name")
292
- acc[:name] << tc_delta["function"]["name"]
356
+ # Handle text content deltas
357
+ if delta.key?("content") && delta["content"]
358
+ text = delta["content"]
359
+ accumulated_text << text
360
+ block.call(StreamEvent.new(type: :text_delta, data: text))
293
361
  end
294
362
 
295
- if tc_delta.dig("function", "arguments")
296
- acc[:arguments] << tc_delta["function"]["arguments"]
297
- end
363
+ # Handle tool call deltas
364
+ if delta.key?("tool_calls")
365
+ delta["tool_calls"].each do |tc_delta|
366
+ index = tc_delta["index"] || 0
367
+
368
+ # Initialize accumulator for this tool call
369
+ tool_call_accumulators[index] ||= { id: nil, name: +"", arguments: +"" }
370
+ acc = tool_call_accumulators[index]
371
+
372
+ acc[:id] = tc_delta["id"] if tc_delta["id"]
373
+
374
+ if tc_delta.dig("function", "name")
375
+ acc[:name] << tc_delta["function"]["name"]
376
+ end
298
377
 
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
- }))
378
+ if tc_delta.dig("function", "arguments")
379
+ acc[:arguments] << tc_delta["function"]["arguments"]
380
+ end
381
+
382
+ block.call(StreamEvent.new(type: :tool_call_delta, data: {
383
+ index: index,
384
+ id: acc[:id],
385
+ name: acc[:name],
386
+ arguments_fragment: tc_delta.dig("function", "arguments") || ""
387
+ }))
388
+ end
389
+ end
305
390
  end
306
391
  end
307
392
  end
308
393
 
394
+ # When on_data is active, the response body was consumed by the
395
+ # callback. Pass the accumulated error_body so ApiError carries the
396
+ # full server message instead of an empty body.
397
+ unless response.success?
398
+ error_body_str = error_body.empty? ? response.body : error_body
399
+ handle_error_response(response, override_body: error_body_str)
400
+ end
401
+
309
402
  # Build final tool calls from accumulators
403
+ # Issue #12: Guard JSON.parse against empty strings. An empty string
404
+ # is truthy in Ruby, so the previous `empty? ? {} : JSON.parse(...)` check
405
+ # was correct for empty strings, but we also guard against malformed JSON
406
+ # from truncated streams.
310
407
  tool_calls = tool_call_accumulators.sort_by { |k, _| k }.map do |_, acc|
311
- arguments = acc[:arguments].empty? ? {} : JSON.parse(acc[:arguments])
408
+ arguments = safe_parse_arguments(acc[:arguments])
312
409
  ToolCall.new(id: acc[:id], name: acc[:name], arguments: arguments)
313
410
  end
314
411
 
@@ -318,7 +415,7 @@ module RubyPi
318
415
  Response.new(
319
416
  content: accumulated_text.empty? ? nil : accumulated_text,
320
417
  tool_calls: tool_calls,
321
- usage: {},
418
+ usage: streaming_usage,
322
419
  finish_reason: normalize_finish_reason(finish_reason)
323
420
  )
324
421
  end
@@ -334,6 +431,11 @@ module RubyPi
334
431
 
335
432
  # Parses an OpenAI Chat Completions response into a normalized Response.
336
433
  #
434
+ # Issue #12: Guards JSON.parse(func["arguments"]) against empty strings.
435
+ # An empty string is truthy in Ruby but causes JSON::ParserError. We now
436
+ # check that arguments is a non-empty string before parsing, and rescue
437
+ # JSON::ParserError to wrap in a ProviderError.
438
+ #
337
439
  # @param data [Hash] parsed JSON response from OpenAI
338
440
  # @return [RubyPi::LLM::Response]
339
441
  def parse_response(data)
@@ -345,7 +447,7 @@ module RubyPi
345
447
 
346
448
  (message["tool_calls"] || []).each do |tc|
347
449
  func = tc["function"] || {}
348
- arguments = func["arguments"] ? JSON.parse(func["arguments"]) : {}
450
+ arguments = safe_parse_arguments(func["arguments"])
349
451
  tool_calls << ToolCall.new(
350
452
  id: tc["id"],
351
453
  name: func["name"],
@@ -384,6 +486,35 @@ module RubyPi
384
486
  else reason
385
487
  end
386
488
  end
489
+
490
+ # Safely parses a JSON arguments string into a Hash.
491
+ #
492
+ # Issue #12: Handles nil, empty strings, and malformed JSON gracefully.
493
+ # Previously, `func["arguments"] ? JSON.parse(func["arguments"]) : {}`
494
+ # would crash on empty strings (truthy but unparseable). Now we validate
495
+ # the input and rescue parse errors with a typed ProviderError.
496
+ #
497
+ # @param raw_args [String, Hash, nil] raw arguments from the API
498
+ # @return [Hash] parsed arguments hash
499
+ # @raise [RubyPi::ProviderError] if JSON parsing fails on non-empty input
500
+ def safe_parse_arguments(raw_args)
501
+ # Already a Hash — pass through
502
+ return raw_args if raw_args.is_a?(Hash)
503
+
504
+ # nil or non-string — return empty hash
505
+ return {} unless raw_args.is_a?(String)
506
+
507
+ # Empty or whitespace-only string — return empty hash
508
+ return {} if raw_args.strip.empty?
509
+
510
+ # Attempt to parse the JSON string
511
+ JSON.parse(raw_args)
512
+ rescue JSON::ParserError => e
513
+ raise RubyPi::ProviderError.new(
514
+ "Failed to parse tool call arguments from OpenAI: #{e.message} (raw: #{raw_args.inspect})",
515
+ provider: :openai
516
+ )
517
+ end
387
518
  end
388
519
  end
389
520
  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,50 @@ 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
+ begin
199
+ value = tool.call(arguments)
200
+ rescue StandardError => e
201
+ error = e
131
202
  end
132
- elapsed_ms = elapsed_since(start_time)
203
+ end
204
+
205
+ # Join with timeout — returns nil if the thread didn't finish in time
206
+ finished = worker.join(@timeout)
207
+
208
+ elapsed_ms = elapsed_since(start_time)
133
209
 
210
+ if finished.nil?
211
+ # Thread did not finish within the timeout. We cannot safely kill it
212
+ # (Thread#kill can corrupt state), so we leave it running and report
213
+ # the timeout. This matches the tradeoff documented for parallel mode.
134
214
  Result.new(
135
215
  name: tool_name,
136
- success: true,
137
- value: value,
216
+ success: false,
217
+ error: "Tool '#{tool_name}' timed out after #{@timeout}s",
138
218
  duration_ms: elapsed_ms
139
219
  )
140
- rescue Timeout::Error
141
- elapsed_ms = elapsed_since(start_time)
220
+ elsif error
142
221
  Result.new(
143
222
  name: tool_name,
144
223
  success: false,
145
- error: "Tool '#{tool_name}' timed out after #{@timeout}s",
224
+ error: "#{error.class}: #{error.message}",
146
225
  duration_ms: elapsed_ms
147
226
  )
148
- rescue StandardError => e
149
- elapsed_ms = elapsed_since(start_time)
227
+ else
150
228
  Result.new(
151
229
  name: tool_name,
152
- success: false,
153
- error: "#{e.class}: #{e.message}",
230
+ success: true,
231
+ value: value,
154
232
  duration_ms: elapsed_ms
155
233
  )
156
234
  end
@@ -163,6 +241,37 @@ module RubyPi
163
241
  def elapsed_since(start_time)
164
242
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
165
243
  end
244
+
245
+ # Recursively converts all string keys in a hash to symbols so that
246
+ # tool implementations can use idiomatic Ruby symbol-key access
247
+ # (e.g. `args[:field]`) regardless of whether the LLM provider
248
+ # returned string-keyed JSON. Exposed as a class method so the agent
249
+ # loop can apply the same transformation to tool_call arguments
250
+ # before recording them in `tool_calls_made`, keeping the agent's
251
+ # observable arguments shape consistent with what tool blocks see.
252
+ #
253
+ # @param obj [Object] the object to transform (Hash, Array, or scalar)
254
+ # @return [Object] the transformed object with symbolized keys
255
+ def self.deep_symbolize_keys(obj)
256
+ case obj
257
+ when Hash
258
+ obj.each_with_object({}) do |(key, value), result|
259
+ result[key.to_sym] = deep_symbolize_keys(value)
260
+ end
261
+ when Array
262
+ obj.map { |item| deep_symbolize_keys(item) }
263
+ else
264
+ obj
265
+ end
266
+ end
267
+
268
+ # Instance-method delegate so existing internal callers keep working.
269
+ #
270
+ # @param obj [Object] the object to transform (Hash, Array, or scalar)
271
+ # @return [Object] the transformed object with symbolized keys
272
+ def deep_symbolize_keys(obj)
273
+ self.class.deep_symbolize_keys(obj)
274
+ end
166
275
  end
167
276
  end
168
277
  end