rsmolagent 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/README.md +254 -0
- data/lib/rsmolagent/agent.rb +164 -0
- data/lib/rsmolagent/llm_provider.rb +70 -0
- data/lib/rsmolagent/memory.rb +65 -0
- data/lib/rsmolagent/tool.rb +73 -0
- data/lib/rsmolagent/tools/custom_tool_base.rb +91 -0
- data/lib/rsmolagent/tools/ruby_executor.rb +189 -0
- data/lib/rsmolagent/tools/web_search.rb +101 -0
- data/lib/rsmolagent/tools.rb +9 -0
- data/lib/rsmolagent/version.rb +3 -0
- data/lib/rsmolagent.rb +10 -0
- metadata +99 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 374e7d1da7f59eece14d7616f9e21cea9f99290a6ccf4eac4697576972f8e260
|
4
|
+
data.tar.gz: a2892e99d70020084fe207dc58b042be5886fcd55f19dc371bf97fd1602fe169
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 63248253961036a8734c03e1d4201fa259896cc8f015d40a8478518e7e3d1ed6831f5afa512de6d3cfaaebdfc9fa728177d796f360285189dc0f19d805696d57
|
7
|
+
data.tar.gz: 1a75e4e82b4c9479e76e16df77fbe55f6ba96ec6bcf06a3146dd268a0aef1bba8e651e9277f8268c7a1998611670d415329d898328903ab7d8890d305b9be8bb
|
data/README.md
ADDED
@@ -0,0 +1,254 @@
|
|
1
|
+
# RSmolagent
|
2
|
+
|
3
|
+
A lightweight Ruby library for creating AI agents that can use tools to solve tasks. Inspired by Python's smolagents library, RSmolagent provides a simple way to build agents that can interact with the world through tools.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'rsmolagent'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
```bash
|
16
|
+
$ bundle install
|
17
|
+
```
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
```bash
|
22
|
+
$ gem install rsmolagent
|
23
|
+
```
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
RSmolagent is designed to be simple to use. Here's a basic example:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
require 'rsmolagent'
|
31
|
+
require 'ruby/openai'
|
32
|
+
|
33
|
+
# Create a custom tool
|
34
|
+
class CalculatorTool < RSmolagent::Tool
|
35
|
+
def initialize
|
36
|
+
super(
|
37
|
+
name: "calculator",
|
38
|
+
description: "Perform mathematical calculations",
|
39
|
+
input_schema: {
|
40
|
+
expression: {
|
41
|
+
type: "string",
|
42
|
+
description: "The mathematical expression to evaluate"
|
43
|
+
}
|
44
|
+
}
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
def execute(args)
|
49
|
+
expression = args[:expression]
|
50
|
+
eval(expression).to_s
|
51
|
+
rescue => e
|
52
|
+
"Error: #{e.message}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Initialize OpenAI client
|
57
|
+
client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"])
|
58
|
+
|
59
|
+
# Create LLM provider
|
60
|
+
llm = RSmolagent::OpenAIProvider.new(
|
61
|
+
model_id: "gpt-3.5-turbo",
|
62
|
+
client: client
|
63
|
+
)
|
64
|
+
|
65
|
+
# Create agent with tools
|
66
|
+
calculator = CalculatorTool.new
|
67
|
+
agent = RSmolagent::Agent.new(
|
68
|
+
llm_provider: llm,
|
69
|
+
tools: [calculator]
|
70
|
+
)
|
71
|
+
|
72
|
+
# Run the agent
|
73
|
+
result = agent.run("What is 123 * 456?")
|
74
|
+
puts result # Outputs the calculated result
|
75
|
+
```
|
76
|
+
|
77
|
+
## Features
|
78
|
+
|
79
|
+
- Simple interface for creating AI agents
|
80
|
+
- Support for custom tools
|
81
|
+
- Built-in memory for tracking conversation history
|
82
|
+
- Works with OpenAI API (easily extensible to other LLM providers)
|
83
|
+
- Automatic handling of tool calls and responses
|
84
|
+
- Built-in tools including web search
|
85
|
+
- Dynamic code execution and custom tool creation
|
86
|
+
- Ability to create tools from Ruby code at runtime
|
87
|
+
|
88
|
+
## Built-in Tools
|
89
|
+
|
90
|
+
RSmolagent comes with several built-in tools:
|
91
|
+
|
92
|
+
### WebSearchTool
|
93
|
+
|
94
|
+
Searches the web using DuckDuckGo's API:
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
# Create a web search tool
|
98
|
+
web_search = RSmolagent::Tools::WebSearchTool.new(max_results: 3)
|
99
|
+
|
100
|
+
# Add it to your agent
|
101
|
+
agent = RSmolagent::Agent.new(
|
102
|
+
llm_provider: llm,
|
103
|
+
tools: [web_search]
|
104
|
+
)
|
105
|
+
|
106
|
+
# The agent can now search the web
|
107
|
+
result = agent.run("What are the latest developments in AI?")
|
108
|
+
```
|
109
|
+
|
110
|
+
### Ruby Code Execution Tools
|
111
|
+
|
112
|
+
RSmolagent provides tools for executing Ruby code, allowing you to create custom tools dynamically:
|
113
|
+
|
114
|
+
#### 1. RubyExecutorTool
|
115
|
+
|
116
|
+
Executes arbitrary Ruby code in a controlled environment:
|
117
|
+
|
118
|
+
```ruby
|
119
|
+
# Create a Ruby executor tool
|
120
|
+
ruby_executor = RSmolagent::Tools::RubyExecutorTool.new
|
121
|
+
|
122
|
+
# Add it to your agent
|
123
|
+
agent = RSmolagent::Agent.new(
|
124
|
+
llm_provider: llm,
|
125
|
+
tools: [ruby_executor]
|
126
|
+
)
|
127
|
+
|
128
|
+
# The agent can now execute Ruby code
|
129
|
+
result = agent.run("Calculate the factorial of 5")
|
130
|
+
```
|
131
|
+
|
132
|
+
#### 2. CustomClassExecutorTool
|
133
|
+
|
134
|
+
Creates and executes custom tool classes with defined structure:
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
# Create a custom tool executor
|
138
|
+
custom_executor = RSmolagent::Tools::CustomClassExecutorTool.new
|
139
|
+
|
140
|
+
# Add it to your agent
|
141
|
+
agent = RSmolagent::Agent.new(
|
142
|
+
llm_provider: llm,
|
143
|
+
tools: [custom_executor]
|
144
|
+
)
|
145
|
+
|
146
|
+
# The agent can now create and use custom tools
|
147
|
+
result = agent.run("Create a tool to get the current date and then use it")
|
148
|
+
```
|
149
|
+
|
150
|
+
#### 3. Creating Custom Tools from Code
|
151
|
+
|
152
|
+
You can also create tools from code directly using the CustomToolFactory:
|
153
|
+
|
154
|
+
```ruby
|
155
|
+
# Define a custom tool class
|
156
|
+
tool_code = <<~RUBY
|
157
|
+
class MyCustomTool < CustomToolBase
|
158
|
+
def initialize
|
159
|
+
super(
|
160
|
+
"my_tool",
|
161
|
+
"Description of what my tool does",
|
162
|
+
{
|
163
|
+
param1: {
|
164
|
+
type: "string",
|
165
|
+
description: "Parameter description"
|
166
|
+
}
|
167
|
+
}
|
168
|
+
)
|
169
|
+
end
|
170
|
+
|
171
|
+
def run(args)
|
172
|
+
# Implement tool logic here
|
173
|
+
"Result: " + args[:param1].upcase
|
174
|
+
end
|
175
|
+
end
|
176
|
+
RUBY
|
177
|
+
|
178
|
+
# Create a tool from the code
|
179
|
+
my_tool = RSmolagent::Tools::CustomToolFactory.create_from_code(tool_code)
|
180
|
+
|
181
|
+
# Use the tool
|
182
|
+
my_tool.call(param1: "hello world") # => "Result: HELLO WORLD"
|
183
|
+
```
|
184
|
+
|
185
|
+
## Creating Custom Tools
|
186
|
+
|
187
|
+
To create a custom tool, simply inherit from `RSmolagent::Tool` and implement the `execute` method:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
class MyTool < RSmolagent::Tool
|
191
|
+
def initialize
|
192
|
+
super(
|
193
|
+
name: "my_tool",
|
194
|
+
description: "Description of what the tool does",
|
195
|
+
input_schema: {
|
196
|
+
param1: {
|
197
|
+
type: "string",
|
198
|
+
description: "Description of parameter 1"
|
199
|
+
},
|
200
|
+
param2: {
|
201
|
+
type: "number",
|
202
|
+
description: "Description of parameter 2"
|
203
|
+
}
|
204
|
+
}
|
205
|
+
)
|
206
|
+
end
|
207
|
+
|
208
|
+
def execute(args)
|
209
|
+
# Implement your tool logic here
|
210
|
+
# args will contain the parameters passed by the agent
|
211
|
+
"Result of the tool execution"
|
212
|
+
end
|
213
|
+
end
|
214
|
+
```
|
215
|
+
|
216
|
+
## Supporting Other LLM Providers
|
217
|
+
|
218
|
+
To add support for other LLM providers, inherit from `RSmolagent::LLMProvider` and implement the required methods:
|
219
|
+
|
220
|
+
```ruby
|
221
|
+
class MyLLMProvider < RSmolagent::LLMProvider
|
222
|
+
def initialize(model_id:, **options)
|
223
|
+
super(model_id: model_id, **options)
|
224
|
+
# Initialize your LLM client
|
225
|
+
end
|
226
|
+
|
227
|
+
def chat(messages, tools: nil, tool_choice: nil)
|
228
|
+
# Call your LLM provider's API
|
229
|
+
# Return the response in a standardized format
|
230
|
+
end
|
231
|
+
|
232
|
+
def extract_tool_calls(response)
|
233
|
+
# Extract tool calls from the response
|
234
|
+
# Return an array of tool calls in the format:
|
235
|
+
# [{ name: "tool_name", arguments: { param1: "value1", ... } }, ...]
|
236
|
+
end
|
237
|
+
end
|
238
|
+
```
|
239
|
+
|
240
|
+
## Testing
|
241
|
+
|
242
|
+
RSmolagent includes a comprehensive test suite using RSpec. To run the tests:
|
243
|
+
|
244
|
+
```bash
|
245
|
+
# Install development dependencies
|
246
|
+
$ bundle install
|
247
|
+
|
248
|
+
# Run the tests
|
249
|
+
$ bundle exec rake spec
|
250
|
+
```
|
251
|
+
|
252
|
+
## License
|
253
|
+
|
254
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,164 @@
|
|
1
|
+
module RSmolagent
|
2
|
+
class Agent
|
3
|
+
attr_reader :memory, :tools, :llm, :max_steps
|
4
|
+
|
5
|
+
def initialize(llm_provider:, tools: [], system_prompt: nil, max_steps: 10)
|
6
|
+
@llm = llm_provider
|
7
|
+
@memory = Memory.new
|
8
|
+
@max_steps = max_steps
|
9
|
+
@dynamic_tools = {}
|
10
|
+
|
11
|
+
# Initialize system prompt
|
12
|
+
default_system_prompt = "You are a helpful assistant that can use tools to solve tasks. " +
|
13
|
+
"When you need information or want to perform actions, use the provided tools. " +
|
14
|
+
"When you have the final answer, use the final_answer tool."
|
15
|
+
|
16
|
+
system_message = system_prompt || default_system_prompt
|
17
|
+
@memory.add_system_message(system_message)
|
18
|
+
|
19
|
+
# Set up tools
|
20
|
+
@tools = {}
|
21
|
+
tools.each { |tool| @tools[tool.name] = tool }
|
22
|
+
|
23
|
+
# Add final answer tool if not present
|
24
|
+
unless @tools["final_answer"]
|
25
|
+
final_answer_tool = FinalAnswerTool.new
|
26
|
+
@tools[final_answer_tool.name] = final_answer_tool
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Method to register a new tool during execution
|
31
|
+
def register_tool(tool)
|
32
|
+
@dynamic_tools[tool.name] = tool
|
33
|
+
# Update memory with information about the new tool
|
34
|
+
@memory.add_assistant_message("I've created a new tool called '#{tool.name}' that #{tool.description}")
|
35
|
+
end
|
36
|
+
|
37
|
+
def run(task, verbose: false)
|
38
|
+
@memory.add_user_message(task)
|
39
|
+
step_count = 0
|
40
|
+
|
41
|
+
while step_count < @max_steps
|
42
|
+
step_count += 1
|
43
|
+
puts "Step #{step_count}/#{@max_steps}" if verbose
|
44
|
+
|
45
|
+
# Get response from LLM
|
46
|
+
response = execute_step
|
47
|
+
|
48
|
+
# Check if we've reached a final answer
|
49
|
+
if response[:final_answer]
|
50
|
+
return response[:answer]
|
51
|
+
end
|
52
|
+
|
53
|
+
puts "Used tool: #{response[:tool_name]}" if verbose
|
54
|
+
end
|
55
|
+
|
56
|
+
# If we reach max steps without a final answer, return what we have
|
57
|
+
"I couldn't complete the task in the allowed number of steps. My progress so far: " +
|
58
|
+
@memory.history.map { |step| step[:type] == "tool_call" ? "#{step[:tool_name]}: #{step[:result]}" : "" }.join("\n")
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def execute_step
|
64
|
+
# Get current messages
|
65
|
+
messages = @memory.to_openai_messages
|
66
|
+
|
67
|
+
# Combine static and dynamic tools
|
68
|
+
all_tools = @tools.merge(@dynamic_tools)
|
69
|
+
|
70
|
+
# Call LLM with all tools
|
71
|
+
response = @llm.chat(messages, tools: all_tools.values)
|
72
|
+
|
73
|
+
# Handle tool calls or final answer
|
74
|
+
tool_calls = @llm.extract_tool_calls(response)
|
75
|
+
|
76
|
+
if tool_calls.empty?
|
77
|
+
# No tool calls, add as assistant message
|
78
|
+
@memory.add_assistant_message(response.content)
|
79
|
+
return { content: response.content }
|
80
|
+
else
|
81
|
+
# Process the first tool call
|
82
|
+
tool_call = tool_calls.first
|
83
|
+
tool_name = tool_call[:name]
|
84
|
+
arguments = tool_call[:arguments]
|
85
|
+
|
86
|
+
# Record assistant message with tool call
|
87
|
+
@memory.add_assistant_message("I'll use the #{tool_name} tool.")
|
88
|
+
|
89
|
+
if tool_name == "final_answer"
|
90
|
+
# Handle final answer
|
91
|
+
answer = arguments["answer"] || arguments[:answer] || ""
|
92
|
+
@memory.add_final_answer(answer)
|
93
|
+
return { final_answer: true, answer: answer }
|
94
|
+
else
|
95
|
+
# Execute the tool
|
96
|
+
result = execute_tool(tool_name, arguments)
|
97
|
+
|
98
|
+
# Check if this is a tool creation tool (like CustomClassExecutorTool)
|
99
|
+
if tool_name == "create_tool" || tool_name == "custom_executor"
|
100
|
+
# Try to create a new tool from the result
|
101
|
+
begin
|
102
|
+
# Parse the code from the result if needed
|
103
|
+
code = extract_tool_code_from_result(result)
|
104
|
+
|
105
|
+
if code
|
106
|
+
# Try to create a tool from the code
|
107
|
+
new_tool = Tools::CustomToolFactory.create_from_code(code)
|
108
|
+
|
109
|
+
# Register the new tool
|
110
|
+
register_tool(new_tool)
|
111
|
+
|
112
|
+
# Add confirmation to the result
|
113
|
+
result += "\n\nTool '#{new_tool.name}' has been created and is now available for use."
|
114
|
+
end
|
115
|
+
rescue => e
|
116
|
+
result += "\n\nFailed to create tool: #{e.message}"
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Add tool result to memory
|
121
|
+
@memory.add_tool_message(tool_name, result)
|
122
|
+
@memory.add_tool_call(tool_name, arguments, result)
|
123
|
+
|
124
|
+
return { tool_name: tool_name, arguments: arguments, result: result }
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def execute_tool(tool_name, arguments)
|
130
|
+
# Look in both static and dynamic tools
|
131
|
+
tool = @tools[tool_name] || @dynamic_tools[tool_name]
|
132
|
+
|
133
|
+
if tool.nil?
|
134
|
+
return "Error: Tool '#{tool_name}' not found"
|
135
|
+
end
|
136
|
+
|
137
|
+
begin
|
138
|
+
tool.call(arguments)
|
139
|
+
rescue => e
|
140
|
+
"Error executing tool: #{e.message}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Helper method to extract tool code from tool results
|
145
|
+
def extract_tool_code_from_result(result)
|
146
|
+
# Look for class definition in the result
|
147
|
+
if result.include?("class") && result.include?("CustomToolBase")
|
148
|
+
# For cleaner extraction, look for Ruby code blocks
|
149
|
+
if result.include?("```ruby")
|
150
|
+
# Extract code from markdown code blocks
|
151
|
+
code_blocks = result.scan(/```ruby\n(.*?)\n```/m)
|
152
|
+
return code_blocks.first.first if code_blocks.any?
|
153
|
+
end
|
154
|
+
|
155
|
+
# If no code blocks, try to extract class definition directly
|
156
|
+
class_match = result.match(/class\s+\w+\s*<\s*CustomToolBase.*?end/m)
|
157
|
+
return class_match[0] if class_match
|
158
|
+
end
|
159
|
+
|
160
|
+
# If we couldn't extract a class definition, return nil
|
161
|
+
nil
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module RSmolagent
|
2
|
+
class LLMProvider
|
3
|
+
attr_reader :last_prompt_tokens, :last_completion_tokens
|
4
|
+
|
5
|
+
def initialize(model_id:, **options)
|
6
|
+
@model_id = model_id
|
7
|
+
@options = options
|
8
|
+
@last_prompt_tokens = 0
|
9
|
+
@last_completion_tokens = 0
|
10
|
+
end
|
11
|
+
|
12
|
+
def chat(messages, tools: nil, tool_choice: nil)
|
13
|
+
raise NotImplementedError, "Subclasses must implement the 'chat' method"
|
14
|
+
end
|
15
|
+
|
16
|
+
def extract_tool_calls(response)
|
17
|
+
raise NotImplementedError, "Subclasses must implement the 'extract_tool_calls' method"
|
18
|
+
end
|
19
|
+
|
20
|
+
def parse_json_if_needed(text)
|
21
|
+
return text unless text.is_a?(String)
|
22
|
+
|
23
|
+
begin
|
24
|
+
JSON.parse(text)
|
25
|
+
rescue JSON::ParserError
|
26
|
+
text
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class OpenAIProvider < LLMProvider
|
32
|
+
def initialize(model_id:, client:, **options)
|
33
|
+
super(model_id: model_id, **options)
|
34
|
+
@client = client
|
35
|
+
end
|
36
|
+
|
37
|
+
def chat(messages, tools: nil, tool_choice: nil)
|
38
|
+
params = {
|
39
|
+
model: @model_id,
|
40
|
+
messages: messages,
|
41
|
+
temperature: @options[:temperature] || 0.7,
|
42
|
+
}
|
43
|
+
|
44
|
+
# Add tools if provided
|
45
|
+
if tools && !tools.empty?
|
46
|
+
params[:tools] = tools.map(&:to_json_schema)
|
47
|
+
params[:tool_choice] = tool_choice || "auto"
|
48
|
+
end
|
49
|
+
|
50
|
+
response = @client.chat(parameters: params)
|
51
|
+
|
52
|
+
# Update token counts
|
53
|
+
@last_prompt_tokens = response.usage.prompt_tokens
|
54
|
+
@last_completion_tokens = response.usage.completion_tokens
|
55
|
+
|
56
|
+
response.choices.first.message
|
57
|
+
end
|
58
|
+
|
59
|
+
def extract_tool_calls(response)
|
60
|
+
return [] unless response.tool_calls
|
61
|
+
|
62
|
+
response.tool_calls.map do |tool_call|
|
63
|
+
{
|
64
|
+
name: tool_call.function.name,
|
65
|
+
arguments: parse_json_if_needed(tool_call.function.arguments)
|
66
|
+
}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module RSmolagent
|
2
|
+
class Memory
|
3
|
+
attr_reader :messages, :history
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@messages = []
|
7
|
+
@history = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def add_system_message(content)
|
11
|
+
add_message("system", content)
|
12
|
+
end
|
13
|
+
|
14
|
+
def add_user_message(content)
|
15
|
+
add_message("user", content)
|
16
|
+
end
|
17
|
+
|
18
|
+
def add_assistant_message(content)
|
19
|
+
add_message("assistant", content)
|
20
|
+
end
|
21
|
+
|
22
|
+
def add_tool_message(name, content)
|
23
|
+
@messages << { role: "tool", name: name, content: content }
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_tool_call(tool_name, arguments, result)
|
27
|
+
step = {
|
28
|
+
type: "tool_call",
|
29
|
+
tool_name: tool_name,
|
30
|
+
arguments: arguments,
|
31
|
+
result: result,
|
32
|
+
timestamp: Time.now
|
33
|
+
}
|
34
|
+
@history << step
|
35
|
+
step
|
36
|
+
end
|
37
|
+
|
38
|
+
def add_final_answer(answer)
|
39
|
+
step = {
|
40
|
+
type: "final_answer",
|
41
|
+
answer: answer,
|
42
|
+
timestamp: Time.now
|
43
|
+
}
|
44
|
+
@history << step
|
45
|
+
step
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_openai_messages
|
49
|
+
@messages.map do |msg|
|
50
|
+
if msg[:role] == "tool"
|
51
|
+
{ role: "tool", tool_call_id: "call_#{msg[:name]}", content: msg[:content].to_s }
|
52
|
+
else
|
53
|
+
{ role: msg[:role], content: msg[:content] }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def add_message(role, content)
|
61
|
+
@messages << { role: role, content: content }
|
62
|
+
{ role: role, content: content }
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module RSmolagent
|
4
|
+
class Tool
|
5
|
+
attr_reader :name, :description, :input_schema
|
6
|
+
|
7
|
+
def initialize(name:, description:, input_schema: {}, output_type: "string")
|
8
|
+
@name = name
|
9
|
+
@description = description
|
10
|
+
@input_schema = input_schema
|
11
|
+
@output_type = output_type
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(args = {})
|
15
|
+
args = args.transform_keys(&:to_sym) if args.is_a?(Hash)
|
16
|
+
execute(args)
|
17
|
+
end
|
18
|
+
|
19
|
+
def execute(args)
|
20
|
+
raise NotImplementedError, "Subclasses must implement the 'execute' method"
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_json_schema
|
24
|
+
{
|
25
|
+
"name" => @name,
|
26
|
+
"description" => @description,
|
27
|
+
"parameters" => {
|
28
|
+
"type" => "object",
|
29
|
+
"properties" => formatted_input_schema,
|
30
|
+
"required" => required_parameters
|
31
|
+
}
|
32
|
+
}
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def formatted_input_schema
|
38
|
+
result = {}
|
39
|
+
@input_schema.each do |name, details|
|
40
|
+
result[name.to_s] = {
|
41
|
+
"type" => details[:type] || "string",
|
42
|
+
"description" => details[:description] || ""
|
43
|
+
}
|
44
|
+
end
|
45
|
+
result
|
46
|
+
end
|
47
|
+
|
48
|
+
def required_parameters
|
49
|
+
@input_schema.select { |_, details| details[:required] != false }
|
50
|
+
.keys
|
51
|
+
.map(&:to_s)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class FinalAnswerTool < Tool
|
56
|
+
def initialize
|
57
|
+
super(
|
58
|
+
name: "final_answer",
|
59
|
+
description: "Use this to provide the final answer to the task",
|
60
|
+
input_schema: {
|
61
|
+
answer: {
|
62
|
+
type: "string",
|
63
|
+
description: "The final answer to the task"
|
64
|
+
}
|
65
|
+
}
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
def execute(args)
|
70
|
+
args[:answer].to_s
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module RSmolagent
|
2
|
+
module Tools
|
3
|
+
# Base class that custom tools can inherit from when using the CustomClassExecutorTool
|
4
|
+
class CustomToolBase
|
5
|
+
attr_reader :name, :description, :input_schema
|
6
|
+
|
7
|
+
def initialize(name, description, input_schema = {})
|
8
|
+
@name = name
|
9
|
+
@description = description
|
10
|
+
@input_schema = input_schema
|
11
|
+
end
|
12
|
+
|
13
|
+
# This method must be implemented by subclasses
|
14
|
+
def run(args = {})
|
15
|
+
raise NotImplementedError, "Subclasses must implement the 'run' method"
|
16
|
+
end
|
17
|
+
|
18
|
+
# Helper methods that custom tools can use
|
19
|
+
|
20
|
+
def fetch_url(url)
|
21
|
+
require 'net/http'
|
22
|
+
require 'uri'
|
23
|
+
|
24
|
+
uri = URI(url)
|
25
|
+
Net::HTTP.get(uri)
|
26
|
+
end
|
27
|
+
|
28
|
+
def parse_json(json_string)
|
29
|
+
require 'json'
|
30
|
+
JSON.parse(json_string)
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_tool
|
34
|
+
# Convert this custom tool to a RSmolagent::Tool
|
35
|
+
RSmolagent::Tool.new(
|
36
|
+
name: @name,
|
37
|
+
description: @description,
|
38
|
+
input_schema: @input_schema
|
39
|
+
) do |args|
|
40
|
+
run(args)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Additional helper methods for safe file operations
|
45
|
+
def read_file(path)
|
46
|
+
# Only allow reading files from a specific directory in a real implementation
|
47
|
+
File.read(path)
|
48
|
+
end
|
49
|
+
|
50
|
+
def write_file(path, content)
|
51
|
+
# Only allow writing files to a specific directory in a real implementation
|
52
|
+
File.write(path, content)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Factory for creating tools from custom tool classes
|
57
|
+
class CustomToolFactory
|
58
|
+
def self.create_from_code(code)
|
59
|
+
# Create a clean binding
|
60
|
+
context = Object.new.instance_eval { binding }
|
61
|
+
|
62
|
+
# Make CustomToolBase available in the context
|
63
|
+
context.eval("CustomToolBase = RSmolagent::Tools::CustomToolBase")
|
64
|
+
|
65
|
+
# Evaluate the code to define the class
|
66
|
+
context.eval(code)
|
67
|
+
|
68
|
+
# Find the class name
|
69
|
+
class_name = extract_class_name(code)
|
70
|
+
|
71
|
+
# Create an instance of the class
|
72
|
+
tool_instance = context.eval("#{class_name}.new")
|
73
|
+
|
74
|
+
# Ensure it's a CustomToolBase
|
75
|
+
unless tool_instance.is_a?(CustomToolBase)
|
76
|
+
raise "The class must inherit from CustomToolBase"
|
77
|
+
end
|
78
|
+
|
79
|
+
# Convert to a Tool
|
80
|
+
tool_instance.to_tool
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def self.extract_class_name(code)
|
86
|
+
match = code.match(/class\s+([A-Z][A-Za-z0-9_]*)\s*(?:<\s*(?:CustomToolBase|[A-Z][A-Za-z0-9_]*))?\s*/)
|
87
|
+
match ? match[1] : nil
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
require 'stringio'
|
2
|
+
|
3
|
+
module RSmolagent
|
4
|
+
module Tools
|
5
|
+
class RubyExecutorTool < Tool
|
6
|
+
def initialize(name: "ruby_executor", description: nil)
|
7
|
+
description ||= "Executes Ruby code in a controlled environment. Use this to run custom Ruby code."
|
8
|
+
|
9
|
+
super(
|
10
|
+
name: name,
|
11
|
+
description: description,
|
12
|
+
input_schema: {
|
13
|
+
code: {
|
14
|
+
type: "string",
|
15
|
+
description: "The Ruby code to execute. Can include class and method definitions."
|
16
|
+
}
|
17
|
+
}
|
18
|
+
)
|
19
|
+
|
20
|
+
# Set of allowed classes/modules to access
|
21
|
+
@allowed_constants = [
|
22
|
+
'Array', 'Hash', 'String', 'Integer', 'Float', 'Time', 'Date',
|
23
|
+
'Enumerable', 'Math', 'JSON', 'CSV', 'URI', 'Net', 'File', 'Dir',
|
24
|
+
'StringIO', 'Regexp'
|
25
|
+
]
|
26
|
+
end
|
27
|
+
|
28
|
+
def execute(args)
|
29
|
+
code = args[:code]
|
30
|
+
return "Error: No code provided" if code.nil? || code.strip.empty?
|
31
|
+
|
32
|
+
# Capture stdout to return it along with the result
|
33
|
+
original_stdout = $stdout
|
34
|
+
captured_stdout = StringIO.new
|
35
|
+
$stdout = captured_stdout
|
36
|
+
|
37
|
+
result = nil
|
38
|
+
begin
|
39
|
+
# Execute the code in the current binding with security limitations
|
40
|
+
result = execute_with_safety(code)
|
41
|
+
|
42
|
+
# Format the output
|
43
|
+
stdout_content = captured_stdout.string.strip
|
44
|
+
|
45
|
+
if stdout_content.empty?
|
46
|
+
"Result: #{result.inspect}"
|
47
|
+
else
|
48
|
+
"Output:\n#{stdout_content}\n\nResult: #{result.inspect}"
|
49
|
+
end
|
50
|
+
rescue => e
|
51
|
+
"Error: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
52
|
+
ensure
|
53
|
+
# Restore stdout
|
54
|
+
$stdout = original_stdout
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def execute_with_safety(code)
|
61
|
+
# Create a secure binding for execution
|
62
|
+
secure_binding = create_secure_binding
|
63
|
+
|
64
|
+
# Execute the code in the secure binding
|
65
|
+
secure_binding.eval(code)
|
66
|
+
end
|
67
|
+
|
68
|
+
def create_secure_binding
|
69
|
+
# Create a new binding with limited access to dangerous methods
|
70
|
+
safe_binding = binding.dup
|
71
|
+
|
72
|
+
# Disable potentially dangerous methods in the binding
|
73
|
+
disable_dangerous_methods(safe_binding)
|
74
|
+
|
75
|
+
# Return the secured binding
|
76
|
+
safe_binding
|
77
|
+
end
|
78
|
+
|
79
|
+
def disable_dangerous_methods(binding_obj)
|
80
|
+
# This is a simplified approach - in a production environment,
|
81
|
+
# you would want a more comprehensive security model
|
82
|
+
dangerous_methods = [
|
83
|
+
'system', 'exec', '`', 'eval', 'syscall', 'fork', 'trap',
|
84
|
+
'require', 'load', 'open', 'Class.new', 'define_method'
|
85
|
+
]
|
86
|
+
|
87
|
+
# This is just a basic example - a real implementation would need
|
88
|
+
# more sophisticated sandboxing
|
89
|
+
# Note: This is not a complete security solution and should be enhanced
|
90
|
+
# for production use
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# A more specific custom tool executor that defines a class template
|
95
|
+
class CustomClassExecutorTool < Tool
|
96
|
+
def initialize(name: "custom_executor", description: nil, base_class: nil)
|
97
|
+
description ||= "Executes a custom Ruby class with predefined methods. " +
|
98
|
+
"The class must implement a 'run' method that will be called."
|
99
|
+
|
100
|
+
@base_class = base_class
|
101
|
+
|
102
|
+
super(
|
103
|
+
name: name,
|
104
|
+
description: description,
|
105
|
+
input_schema: {
|
106
|
+
code: {
|
107
|
+
type: "string",
|
108
|
+
description: "The Ruby class definition, must include a 'run' method"
|
109
|
+
},
|
110
|
+
args: {
|
111
|
+
type: "object",
|
112
|
+
description: "Arguments to pass to the run method (optional)",
|
113
|
+
required: false
|
114
|
+
}
|
115
|
+
}
|
116
|
+
)
|
117
|
+
end
|
118
|
+
|
119
|
+
def execute(args)
|
120
|
+
code = args[:code]
|
121
|
+
run_args = args[:args] || {}
|
122
|
+
|
123
|
+
return "Error: No code provided" if code.nil? || code.strip.empty?
|
124
|
+
|
125
|
+
# Capture stdout
|
126
|
+
original_stdout = $stdout
|
127
|
+
captured_stdout = StringIO.new
|
128
|
+
$stdout = captured_stdout
|
129
|
+
|
130
|
+
begin
|
131
|
+
# Create a clean context
|
132
|
+
context = Object.new.instance_eval { binding }
|
133
|
+
|
134
|
+
# Inject base class if provided
|
135
|
+
if @base_class
|
136
|
+
context.eval("BaseClass = #{@base_class.name}")
|
137
|
+
end
|
138
|
+
|
139
|
+
# Evaluate the code to define the class
|
140
|
+
context.eval(code)
|
141
|
+
|
142
|
+
# Find the class we just defined
|
143
|
+
class_name = extract_class_name(code)
|
144
|
+
|
145
|
+
if class_name.nil?
|
146
|
+
return "Error: Could not find a class definition in the provided code"
|
147
|
+
end
|
148
|
+
|
149
|
+
# Instantiate the class
|
150
|
+
runner = context.eval("#{class_name}.new")
|
151
|
+
|
152
|
+
# Ensure it has a run method
|
153
|
+
unless runner.respond_to?(:run)
|
154
|
+
return "Error: The class must implement a 'run' method"
|
155
|
+
end
|
156
|
+
|
157
|
+
# Run the class's run method with provided arguments
|
158
|
+
if run_args.is_a?(Hash)
|
159
|
+
result = runner.run(**run_args)
|
160
|
+
else
|
161
|
+
result = runner.run(run_args)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Format the output
|
165
|
+
stdout_content = captured_stdout.string.strip
|
166
|
+
|
167
|
+
if stdout_content.empty?
|
168
|
+
"Result: #{result.inspect}"
|
169
|
+
else
|
170
|
+
"Output:\n#{stdout_content}\n\nResult: #{result.inspect}"
|
171
|
+
end
|
172
|
+
rescue => e
|
173
|
+
"Error: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
174
|
+
ensure
|
175
|
+
# Restore stdout
|
176
|
+
$stdout = original_stdout
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
private
|
181
|
+
|
182
|
+
def extract_class_name(code)
|
183
|
+
# Simple regex to extract the class name
|
184
|
+
match = code.match(/class\s+([A-Z][A-Za-z0-9_]*)\s*(?:<\s*(?:BaseClass|[A-Z][A-Za-z0-9_]*))?\s*/)
|
185
|
+
match ? match[1] : nil
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
require 'cgi'
|
5
|
+
|
6
|
+
module RSmolagent
|
7
|
+
module Tools
|
8
|
+
class WebSearchTool < Tool
|
9
|
+
def initialize(max_results: 5)
|
10
|
+
super(
|
11
|
+
name: "web_search",
|
12
|
+
description: "Performs a web search based on your query (like a Google search) and returns the top search results",
|
13
|
+
input_schema: {
|
14
|
+
query: {
|
15
|
+
type: "string",
|
16
|
+
description: "The search query to perform"
|
17
|
+
}
|
18
|
+
}
|
19
|
+
)
|
20
|
+
@max_results = max_results
|
21
|
+
end
|
22
|
+
|
23
|
+
def execute(args)
|
24
|
+
query = args[:query]
|
25
|
+
|
26
|
+
# Ensure query isn't empty
|
27
|
+
return "Error: Search query cannot be empty" if query.nil? || query.strip.empty?
|
28
|
+
|
29
|
+
begin
|
30
|
+
results = search_duckduckgo(query)
|
31
|
+
|
32
|
+
if results.nil? || results.empty?
|
33
|
+
return "No results found for query: '#{query}'. Try a less restrictive/shorter query."
|
34
|
+
end
|
35
|
+
|
36
|
+
# Format the results as markdown
|
37
|
+
formatted_results = format_results(results)
|
38
|
+
return "## Search Results\n\n#{formatted_results}"
|
39
|
+
rescue => e
|
40
|
+
return "Error performing search: #{e.message}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def search_duckduckgo(query)
|
47
|
+
# Use the DuckDuckGo API
|
48
|
+
encoded_query = CGI.escape(query)
|
49
|
+
uri = URI("https://api.duckduckgo.com/?q=#{encoded_query}&format=json")
|
50
|
+
|
51
|
+
response = Net::HTTP.get_response(uri)
|
52
|
+
|
53
|
+
if response.is_a?(Net::HTTPSuccess)
|
54
|
+
# Parse the response
|
55
|
+
data = JSON.parse(response.body)
|
56
|
+
|
57
|
+
# Extract results
|
58
|
+
results = []
|
59
|
+
|
60
|
+
# Get results from "AbstractText" if available
|
61
|
+
if data["AbstractText"] && !data["AbstractText"].empty?
|
62
|
+
results << {
|
63
|
+
title: data["Heading"],
|
64
|
+
url: data["AbstractURL"],
|
65
|
+
body: data["AbstractText"]
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
# Get results from "RelatedTopics"
|
70
|
+
if data["RelatedTopics"]
|
71
|
+
data["RelatedTopics"].each do |topic|
|
72
|
+
next if topic["Text"].nil? || topic["FirstURL"].nil?
|
73
|
+
|
74
|
+
# Extract title from the text (first few words)
|
75
|
+
text_parts = topic["Text"].split(" - ", 2)
|
76
|
+
title = text_parts.first || "Related Topic"
|
77
|
+
body = text_parts.last || text_parts.first
|
78
|
+
|
79
|
+
results << {
|
80
|
+
title: title,
|
81
|
+
url: topic["FirstURL"],
|
82
|
+
body: body
|
83
|
+
}
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Limit results to max_results
|
88
|
+
return results.first(@max_results)
|
89
|
+
else
|
90
|
+
raise "Request failed with status: #{response.code}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def format_results(results)
|
95
|
+
results.map do |result|
|
96
|
+
"[#{result[:title]}](#{result[:url]})\n#{result[:body]}"
|
97
|
+
end.join("\n\n")
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
data/lib/rsmolagent.rb
ADDED
metadata
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rsmolagent
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- gkosmo
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-03-10 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: json
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: ruby-openai
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.0'
|
55
|
+
description: RSmolagent is a Ruby library for building AI agents that can use tools
|
56
|
+
to solve tasks
|
57
|
+
email:
|
58
|
+
- gkosmo1@hotmail.com
|
59
|
+
executables: []
|
60
|
+
extensions: []
|
61
|
+
extra_rdoc_files: []
|
62
|
+
files:
|
63
|
+
- README.md
|
64
|
+
- lib/rsmolagent.rb
|
65
|
+
- lib/rsmolagent/agent.rb
|
66
|
+
- lib/rsmolagent/llm_provider.rb
|
67
|
+
- lib/rsmolagent/memory.rb
|
68
|
+
- lib/rsmolagent/tool.rb
|
69
|
+
- lib/rsmolagent/tools.rb
|
70
|
+
- lib/rsmolagent/tools/custom_tool_base.rb
|
71
|
+
- lib/rsmolagent/tools/ruby_executor.rb
|
72
|
+
- lib/rsmolagent/tools/web_search.rb
|
73
|
+
- lib/rsmolagent/version.rb
|
74
|
+
homepage: https://github.com/gkosmo/rsmolagent
|
75
|
+
licenses:
|
76
|
+
- MIT
|
77
|
+
metadata:
|
78
|
+
homepage_uri: https://github.com/gkosmo/rsmolagent
|
79
|
+
source_code_uri: https://github.com/gkosmo/rsmolagent
|
80
|
+
post_install_message:
|
81
|
+
rdoc_options: []
|
82
|
+
require_paths:
|
83
|
+
- lib
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - ">="
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: 2.6.0
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
90
|
+
requirements:
|
91
|
+
- - ">="
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
requirements: []
|
95
|
+
rubygems_version: 3.5.9
|
96
|
+
signing_key:
|
97
|
+
specification_version: 4
|
98
|
+
summary: A simple AI agent framework in Ruby
|
99
|
+
test_files: []
|