ruby-pi 0.1.8 → 0.1.10

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.
@@ -28,6 +28,35 @@ module RubyPi
28
28
  class Executor
29
29
  # Default timeout for each tool execution, in seconds.
30
30
  DEFAULT_TIMEOUT = 30
31
+ DEFAULT_MAX_CALLS = 32
32
+ DEFAULT_MAX_CONCURRENCY = 4
33
+ MAX_ACTIVE_EXECUTIONS = 16
34
+
35
+ @capacity_mutex = Mutex.new
36
+ @active_executions = 0
37
+
38
+ class << self
39
+ def active_execution_count
40
+ @capacity_mutex.synchronize { @active_executions }
41
+ end
42
+
43
+ private
44
+
45
+ def reserve_execution_capacity!
46
+ @capacity_mutex.synchronize do
47
+ if @active_executions >= MAX_ACTIVE_EXECUTIONS
48
+ raise RubyPi::ToolExecutionCapacityError,
49
+ "Process already has #{MAX_ACTIVE_EXECUTIONS} active tool executions"
50
+ end
51
+
52
+ @active_executions += 1
53
+ end
54
+ end
55
+
56
+ def release_execution_capacity!
57
+ @capacity_mutex.synchronize { @active_executions -= 1 }
58
+ end
59
+ end
31
60
 
32
61
  # @return [Symbol] The execution mode (:parallel or :sequential).
33
62
  attr_reader :mode
@@ -35,20 +64,41 @@ module RubyPi
35
64
  # @return [Numeric] The per-tool timeout in seconds.
36
65
  attr_reader :timeout
37
66
 
67
+ # Recursively converts all string keys in a hash to symbols.
68
+ def self.deep_symbolize_keys(obj)
69
+ case obj
70
+ when Hash
71
+ obj.each_with_object({}) do |(key, value), result|
72
+ result[key.to_sym] = deep_symbolize_keys(value)
73
+ end
74
+ when Array
75
+ obj.map { |item| deep_symbolize_keys(item) }
76
+ else
77
+ obj
78
+ end
79
+ end
80
+
38
81
  # Creates a new Executor.
39
82
  #
40
83
  # @param registry [RubyPi::Tools::Registry] The registry to look up tools from.
41
84
  # @param mode [Symbol] Execution mode — :parallel or :sequential.
42
85
  # @param timeout [Numeric] Per-tool timeout in seconds (default: 30).
43
86
  # @raise [ArgumentError] If mode is not :parallel or :sequential.
44
- def initialize(registry, mode: :parallel, timeout: DEFAULT_TIMEOUT)
87
+ def initialize(registry, mode: :parallel, timeout: DEFAULT_TIMEOUT,
88
+ max_calls: DEFAULT_MAX_CALLS, max_concurrency: DEFAULT_MAX_CONCURRENCY)
45
89
  unless %i[parallel sequential].include?(mode)
46
90
  raise ArgumentError, "Mode must be :parallel or :sequential, got #{mode.inspect}"
47
91
  end
48
92
 
93
+ validate_positive_numeric!(:timeout, timeout)
94
+ validate_positive_integer!(:max_calls, max_calls)
95
+ validate_positive_integer!(:max_concurrency, max_concurrency)
96
+
49
97
  @registry = registry
50
98
  @mode = mode
51
99
  @timeout = timeout
100
+ @max_calls = max_calls
101
+ @max_concurrency = max_concurrency
52
102
  end
53
103
 
54
104
  # Executes a list of tool calls and returns their results.
@@ -64,6 +114,22 @@ module RubyPi
64
114
  # @return [Array<RubyPi::Tools::Result>] Results in the same order as the calls.
65
115
  # @raise [RubyPi::NoToolsRegisteredError] if registry is nil
66
116
  def execute(calls)
117
+ unless calls.is_a?(Array)
118
+ raise ArgumentError, "calls must be an Array, got #{calls.class}"
119
+ end
120
+
121
+ if calls.size > @max_calls
122
+ raise RubyPi::ToolCallLimitError,
123
+ "Model returned #{calls.size} tool calls; maximum is #{@max_calls} per execution"
124
+ end
125
+
126
+ return [] if calls.empty?
127
+
128
+ if Executor.active_execution_count >= MAX_ACTIVE_EXECUTIONS
129
+ raise RubyPi::ToolExecutionCapacityError,
130
+ "Refusing tool execution while #{MAX_ACTIVE_EXECUTIONS} tools are still running"
131
+ end
132
+
67
133
  # Issue #17: Guard against nil registry — if the LLM hallucinated tool
