ai-agents 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/.rspec +3 -0
- data/.rubocop.yml +26 -0
- data/CLAUDE.md +188 -0
- data/LICENSE +21 -0
- data/README.md +283 -0
- data/Rakefile +12 -0
- data/examples/README.md +64 -0
- data/examples/isp-support/README.md +121 -0
- data/examples/isp-support/agents_factory.rb +142 -0
- data/examples/isp-support/data/customers.json +133 -0
- data/examples/isp-support/data/docs.json +133 -0
- data/examples/isp-support/data/plans.json +86 -0
- data/examples/isp-support/interactive.rb +135 -0
- data/examples/isp-support/tools/create_checkout_tool.rb +16 -0
- data/examples/isp-support/tools/create_lead_tool.rb +15 -0
- data/examples/isp-support/tools/crm_lookup_tool.rb +28 -0
- data/examples/isp-support/tools/escalate_to_human_tool.rb +12 -0
- data/examples/isp-support/tools/search_docs_tool.rb +22 -0
- data/lib/agents/agent.rb +170 -0
- data/lib/agents/handoff.rb +116 -0
- data/lib/agents/result.rb +13 -0
- data/lib/agents/run_context.rb +106 -0
- data/lib/agents/runner.rb +233 -0
- data/lib/agents/tool.rb +153 -0
- data/lib/agents/tool_context.rb +98 -0
- data/lib/agents/tool_wrapper.rb +71 -0
- data/lib/agents/version.rb +5 -0
- data/lib/agents.rb +70 -0
- data/sig/ruby/agents.rbs +6 -0
- metadata +86 -0
@@ -0,0 +1,233 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Agents
|
4
|
+
# The execution engine that orchestrates conversations between users and agents.
|
5
|
+
# Runner manages the conversation flow, handles tool execution through RubyLLM,
|
6
|
+
# coordinates handoffs between agents, and ensures thread-safe operation.
|
7
|
+
#
|
8
|
+
# The Runner follows a turn-based execution model where each turn consists of:
|
9
|
+
# 1. Sending a message to the LLM with current context
|
10
|
+
# 2. Receiving a response that may include tool calls
|
11
|
+
# 3. Executing tools and getting results (handled by RubyLLM)
|
12
|
+
# 4. Checking for agent handoffs
|
13
|
+
# 5. Continuing until no more tools are called
|
14
|
+
#
|
15
|
+
# ## Thread Safety
|
16
|
+
# The Runner ensures thread safety by:
|
17
|
+
# - Creating new context wrappers for each execution
|
18
|
+
# - Using tool wrappers that pass context through parameters
|
19
|
+
# - Never storing execution state in shared variables
|
20
|
+
#
|
21
|
+
# ## Integration with RubyLLM
|
22
|
+
# We leverage RubyLLM for LLM communication and tool execution while
|
23
|
+
# maintaining our own context management and handoff logic.
|
24
|
+
#
|
25
|
+
# @example Simple conversation
|
26
|
+
# agent = Agents::Agent.new(
|
27
|
+
# name: "Assistant",
|
28
|
+
# instructions: "You are a helpful assistant",
|
29
|
+
# tools: [weather_tool]
|
30
|
+
# )
|
31
|
+
#
|
32
|
+
# result = Agents::Runner.run(agent, "What's the weather?")
|
33
|
+
# puts result.output
|
34
|
+
# # => "Let me check the weather for you..."
|
35
|
+
#
|
36
|
+
# @example Conversation with context
|
37
|
+
# result = Agents::Runner.run(
|
38
|
+
# support_agent,
|
39
|
+
# "I need help with my order",
|
40
|
+
# context: { user_id: 123, order_id: 456 }
|
41
|
+
# )
|
42
|
+
#
|
43
|
+
# @example Multi-agent handoff
|
44
|
+
# triage = Agents::Agent.new(
|
45
|
+
# name: "Triage",
|
46
|
+
# instructions: "Route users to the right specialist",
|
47
|
+
# handoff_agents: [billing_agent, tech_agent]
|
48
|
+
# )
|
49
|
+
#
|
50
|
+
# result = Agents::Runner.run(triage, "I can't pay my bill")
|
51
|
+
# # Triage agent will handoff to billing_agent
|
52
|
+
class Runner
|
53
|
+
DEFAULT_MAX_TURNS = 10
|
54
|
+
|
55
|
+
class MaxTurnsExceeded < StandardError; end
|
56
|
+
|
57
|
+
# Convenience class method for running agents
|
58
|
+
def self.run(agent, input, context: {}, max_turns: DEFAULT_MAX_TURNS)
|
59
|
+
new.run(agent, input, context: context, max_turns: max_turns)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Execute an agent with the given input and context
|
63
|
+
#
|
64
|
+
# @param starting_agent [Agents::Agent] The initial agent to run
|
65
|
+
# @param input [String] The user's input message
|
66
|
+
# @param context [Hash] Shared context data accessible to all tools
|
67
|
+
# @param max_turns [Integer] Maximum conversation turns before stopping
|
68
|
+
# @return [RunResult] The result containing output, messages, and usage
|
69
|
+
def run(starting_agent, input, context: {}, max_turns: DEFAULT_MAX_TURNS)
|
70
|
+
# Determine current agent from context or use starting agent
|
71
|
+
current_agent = context[:current_agent] || starting_agent
|
72
|
+
|
73
|
+
# Create context wrapper with deep copy for thread safety
|
74
|
+
context_copy = deep_copy_context(context)
|
75
|
+
context_wrapper = RunContext.new(context_copy)
|
76
|
+
current_turn = 0
|
77
|
+
|
78
|
+
# Create chat and restore conversation history
|
79
|
+
chat = create_chat(current_agent, context_wrapper)
|
80
|
+
restore_conversation_history(chat, context_wrapper)
|
81
|
+
|
82
|
+
loop do
|
83
|
+
current_turn += 1
|
84
|
+
raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
|
85
|
+
|
86
|
+
# Get response from LLM (RubyLLM handles tool execution)
|
87
|
+
response = if current_turn == 1
|
88
|
+
chat.ask(input)
|
89
|
+
else
|
90
|
+
chat.complete
|
91
|
+
end
|
92
|
+
|
93
|
+
# Update usage
|
94
|
+
context_wrapper.usage.add(response.usage) if response.respond_to?(:usage) && response.usage
|
95
|
+
|
96
|
+
# Check for handoff via context (set by HandoffTool)
|
97
|
+
if context_wrapper.context[:pending_handoff]
|
98
|
+
next_agent = context_wrapper.context[:pending_handoff]
|
99
|
+
puts "[Agents] Handoff from #{current_agent.name} to #{next_agent.name}"
|
100
|
+
|
101
|
+
# Save current conversation state before switching
|
102
|
+
save_conversation_state(chat, context_wrapper, current_agent)
|
103
|
+
|
104
|
+
# Switch to new agent
|
105
|
+
current_agent = next_agent
|
106
|
+
context_wrapper.context[:current_agent] = next_agent
|
107
|
+
context_wrapper.context.delete(:pending_handoff)
|
108
|
+
|
109
|
+
# Create new chat for new agent with restored history
|
110
|
+
chat = create_chat(current_agent, context_wrapper)
|
111
|
+
restore_conversation_history(chat, context_wrapper)
|
112
|
+
next
|
113
|
+
end
|
114
|
+
|
115
|
+
# If no tools were called, we have our final response
|
116
|
+
next if response.tool_call?
|
117
|
+
|
118
|
+
# Save final state before returning
|
119
|
+
save_conversation_state(chat, context_wrapper, current_agent)
|
120
|
+
|
121
|
+
return RunResult.new(
|
122
|
+
output: response.content,
|
123
|
+
messages: extract_messages(chat),
|
124
|
+
usage: context_wrapper.usage,
|
125
|
+
context: context_wrapper.context
|
126
|
+
)
|
127
|
+
end
|
128
|
+
rescue MaxTurnsExceeded => e
|
129
|
+
# Save state even on error
|
130
|
+
save_conversation_state(chat, context_wrapper, current_agent) if chat
|
131
|
+
|
132
|
+
RunResult.new(
|
133
|
+
output: "Conversation ended: #{e.message}",
|
134
|
+
messages: chat ? extract_messages(chat) : [],
|
135
|
+
usage: context_wrapper.usage,
|
136
|
+
error: e,
|
137
|
+
context: context_wrapper.context
|
138
|
+
)
|
139
|
+
rescue StandardError => e
|
140
|
+
# Save state even on error
|
141
|
+
save_conversation_state(chat, context_wrapper, current_agent) if chat
|
142
|
+
|
143
|
+
RunResult.new(
|
144
|
+
output: nil,
|
145
|
+
messages: chat ? extract_messages(chat) : [],
|
146
|
+
usage: context_wrapper.usage,
|
147
|
+
error: e,
|
148
|
+
context: context_wrapper.context
|
149
|
+
)
|
150
|
+
end
|
151
|
+
|
152
|
+
private
|
153
|
+
|
154
|
+
def deep_copy_context(context)
|
155
|
+
# Handle deep copying for thread safety
|
156
|
+
context.dup.tap do |copied|
|
157
|
+
copied[:conversation_history] = context[:conversation_history]&.map(&:dup) || []
|
158
|
+
# Don't copy agents - they're immutable
|
159
|
+
copied[:current_agent] = context[:current_agent]
|
160
|
+
copied[:turn_count] = context[:turn_count] || 0
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def restore_conversation_history(chat, context_wrapper)
|
165
|
+
history = context_wrapper.context[:conversation_history] || []
|
166
|
+
|
167
|
+
history.each do |msg|
|
168
|
+
# Only restore user and assistant messages with content
|
169
|
+
next unless %i[user assistant].include?(msg[:role])
|
170
|
+
next unless msg[:content] && !msg[:content].strip.empty?
|
171
|
+
|
172
|
+
chat.add_message(
|
173
|
+
role: msg[:role].to_sym,
|
174
|
+
content: msg[:content]
|
175
|
+
)
|
176
|
+
rescue StandardError => e
|
177
|
+
# Continue with partial history on error
|
178
|
+
puts "[Agents] Failed to restore message: #{e.message}"
|
179
|
+
end
|
180
|
+
rescue StandardError => e
|
181
|
+
# If history restoration completely fails, continue with empty history
|
182
|
+
puts "[Agents] Failed to restore conversation history: #{e.message}"
|
183
|
+
context_wrapper.context[:conversation_history] = []
|
184
|
+
end
|
185
|
+
|
186
|
+
def save_conversation_state(chat, context_wrapper, current_agent)
|
187
|
+
# Extract messages from chat
|
188
|
+
messages = extract_messages(chat)
|
189
|
+
|
190
|
+
# Update context with latest state
|
191
|
+
context_wrapper.context[:conversation_history] = messages
|
192
|
+
context_wrapper.context[:current_agent] = current_agent
|
193
|
+
context_wrapper.context[:turn_count] = (context_wrapper.context[:turn_count] || 0) + 1
|
194
|
+
context_wrapper.context[:last_updated] = Time.now
|
195
|
+
|
196
|
+
# Clean up temporary handoff state
|
197
|
+
context_wrapper.context.delete(:pending_handoff)
|
198
|
+
rescue StandardError => e
|
199
|
+
puts "[Agents] Failed to save conversation state: #{e.message}"
|
200
|
+
end
|
201
|
+
|
202
|
+
def create_chat(agent, context_wrapper)
|
203
|
+
# Get system prompt (may be dynamic)
|
204
|
+
system_prompt = agent.get_system_prompt(context_wrapper)
|
205
|
+
|
206
|
+
# Wrap tools with context for thread-safe execution
|
207
|
+
wrapped_tools = agent.all_tools.map do |tool|
|
208
|
+
ToolWrapper.new(tool, context_wrapper)
|
209
|
+
end
|
210
|
+
|
211
|
+
# Create chat with proper RubyLLM API
|
212
|
+
chat = RubyLLM.chat(model: agent.model)
|
213
|
+
chat.with_instructions(system_prompt) if system_prompt
|
214
|
+
chat.with_tools(*wrapped_tools) if wrapped_tools.any?
|
215
|
+
chat
|
216
|
+
end
|
217
|
+
|
218
|
+
def extract_messages(chat)
|
219
|
+
return [] unless chat.respond_to?(:messages)
|
220
|
+
|
221
|
+
chat.messages.filter_map do |msg|
|
222
|
+
# Only include user and assistant messages with content
|
223
|
+
next unless %i[user assistant].include?(msg.role)
|
224
|
+
next unless msg.content && !msg.content.strip.empty?
|
225
|
+
|
226
|
+
{
|
227
|
+
role: msg.role,
|
228
|
+
content: msg.content
|
229
|
+
}
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
data/lib/agents/tool.rb
ADDED
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Tool is the base class for all agent tools, providing a thread-safe interface for
|
4
|
+
# agents to interact with external systems and perform actions. Tools extend RubyLLM::Tool
|
5
|
+
# while adding critical thread-safety guarantees and enhanced error handling.
|
6
|
+
#
|
7
|
+
# ## Thread-Safe Design Principles
|
8
|
+
# Tools extend RubyLLM::Tool but maintain thread safety by:
|
9
|
+
# 1. **No execution state in instance variables** - Only configuration
|
10
|
+
# 2. **All state passed through parameters** - ToolContext as first param
|
11
|
+
# 3. **Immutable tool instances** - Create once, use everywhere
|
12
|
+
# 4. **Stateless perform methods** - Pure functions with context input
|
13
|
+
#
|
14
|
+
# ## Why Thread Safety Matters
|
15
|
+
# In a multi-agent system, the same tool instance may be used concurrently by different
|
16
|
+
# agents running in separate threads or fibers. Storing execution state in instance
|
17
|
+
# variables would cause race conditions and data corruption.
|
18
|
+
#
|
19
|
+
# @example Defining a thread-safe tool
|
20
|
+
# class WeatherTool < Agents::Tool
|
21
|
+
# name "get_weather"
|
22
|
+
# description "Get current weather for a location"
|
23
|
+
# param :location, type: "string", desc: "City name or coordinates"
|
24
|
+
#
|
25
|
+
# def perform(tool_context, location:)
|
26
|
+
# # All state comes from parameters - no instance variables!
|
27
|
+
# api_key = tool_context.context[:weather_api_key]
|
28
|
+
# cache_duration = tool_context.context[:cache_duration] || 300
|
29
|
+
#
|
30
|
+
# begin
|
31
|
+
# # Make API call...
|
32
|
+
# "Sunny, 72°F in #{location}"
|
33
|
+
# rescue => e
|
34
|
+
# "Weather service unavailable: #{e.message}"
|
35
|
+
# end
|
36
|
+
# end
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# @example Using the functional tool definition
|
40
|
+
# # Define a calculator tool
|
41
|
+
# calculator = Agents::Tool.tool(
|
42
|
+
# "calculate",
|
43
|
+
# description: "Perform mathematical calculations"
|
44
|
+
# ) do |tool_context, expression:|
|
45
|
+
# begin
|
46
|
+
# result = eval(expression)
|
47
|
+
# result.to_s
|
48
|
+
# rescue => e
|
49
|
+
# "Calculation error: #{e.message}"
|
50
|
+
# end
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# # Use the tool in an agent
|
54
|
+
# agent = Agents::Agent.new(
|
55
|
+
# name: "Math Assistant",
|
56
|
+
# instructions: "You are a helpful math assistant",
|
57
|
+
# tools: [calculator]
|
58
|
+
# )
|
59
|
+
#
|
60
|
+
# # During execution, the runner would call it like this:
|
61
|
+
# run_context = Agents::RunContext.new({ user_id: 123 })
|
62
|
+
# tool_context = Agents::ToolContext.new(run_context: run_context)
|
63
|
+
#
|
64
|
+
# result = calculator.execute(tool_context, expression: "2 + 2 * 3")
|
65
|
+
# # => "8"
|
66
|
+
module Agents
|
67
|
+
class Tool < RubyLLM::Tool
|
68
|
+
class << self
|
69
|
+
def param(name, type = String, desc = nil, required: true, **options)
|
70
|
+
# Convert Ruby types to JSON schema types
|
71
|
+
json_type = case type
|
72
|
+
when String, "string" then "string"
|
73
|
+
when Integer, "integer" then "integer"
|
74
|
+
when Float, "number" then "number"
|
75
|
+
when TrueClass, FalseClass, "boolean" then "boolean"
|
76
|
+
when Array, "array" then "array"
|
77
|
+
when Hash, "object" then "object"
|
78
|
+
else "string"
|
79
|
+
end
|
80
|
+
|
81
|
+
# Call parent param method
|
82
|
+
super(name, type: json_type, desc: desc, required: required, **options)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
# Execute the tool with context injection.
|
86
|
+
# This method is called by the runner and handles the thread-safe
|
87
|
+
# execution pattern by passing all state through parameters.
|
88
|
+
#
|
89
|
+
# @param tool_context [Agents::ToolContext] The execution context containing shared state and usage tracking
|
90
|
+
# @param params [Hash] Tool-specific parameters as defined by the tool's param declarations
|
91
|
+
# @return [String] The tool's result
|
92
|
+
def execute(tool_context, **params)
|
93
|
+
perform(tool_context, **params)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Perform the tool's action. Subclasses must implement this method.
|
97
|
+
# This is where the actual tool logic lives. The method receives all
|
98
|
+
# execution state through parameters, ensuring thread safety.
|
99
|
+
#
|
100
|
+
# @param tool_context [Agents::ToolContext] The execution context
|
101
|
+
# @param params [Hash] Tool-specific parameters
|
102
|
+
# @return [String] The tool's result
|
103
|
+
# @raise [NotImplementedError] If not implemented by subclass
|
104
|
+
# @example Implementing perform in a subclass
|
105
|
+
# class SearchTool < Agents::Tool
|
106
|
+
# def perform(tool_context, query:, max_results: 10)
|
107
|
+
# api_key = tool_context.context[:search_api_key]
|
108
|
+
# results = SearchAPI.search(query, api_key: api_key, limit: max_results)
|
109
|
+
# results.map(&:title).join("\n")
|
110
|
+
# end
|
111
|
+
# end
|
112
|
+
def perform(tool_context, **params)
|
113
|
+
raise NotImplementedError, "Tools must implement #perform(tool_context, **params)"
|
114
|
+
end
|
115
|
+
|
116
|
+
# Create a tool instance using a functional style definition.
|
117
|
+
# This is an alternative to creating a full class for simple tools.
|
118
|
+
# The block becomes the tool's perform method.
|
119
|
+
#
|
120
|
+
# @param name [String] The tool's name (used in function calling)
|
121
|
+
# @param description [String] Brief description of what the tool does
|
122
|
+
# @yield [tool_context, **params] The block that implements the tool's logic
|
123
|
+
# @return [Agents::Tool] A new tool instance
|
124
|
+
# @example Creating a simple tool functionally
|
125
|
+
# math_tool = Agents::Tool.tool(
|
126
|
+
# "add_numbers",
|
127
|
+
# description: "Add two numbers together"
|
128
|
+
# ) do |tool_context, a:, b:|
|
129
|
+
# (a + b).to_s
|
130
|
+
# end
|
131
|
+
#
|
132
|
+
# @example Tool accessing context with error handling
|
133
|
+
# greeting_tool = Agents::Tool.tool("greet", description: "Greet a user") do |tool_context, name:|
|
134
|
+
# language = tool_context.context[:language] || "en"
|
135
|
+
# case language
|
136
|
+
# when "es" then "¡Hola, #{name}!"
|
137
|
+
# when "fr" then "Bonjour, #{name}!"
|
138
|
+
# else "Hello, #{name}!"
|
139
|
+
# end
|
140
|
+
# rescue => e
|
141
|
+
# "Sorry, I couldn't greet you: #{e.message}"
|
142
|
+
# end
|
143
|
+
def self.tool(name, description: "", &block)
|
144
|
+
# Create anonymous class that extends Tool
|
145
|
+
Class.new(Tool) do
|
146
|
+
self.name = name
|
147
|
+
self.description = description
|
148
|
+
|
149
|
+
define_method :perform, &block
|
150
|
+
end.new
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# ToolContext provides tools with controlled access to execution state during their invocation.
|
4
|
+
# It wraps the RunContext and adds tool-specific metadata like retry count. This is a critical
|
5
|
+
# component of the thread-safe design - tools receive all their execution state through this
|
6
|
+
# context object rather than storing it in instance variables.
|
7
|
+
#
|
8
|
+
# @example Tool receiving context during execution
|
9
|
+
# class CalculatorTool < Agents::Tool
|
10
|
+
# def perform(tool_context, expression:)
|
11
|
+
# # Access shared context data
|
12
|
+
# precision = tool_context.context[:precision] || 2
|
13
|
+
#
|
14
|
+
# # Track retry attempts
|
15
|
+
# if tool_context.retry_count > 0
|
16
|
+
# logger.warn "Retrying calculation (attempt #{tool_context.retry_count + 1})"
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# result = eval(expression)
|
20
|
+
# result.round(precision)
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# @example Runner creating ToolContext for execution
|
25
|
+
# run_context = Agents::RunContext.new({ user: current_user })
|
26
|
+
# tool_context = Agents::ToolContext.new(
|
27
|
+
# run_context: run_context,
|
28
|
+
# retry_count: 0
|
29
|
+
# )
|
30
|
+
#
|
31
|
+
# result = tool.execute(tool_context, expression: "2 + 2")
|
32
|
+
#
|
33
|
+
# ## How Thread Safety is Ensured
|
34
|
+
#
|
35
|
+
# ToolContext enables thread-safe tool execution by enforcing a critical principle:
|
36
|
+
# all execution state must flow through method parameters, never through instance variables.
|
37
|
+
#
|
38
|
+
# Here's how it works:
|
39
|
+
#
|
40
|
+
# 1. **Tool instances are stateless** - They only store configuration (name, description),
|
41
|
+
# never execution data like current user, session, or request details.
|
42
|
+
#
|
43
|
+
# 2. **State flows through parameters** - When a tool is executed, it receives a ToolContext
|
44
|
+
# parameter that contains all the execution-specific state it needs.
|
45
|
+
#
|
46
|
+
# 3. **Each execution is isolated** - Multiple threads can call the same tool instance
|
47
|
+
# simultaneously, but each call gets its own ToolContext with its own state.
|
48
|
+
#
|
49
|
+
# This is similar to how web frameworks handle concurrent requests - the controller
|
50
|
+
# instance might be shared, but each request gets its own params and session objects.
|
51
|
+
# The ToolContext serves the same purpose, providing isolation without requiring
|
52
|
+
# new object creation for each execution.
|
53
|
+
module Agents
|
54
|
+
class ToolContext
|
55
|
+
attr_reader :run_context, :retry_count
|
56
|
+
|
57
|
+
# Initialize a new ToolContext wrapping a RunContext
|
58
|
+
#
|
59
|
+
# @param run_context [Agents::RunContext] The run context containing shared execution state
|
60
|
+
# @param retry_count [Integer] Number of times this tool execution has been retried (default: 0)
|
61
|
+
def initialize(run_context:, retry_count: 0)
|
62
|
+
@run_context = run_context
|
63
|
+
@retry_count = retry_count
|
64
|
+
end
|
65
|
+
|
66
|
+
# Convenient access to the shared context hash from the RunContext.
|
67
|
+
# This delegation makes it easier for tools to access context data without
|
68
|
+
# having to navigate through run_context.context.
|
69
|
+
#
|
70
|
+
# @return [Hash] The shared context hash from the RunContext
|
71
|
+
# @example Accessing context data in a tool
|
72
|
+
# def perform(tool_context, **params)
|
73
|
+
# user_id = tool_context.context[:user_id]
|
74
|
+
# session = tool_context.context[:session]
|
75
|
+
# # ...
|
76
|
+
# end
|
77
|
+
def context
|
78
|
+
@run_context.context
|
79
|
+
end
|
80
|
+
|
81
|
+
# Convenient access to the usage tracking object from the RunContext.
|
82
|
+
# Tools can use this to add their own usage metrics if they make
|
83
|
+
# additional LLM calls.
|
84
|
+
#
|
85
|
+
# @return [Agents::RunContext::Usage] The usage tracking object
|
86
|
+
# @example Tool tracking additional LLM usage
|
87
|
+
# def perform(tool_context, **params)
|
88
|
+
# # Tool makes its own LLM call
|
89
|
+
# response = llm.complete("Analyze: #{params[:data]}")
|
90
|
+
# tool_context.usage.add(response.usage)
|
91
|
+
#
|
92
|
+
# response.content
|
93
|
+
# end
|
94
|
+
def usage
|
95
|
+
@run_context.usage
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Agents
|
4
|
+
# A thread-safe wrapper that bridges RubyLLM's tool execution with our context injection pattern.
|
5
|
+
# This wrapper solves a critical problem: RubyLLM calls tools with just the LLM-provided
|
6
|
+
# parameters, but our tools need access to the execution context for shared state.
|
7
|
+
#
|
8
|
+
# ## The Thread Safety Problem
|
9
|
+
# Without this wrapper, we'd need to store context in tool instance variables, which would
|
10
|
+
# cause race conditions when the same tool is used by multiple concurrent requests:
|
11
|
+
#
|
12
|
+
# # UNSAFE - Don't do this!
|
13
|
+
# class BadTool < Agents::Tool
|
14
|
+
# def set_context(ctx)
|
15
|
+
# @context = ctx # Race condition!
|
16
|
+
# end
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# ## The Solution
|
20
|
+
# Each Runner creates new wrapper instances for each execution, capturing the context
|
21
|
+
# in the wrapper's closure. When RubyLLM calls execute(), the wrapper injects the
|
22
|
+
# context before calling the actual tool:
|
23
|
+
#
|
24
|
+
# # Runner creates wrapper
|
25
|
+
# wrapped = ToolWrapper.new(my_tool, context_wrapper)
|
26
|
+
#
|
27
|
+
# # RubyLLM calls wrapper
|
28
|
+
# wrapped.execute(city: "NYC") # No context parameter
|
29
|
+
#
|
30
|
+
# # Wrapper injects context
|
31
|
+
# tool_context = ToolContext.new(run_context: context_wrapper)
|
32
|
+
# my_tool.execute(tool_context, city: "NYC") # Context injected!
|
33
|
+
#
|
34
|
+
# This ensures each execution has its own context without any shared mutable state.
|
35
|
+
class ToolWrapper
|
36
|
+
def initialize(tool, context_wrapper)
|
37
|
+
@tool = tool
|
38
|
+
@context_wrapper = context_wrapper
|
39
|
+
|
40
|
+
# Copy tool metadata for RubyLLM
|
41
|
+
@name = tool.name
|
42
|
+
@description = tool.description
|
43
|
+
@params = tool.class.params if tool.class.respond_to?(:params)
|
44
|
+
end
|
45
|
+
|
46
|
+
# RubyLLM calls this method (follows RubyLLM::Tool pattern)
|
47
|
+
def call(args)
|
48
|
+
tool_context = ToolContext.new(run_context: @context_wrapper)
|
49
|
+
@tool.execute(tool_context, **args.transform_keys(&:to_sym))
|
50
|
+
end
|
51
|
+
|
52
|
+
# Delegate metadata methods to the tool
|
53
|
+
def name
|
54
|
+
@name || @tool.name
|
55
|
+
end
|
56
|
+
|
57
|
+
def description
|
58
|
+
@description || @tool.description
|
59
|
+
end
|
60
|
+
|
61
|
+
# RubyLLM calls this to get parameter definitions
|
62
|
+
def parameters
|
63
|
+
@tool.parameters
|
64
|
+
end
|
65
|
+
|
66
|
+
# Make this work with RubyLLM's tool calling
|
67
|
+
def to_s
|
68
|
+
name
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
data/lib/agents.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Main entry point for the Ruby AI Agents SDK
|
4
|
+
# This file sets up the core Agents module namespace and provides global configuration
|
5
|
+
# for the multi-agent system including LLM provider setup, API keys, and system defaults.
|
6
|
+
# It serves as the central configuration hub that other components depend on.
|
7
|
+
|
8
|
+
require "ruby_llm"
|
9
|
+
require_relative "agents/version"
|
10
|
+
|
11
|
+
module Agents
|
12
|
+
class Error < StandardError; end
|
13
|
+
|
14
|
+
class << self
|
15
|
+
# Logger for debugging (can be set by users)
|
16
|
+
attr_accessor :logger
|
17
|
+
|
18
|
+
# Configure both Agents and RubyLLM in one block
|
19
|
+
def configure
|
20
|
+
yield(configuration) if block_given?
|
21
|
+
configure_ruby_llm!
|
22
|
+
configuration
|
23
|
+
end
|
24
|
+
|
25
|
+
def configuration
|
26
|
+
@configuration ||= Configuration.new
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def configure_ruby_llm!
|
32
|
+
RubyLLM.configure do |config|
|
33
|
+
config.openai_api_key = configuration.openai_api_key if configuration.openai_api_key
|
34
|
+
config.anthropic_api_key = configuration.anthropic_api_key if configuration.anthropic_api_key
|
35
|
+
config.gemini_api_key = configuration.gemini_api_key if configuration.gemini_api_key
|
36
|
+
config.default_model = configuration.default_model
|
37
|
+
config.log_level = configuration.debug == true ? :debug : :info
|
38
|
+
config.request_timeout = configuration.request_timeout if configuration.request_timeout
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class Configuration
|
44
|
+
attr_accessor :openai_api_key, :anthropic_api_key, :gemini_api_key, :request_timeout, :default_model, :debug
|
45
|
+
|
46
|
+
def initialize
|
47
|
+
@default_model = "gpt-4o-mini"
|
48
|
+
@request_timeout = 120
|
49
|
+
@debug = false
|
50
|
+
end
|
51
|
+
|
52
|
+
# Check if at least one provider is configured
|
53
|
+
# @return [Boolean] True if any provider has an API key
|
54
|
+
def configured?
|
55
|
+
@openai_api_key || @anthropic_api_key || @gemini_api_key
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Core components
|
61
|
+
require_relative "agents/result"
|
62
|
+
require_relative "agents/run_context"
|
63
|
+
require_relative "agents/tool_context"
|
64
|
+
require_relative "agents/tool"
|
65
|
+
require_relative "agents/handoff"
|
66
|
+
require_relative "agents/agent"
|
67
|
+
|
68
|
+
# Execution components
|
69
|
+
require_relative "agents/tool_wrapper"
|
70
|
+
require_relative "agents/runner"
|