ruby-pi 0.1.0
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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +415 -0
- data/lib/ruby_pi/agent/core.rb +175 -0
- data/lib/ruby_pi/agent/events.rb +120 -0
- data/lib/ruby_pi/agent/loop.rb +265 -0
- data/lib/ruby_pi/agent/result.rb +101 -0
- data/lib/ruby_pi/agent/state.rb +155 -0
- data/lib/ruby_pi/configuration.rb +80 -0
- data/lib/ruby_pi/context/compaction.rb +160 -0
- data/lib/ruby_pi/context/transform.rb +115 -0
- data/lib/ruby_pi/errors.rb +97 -0
- data/lib/ruby_pi/extensions/base.rb +96 -0
- data/lib/ruby_pi/llm/anthropic.rb +314 -0
- data/lib/ruby_pi/llm/base_provider.rb +220 -0
- data/lib/ruby_pi/llm/fallback.rb +96 -0
- data/lib/ruby_pi/llm/gemini.rb +260 -0
- data/lib/ruby_pi/llm/model.rb +82 -0
- data/lib/ruby_pi/llm/openai.rb +287 -0
- data/lib/ruby_pi/llm/response.rb +82 -0
- data/lib/ruby_pi/llm/stream_event.rb +91 -0
- data/lib/ruby_pi/llm/tool_call.rb +78 -0
- data/lib/ruby_pi/tools/definition.rb +149 -0
- data/lib/ruby_pi/tools/executor.rb +168 -0
- data/lib/ruby_pi/tools/registry.rb +120 -0
- data/lib/ruby_pi/tools/result.rb +83 -0
- data/lib/ruby_pi/tools/schema.rb +170 -0
- data/lib/ruby_pi/version.rb +11 -0
- data/lib/ruby_pi.rb +112 -0
- metadata +192 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/llm/response.rb
|
|
4
|
+
#
|
|
5
|
+
# Represents a unified response from any LLM provider. Normalizes the varied
|
|
6
|
+
# response formats of Gemini, Anthropic, and OpenAI into a single, consistent
|
|
7
|
+
# structure that calling code can depend on regardless of provider.
|
|
8
|
+
|
|
9
|
+
module RubyPi
|
|
10
|
+
module LLM
|
|
11
|
+
# A normalized response object returned by all LLM providers after a
|
|
12
|
+
# completion request. Encapsulates the generated text content, any tool
|
|
13
|
+
# calls the model wants to invoke, token usage statistics, and the reason
|
|
14
|
+
# the model stopped generating.
|
|
15
|
+
#
|
|
16
|
+
# @example Accessing response data
|
|
17
|
+
# response = provider.complete(messages: messages)
|
|
18
|
+
# puts response.content
|
|
19
|
+
# response.tool_calls.each { |tc| handle_tool(tc) }
|
|
20
|
+
# puts "Tokens used: #{response.usage[:total_tokens]}"
|
|
21
|
+
class Response
|
|
22
|
+
# @return [String, nil] the generated text content from the model
|
|
23
|
+
attr_reader :content
|
|
24
|
+
|
|
25
|
+
# @return [Array<RubyPi::LLM::ToolCall>] tool calls the model wants to invoke
|
|
26
|
+
attr_reader :tool_calls
|
|
27
|
+
|
|
28
|
+
# @return [Hash] token usage statistics with keys like :prompt_tokens,
|
|
29
|
+
# :completion_tokens, :total_tokens
|
|
30
|
+
attr_reader :usage
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] the reason the model stopped generating
|
|
33
|
+
# (e.g., "stop", "tool_calls", "max_tokens")
|
|
34
|
+
attr_reader :finish_reason
|
|
35
|
+
|
|
36
|
+
# Creates a new Response instance.
|
|
37
|
+
#
|
|
38
|
+
# @param content [String, nil] the generated text content
|
|
39
|
+
# @param tool_calls [Array<RubyPi::LLM::ToolCall>] list of tool invocations
|
|
40
|
+
# @param usage [Hash] token usage statistics
|
|
41
|
+
# @param finish_reason [String, nil] why the model stopped generating
|
|
42
|
+
def initialize(content: nil, tool_calls: [], usage: {}, finish_reason: nil)
|
|
43
|
+
@content = content
|
|
44
|
+
@tool_calls = Array(tool_calls)
|
|
45
|
+
@usage = usage
|
|
46
|
+
@finish_reason = finish_reason
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns true if the response includes one or more tool calls.
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def tool_calls?
|
|
53
|
+
!@tool_calls.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns a hash representation of the response for serialization.
|
|
57
|
+
#
|
|
58
|
+
# @return [Hash] the response as a plain hash
|
|
59
|
+
def to_h
|
|
60
|
+
{
|
|
61
|
+
content: @content,
|
|
62
|
+
tool_calls: @tool_calls.map(&:to_h),
|
|
63
|
+
usage: @usage,
|
|
64
|
+
finish_reason: @finish_reason
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns a human-readable string representation of the response.
|
|
69
|
+
#
|
|
70
|
+
# @return [String]
|
|
71
|
+
def to_s
|
|
72
|
+
parts = []
|
|
73
|
+
parts << "content=#{@content.inspect}" if @content
|
|
74
|
+
parts << "tool_calls=#{@tool_calls.length}" if tool_calls?
|
|
75
|
+
parts << "finish_reason=#{@finish_reason}" if @finish_reason
|
|
76
|
+
"#<RubyPi::LLM::Response #{parts.join(', ')}>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
alias_method :inspect, :to_s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/llm/stream_event.rb
|
|
4
|
+
#
|
|
5
|
+
# Represents a single event in a streaming LLM response. When streaming is
|
|
6
|
+
# enabled, the provider yields a sequence of StreamEvent objects to the caller's
|
|
7
|
+
# block, allowing incremental processing of text deltas and tool call fragments.
|
|
8
|
+
|
|
9
|
+
module RubyPi
|
|
10
|
+
module LLM
|
|
11
|
+
# An event yielded during a streaming completion. Events carry a type
|
|
12
|
+
# indicating what kind of data they contain and a data payload with
|
|
13
|
+
# the actual content.
|
|
14
|
+
#
|
|
15
|
+
# @example Processing streaming events
|
|
16
|
+
# provider.complete(messages: msgs, stream: true) do |event|
|
|
17
|
+
# case event.type
|
|
18
|
+
# when :text_delta
|
|
19
|
+
# print event.data
|
|
20
|
+
# when :tool_call_delta
|
|
21
|
+
# accumulate_tool_call(event.data)
|
|
22
|
+
# when :done
|
|
23
|
+
# puts "\nStream complete"
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
class StreamEvent
|
|
27
|
+
# Valid event types for stream events.
|
|
28
|
+
VALID_TYPES = %i[text_delta tool_call_delta done].freeze
|
|
29
|
+
|
|
30
|
+
# @return [Symbol] the type of stream event — one of :text_delta,
|
|
31
|
+
# :tool_call_delta, or :done
|
|
32
|
+
attr_reader :type
|
|
33
|
+
|
|
34
|
+
# @return [Object] the event payload. For :text_delta this is a String
|
|
35
|
+
# fragment; for :tool_call_delta it is a Hash with partial tool call
|
|
36
|
+
# data; for :done it is nil or a final summary hash.
|
|
37
|
+
attr_reader :data
|
|
38
|
+
|
|
39
|
+
# Creates a new StreamEvent instance.
|
|
40
|
+
#
|
|
41
|
+
# @param type [Symbol] event type (:text_delta, :tool_call_delta, :done)
|
|
42
|
+
# @param data [Object] event payload
|
|
43
|
+
# @raise [ArgumentError] if the type is not recognized
|
|
44
|
+
def initialize(type:, data: nil)
|
|
45
|
+
unless VALID_TYPES.include?(type)
|
|
46
|
+
raise ArgumentError, "Invalid stream event type: #{type.inspect}. Must be one of: #{VALID_TYPES.join(', ')}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@type = type
|
|
50
|
+
@data = data
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns true if this is a text delta event.
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def text_delta?
|
|
57
|
+
@type == :text_delta
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns true if this is a tool call delta event.
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def tool_call_delta?
|
|
64
|
+
@type == :tool_call_delta
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns true if this is a done/completion event.
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def done?
|
|
71
|
+
@type == :done
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns a hash representation of the stream event.
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash]
|
|
77
|
+
def to_h
|
|
78
|
+
{ type: @type, data: @data }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns a human-readable string representation.
|
|
82
|
+
#
|
|
83
|
+
# @return [String]
|
|
84
|
+
def to_s
|
|
85
|
+
"#<RubyPi::LLM::StreamEvent type=#{@type.inspect} data=#{@data.inspect}>"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
alias_method :inspect, :to_s
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/llm/tool_call.rb
|
|
4
|
+
#
|
|
5
|
+
# Represents a tool (function) call requested by the LLM. When the model
|
|
6
|
+
# decides to invoke a tool, it returns one or more ToolCall objects describing
|
|
7
|
+
# which function to call and with what arguments.
|
|
8
|
+
|
|
9
|
+
module RubyPi
|
|
10
|
+
module LLM
|
|
11
|
+
# A tool call extracted from an LLM response. Contains the unique call ID,
|
|
12
|
+
# the function name, and the parsed arguments hash. Provider-specific
|
|
13
|
+
# formats are normalized into this common structure.
|
|
14
|
+
#
|
|
15
|
+
# @example Handling a tool call
|
|
16
|
+
# response.tool_calls.each do |tool_call|
|
|
17
|
+
# result = dispatch(tool_call.name, tool_call.arguments)
|
|
18
|
+
# # Feed result back into conversation
|
|
19
|
+
# end
|
|
20
|
+
class ToolCall
|
|
21
|
+
# @return [String] unique identifier for this tool call, used to match
|
|
22
|
+
# results back to the calling context
|
|
23
|
+
attr_reader :id
|
|
24
|
+
|
|
25
|
+
# @return [String] the name of the tool/function to invoke
|
|
26
|
+
attr_reader :name
|
|
27
|
+
|
|
28
|
+
# @return [Hash] the parsed arguments to pass to the tool
|
|
29
|
+
attr_reader :arguments
|
|
30
|
+
|
|
31
|
+
# Creates a new ToolCall instance.
|
|
32
|
+
#
|
|
33
|
+
# @param id [String] unique call identifier
|
|
34
|
+
# @param name [String] tool/function name
|
|
35
|
+
# @param arguments [Hash] parsed arguments hash
|
|
36
|
+
def initialize(id:, name:, arguments: {})
|
|
37
|
+
@id = id
|
|
38
|
+
@name = name
|
|
39
|
+
@arguments = arguments.is_a?(Hash) ? arguments : parse_arguments(arguments)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns a hash representation of the tool call for serialization.
|
|
43
|
+
#
|
|
44
|
+
# @return [Hash]
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
id: @id,
|
|
48
|
+
name: @name,
|
|
49
|
+
arguments: @arguments
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns a human-readable string representation of the tool call.
|
|
54
|
+
#
|
|
55
|
+
# @return [String]
|
|
56
|
+
def to_s
|
|
57
|
+
"#<RubyPi::LLM::ToolCall id=#{@id.inspect} name=#{@name.inspect} arguments=#{@arguments.inspect}>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
alias_method :inspect, :to_s
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Attempts to parse a JSON string into a Hash. Falls back to wrapping
|
|
65
|
+
# the raw value in a hash if parsing fails.
|
|
66
|
+
#
|
|
67
|
+
# @param raw [String, Object] raw arguments data
|
|
68
|
+
# @return [Hash] parsed arguments
|
|
69
|
+
def parse_arguments(raw)
|
|
70
|
+
return {} if raw.nil? || raw.empty?
|
|
71
|
+
|
|
72
|
+
JSON.parse(raw.to_s)
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
{ "_raw" => raw.to_s }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/tools/definition.rb
|
|
4
|
+
#
|
|
5
|
+
# RubyPi::Tools::Definition — Describes a callable tool with its metadata.
|
|
6
|
+
#
|
|
7
|
+
# A Definition encapsulates everything needed to declare and invoke a tool:
|
|
8
|
+
# its name, description, category, parameter schema, and implementation block.
|
|
9
|
+
# It also provides format converters for major LLM provider APIs (Gemini,
|
|
10
|
+
# Anthropic, OpenAI) so the same tool definition can be used across providers.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# tool = RubyPi::Tools::Definition.new(
|
|
14
|
+
# name: "create_post",
|
|
15
|
+
# description: "Creates a social media post",
|
|
16
|
+
# category: :content,
|
|
17
|
+
# parameters: RubyPi::Schema.object(
|
|
18
|
+
# content: RubyPi::Schema.string("Post content", required: true)
|
|
19
|
+
# )
|
|
20
|
+
# ) { |args| { post_id: "123", status: "created" } }
|
|
21
|
+
#
|
|
22
|
+
# tool.call(content: "Hello world")
|
|
23
|
+
# # => { post_id: "123", status: "created" }
|
|
24
|
+
|
|
25
|
+
module RubyPi
|
|
26
|
+
module Tools
|
|
27
|
+
class Definition
|
|
28
|
+
# @return [Symbol] The unique name identifying this tool.
|
|
29
|
+
attr_reader :name
|
|
30
|
+
|
|
31
|
+
# @return [String] A human-readable description of what this tool does.
|
|
32
|
+
attr_reader :description
|
|
33
|
+
|
|
34
|
+
# @return [Symbol, nil] An optional category for grouping related tools.
|
|
35
|
+
attr_reader :category
|
|
36
|
+
|
|
37
|
+
# @return [Hash] A JSON Schema hash describing the tool's parameters.
|
|
38
|
+
attr_reader :parameters
|
|
39
|
+
|
|
40
|
+
# Creates a new tool definition.
|
|
41
|
+
#
|
|
42
|
+
# @param name [String, Symbol] Unique identifier for the tool.
|
|
43
|
+
# @param description [String] What the tool does (shown to the LLM).
|
|
44
|
+
# @param category [Symbol, nil] Optional grouping category.
|
|
45
|
+
# @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.
|
|
48
|
+
def initialize(name:, description:, category: nil, parameters: {}, &block)
|
|
49
|
+
raise ArgumentError, "Tool name is required" if name.nil? || name.to_s.strip.empty?
|
|
50
|
+
raise ArgumentError, "Tool description is required" if description.nil? || description.strip.empty?
|
|
51
|
+
raise ArgumentError, "Tool implementation block is required" unless block_given?
|
|
52
|
+
|
|
53
|
+
@name = name.to_sym
|
|
54
|
+
@description = description
|
|
55
|
+
@category = category&.to_sym
|
|
56
|
+
@parameters = parameters
|
|
57
|
+
@implementation = block
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Invokes the tool with the given arguments.
|
|
61
|
+
#
|
|
62
|
+
# @param args [Hash] The arguments to pass to the tool implementation.
|
|
63
|
+
# @return [Object] Whatever the implementation block returns.
|
|
64
|
+
def call(args = {})
|
|
65
|
+
@implementation.call(args)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Converts this tool definition to Google Gemini function declaration format.
|
|
69
|
+
#
|
|
70
|
+
# Gemini expects:
|
|
71
|
+
# { name: "...", description: "...", parameters: { ... } }
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] The tool in Gemini's function declaration format.
|
|
74
|
+
def to_gemini_format
|
|
75
|
+
declaration = {
|
|
76
|
+
name: @name.to_s,
|
|
77
|
+
description: @description
|
|
78
|
+
}
|
|
79
|
+
declaration[:parameters] = @parameters unless @parameters.empty?
|
|
80
|
+
declaration
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Converts this tool definition to Anthropic's tool format.
|
|
84
|
+
#
|
|
85
|
+
# Anthropic expects:
|
|
86
|
+
# { name: "...", description: "...", input_schema: { ... } }
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash] The tool in Anthropic's tool format.
|
|
89
|
+
def to_anthropic_format
|
|
90
|
+
tool = {
|
|
91
|
+
name: @name.to_s,
|
|
92
|
+
description: @description
|
|
93
|
+
}
|
|
94
|
+
tool[:input_schema] = @parameters unless @parameters.empty?
|
|
95
|
+
tool
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Converts this tool definition to OpenAI's function calling format.
|
|
99
|
+
#
|
|
100
|
+
# OpenAI expects:
|
|
101
|
+
# { type: "function", function: { name: "...", description: "...", parameters: { ... } } }
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash] The tool in OpenAI's function format.
|
|
104
|
+
def to_openai_format
|
|
105
|
+
function = {
|
|
106
|
+
name: @name.to_s,
|
|
107
|
+
description: @description
|
|
108
|
+
}
|
|
109
|
+
function[:parameters] = @parameters unless @parameters.empty?
|
|
110
|
+
{
|
|
111
|
+
type: "function",
|
|
112
|
+
function: function
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Provides a human-readable string representation of the definition.
|
|
117
|
+
#
|
|
118
|
+
# @return [String] A summary string for debugging/logging.
|
|
119
|
+
def inspect
|
|
120
|
+
"#<RubyPi::Tools::Definition name=#{@name.inspect} category=#{@category.inspect}>"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Top-level convenience module for defining tools with a short syntax.
|
|
126
|
+
#
|
|
127
|
+
# Usage:
|
|
128
|
+
# tool = RubyPi::Tool.define(name: "my_tool", description: "Does stuff") { |args| ... }
|
|
129
|
+
module Tool
|
|
130
|
+
class << self
|
|
131
|
+
# Creates a new tool Definition using the same arguments as Definition.new.
|
|
132
|
+
#
|
|
133
|
+
# This is the primary public API for defining tools. It provides a cleaner
|
|
134
|
+
# entry point than instantiating Definition directly.
|
|
135
|
+
#
|
|
136
|
+
# @param (see RubyPi::Tools::Definition#initialize)
|
|
137
|
+
# @return [RubyPi::Tools::Definition] The constructed tool definition.
|
|
138
|
+
def define(name:, description:, category: nil, parameters: {}, &block)
|
|
139
|
+
Tools::Definition.new(
|
|
140
|
+
name: name,
|
|
141
|
+
description: description,
|
|
142
|
+
category: category,
|
|
143
|
+
parameters: parameters,
|
|
144
|
+
&block
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/tools/executor.rb
|
|
4
|
+
#
|
|
5
|
+
# RubyPi::Tools::Executor — Executes tool calls in parallel or sequentially.
|
|
6
|
+
#
|
|
7
|
+
# The Executor takes a Registry of tools and a list of tool call requests,
|
|
8
|
+
# dispatching each call to the appropriate tool. In `:parallel` mode it uses
|
|
9
|
+
# `concurrent-ruby`'s thread pool (Concurrent::Future) to run tools concurrently.
|
|
10
|
+
# In `:sequential` mode, tools are executed one after another.
|
|
11
|
+
#
|
|
12
|
+
# Each execution is wrapped in error handling: if a tool raises an exception,
|
|
13
|
+
# the error is captured in a Result with `success: false`. A configurable
|
|
14
|
+
# per-tool timeout (default 30 seconds) prevents runaway executions.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# executor = RubyPi::Tools::Executor.new(registry, mode: :parallel, timeout: 30)
|
|
18
|
+
# results = executor.execute([
|
|
19
|
+
# { name: "create_post", arguments: { content: "Hello" } },
|
|
20
|
+
# { name: "get_analytics", arguments: { period: "7d" } }
|
|
21
|
+
# ])
|
|
22
|
+
# # => Array of RubyPi::Tools::Result
|
|
23
|
+
|
|
24
|
+
require "concurrent"
|
|
25
|
+
|
|
26
|
+
module RubyPi
|
|
27
|
+
module Tools
|
|
28
|
+
class Executor
|
|
29
|
+
# Default timeout for each tool execution, in seconds.
|
|
30
|
+
DEFAULT_TIMEOUT = 30
|
|
31
|
+
|
|
32
|
+
# @return [Symbol] The execution mode (:parallel or :sequential).
|
|
33
|
+
attr_reader :mode
|
|
34
|
+
|
|
35
|
+
# @return [Numeric] The per-tool timeout in seconds.
|
|
36
|
+
attr_reader :timeout
|
|
37
|
+
|
|
38
|
+
# Creates a new Executor.
|
|
39
|
+
#
|
|
40
|
+
# @param registry [RubyPi::Tools::Registry] The registry to look up tools from.
|
|
41
|
+
# @param mode [Symbol] Execution mode — :parallel or :sequential.
|
|
42
|
+
# @param timeout [Numeric] Per-tool timeout in seconds (default: 30).
|
|
43
|
+
# @raise [ArgumentError] If mode is not :parallel or :sequential.
|
|
44
|
+
def initialize(registry, mode: :parallel, timeout: DEFAULT_TIMEOUT)
|
|
45
|
+
unless %i[parallel sequential].include?(mode)
|
|
46
|
+
raise ArgumentError, "Mode must be :parallel or :sequential, got #{mode.inspect}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@registry = registry
|
|
50
|
+
@mode = mode
|
|
51
|
+
@timeout = timeout
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Executes a list of tool calls and returns their results.
|
|
55
|
+
#
|
|
56
|
+
# Each call is a hash with `:name` (String or Symbol) and `:arguments` (Hash).
|
|
57
|
+
# Tools are looked up in the registry; if a tool is not found, a failure
|
|
58
|
+
# Result is returned for that call.
|
|
59
|
+
#
|
|
60
|
+
# @param calls [Array<Hash>] Tool call requests, each with :name and :arguments.
|
|
61
|
+
# @return [Array<RubyPi::Tools::Result>] Results in the same order as the calls.
|
|
62
|
+
def execute(calls)
|
|
63
|
+
case @mode
|
|
64
|
+
when :parallel
|
|
65
|
+
execute_parallel(calls)
|
|
66
|
+
when :sequential
|
|
67
|
+
execute_sequential(calls)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Executes tool calls sequentially, one after another.
|
|
74
|
+
#
|
|
75
|
+
# @param calls [Array<Hash>] The tool call requests.
|
|
76
|
+
# @return [Array<RubyPi::Tools::Result>] Ordered results.
|
|
77
|
+
def execute_sequential(calls)
|
|
78
|
+
calls.map { |call| execute_single(call) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Executes tool calls in parallel using concurrent-ruby Futures.
|
|
82
|
+
#
|
|
83
|
+
# Each call is dispatched as a Future on the global I/O thread pool.
|
|
84
|
+
# Results are collected in order, respecting the per-tool timeout.
|
|
85
|
+
#
|
|
86
|
+
# @param calls [Array<Hash>] The tool call requests.
|
|
87
|
+
# @return [Array<RubyPi::Tools::Result>] Ordered results.
|
|
88
|
+
def execute_parallel(calls)
|
|
89
|
+
futures = calls.map do |call|
|
|
90
|
+
Concurrent::Future.execute(executor: :io) do
|
|
91
|
+
execute_single(call)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Collect results, respecting the configured timeout for each future.
|
|
96
|
+
futures.map do |future|
|
|
97
|
+
future.value(@timeout) || Result.new(
|
|
98
|
+
name: "unknown",
|
|
99
|
+
success: false,
|
|
100
|
+
error: "Tool execution timed out after #{@timeout}s",
|
|
101
|
+
duration_ms: @timeout * 1000.0
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Executes a single tool call with error handling and timing.
|
|
107
|
+
#
|
|
108
|
+
# @param call [Hash] A tool call with :name and :arguments keys.
|
|
109
|
+
# @return [RubyPi::Tools::Result] The execution result.
|
|
110
|
+
def execute_single(call)
|
|
111
|
+
tool_name = (call[:name] || call["name"]).to_s
|
|
112
|
+
arguments = call[:arguments] || call["arguments"] || {}
|
|
113
|
+
|
|
114
|
+
tool = @registry.find(tool_name)
|
|
115
|
+
|
|
116
|
+
# Return an error result if the tool is not registered
|
|
117
|
+
unless tool
|
|
118
|
+
return Result.new(
|
|
119
|
+
name: tool_name,
|
|
120
|
+
success: false,
|
|
121
|
+
error: "Tool '#{tool_name}' not found in registry",
|
|
122
|
+
duration_ms: 0.0
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Execute the tool with timeout and error handling
|
|
127
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
128
|
+
begin
|
|
129
|
+
value = Timeout.timeout(@timeout) do
|
|
130
|
+
tool.call(arguments)
|
|
131
|
+
end
|
|
132
|
+
elapsed_ms = elapsed_since(start_time)
|
|
133
|
+
|
|
134
|
+
Result.new(
|
|
135
|
+
name: tool_name,
|
|
136
|
+
success: true,
|
|
137
|
+
value: value,
|
|
138
|
+
duration_ms: elapsed_ms
|
|
139
|
+
)
|
|
140
|
+
rescue Timeout::Error
|
|
141
|
+
elapsed_ms = elapsed_since(start_time)
|
|
142
|
+
Result.new(
|
|
143
|
+
name: tool_name,
|
|
144
|
+
success: false,
|
|
145
|
+
error: "Tool '#{tool_name}' timed out after #{@timeout}s",
|
|
146
|
+
duration_ms: elapsed_ms
|
|
147
|
+
)
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
elapsed_ms = elapsed_since(start_time)
|
|
150
|
+
Result.new(
|
|
151
|
+
name: tool_name,
|
|
152
|
+
success: false,
|
|
153
|
+
error: "#{e.class}: #{e.message}",
|
|
154
|
+
duration_ms: elapsed_ms
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Calculates milliseconds elapsed since a monotonic clock timestamp.
|
|
160
|
+
#
|
|
161
|
+
# @param start_time [Float] The start timestamp from Process.clock_gettime.
|
|
162
|
+
# @return [Float] Elapsed time in milliseconds.
|
|
163
|
+
def elapsed_since(start_time)
|
|
164
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/tools/registry.rb
|
|
4
|
+
#
|
|
5
|
+
# RubyPi::Tools::Registry — A thread-safe store for tool definitions.
|
|
6
|
+
#
|
|
7
|
+
# The Registry holds a collection of tool definitions and provides methods
|
|
8
|
+
# for looking them up by name, filtering by category, and extracting subsets.
|
|
9
|
+
# It uses a Mutex for thread safety when registering tools concurrently.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# registry = RubyPi::Tools::Registry.new
|
|
13
|
+
# registry.register(my_tool)
|
|
14
|
+
# registry.find(:my_tool) # => Definition or nil
|
|
15
|
+
# registry.by_category(:content) # => [Definition, ...]
|
|
16
|
+
# registry.subset([:tool_a, :tool_b])# => Registry (new instance)
|
|
17
|
+
# registry.names # => [:tool_a, :tool_b, ...]
|
|
18
|
+
|
|
19
|
+
module RubyPi
|
|
20
|
+
module Tools
|
|
21
|
+
class Registry
|
|
22
|
+
# Creates a new, empty Registry.
|
|
23
|
+
def initialize
|
|
24
|
+
@tools = {}
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Registers a tool definition in the registry.
|
|
29
|
+
#
|
|
30
|
+
# If a tool with the same name already exists, it will be overwritten
|
|
31
|
+
# and a warning is emitted to stderr.
|
|
32
|
+
#
|
|
33
|
+
# @param tool [RubyPi::Tools::Definition] The tool to register.
|
|
34
|
+
# @return [RubyPi::Tools::Definition] The registered tool.
|
|
35
|
+
# @raise [ArgumentError] If the argument is not a Definition.
|
|
36
|
+
def register(tool)
|
|
37
|
+
unless tool.is_a?(RubyPi::Tools::Definition)
|
|
38
|
+
raise ArgumentError, "Expected a RubyPi::Tools::Definition, got #{tool.class}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
if @tools.key?(tool.name)
|
|
43
|
+
warn "RubyPi::Tools::Registry: overwriting existing tool '#{tool.name}'"
|
|
44
|
+
end
|
|
45
|
+
@tools[tool.name] = tool
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
tool
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Finds a tool by name.
|
|
52
|
+
#
|
|
53
|
+
# @param name [String, Symbol] The name of the tool to look up.
|
|
54
|
+
# @return [RubyPi::Tools::Definition, nil] The tool, or nil if not found.
|
|
55
|
+
def find(name)
|
|
56
|
+
@tools[name.to_sym]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns a new Registry containing only the tools with the given names.
|
|
60
|
+
#
|
|
61
|
+
# Tools that are not found in this registry are silently skipped.
|
|
62
|
+
#
|
|
63
|
+
# @param names [Array<String, Symbol>] The tool names to include.
|
|
64
|
+
# @return [RubyPi::Tools::Registry] A new registry with the matching tools.
|
|
65
|
+
def subset(names)
|
|
66
|
+
sub = Registry.new
|
|
67
|
+
names.each do |name|
|
|
68
|
+
tool = find(name)
|
|
69
|
+
sub.register(tool) if tool
|
|
70
|
+
end
|
|
71
|
+
sub
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns all tools that belong to the given category.
|
|
75
|
+
#
|
|
76
|
+
# @param category [Symbol, String] The category to filter by.
|
|
77
|
+
# @return [Array<RubyPi::Tools::Definition>] Tools matching the category.
|
|
78
|
+
def by_category(category)
|
|
79
|
+
cat = category.to_sym
|
|
80
|
+
@tools.values.select { |tool| tool.category == cat }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns all registered tool definitions.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<RubyPi::Tools::Definition>] All tools in registration order.
|
|
86
|
+
def all
|
|
87
|
+
@tools.values
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the names of all registered tools.
|
|
91
|
+
#
|
|
92
|
+
# @return [Array<Symbol>] An array of tool name symbols.
|
|
93
|
+
def names
|
|
94
|
+
@tools.keys
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns the number of registered tools.
|
|
98
|
+
#
|
|
99
|
+
# @return [Integer] The count of tools.
|
|
100
|
+
def size
|
|
101
|
+
@tools.size
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Checks whether a tool with the given name is registered.
|
|
105
|
+
#
|
|
106
|
+
# @param name [String, Symbol] The tool name to check.
|
|
107
|
+
# @return [Boolean] true if the tool exists in the registry.
|
|
108
|
+
def registered?(name)
|
|
109
|
+
@tools.key?(name.to_sym)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Provides a human-readable string representation.
|
|
113
|
+
#
|
|
114
|
+
# @return [String] Summary of the registry contents.
|
|
115
|
+
def inspect
|
|
116
|
+
"#<RubyPi::Tools::Registry tools=#{names.inspect}>"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|