68
134
  # calls but no tools are registered, raise a typed error immediately
69
135
  # rather than crashing with NoMethodError on nil.find.
@@ -87,7 +153,24 @@ module RubyPi
87
153
  # @param calls [Array<Hash>] The tool call requests.
88
154
  # @return [Array<RubyPi::Tools::Result>] Ordered results.
89
155
  def execute_sequential(calls)
90
- calls.map { |call| execute_single(call) }
156
+ results = []
157
+ calls.each_with_index do |call, index|
158
+ result = execute_single(call)
159
+ results << result
160
+ next unless result.completion_unknown?
161
+
162
+ calls.drop(index + 1).each do |skipped_call|
163
+ name = (skipped_call[:name] || skipped_call["name"]).to_s
164
+ results << Result.new(
165
+ name: name,
166
+ success: false,
167
+ status: :skipped,
168
+ error: "Tool '#{name}' was not started because a previous tool timed out with completion unknown"
169
+ )
170
+ end
171
+ break
172
+ end
173
+ results
91
174
  end
92
175
 
93
176
  # Executes tool calls in parallel using concurrent-ruby Futures.
@@ -108,9 +191,24 @@ module RubyPi
108
191
  # @param calls [Array<Hash>] The tool call requests.
109
192
  # @return [Array<RubyPi::Tools::Result>] Ordered results.
110
193
  def execute_parallel(calls)
194
+ return [] if calls.empty?
195
+
196
+ available_capacity = MAX_ACTIVE_EXECUTIONS - Executor.active_execution_count
197
+ pool_size = [@max_concurrency, calls.size, available_capacity].min
198
+ if pool_size <= 0
199
+ raise RubyPi::ToolExecutionCapacityError, "No safe capacity remains for parallel tool execution"
200
+ end
201
+
202
+ pool = Concurrent::FixedThreadPool.new(pool_size)
203
+ batch_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
204
  futures = calls.map do |call|
112
- Concurrent::Future.execute(executor: :io) do
113
- execute_single(call)
205
+ Concurrent::Future.execute(executor: pool) do
206
+ Executor.send(:reserve_execution_capacity!)
207
+ begin
208
+ execute_direct(call)
209
+ ensure
210
+ Executor.send(:release_execution_capacity!)
211
+ end
114
212
  end
115
213
  end
116
214
 
@@ -124,7 +222,8 @@ module RubyPi
124
222
  # Issue #10: Wait for the future to complete, then check its state
125
223
  # explicitly. Future#value returns nil both on timeout AND when the
126
224
  # block legitimately returned nil, so we cannot use || to distinguish.
127
- future.wait(@timeout)
225
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - batch_started_at
226
+ future.wait([@timeout - elapsed, 0].max)
128
227
 
129
228
  if future.complete?
130
229
  if future.fulfilled?
@@ -157,11 +256,14 @@ module RubyPi
157
256
  Result.new(
158
257
  name: tool_name,
159
258
  success: false,
160
- error: "Tool '#{tool_name}' timed out after #{@timeout}s",
259
+ status: :timeout_unknown,
260
+ error: "Tool '#{tool_name}' timed out after #{@timeout}s; completion and side effects are unknown",
161
261
  duration_ms: @timeout * 1000.0
162
262
  )
163
263
  end
164
264
  end
265
+ ensure
266
+ pool&.shutdown
165
267
  end
166
268
 
167
269
  # Executes a single tool call with error handling and timing.
@@ -179,44 +281,22 @@ module RubyPi
179
281
  # @return [RubyPi::Tools::Result] The execution result.
180
282
  def execute_single(call)
181
283
  tool_name = (call[:name] || call["name"]).to_s
182
- arguments = deep_symbolize_keys(call[:arguments] || call["arguments"] || {})
183
-
184
- tool = @registry.find(tool_name)
185
-
186
- # Return an error result if the tool is not registered
187
- unless tool
188
- return Result.new(
189
- name: tool_name,
190
- success: false,
191
- error: "Tool '#{tool_name}' not found in registry",
192
- duration_ms: 0.0
193
- )
194
- end
195
-
196
- # Execute the tool with a safe timeout mechanism.
197
- # Instead of the stdlib timeout (which uses Thread#raise and is unsafe),
198
- # we spawn a worker thread and join with a timeout.
199
284
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
285
+ worker_result = nil
200
286
 
