ruby-pi 0.1.6 → 0.1.9
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 +63 -0
- data/README.md +13 -6
- 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 +66 -27
- data/lib/ruby_pi/agent/result.rb +39 -11
- data/lib/ruby_pi/agent/state.rb +31 -5
- data/lib/ruby_pi/configuration.rb +53 -5
- data/lib/ruby_pi/context/compaction.rb +59 -50
- data/lib/ruby_pi/llm/anthropic.rb +27 -10
- data/lib/ruby_pi/llm/base_provider.rb +126 -5
- data/lib/ruby_pi/llm/fallback.rb +36 -9
- data/lib/ruby_pi/llm/gemini.rb +25 -11
- data/lib/ruby_pi/llm/openai.rb +18 -8
- data/lib/ruby_pi/llm/stream_event.rb +10 -3
- data/lib/ruby_pi/llm/tool_call.rb +2 -0
- data/lib/ruby_pi/tools/definition.rb +39 -4
- data/lib/ruby_pi/tools/executor.rb +14 -6
- data/lib/ruby_pi/tools/schema.rb +10 -0
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +7 -0
- metadata +17 -3
|
@@ -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 fallback_start].freeze
|
|
28
|
+
VALID_TYPES = %i[text_delta tool_call_delta done retry_start fallback_start].freeze
|
|
29
29
|
|
|
30
30
|
# @return [Symbol] the type of stream event — one of :text_delta,
|
|
31
|
-
# :tool_call_delta, :done, or :fallback_start
|
|
31
|
+
# :tool_call_delta, :done, :retry_start, 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,8 @@ 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,
|
|
42
|
+
# :retry_start, :fallback_start)
|
|
42
43
|
# @param data [Object] event payload
|
|
43
44
|
# @raise [ArgumentError] if the type is not recognized
|
|
44
45
|
def initialize(type:, data: nil)
|
|
@@ -71,6 +72,12 @@ module RubyPi
|
|
|
71
72
|
@type == :done
|
|
72
73
|
end
|
|
73
74
|
|
|
75
|
+
# Returns true when a failed streaming attempt is being discarded and
|
|
76
|
+
# the same provider is about to retry from the beginning.
|
|
77
|
+
def retry_start?
|
|
78
|
+
@type == :retry_start
|
|
79
|
+
end
|
|
80
|
+
|
|
74
81
|
# Returns true if this is a fallback_start event, signaling that the
|
|
75
82
|
# primary provider failed mid-stream and the fallback provider is
|
|
76
83
|
# taking over. Consumers should clear any partial output rendered
|
|
@@ -37,16 +37,32 @@ module RubyPi
|
|
|
37
37
|
# @return [Hash] A JSON Schema hash describing the tool's parameters.
|
|
38
38
|
attr_reader :parameters
|
|
39
39
|
|
|
40
|
+
# Tool names must satisfy the strictest provider constraint (Anthropic's
|
|
41
|
+
# ^[a-zA-Z0-9_-]{1,64}$). Without this guard, a name like "send.email"
|
|
42
|
+
# registers fine and then 400s on every API request with an opaque
|
|
43
|
+
# server-side validation error that doesn't point back to the tool.
|
|
44
|
+
NAME_FORMAT = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
45
|
+
|
|
40
46
|
# Creates a new tool definition.
|
|
41
47
|
#
|
|
42
|
-
# @param name [String, Symbol] Unique identifier for the tool.
|
|
48
|
+
# @param name [String, Symbol] Unique identifier for the tool. Must match
|
|
49
|
+
# NAME_FORMAT (letters, digits, underscore, hyphen; max 64 chars).
|
|
43
50
|
# @param description [String] What the tool does (shown to the LLM).
|
|
44
51
|
# @param category [Symbol, nil] Optional grouping category.
|
|
45
52
|
# @param parameters [Hash] JSON Schema hash for the tool's input parameters.
|
|
46
|
-
# @yield [Hash] Block that implements the tool logic. Receives a hash of
|
|
47
|
-
#
|
|
53
|
+
# @yield [Hash] Block that implements the tool logic. Receives a hash of
|
|
54
|
+
# symbol-keyed arguments, or keyword arguments if the block declares
|
|
55
|
+
# keyword parameters (see #call).
|
|
56
|
+
# @raise [ArgumentError] If name is missing or violates NAME_FORMAT,
|
|
57
|
+
# description is missing, or no block given.
|
|
48
58
|
def initialize(name:, description:, category: nil, parameters: {}, &block)
|
|
49
59
|
raise ArgumentError, "Tool name is required" if name.nil? || name.to_s.strip.empty?
|
|
60
|
+
unless name.to_s.match?(NAME_FORMAT)
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"Tool name #{name.to_s.inspect} is invalid — provider APIs require " \
|
|
63
|
+
"names matching #{NAME_FORMAT.inspect} (letters, digits, underscore, " \
|
|
64
|
+
"hyphen; 1-64 characters)"
|
|
65
|
+
end
|
|
50
66
|
raise ArgumentError, "Tool description is required" if description.nil? || description.strip.empty?
|
|
51
67
|
raise ArgumentError, "Tool implementation block is required" unless block_given?
|
|
52
68
|
|
|
@@ -55,14 +71,33 @@ module RubyPi
|
|
|
55
71
|
@category = category&.to_sym
|
|
56
72
|
@parameters = parameters
|
|
57
73
|
@implementation = block
|
|
74
|
+
# On Ruby 3.x a positional Hash is never auto-splatted to keywords, so
|
|
75
|
+
# a block written `{ |content:, platform:| ... }` — the natural style
|
|
76
|
+
# given named schema parameters — would fail every call with
|
|
77
|
+
# "missing keyword". Detect keyword parameters once here and splat in
|
|
78
|
+
# #call accordingly.
|
|
79
|
+
@expects_keywords = block.parameters.any? { |type, _| %i[key keyreq keyrest].include?(type) }
|
|
58
80
|
end
|
|
59
81
|
|
|
60
82
|
# Invokes the tool with the given arguments.
|
|
61
83
|
#
|
|
84
|
+
# Blocks may be written either style:
|
|
85
|
+
# { |args| args[:content] } # single positional Hash
|
|
86
|
+
# { |content:, platform: "x"| ... } # keyword parameters
|
|
87
|
+
#
|
|
88
|
+
# When the block declares keyword parameters, the arguments hash is
|
|
89
|
+
# splatted to keywords. Note that a keyword-style block without **rest
|
|
90
|
+
# raises ArgumentError on unexpected keys — strict by design, since the
|
|
91
|
+
# keys come from the LLM.
|
|
92
|
+
#
|
|
62
93
|
# @param args [Hash] The arguments to pass to the tool implementation.
|
|
63
94
|
# @return [Object] Whatever the implementation block returns.
|
|
64
95
|
def call(args = {})
|
|
65
|
-
@
|
|
96
|
+
if @expects_keywords
|
|
97
|
+
@implementation.call(**args)
|
|
98
|
+
else
|
|
99
|
+
@implementation.call(args)
|
|
100
|
+
end
|
|
66
101
|
end
|
|
67
102
|
|
|
68
103
|
# Converts this tool definition to Google Gemini function declaration format.
|
|
@@ -115,7 +115,12 @@ module RubyPi
|
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
# Collect results, respecting the configured timeout for each future.
|
|
118
|
-
|
|
118
|
+
# Zip each future with its originating call so failure Results carry
|
|
119
|
+
# the real tool name — with several tools timing out in parallel,
|
|
120
|
+
# "unknown" Results are indistinguishable in logs and extension events.
|
|
121
|
+
calls.zip(futures).map do |call, future|
|
|
122
|
+
tool_name = (call[:name] || call["name"]).to_s
|
|
123
|
+
|
|
119
124
|
# Issue #10: Wait for the future to complete, then check its state
|
|
120
125
|
# explicitly. Future#value returns nil both on timeout AND when the
|
|
121
126
|
# block legitimately returned nil, so we cannot use || to distinguish.
|
|
@@ -128,13 +133,16 @@ module RubyPi
|
|
|
128
133
|
else
|
|
129
134
|
# Future was rejected (raised an exception within the block).
|
|
130
135
|
# This shouldn't normally happen since execute_single rescues
|
|
131
|
-
# internally, but handle it defensively.
|
|
136
|
+
# internally, but handle it defensively. The actual run time is
|
|
137
|
+
# unknown here (the future failed at some point before the wait
|
|
138
|
+
# elapsed), so report 0.0 rather than a misleading full-timeout
|
|
139
|
+
# duration for what may have been an instant failure.
|
|
132
140
|
error = future.reason
|
|
133
141
|
Result.new(
|
|
134
|
-
name:
|
|
142
|
+
name: tool_name,
|
|
135
143
|
success: false,
|
|
136
144
|
error: "#{error.class}: #{error.message}",
|
|
137
|
-
duration_ms:
|
|
145
|
+
duration_ms: 0.0
|
|
138
146
|
)
|
|
139
147
|
end
|
|
140
148
|
else
|
|
@@ -147,9 +155,9 @@ module RubyPi
|
|
|
147
155
|
future.cancel if future.respond_to?(:cancel)
|
|
148
156
|
|
|
149
157
|
Result.new(
|
|
150
|
-
name:
|
|
158
|
+
name: tool_name,
|
|
151
159
|
success: false,
|
|
152
|
-
error: "Tool
|
|
160
|
+
error: "Tool '#{tool_name}' timed out after #{@timeout}s",
|
|
153
161
|
duration_ms: @timeout * 1000.0
|
|
154
162
|
)
|
|
155
163
|
end
|
data/lib/ruby_pi/tools/schema.rb
CHANGED
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
# flag consumed by `.object` to populate the top-level "required" array.
|
|
14
14
|
# It is stripped from the property's own schema hash before inclusion.
|
|
15
15
|
#
|
|
16
|
+
# IMPORTANT: Schemas are LLM-facing hints, NOT runtime input validation.
|
|
17
|
+
# Nothing in the execution pipeline validates the model's arguments against
|
|
18
|
+
# the schema before invoking the tool block: `required`, `enum`, `minimum`,
|
|
19
|
+
# and type declarations constrain what the model is *asked* to produce, but a
|
|
20
|
+
# misbehaving model can still omit required fields, send extra keys, or pass
|
|
21
|
+
# a String where an Integer is declared — no coercion is performed. Tool
|
|
22
|
+
# blocks should treat their arguments as untrusted input and validate or
|
|
23
|
+
# coerce what they depend on. (This is deliberate, per the anti-framework
|
|
24
|
+
# philosophy: validation policy belongs to the tool, not the harness.)
|
|
25
|
+
#
|
|
16
26
|
# Usage:
|
|
17
27
|
# schema = RubyPi::Schema.object(
|
|
18
28
|
# name: RubyPi::Schema.string("User's name", required: true),
|
data/lib/ruby_pi/version.rb
CHANGED
data/lib/ruby_pi.rb
CHANGED
|
@@ -82,6 +82,13 @@ module RubyPi
|
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# Eagerly initialize the global configuration at load time. The lazy
|
|
86
|
+
# `@configuration ||= ...` in .configuration is not synchronized; two
|
|
87
|
+
# threads hitting it concurrently on first access could each construct a
|
|
88
|
+
# Configuration, with one silently discarded. Initializing here (requires
|
|
89
|
+
# run single-threaded) removes the race without adding a mutex to every read.
|
|
90
|
+
@configuration = Configuration.new
|
|
91
|
+
|
|
85
92
|
# Namespace for large language model providers and related abstractions.
|
|
86
93
|
module LLM
|
|
87
94
|
class << self
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- RubyPi Contributors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: faraday
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '1.2'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: json
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.0'
|
|
54
68
|
- !ruby/object:Gem::Dependency
|
|
55
69
|
name: rspec
|
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -157,7 +171,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
157
171
|
- !ruby/object:Gem::Version
|
|
158
172
|
version: '0'
|
|
159
173
|
requirements: []
|
|
160
|
-
rubygems_version:
|
|
174
|
+
rubygems_version: 4.0.16
|
|
161
175
|
specification_version: 4
|
|
162
176
|
summary: AI agent harness for Ruby — build LLM agents with tool calling, streaming,
|
|
163
177
|
and a unified interface to OpenAI, Anthropic Claude, and Google Gemini.
|