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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +31 -7
- data/lib/ruby_pi/agent/core.rb +6 -0
- data/lib/ruby_pi/agent/events.rb +3 -0
- data/lib/ruby_pi/agent/loop.rb +26 -2
- data/lib/ruby_pi/agent/result.rb +60 -11
- data/lib/ruby_pi/agent/state.rb +29 -5
- data/lib/ruby_pi/configuration.rb +6 -3
- data/lib/ruby_pi/context/compaction.rb +27 -7
- data/lib/ruby_pi/errors.rb +16 -0
- data/lib/ruby_pi/extensions/base.rb +0 -8
- data/lib/ruby_pi/llm/anthropic.rb +34 -71
- data/lib/ruby_pi/llm/base_provider.rb +127 -9
- data/lib/ruby_pi/llm/fallback.rb +20 -5
- data/lib/ruby_pi/llm/gemini.rb +63 -81
- data/lib/ruby_pi/llm/openai.rb +70 -91
- data/lib/ruby_pi/llm/sse_parser.rb +125 -0
- data/lib/ruby_pi/llm/stream_event.rb +10 -3
- data/lib/ruby_pi/tools/executor.rb +162 -76
- data/lib/ruby_pi/tools/result.rb +17 -2
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +1 -0
- metadata +3 -2
|
@@ -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
|
-
|
|
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:
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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:
|
|
248
|
-
|
|
249
|
-
duration_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
|
data/lib/ruby_pi/tools/result.rb
CHANGED
|
@@ -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
|
|
data/lib/ruby_pi/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|