201
- # Container for the worker thread's result/error
202
- value = nil
203
- error = nil
204
-
205
- worker = Thread.new do
206
- # Don't spam stderr from the rescued worker thread.
207
- Thread.current.report_on_exception = false
208
- begin
209
- value = tool.call(arguments)
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.
218
- error = e
287
+ Executor.send(:reserve_execution_capacity!)
288
+ begin
289
+ worker = Thread.new do
290
+ Thread.current.report_on_exception = false
291
+ begin
292
+ worker_result = execute_direct(call)
293
+ ensure
294
+ Executor.send(:release_execution_capacity!)
295
+ end
219
296
  end
297
+ rescue Exception # rubocop:disable Lint/RescueException
298
+ Executor.send(:release_execution_capacity!)
299
+ raise
220
300
  end
221
301
 
222
302
  # Join with timeout — returns nil if the thread didn't finish in time
@@ -231,24 +311,42 @@ module RubyPi
231
311
  Result.new(
232
312
  name: tool_name,
233
313
  success: false,
234
- error: "Tool '#{tool_name}' timed out after #{@timeout}s",
235
- duration_ms: elapsed_ms
236
- )
237
- elsif error
238
- Result.new(
239
- name: tool_name,
240
- success: false,
241
- error: "#{error.class}: #{error.message}",
314
+ status: :timeout_unknown,
315
+ error: "Tool '#{tool_name}' timed out after #{@timeout}s; completion and side effects are unknown",
242
316
  duration_ms: elapsed_ms
243
317
  )
244
318
  else
245
- Result.new(
319
+ worker_result
320
+ end
321
+ end
322
+
323
+ # Executes a tool directly on the current thread. Parallel mode runs
324
+ # this method on a bounded pool; sequential mode wraps it in one worker
325
+ # solely to observe its deadline without asynchronously interrupting it.
326
+ def execute_direct(call)
327
+ tool_name = (call[:name] || call["name"]).to_s
328
+ arguments = deep_symbolize_keys(call[:arguments] || call["arguments"] || {})
329
+ tool = @registry.find(tool_name)
330
+
331
+ unless tool
332
+ return Result.new(
246
333
  name: tool_name,
247
- success: true,
248
- value: value,
249
- duration_ms: elapsed_ms
334
+ success: false,
335
+ error: "Tool '#{tool_name}' not found in registry",
336
+ duration_ms: 0.0
250
337
  )
251
338
  end
339
+
340
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
341
+ value = tool.call(arguments)
342
+ Result.new(name: tool_name, success: true, value: value, duration_ms: elapsed_since(start_time))
343
+ rescue Exception => e # rubocop:disable Lint/RescueException
344
+ Result.new(
345
+ name: tool_name,
346
+ success: false,
347
+ error: "#{e.class}: #{e.message}",
348
+ duration_ms: start_time ? elapsed_since(start_time) : 0.0
349
+ )
252
350
  end
253
351
 
254
352
  # Calculates milliseconds elapsed since a monotonic clock timestamp.
@@ -259,29 +357,6 @@ module RubyPi
259
357
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
260
358
  end
261
359
 
262
- # Recursively converts all string keys in a hash to symbols so that
263
- # tool implementations can use idiomatic Ruby symbol-key access
264
- # (e.g. `args[:field]`) regardless of whether the LLM provider
265
- # returned string-keyed JSON. Exposed as a class method so the agent
266
- # loop can apply the same transformation to tool_call arguments
267
- # before recording them in `tool_calls_made`, keeping the agent's
268
- # observable arguments shape consistent with what tool blocks see.
269
- #
270
- # @param obj [Object] the object to transform (Hash, Array, or scalar)
271
- # @return [Object] the transformed object with symbolized keys
272
- def self.deep_symbolize_keys(obj)
273
- case obj
274
- when Hash
275
- obj.each_with_object({}) do |(key, value), result|
276
- result[key.to_sym] = deep_symbolize_keys(value)
277
- end
278
- when Array
279
- obj.map { |item| deep_symbolize_keys(item) }
280
- else
281
- obj
282
- end
283
- end
284
-
285
360
  # Instance-method delegate so existing internal callers keep working.
286
361
  #
287
362
  # @param obj [Object] the object to transform (Hash, Array, or scalar)
@@ -289,6 +364,17 @@ module RubyPi
289
364
  def deep_symbolize_keys(obj)
290
365
  self.class.deep_symbolize_keys(obj)
291
366
  end
367
+
368
+ def validate_positive_numeric!(name, value)
369
+ valid = value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value.positive?
370
+ raise ArgumentError, "#{name} must be a finite positive number, got #{value.inspect}" unless valid
371
+ end
372
+
373
+ def validate_positive_integer!(name, value)
374
+ return if value.is_a?(Integer) && value.positive?
375
+
376
+ raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}"
377
+ end
292
378
  end
