regent 0.3.2 → 0.3.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1c3bc1771d81f732f941e27dfc393ef56b6f6c42d6c448385202f065dfc5811
4
- data.tar.gz: 8950e15664e538beffc54d5e0e92da76bc8390e7ddd20bc923914ad14d3613e3
3
+ metadata.gz: fac176028645aa269bc29fbbb118922bf9a33e67ba507cfeae7b13540f580890
4
+ data.tar.gz: eb332cd19a06a2ab897adb81ebb6571406b60fc78b8018749af31be05db56d97
5
5
  SHA512:
6
- metadata.gz: 3d6a447a3df128617b5ede55412752a331f6edcb556039ea09472357b258803889722331408baf1e7b8add8111c5addbf58016a6799b3d2d98d608a88b501e39
7
- data.tar.gz: f496a6abdd8646342bb0d2fd34b59893d9770e74d7325d1fdf79d3d0d33751765b3dfd17a2dacf4636fd045edbc0c9661fadb143df24be206a281e6b7b2a356e
6
+ metadata.gz: 268f787d1a98e24e6d9874bb87b4ddf9bf1f0577274de882d16a393577b45b5500d306ee7551812c29ee342302596e68d95347eed787a28f71a982e6752ddeaa
7
+ data.tar.gz: 2eeac3ebe58c0d2d6b2998d3ef7795129806c79adbeff46effa2e42e390ecf624ffc5b809cc1dfcac73c86ea60d6fa2312edc7d58e2114fdb275329f65c06ef2
data/README.md CHANGED
@@ -12,8 +12,10 @@
12
12
 
13
13
  **Regent** is a small and elegant Ruby framework for building AI agents that can think, reason, and take actions through tools. It provides a clean, intuitive interface for creating agents that can solve complex problems by breaking them down into logical steps.
14
14
 
15
- > [!WARNING]
15
+ > [!NOTE]
16
16
  > Regent is currently an experiment intended to explore patterns for building easily traceable and debuggable AI agents of different architectures. It is not yet intended to be used in production and is currently in development.
