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.
@@ -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, :fallback_start)
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
@@ -6,6 +6,8 @@
6
6
  # decides to invoke a tool, it returns one or more ToolCall objects describing
7
7
  # which function to call and with what arguments.
8
8
 
9
+ require "json"
10
+
9
11
  module RubyPi
10
12
  module LLM
11
13
  # A tool call extracted from an LLM response. Contains the unique call ID,
@@ -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 arguments.
47
- # @raise [ArgumentError] If name or description is missing, or no block given.
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
- @implementation.call(args)
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
- futures.map do |future|
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: "unknown",
142
+ name: tool_name,
135
143
  success: false,
136
144
  error: "#{error.class}: #{error.message}",
137
- duration_ms: @timeout * 1000.0
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: "unknown",
158
+ name: tool_name,
151
159
  success: false,
152
- error: "Tool execution timed out after #{@timeout}s",
160
+ error: "Tool '#{tool_name}' timed out after #{@timeout}s",
153
161
  duration_ms: @timeout * 1000.0
154
162
  )
155
163
  end
@@ -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),
@@ -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.6"
10
+ VERSION = "0.1.9"
11
11
  end
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.6
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: 2026-05-01 00:00:00.000000000 Z
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: 3.6.2
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.