293
379
  end
294
380
  end
@@ -36,6 +36,9 @@ module RubyPi
36
36
  # @return [Float] The execution time in milliseconds.
37
37
  attr_reader :duration_ms
38
38
 
39
+ # @return [Symbol] :success, :error, :timeout_unknown, or :skipped
40
+ attr_reader :status
41
+
39
42
  # Creates a new Result instance.
40
43
  #
41
44
  # @param name [String, Symbol] The name of the tool that produced this result.
@@ -43,12 +46,13 @@ module RubyPi
43
46
  # @param value [Object, nil] The return value from the tool (on success).
44
47
  # @param error [String, nil] The error message (on failure).
45
48
  # @param duration_ms [Float] How long the tool took to execute, in milliseconds.
46
- def initialize(name:, success:, value: nil, error: nil, duration_ms: 0.0)
49
+ def initialize(name:, success:, value: nil, error: nil, duration_ms: 0.0, status: nil)
47
50
  @name = name.to_s
48
51
  @success = success
49
52
  @value = value
50
53
  @error = error
51
54
  @duration_ms = duration_ms.to_f
55
+ @status = status || (success ? :success : :error)
52
56
  end
53
57
 
54
58
  # Returns whether the tool execution was successful.
@@ -58,6 +62,16 @@ module RubyPi
58
62
  @success
59
63
  end
60
64
 
65
+ # A timeout cannot prove that side effects did not commit. This predicate
66
+ # makes that uncertainty explicit to callers.
67
+ def completion_unknown?
68
+ @status == :timeout_unknown
69
+ end
70
+
71
+ def skipped?
72
+ @status == :skipped
73
+ end
74
+
61
75
  # Returns a hash representation of the result, useful for serialization.
62
76
  #
63
77
  # @return [Hash] A hash containing all result attributes.
@@ -67,7 +81,8 @@ module RubyPi
67
81
  success: @success,
68
82
  value: @value,
69
83
  error: @error,
70
- duration_ms: @duration_ms
84
+ duration_ms: @duration_ms,
85
+ status: @status
71
86
  }
72
87
  end
73
88
 
@@ -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.8"
10
+ VERSION = "0.1.10"
11
11
  end
data/lib/ruby_pi.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "ruby_pi/errors"
21
21
  require_relative "ruby_pi/llm/response"
22
22
  require_relative "ruby_pi/llm/tool_call"
23
23
  require_relative "ruby_pi/llm/stream_event"
24
+ require_relative "ruby_pi/llm/sse_parser"
24
25
  require_relative "ruby_pi/llm/model"
25
26
  require_relative "ruby_pi/llm/base_provider"
26
27
  require_relative "ruby_pi/llm/gemini"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-pi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - RubyPi Contributors
@@ -141,6 +141,7 @@ files:
141
141
  - lib/ruby_pi/llm/model.rb
142
142
  - lib/ruby_pi/llm/openai.rb
143
143
  - lib/ruby_pi/llm/response.rb
144
+ - lib/ruby_pi/llm/sse_parser.rb
144
145
  - lib/ruby_pi/llm/stream_event.rb
145
146
  - lib/ruby_pi/llm/tool_call.rb
146
147
  - lib/ruby_pi/tools/definition.rb
@@ -171,7 +172,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
171
172
  - !ruby/object:Gem::Version
172
173
  version: '0'
173
174
  requirements: []
174
- rubygems_version: 3.6.9
175
+ rubygems_version: 4.0.15
175
176
  specification_version: 4
176
177
  summary: AI agent harness for Ruby — build LLM agents with tool calling, streaming,
177
178
  and a unified interface to OpenAI, Anthropic Claude, and Google Gemini.