17
+ >
18
+ > Read more about Regent in a Medium article: [Building AI Agent from scratch with Ruby](https://medium.com/towards-artificial-intelligence/building-ai-agent-from-scratch-with-ruby-c6260dad45b7)
17
19
 
18
20
  ## Key Features
19
21
 
@@ -30,7 +32,7 @@
30
32
 
31
33
  A basic Regnt Agent extended with a `price_tool` that allows for retrieving cryptocurrency prices from coingecko.com.
32
34
 
33
- ![screencast 2024-12-25 21-53-47](https://github.com/user-attachments/assets/4e65b731-bbd7-4732-b157-b705d35a7824)
35
+ ![Screen_gif](https://github.com/user-attachments/assets/63c8c923-0c1e-48db-99f6-33758411623f)
34
36
 
35
37
  ## Quick Start
36
38
 
@@ -52,40 +54,191 @@ bundle install
52
54
 
53
55
  ## Usage
54
56
 
55
- Create your first agent:
57
+ ### Quick Example
58
+
59
+ Create your first weather agent:
56
60
 
57
61
  ```ruby
58
- # Initialize the LLM
59
- model = Regent::LLM.new("gpt-4o")
62
+ # Define agent class
63
+ class WeatherAgent < Regent::Agent
64
+ tool(:weather_tool, "Get current weather for a location")
60
65
 
61
- # Create a custom tool
62
- class WeatherTool < Regent::Tool
63
- def call(location)
64
- # Implement weather lookup logic
66
+ def weather_tool(location)
65
67
  "Currently 72°F and sunny in #{location}"
66
68
  end
67
69
  end
68
70
 
69
- # Create and configure the agent
70
- agent = Regent::Agent.new(
71
- "You are a helpful weather assistant",
72
- model: model,
73
- tools: [WeatherTool.new(
74
- name: "weather_tool",
75
- description: "Get current weather for a location"
76
- )]
77
- )
71
+ # Instantiate an agent
72
+ agent = WeatherAgent.new("You are a helpful weather assistant", model: "gpt-4o")
78
73
 
79
74
  # Execute a query
80
75
  agent.run("What's the weather like in Tokyo?") # => "It is currently 72°F and sunny in Tokyo."
81
76
  ```
82
77
 
78
+ ### LLMs
79
+ Regent provides an interface for invoking an LLM through an instance of `Regent::LLM` class. Even though Agent initializer allows you to pass a modal name as a string, sometimes it is useful to create a model instance if you want to tune model params before passing it to the agent. Or if you need to invoke a model directly without passing it to an Agent you can do that by creating an instance of LLM class:
80
+
81
+ ```ruby
82
+ model = Regent::LLM.new("gemini-1.5-flash")
83
+ # or with options
84
+ model = Regent::LLM.new("gemini-1.5-flash", temperature: 0.5) # supports options that are supported by the model
85
+ ```
86
+
87
+ #### API keys
88
+ By default, **Regent** will try to fetch API keys for corresponding models from environment variables. Make sure that the following ENV variables are set depending on your model choice:
89
+
90
+ | Model series | ENV variable name |
91
+ |--------------|---------------------|
92
+ | `gpt-` | `OPENAI_API_KEY` |
93
+ | `gemini-` | `GEMINI_API_KEY` |
94
+ | `claude-` | `ANTHROPIC_API_KEY` |
95
+
96
+ But you can also pass an `api_key` option to the` Regent::LLM` constructor should you need to override this behavior:
97
+
98
+ ```ruby
99
+ model = Regent::LLM.new("gemini-1.5-flash", api_key: "AIza...")
100
+ ```
101
+
102
+ > [!NOTE]
103
+ > Currently **Regent** supports only `gpt-`, `gemini-` and `claude-` models series and local **ollama** models. But you can build, your custom model classes that conform to the Regent's interface and pass those instances to the Agent.
104
+
105
+ #### Calling LLM
106
+ Once your model is instantiated you can call the `invoke` method:
107
+
108
+ ```ruby
109
+ model.invoke("Hello!")
110
+ ```
111
+
112
+ Alternatively, you can pass message history to the `invoke` method. Messages need to follow OpenAI's message format (eg. `{role: "user", content: "..."}`)
113
+
114
+ ```ruby
115
+ model.invoke([
116
+ {role: "system", content: "You are a helpful assistant"},
117
+ {role: "user", content: "Hello!"}
118
+ ])
119
+ ```
120
+
121
+ This method returns an instance of the `Regent::LLM::Result` class, giving access to the content or error and token usage stats.
122
+
123
+ ```ruby
124
+ result = model.invoke("Hello!")
125
+
126
+ result.content # => Hello there! How can I help you today?
127
+ result.input_tokens # => 2
128
+ result.output_tokens # => 11
129
+ result.error # => nil
130
+ ```
131
+
132
+ ### Tools
133
+
134
+ There are multiple ways how you can give agents tools for performing actions and retrieving additional information. First of all you can define a **function tool** directly on the agent class:
135
+
136
+ ```ruby
137
+ class MyAgent < Regent::Agent
138
+ # define the tool by giving a unique name and description
139
+ tool :search_web, "Search for information on the web"
140
+
141
+ def search_web(query)
142
+ # Implement tool logic within the method with the same name
143
+ end
144
+ end
145
+ ```
146
+
147
+ For more complex tools we can define a dedicated class with a `call` method that will get called. And then pass an instance of this tool to an agent:
148
+
149
+ ```ruby
150
+ class SearchTool < Regent::Tool
151
+ def call(query)
152
+ # Implement tool logic
153
+ end
154
+ end
155
+
156
+ agent = Regent::Agent.new("Find information and answer any question", {
157
+ model: "gpt-4o",
158
+ tools: [SearchTool.new]
159
+ })
160
+
161
+ ```
162
+
163
+ ### Agent
164
+
165
+ **Agent** class is the core of the library. To crate an agent, you can use `Regent::Agent` class directly if you don't need to add any business logic. Or you can create your own class inheriting from `Regent::Agent`. To instantiate an agent you need to pass a **purpose** of an agent and a model it should use.
166
+
167
+ ```ruby
168
+ agent = Regent::Agent.new("You are a helpful assistant", model: "gpt-4o-mini")
169
+ ```
170
+
171
+ Additionally, you can pass a list of Tools to extend the agent's capabilities. Those should be instances of classes that inherit from `Regent::Tool` class:
172
+
173
+ ```ruby
174
+ class SearchTool < Regent::Tool
175
+ def call
176
+ # make a call to search API
177
+ end
178
+ end
179
+
180
+ class CalculatorTool < Regent::Tool
181
+ def call
182
+ # perform calculations
183
+ end
184
+ end
185
+
186
+ tools = [SearchTool.new, CalculatorTool.new]
187
+
188
+ agent = Regent::Agent.new("You are a helpful assistant", model: "gpt-4o-mini", tools: tools)
189
+ ```
190
+
191
+ Each agent run creates a **session** that contains every operation that is performed by the agent while working on a task. Sessions can be replayed and drilled down into while debugging.
192
+ ```ruby
193
+ agent.sessions # => Returns all sessions performed by the agent
194
+ agent.session # => Returns last session performed by the agent
195
+ agent.session.result # => Returns result of latest agent run
196
+ ```
197
+
198
+ While running agent logs all session spans (all operations) to the console with all sorts of useful information, that helps to understand what the agent was doing and why it took a certain path.
199
+ ```ruby
200
+ weather_agent.run("What is the weather in San Francisco?")
201
+ ```
202
+
203
+ Outputs:
204
+ ```console
205
+ [✔] [INPUT][0.0s]: What is the weather in San Francisco?
206
+ ├──[✔] [LLM ❯ gpt-4o-mini][242 → 30 tokens][0.02s]: What is the weather in San Francisco?
207
+ ├──[✔] [TOOL ❯ get_weather][0.0s]: ["San Francisco"] → The weather in San Francisco is 70 degrees and sunny.
208
+ ├──[✔] [LLM ❯ gpt-4o-mini][294 → 26 tokens][0.01s]: Observation: The weather in San Francisco is 70 degrees and sunny.
209
+ [✔] [ANSWER ❯ success][0.03s]: It is 70 degrees and sunny in San Francisco.
210
+ ```
211
+
212
+ ### Engine
213
+ By default, Regent uses ReAct agent architecture. You can see the [details of its implementation](https://github.com/alchaplinsky/regent/blob/main/lib/regent/engine/react.rb). However, Agent constructor accepts an `engine` option that allows you to swap agent engine when instantiating an Agent. This way you can implement your own agent architecture that can be plugged in and user within Regent framework.
214
+
215
+ ```ruby
216
+ agent = CustomAgent.new("You are a self-correcting assistant", model: "gpt-4o", engine: CustomEngine)
217
+ ```
218
+
219
+ In order to implement your own engine you need to define a class that inherits from `Regent::Engine::Base` class and implements `reason` method:
220
+
221
+ ```ruby
222
+ class CustomEngine < Regent::Engine::Base
223
+ def reason(task)
224
+ # Your implementation of an Agent lifecycle
225
+ end
226
+ end
227
+ ```
228
+
229
+ Note that Base class already handles `max_iteration` check, so you won't end up in an infinite loop. Also, it allows you to use `llm_call_response` and `tool_call_response` methods for agent reasoning as well as `success_answer` and `error_answer` for the final result.
230
+
231
+ For any other operation that happens in your agent architecture that you want to track separately call it within the `session.exec` block. See examples in `Regent::Engine::Base` class.
232
+
233
+
234
+ ---
83
235
  ## Why Regent?
84
236
 
85
237
  - **Transparent Decision Making**: Watch your agent's thought process as it reasons through problems
86
238
  - **Flexible Architecture**: Easy to extend with custom tools and adapt to different use cases
87
- - **Production Ready**: Built with tracing, error handling, and clean abstractions
88
239
  - **Ruby-First Design**: Takes advantage of Ruby's elegant syntax and conventions
240
+ - **Transparent Execution**: Built with tracing, error handling, and clean abstractions
241
+
89
242
 
90
243
  ## Development
91
244
 
data/lib/regent/agent.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Regent
4
4
  class Agent
5
5
  include Concerns::Identifiable
6
+ include Concerns::Toolable
6
7
 
7
8
  DEFAULT_MAX_ITERATIONS = 10
8
9
 
@@ -10,14 +11,14 @@ module Regent
10
11
  super()
11
12
 
12
13
  @context = context
13
- @model = model
14
+ @model = model.is_a?(String) ? Regent::LLM.new(model) : model
14
15
  @engine = engine
15
16
  @sessions = []
16
- @tools = tools.is_a?(Toolchain) ? tools : Toolchain.new(Array(tools))
17
+ @tools = build_toolchain(tools)
17
18
  @max_iterations = options[:max_iterations] || DEFAULT_MAX_ITERATIONS
18
19
  end
19
20
 
20
- attr_reader :context, :sessions, :model, :tools
21
+ attr_reader :context, :sessions, :model, :tools, :inline_tools
21
22
 
22
23
  def run(task)
23
24
  raise ArgumentError, "Task cannot be empty" if task.to_s.strip.empty?
@@ -52,6 +53,18 @@ module Regent
52
53
  session&.complete if running?
53
54
  end
54
55
 
56
+ def build_toolchain(tools)
57
+ context = self
58
+
59
+ toolchain = Toolchain.new(Array(tools))
60
+
61
+ self.class.function_tools.each do |entry|
62
+ toolchain.add(entry, context)
63
+ end
64
+
65
+ toolchain
66
+ end
67
+
55
68
  def engine
56
69
  @engine.new(context, model, tools, session, @max_iterations)
57
70
  end
@@ -38,14 +38,14 @@ module Regent
38
38
 
39
39
  def load_dependency(name)
40
40
  gem(name)
41
-
42
- return true unless defined? Bundler
43
-
44
41
  gem_spec = Gem::Specification.find_by_name(name)
45
- gem_requirement = dependencies.find { |gem| gem.name == gem_spec.name }.requirement
46
42
 
47
- unless gem_requirement.satisfied_by?(gem_spec.version)
48
- raise VersionError, version_error(gem_spec, gem_requirement)
43
+ if defined?(Bundler)
44
+ gem_requirement = dependencies.find { |gem| gem.name == gem_spec.name }.requirement
45
+
46
+ unless gem_requirement.satisfied_by?(gem_spec.version)
47
+ raise VersionError, version_error(gem_spec, gem_requirement)
48
+ end
49
49
  end
50
50
 
51
51
  require_gem(gem_spec)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Regent
4
+ module Concerns
5
+ module Toolable
6
+ def self.included(base)
7
+ base.class_eval do
8
+ class << self
9
+ def tool(name, description)
10
+ @function_tools ||= []
11
+ @function_tools << { name: name, description: description }
12
+ end
13
+
14
+ def function_tools
15
+ @function_tools || []
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -52,7 +52,7 @@ module Regent
52
52
 
53
53
  # Complete a session with a success answer
54
54
  def success_answer(content)
55
- session.exec(Span::Type::ANSWER, top_level: true,type: :success, message: content, duration: session.duration.round(2)) { content }
55
+ session.exec(Span::Type::ANSWER, top_level: true, type: :success, message: content, duration: session.duration.round(2)) { content }
56
56
  end
57
57
 
58
58
  # Complete a session with an error answer
@@ -9,14 +9,18 @@ module Regent
9
9
  depends_on "anthropic"
10
10
 
11
11
  def invoke(messages, **args)
12
- response = client.messages(parameters: {
12
+ parameters = {
13
13
  messages: format_messages(messages),
14
- system: system_instruction(messages),
15
14
  model: model,
16
15
  temperature: args[:temperature] || 0.0,
17
16
  stop_sequences: args[:stop] || [],
18
17
  max_tokens: MAX_TOKENS
19
- })
18
+ }
19
+ if system_instruction = system_instruction(messages)
20
+ parameters[:system] = system_instruction
21
+ end
22
+
23
+ response = client.messages(parameters:)
20
24
 
21
25
  result(
22
26
  model: model,
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Regent
4
+ class LLM
5
+ class Ollama < Base
6
+ # Default host for Ollama API.
7
+ DEFAULT_HOST = "http://localhost:11434"
8
+
9
+ def initialize(model:, host: nil, **options)
10
+ @model = model
11
+ @host = host || DEFAULT_HOST
12
+ @options = options
13
+ end
14
+
15
+ attr_reader :model
16
+
17
+ def invoke(messages, **args)
18
+ response = client.post("/api/chat", {
19
+ model: model,
20
+ messages: messages,
21
+ stream: false
22
+ })
23
+
24
+ if response.status == 200
25
+ result(
26
+ model: response.body.dig("model"),
27
+ content: response.body.dig("message", "content").strip,
28
+ input_tokens: nil,
29
+ output_tokens: nil
30
+ )
31
+ else
32
+ raise ApiError, response.body.dig("error")
33
+ end
34
+ end
35
+
36
+ def parse_error(error)
37
+ error.message
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :host
43
+
44
+ def client
45
+ @client ||= Faraday.new(host) do |f|
46
+ f.request :json
47
+ f.response :json
48
+ f.adapter :net_http
49
+ end
50
+ end
51
+
52
+ def api_key_from_env
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Regent
4
+ class LLM
5
+ class OpenRouter < Base
6
+ ENV_KEY = "OPEN_ROUTER_API_KEY"
7
+
8
+ depends_on "open_router"
9
+
10
+ def invoke(messages, **args)
11
+ response = client.complete(
12
+ messages,
13
+ model: model,
14
+ extras: {
15
+ temperature: args[:temperature] || 0.0,
16
+ stop: args[:stop] || [],
17
+ **args
18
+ }
19
+ )
20
+ result(
21
+ model: model,
22
+ content: response.dig("choices", 0, "message", "content"),
23
+ input_tokens: response.dig("usage", "prompt_tokens"),
24
+ output_tokens: response.dig("usage", "completion_tokens")
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def client
31
+ @client ||= ::OpenRouter::Client.new access_token: api_key
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/regent/llm.rb CHANGED
@@ -14,19 +14,27 @@ module Regent
14
14
  class ApiError < StandardError; end
15
15
 
16
16
  def initialize(model, strict_mode: true, **options)
17
- @model = model
18
17
  @strict_mode = strict_mode
19
18
  @options = options
20
- instantiate_provider
19
+ if model.class.ancestors.include?(Regent::LLM::Base)
20
+ @model = model.model
21
+ @provider = model
22
+ else
23
+ @model = model
24
+ @provider = instantiate_provider
25
+ end
21
26
  end
22
27
 
23
28
  attr_reader :model, :options
24
29
 
25
30
  def invoke(messages, **args)
26
31
  retries = 0
32
+
33
+ messages = [{ role: "user", content: messages }] if messages.is_a?(String)
34
+
27
35
  provider.invoke(messages, **args)
28
36
 
29
- rescue Faraday::Error => error
37
+ rescue Faraday::Error, ApiError => error
30
38
  if error.respond_to?(:retryable?) && error.retryable? && retries < DEFAULT_RETRY_COUNT
31
39
  sleep(exponential_backoff(retries))
32
40
  retry
@@ -42,7 +50,7 @@ module Regent
42
50
  provider_class = find_provider_class
43
51
  raise ProviderNotFoundError, "Provider for #{model} is not found" if provider_class.nil?
44
52
 
45
- @provider ||= create_provider(provider_class)
53
+ create_provider(provider_class)
46
54
  end
47
55
 
48
56
  def find_provider_class
data/lib/regent/span.rb CHANGED
@@ -85,9 +85,13 @@ module Regent
85
85
  result = yield
86
86
 
87
87
  @end_time = live ? Time.now.freeze : @end_time
88
+ update_message_with_result(result) if type == Type::TOOL_EXECUTION
88
89
  logger.success(label: type, **({ duration: duration.round(2), meta: meta }.merge(arguments)))
89
-
90
90
  result
91
91
  end
92
+
93
+ def update_message_with_result(message)
94
+ arguments[:message] = "#{arguments[:message]} → #{message}"
95
+ end
92
96
  end
93
97
  end
@@ -12,8 +12,23 @@ module Regent
12
12
  tools.find { |tool| tool.name.downcase == name.downcase }
13
13
  end
14
14
 
15
+ def add(tool, context)
16
+ @tools << Regent::Tool.new(name: tool[:name].to_s, description: tool[:description]).instance_eval do
17
+ raise "A tool method '#{tool[:name]}' is missing in the #{context.class.name}" unless context.respond_to?(tool[:name])
18
+
19
+ define_singleton_method(:call){ |*args| context.send(tool[:name], *args) }
20
+ self
21
+ end
22
+ end
23
+
15
24
  def to_s
16
25
  tools.map(&:to_s).join("\n")
17
26
  end
27
+
28
+ private
29
+
30
+ def tool_missing_error(tool_name, context_name)
31
+ "A tool method '#{tool_name}' is missing in the #{context_name}"
32
+ end
18
33
  end
19
34
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Regent
4
- VERSION = "0.3.2"
4
+ VERSION = "0.3.4"
5
5
  end
data/lib/regent.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
- require 'faraday'
5
4
  require 'json'
5
+ require 'faraday'
6
6
  require 'pastel'
7
7
  require 'tty-spinner'
8
8
  require 'zeitwerk'
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: regent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Chaplinsky
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-01-04 00:00:00.000000000 Z
11
+ date: 2025-02-08 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.12'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.12'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: zeitwerk
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -71,6 +85,7 @@ files:
71
85
  - lib/regent/concerns/dependable.rb
72
86
  - lib/regent/concerns/durationable.rb
73
87
  - lib/regent/concerns/identifiable.rb
88
+ - lib/regent/concerns/toolable.rb
74
89
  - lib/regent/engine/base.rb
75
90
  - lib/regent/engine/react.rb
76
91
  - lib/regent/engine/react/prompt_template.rb
@@ -78,7 +93,9 @@ files:
78
93
  - lib/regent/llm/anthropic.rb
79
94
  - lib/regent/llm/base.rb
80
95
  - lib/regent/llm/gemini.rb
96
+ - lib/regent/llm/ollama.rb
81
97
  - lib/regent/llm/open_ai.rb
98
+ - lib/regent/llm/open_router.rb
82
99
  - lib/regent/logger.rb
83
100
  - lib/regent/session.rb
84
101
  - lib/regent/span.rb