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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +77 -29
- data/lib/ruby_pi/agent/core.rb +59 -4
- data/lib/ruby_pi/agent/events.rb +17 -3
- data/lib/ruby_pi/agent/loop.rb +103 -18
- data/lib/ruby_pi/agent/result.rb +46 -7
- data/lib/ruby_pi/agent/state.rb +12 -0
- data/lib/ruby_pi/configuration.rb +28 -7
- data/lib/ruby_pi/context/compaction.rb +17 -2
- data/lib/ruby_pi/context/transform.rb +67 -3
- data/lib/ruby_pi/errors.rb +19 -1
- data/lib/ruby_pi/llm/anthropic.rb +231 -59
- data/lib/ruby_pi/llm/base_provider.rb +44 -46
- data/lib/ruby_pi/llm/fallback.rb +106 -1
- data/lib/ruby_pi/llm/gemini.rb +161 -41
- data/lib/ruby_pi/llm/openai.rb +173 -42
- data/lib/ruby_pi/llm/stream_event.rb +13 -3
- data/lib/ruby_pi/llm/tool_call.rb +26 -3
- data/lib/ruby_pi/tools/executor.rb +130 -21
- data/lib/ruby_pi/tools/registry.rb +26 -16
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +2 -1
- metadata +5 -39
data/lib/ruby_pi/llm/openai.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
206
|
+
id: tc_id,
|
|
176
207
|
type: "function",
|
|
177
208
|
function: {
|
|
178
|
-
name: tc_name || "
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
328
|
+
data_str = line.sub(/\Adata: /, "")
|
|
329
|
+
next if data_str == "[DONE]"
|
|
272
330
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
end
|
|
331
|
+
begin
|
|
332
|
+
data = JSON.parse(data_str)
|
|
333
|
+
rescue JSON::ParserError
|
|
334
|
+
next
|
|
335
|
+
end
|
|
279
336
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
348
|
+
# Process this SSE event
|
|
349
|
+
choices = data["choices"] || []
|
|
350
|
+
choice = choices.first
|
|
351
|
+
next unless choice
|
|
288
352
|
|
|
289
|
-
|
|
353
|
+
delta = choice["delta"] || {}
|
|
354
|
+
finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
290
355
|
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 =
|
|
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 =
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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:
|
|
137
|
-
|
|
216
|
+
success: false,
|
|
217
|
+
error: "Tool '#{tool_name}' timed out after #{@timeout}s",
|
|
138
218
|
duration_ms: elapsed_ms
|
|
139
219
|
)
|
|
140
|
-
|
|
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: "
|
|
224
|
+
error: "#{error.class}: #{error.message}",
|
|
146
225
|
duration_ms: elapsed_ms
|
|
147
226
|
)
|
|
148
|
-
|
|
149
|
-
elapsed_ms = elapsed_since(start_time)
|
|
227
|
+
else
|
|
150
228
|
Result.new(
|
|
151
229
|
name: tool_name,
|
|
152
|
-
success:
|
|
153
|
-
|
|
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
|