regent 0.3.1 → 0.3.3

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: 9a728784fd3d7720bb7fd96bf2cd22a8d53e59eaed01c9b4240a2cb09023e261
4
- data.tar.gz: 78ddbe87cf964ca94039f560ddfcb743a14757e551af0847363b0fa82228e4a7
3
+ metadata.gz: b507ccf9494ec028a77c396a80acd23bb686b4e1e15ba6b993430b479f495aeb
4
+ data.tar.gz: 8a00bcfaddae77f89b19ec72a9112c82e07086a2fee2713fc71fac5cbf0581b1
5
5
  SHA512:
6
- metadata.gz: c0306c99637469cff9e51a9b1b50424e646f38dd601dcb5b36d8eee417e2f280488b3e6c78747c242d78d8213fd6a9e004d3cd7065b692d7dc63a5900bf20e16
7
- data.tar.gz: 68302a81b5062f54415a89e49513e2186afd0fb6d129dfa1111b33565ade932cbae9e657218d66b37cf2fd58b78103456da0dcc7c52c26948016134117cc4e42
6
+ metadata.gz: e1249458a5fa9e035a9bc9c6dfb7102ec9efcdd6f1fbb71fe8638194e6181256ae09df6f7ec42ccb781bf641cdcae13c84c2104ec33e8543e5480868fe03bd77
7
+ data.tar.gz: be2357b3af64f96d69573bbc913c3322a11aa40434f84aba314e64ef066c78821ead732becb2f455c5f93251907f724ca7cd48109706108c66d3d57a9837b5b4
data/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  <div align="center">
4
4
 
5
5
  # Regent
6
+
6
7
  [![Gem Version](https://badge.fury.io/rb/regent.svg)](https://badge.fury.io/rb/regent)
7
8
  [![Build](https://github.com/alchaplinsky/regent/actions/workflows/main.yml/badge.svg)](https://github.com/alchaplinsky/regent/actions/workflows/main.yml)
8
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -11,8 +12,10 @@
11
12
 
12
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.
13
14
 
14
- > [!WARNING]
15
+ > [!NOTE]
15
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)
16
19
 
17
20
  ## Key Features
18
21
 
@@ -29,7 +32,7 @@
29
32
 
30
33
  A basic Regnt Agent extended with a `price_tool` that allows for retrieving cryptocurrency prices from coingecko.com.
31
34
 
32
- ![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)
33
36
 
34
37
  ## Quick Start
35
38
 
@@ -51,39 +54,191 @@ bundle install
51
54
 
52
55
  ## Usage
53
56
 
54
- Create your first agent:
57
+ ### Quick Example
58
+
59
+ Create your first weather agent:
55
60
 
56
61
  ```ruby
57
- # Initialize the LLM
58
- llm = Regent::LLM.new("gpt-4o")
62
+ # Define agent class
63
+ class WeatherAgent < Regent::Agent
64
+ tool(:weather_tool, "Get current weather for a location")
59
65
 
60
- # Create a custom tool
61
- class WeatherTool < Regent::Tool
62
- def call(location)
63
- # Implement weather lookup logic
66
+ def weather_tool(location)
64
67
  "Currently 72°F and sunny in #{location}"
65
68
  end
66
69
  end
67
70
 
68
- # Create and configure the agent
69
- agent = Regent::Agent.new(
70
- "You are a helpful weather assistant",
71
- llm: llm,
72
- tools: [WeatherTool.new(
73
- name: "weather_tool",
74
- description: "Get current weather for a location"
75
- )]
76
- )
71
+ # Instantiate an agent
72
+ agent = WeatherAgent.new("You are a helpful weather assistant", model: "gpt-4o")
77
73
 
78
74
  # Execute a query
79
- result = agent.execute("What's the weather like in Tokyo?") # => "It is currently 72°F and sunny in Tokyo."
75
+ agent.run("What's the weather like in Tokyo?") # => "It is currently 72°F and sunny in Tokyo."
76
+ ```
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
+ ])
80
119
  ```
81
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
+ ---
82
235
  ## Why Regent?
236
+
83
237
  - **Transparent Decision Making**: Watch your agent's thought process as it reasons through problems
84
238
  - **Flexible Architecture**: Easy to extend with custom tools and adapt to different use cases
85
- - **Production Ready**: Built with tracing, error handling, and clean abstractions
86
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
+
87
242
 
88
243
  ## Development
89
244
 
data/lib/regent/agent.rb CHANGED
@@ -3,26 +3,28 @@
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
 
9
- def initialize(context, llm:, tools: [], **options)
10
+ def initialize(context, model:, tools: [], engine: Regent::Engine::React, **options)
10
11
  super()
11
12
 
12
13
  @context = context
13
- @llm = llm
14
+ @model = model.is_a?(String) ? Regent::LLM.new(model) : model
15
+ @engine = engine
14
16
  @sessions = []
15
- @tools = tools.is_a?(Toolchain) ? tools : Toolchain.new(Array(tools))
17
+ @tools = build_toolchain(tools)
16
18
  @max_iterations = options[:max_iterations] || DEFAULT_MAX_ITERATIONS
17
19
  end
18
20
 
19
- attr_reader :context, :sessions, :llm, :tools
21
+ attr_reader :context, :sessions, :model, :tools, :inline_tools
20
22
 
21
- def execute(task)
23
+ def run(task)
22
24
  raise ArgumentError, "Task cannot be empty" if task.to_s.strip.empty?
23
25
 
24
26
  start_session
25
- react.reason(task)
27
+ reason(task)
26
28
  ensure
27
29
  complete_session
28
30
  end
@@ -37,6 +39,10 @@ module Regent
37
39
 
38
40
  private
39
41
 
42
+ def reason(task)
43
+ engine.reason(task)
44
+ end
45
+
40
46
  def start_session
41
47
  complete_session
42
48
  @sessions << Session.new
@@ -47,8 +53,20 @@ module Regent
47
53
  session&.complete if running?
48
54
  end
49
55
 
50
- def react
51
- Regent::Engine::React.new(context, llm, tools, session, @max_iterations)
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
+
68
+ def engine
69
+ @engine.new(context, model, tools, session, @max_iterations)
52
70
  end
53
71
  end
54
72
  end
@@ -25,7 +25,7 @@ module Regent
25
25
 
26
26
  super()
27
27
  rescue Gem::LoadError
28
- warn_and_exit(dependency, options[:model])
28
+ Regent::Logger.warn_and_exit dependency_warning(dependency, model)
29
29
  end
30
30
 
31
31
  def require_dynamic(*names)
@@ -34,6 +34,8 @@ module Regent
34
34
 
35
35
  private
36
36
 
37
+ attr_reader :dependency
38
+
37
39
  def load_dependency(name)
38
40
  gem(name)
39
41
 
@@ -63,9 +65,8 @@ module Regent
63
65
  Bundler.load.dependencies
64
66
  end
65
67
 
66
- def warn_and_exit(name, model)
67
- warn "\n\e[33mIn order to use \e[33;1m#{model}\e[0m\e[33m model you need to install \e[33;1m#{name}\e[0m\e[33m gem. Please add \e[33;1mgem \"#{name}\"\e[0m\e[33m to your Gemfile.\e[0m"
68
- exit 1
68
+ def dependency_warning(dependency, model)
69
+ "\n\e[33mIn order to use \e[33;1m#{model}\e[0m\e[33m model you need to install \e[33;1m#{dependency}\e[0m\e[33m gem. Please add \e[33;1mgem \"#{dependency}\"\e[0m\e[33m to your Gemfile.\e[0m"
69
70
  end
70
71
  end
71
72
  end
@@ -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
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Regent
4
+ module Engine
5
+ class Base
6
+ def initialize(context, llm, toolchain, session, max_iterations)
7
+ @context = context
8
+ @llm = llm
9
+ @toolchain = toolchain
10
+ @session = session
11
+ @max_iterations = max_iterations
12
+ end
13
+
14
+ attr_reader :context, :llm, :toolchain, :session, :max_iterations
15
+
16
+ private
17
+
18
+ # Run reasoning block within this method to ensure that it
19
+ # will not run more than max_iterations times.
20
+ def with_max_iterations
21
+ max_iterations.times do
22
+ yield
23
+ end
24
+
25
+ error_answer("Max iterations reached without finding an answer.")
26
+ end
27
+
28
+ # Make a call to LLM and return the response.
29
+ def llm_call_response(args)
30
+ session.exec(Span::Type::LLM_CALL, type: llm.model, message: session.messages.last[:content]) do
31
+ result = llm.invoke(session.messages, **args)
32
+
33
+ session.current_span.set_meta("#{result.input_tokens} → #{result.output_tokens} tokens")
34
+ result.content
35
+ end
36
+ end
37
+
38
+ # Make a call to a tool and return the response.
39
+ def tool_call_response(tool, arguments)
40
+ session.exec(Span::Type::TOOL_EXECUTION, { type: tool.name, message: arguments }) do
41
+ tool.execute(*arguments)
42
+ end
43
+ end
44
+
45
+ # Find a tool in the toolchain by name and return it.
46
+ def find_tool(tool_name)
47
+ tool = toolchain.find(tool_name)
48
+ return tool if tool
49
+
50
+ session.exec(Span::Type::ANSWER, type: :failure, message: "No matching tool found for: #{tool_name}")
51
+ end
52
+
53
+ # Complete a session with a success answer
54
+ def success_answer(content)
55
+ session.exec(Span::Type::ANSWER, top_level: true, type: :success, message: content, duration: session.duration.round(2)) { content }
56
+ end
57
+
58
+ # Complete a session with an error answer
59
+ def error_answer(content)
60
+ session.exec(Span::Type::ANSWER, top_level: true, type: :failure, message: content, duration: session.duration.round(2)) { content }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -13,7 +13,7 @@ module Regent
13
13
  Thought - a description of your thoughts about the question.
14
14
  Action - pick a an action from available tools if required. If there are no tools that can help return an Answer saying you are not able to help.
15
15
  Observation - is the result of running a tool.
16
- PAUSE - is always present after an Action.
16
+ PAUSE - a stop sequence that will always be present after an Action.
17
17
 
18
18
  ## Available tools:
19
19
  #{tool_list}
@@ -21,7 +21,7 @@ module Regent
21
21
  ## Example session
22
22
  Question: What is the weather in London today?
23
23
  Thought: I need to get current weather in London
24
- Action: weather_tool | London
24
+ Action: {"tool": "weather_tool", "args": ["London"]}
25
25
  PAUSE
26
26
 
27
27
  You will have a response form a user with Observation:
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Regent
4
4
  module Engine
5
- class React
5
+ class React < Base
6
6
  SEQUENCES = {
7
7
  answer: "Answer:",
8
8
  action: "Action:",
@@ -10,70 +10,29 @@ module Regent
10
10
  stop: "PAUSE"
11
11
  }.freeze
12
12
 
13
- def initialize(context, llm, toolchain, session, max_iterations)
14
- @context = context
15
- @llm = llm
16
- @toolchain = toolchain
17
- @session = session
18
- @max_iterations = max_iterations
19
- end
20
-
21
- attr_reader :context, :llm, :toolchain, :session, :max_iterations
22
-
23
13
  def reason(task)
24
- initialize_session(task)
14
+ session.exec(Span::Type::INPUT, top_level: true, message: task) { task }
15
+ session.add_message({role: :system, content: Regent::Engine::React::PromptTemplate.system_prompt(context, toolchain.to_s)})
16
+ session.add_message({role: :user, content: task})
25
17
 
26
- max_iterations.times do |i|
27
- content = get_llm_response
18
+ with_max_iterations do
19
+ content = llm_call_response(stop: [SEQUENCES[:stop]])
28
20
  session.add_message({role: :assistant, content: content })
21
+
29
22
  return extract_answer(content) if answer_present?(content)
30
23
 
31
24
  if action_present?(content)
32
- tool, argument = parse_action(content)
25
+ tool_name, arguments = parse_tool_signature(content)
26
+ tool = find_tool(tool_name)
33
27
  return unless tool
34
-
35
- process_tool_execution(tool, argument)
28
+ result = tool_call_response(tool, arguments)
29
+ session.add_message({ role: :user, content: "#{SEQUENCES[:observation]} #{result}" })
36
30
  end
37
31
  end
38
-
39
- error_answer("Max iterations reached without finding an answer.")
40
32
  end
41
33
 
42
34
  private
43
35
 
44
- def initialize_session(task)
45
- session.add_message({role: :system, content: Regent::Engine::React::PromptTemplate.system_prompt(context, toolchain.to_s)})
46
- session.add_message({role: :user, content: task})
47
- session.exec(Span::Type::INPUT, top_level: true, message: task) { task }
48
- end
49
-
50
- def get_llm_response
51
- session.exec(Span::Type::LLM_CALL, type: llm.model, message: session.messages.last[:content]) do
52
- result = llm.invoke(session.messages, stop: [SEQUENCES[:stop]])
53
-
54
- session.current_span.set_meta("#{result.usage.input_tokens} → #{result.usage.output_tokens} tokens")
55
- result.content
56
- end
57
- end
58
-
59
- def extract_answer(content)
60
- answer = content.split(SEQUENCES[:answer])[1]&.strip
61
- success_answer(answer)
62
- end
63
-
64
- def parse_action(content)
65
- sanitized_content = content.gsub(SEQUENCES[:stop], "")
66
- lookup_tool(sanitized_content)
67
- end
68
-
69
- def process_tool_execution(tool, argument)
70
- result = session.exec(Span::Type::TOOL_EXECUTION, { type: tool.name, message: argument }) do
71
- tool.call(argument)
72
- end
73
-
74
- session.add_message({ role: :user, content: "#{SEQUENCES[:observation]} #{result}" })
75
- end
76
-
77
36
  def answer_present?(content)
78
37
  content.include?(SEQUENCES[:answer])
79
38
  end
@@ -82,39 +41,17 @@ module Regent
82
41
  content.include?(SEQUENCES[:action])
83
42
  end
84
43
 
85
- def success_answer(content)
86
- session.exec(Span::Type::ANSWER, top_level: true,type: :success, message: content, duration: session.duration.round(2)) { content }
87
- end
88
-
89
- def error_answer(content)
90
- session.exec(Span::Type::ANSWER, top_level: true, type: :failure, message: content, duration: session.duration.round(2)) { content }
91
- end
92
-
93
- def lookup_tool(content)
94
- tool_name, argument = parse_tool_signature(content)
95
- tool = toolchain.find(tool_name)
96
-
97
- unless tool
98
- session.exec(Span::Type::ANSWER, type: :failure, message: "No matching tool found for: #{tool_name}")
99
- return [nil, nil]
100
- end
101
-
102
- [tool, argument]
44
+ def extract_answer(content)
45
+ success_answer content.split(SEQUENCES[:answer])[1]&.strip
103
46
  end
104
47
 
105
48
  def parse_tool_signature(content)
106
- action = content.split(SEQUENCES[:action])[1]&.strip
107
- return [nil, nil] unless action
108
-
109
- parts = action.split('|').map(&:strip)
110
- tool_name = parts[0].gsub(/["`']/, '')
111
- argument = parts[1].gsub(/["`']/, '')
112
-
113
- # Handle cases where argument is nil, empty, or only whitespace
114
- argument = nil if argument.nil? || argument.empty?
49
+ return [nil, nil] unless match = content.match(/Action:.*?\{.*"tool".*\}/m)
115
50
 
116
- [tool_name, argument]
117
- rescue
51
+ # Extract just the JSON part using a second regex
52
+ json = JSON.parse(match[0].match(/\{.*\}/m)[0])
53
+ [json["tool"], json["args"] || []]
54
+ rescue JSON::ParserError
118
55
  [nil, nil]
119
56
  end
120
57
  end
@@ -9,14 +9,25 @@ 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
- model: options[:model],
16
- stop_sequences: args[:stop] ? args[:stop] : nil,
14
+ model: model,
15
+ temperature: args[:temperature] || 0.0,
16
+ stop_sequences: args[:stop] || [],
17
17
  max_tokens: MAX_TOKENS
18
- })
19
- format_response(response)
18
+ }
19
+ if system_instruction = system_instruction(messages)
20
+ parameters[:system] = system_instruction
21
+ end
22
+
23
+ response = client.messages(parameters:)
24
+
25
+ result(
26
+ model: model,
27
+ content: response.dig("content", 0, "text"),
28
+ input_tokens: response.dig("usage", "input_tokens"),
29
+ output_tokens: response.dig("usage", "output_tokens")
30
+ )
20
31
  end
21
32
 
22
33
  private
@@ -32,17 +43,6 @@ module Regent
32
43
  def format_messages(messages)
33
44
  messages.reject { |message| message[:role].to_s == "system" }
34
45
  end
35
-
36
- def format_response(response)
37
- Response.new(
38
- content: response.dig("content", 0, "text"),
39
- model: options[:model],
40
- usage: Usage.new(
41
- input_tokens: response.dig("usage", "input_tokens"),
42
- output_tokens: response.dig("usage", "output_tokens")
43
- )
44
- )
45
- end
46
46
  end
47
47
  end
48
48
  end
@@ -2,55 +2,36 @@
2
2
 
3
3
  module Regent
4
4
  class LLM
5
- class Response
6
- def initialize(content:, usage:, model:)
7
- @content = content
8
- @usage = usage
9
- @model = model
10
- end
11
-
12
- attr_reader :content, :usage, :model
13
- end
14
-
15
- class Usage
16
- def initialize(input_tokens:, output_tokens:)
17
- @input_tokens = input_tokens
18
- @output_tokens = output_tokens
19
- end
20
-
21
- attr_reader :input_tokens, :output_tokens
22
- end
5
+ Result = Struct.new(:model, :content, :input_tokens, :output_tokens, keyword_init: true)
23
6
 
24
7
  class Base
25
8
  include Concerns::Dependable
26
9
 
27
- def initialize(**options)
10
+ def initialize(model:, api_key: nil, **options)
11
+ @model = model
12
+ @api_key = api_key || api_key_from_env
28
13
  @options = options
29
- api_key.nil?
30
14
 
31
15
  super()
32
16
  end
33
17
 
34
- def invoke(messages, **args)
35
- provider.chat(messages: format_messages(messages), **args)
18
+ def parse_error(error)
19
+ error.response.dig(:body, "error", "message")
36
20
  end
37
21
 
38
22
  private
39
23
 
40
- attr_reader :options, :dependency
24
+ attr_reader :model, :api_key, :options
41
25
 
42
- def format_response(response)
43
- Response.new(
44
- content: response.chat_completion,
45
- model: options[:model],
46
- usage: Usage.new(input_tokens: response.prompt_tokens, output_tokens: response.completion_tokens)
26
+ def result(model:, content:, input_tokens:, output_tokens:)
27
+ Result.new(
28
+ model: model,
29
+ content: content,
30
+ input_tokens: input_tokens,
31
+ output_tokens: output_tokens
47
32
  )
48
33
  end
49
34
 
50
- def api_key
51
- @api_key ||= options[:api_key] || api_key_from_env
52
- end
53
-
54
35
  def api_key_from_env
55
36
  ENV.fetch(self.class::ENV_KEY) do
56
37
  raise APIKeyNotFoundError, "API key not found. Make sure to set #{self.class::ENV_KEY} environment variable."
@@ -4,20 +4,37 @@ module Regent
4
4
  class LLM
5
5
  class Gemini < Base
6
6
  ENV_KEY = "GEMINI_API_KEY"
7
+ SERVICE = "generative-language-api"
7
8
 
8
9
  depends_on "gemini-ai"
9
10
 
10
11
  def invoke(messages, **args)
11
- response = client.generate_content({ contents: format_messages(messages) })
12
- format_response(response)
12
+ response = client.generate_content({
13
+ contents: format_messages(messages),
14
+ generation_config: {
15
+ temperature: args[:temperature] || 0.0,
16
+ stop_sequences: args[:stop] || []
17
+ }
18
+ })
19
+
20
+ result(
21
+ model: model,
22
+ content: response.dig("candidates", 0, "content", "parts", 0, "text").strip,
23
+ input_tokens: response.dig("usageMetadata", "promptTokenCount"),
24
+ output_tokens: response.dig("usageMetadata", "candidatesTokenCount")
25
+ )
26
+ end
27
+
28
+ def parse_error(error)
29
+ JSON.parse(error.response.dig(:body)).dig("error", "message")
13
30
  end
14
31
 
15
32
  private
16
33
 
17
34
  def client
18
35
  @client ||= ::Gemini.new(
19
- credentials: { service: 'generative-language-api', api_key: api_key },
20
- options: { model: options[:model] }
36
+ credentials: { service: SERVICE, api_key: api_key },
37
+ options: { model: model }
21
38
  )
22
39
  end
23
40
 
@@ -26,17 +43,6 @@ module Regent
26
43
  { role: message[:role].to_s == "system" ? "user" : message[:role], parts: [{ text: message[:content] }] }
27
44
  end
28
45
  end
29
-
30
- def format_response(response)
31
- Response.new(
32
- content: response.dig("candidates", 0, "content", "parts", 0, "text").strip,
33
- model: options[:model],
34
- usage: Usage.new(
35
- input_tokens: response.dig("usageMetadata", "promptTokenCount"),
36
- output_tokens: response.dig("usageMetadata", "candidatesTokenCount")
37
- )
38
- )
39
- end
40
46
  end
41
47
  end
42
48
  end
@@ -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
@@ -10,10 +10,17 @@ module Regent
10
10
  def invoke(messages, **args)
11
11
  response = client.chat(parameters: {
12
12
  messages: messages,
13
- model: options[:model],
14
- stop: args[:stop]
13
+ model: model,
14
+ temperature: args[:temperature] || 0.0,
15
+ stop: args[:stop] || []
15
16
  })
16
- format_response(response)
17
+
18
+ result(
19
+ model: model,
20
+ content: response.dig("choices", 0, "message", "content"),
21
+ input_tokens: response.dig("usage", "prompt_tokens"),
22
+ output_tokens: response.dig("usage", "completion_tokens")
23
+ )
17
24
  end
18
25
 
19
26
  private
@@ -21,17 +28,6 @@ module Regent
21
28
  def client
22
29
  @client ||= ::OpenAI::Client.new(access_token: api_key)
23
30
  end
24
-
25
- def format_response(response)
26
- Response.new(
27
- content: response.dig("choices", 0, "message", "content"),
28
- model: options[:model],
29
- usage: Usage.new(
30
- input_tokens: response.dig("usage", "prompt_tokens"),
31
- output_tokens: response.dig("usage", "completion_tokens")
32
- )
33
- )
34
- end
35
31
  end
36
32
  end
37
33
  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
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Regent
4
4
  class LLM
5
+ DEFAULT_RETRY_COUNT = 3
5
6
  PROVIDER_PATTERNS = {
6
7
  OpenAI: /^gpt-/,
7
8
  Gemini: /^gemini-/,
@@ -10,28 +11,46 @@ module Regent
10
11
 
11
12
  class ProviderNotFoundError < StandardError; end
12
13
  class APIKeyNotFoundError < StandardError; end
14
+ class ApiError < StandardError; end
13
15
 
14
- def initialize(model, **options)
15
- @model = model
16
+ def initialize(model, strict_mode: true, **options)
17
+ @strict_mode = strict_mode
16
18
  @options = options
17
- 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
18
26
  end
19
27
 
20
28
  attr_reader :model, :options
21
29
 
22
30
  def invoke(messages, **args)
31
+ retries = 0
32
+
33
+ messages = [{ role: "user", content: messages }] if messages.is_a?(String)
34
+
23
35
  provider.invoke(messages, **args)
36
+
37
+ rescue Faraday::Error, ApiError => error
38
+ if error.respond_to?(:retryable?) && error.retryable? && retries < DEFAULT_RETRY_COUNT
39
+ sleep(exponential_backoff(retries))
40
+ retry
41
+ end
42
+ handle_error(error)
24
43
  end
25
44
 
26
45
  private
27
46
 
28
- attr_reader :provider
47
+ attr_reader :provider, :strict_mode
29
48
 
30
49
  def instantiate_provider
31
50
  provider_class = find_provider_class
32
51
  raise ProviderNotFoundError, "Provider for #{model} is not found" if provider_class.nil?
33
52
 
34
- @provider ||= create_provider(provider_class)
53
+ create_provider(provider_class)
35
54
  end
36
55
 
37
56
  def find_provider_class
@@ -41,5 +60,16 @@ module Regent
41
60
  def create_provider(provider_class)
42
61
  Regent::LLM.const_get(provider_class).new(**options.merge(model: model))
43
62
  end
63
+
64
+ def handle_error(error)
65
+ message = provider.parse_error(error) || error.message
66
+ raise ApiError, message if strict_mode
67
+ Result.new(model: model, content: message, input_tokens: nil, output_tokens: nil)
68
+ end
69
+
70
+ def exponential_backoff(retry_count)
71
+ # Exponential backoff with jitter: 2^n * 100ms + random jitter
72
+ (2**retry_count * 0.1) + rand(0.1)
73
+ end
44
74
  end
45
75
  end
data/lib/regent/logger.rb CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  module Regent
4
4
  class Logger
5
- COLORS = %i[dim green yellow red blue cyan clear].freeze
5
+ COLORS = %i[dim white green yellow red blue cyan clear].freeze
6
+
7
+ class << self
8
+ def warn_and_exit(message)
9
+ warn message
10
+ exit 1
11
+ end
12
+ end
6
13
 
7
14
  def initialize(output: $stdout)
8
15
  @pastel = Pastel.new
@@ -14,7 +21,6 @@ module Regent
14
21
 
15
22
  def info(label:, message:, duration: nil, type: nil, meta: nil, top_level: false)
16
23
  current_spinner = top_level ? spinner : nested_spinner
17
-
18
24
  current_spinner.update(title: format_message(label, message, duration, type, meta))
19
25
  current_spinner
20
26
  end
@@ -47,7 +53,7 @@ module Regent
47
53
  end
48
54
 
49
55
  def spinner_symbol
50
- "#{dim("[")}#{green(":spinner")}#{dim("]")}"
56
+ "#{dim("[")}#{white(":spinner")}#{dim("]")}"
51
57
  end
52
58
 
53
59
  def build_spinner(spinner_format, output)
data/lib/regent/span.rb CHANGED
@@ -43,7 +43,8 @@ module Regent
43
43
  def run
44
44
  @output = log_operation do
45
45
  yield
46
- rescue StandardError => e
46
+
47
+ rescue StandardError, ToolError => e
47
48
  logger.error(label: type, message: e.message, **arguments)
48
49
  raise
49
50
  end
@@ -84,9 +85,13 @@ module Regent
84
85
  result = yield
85
86
 
86
87
  @end_time = live ? Time.now.freeze : @end_time
88
+ update_message_with_result(result) if type == Type::TOOL_EXECUTION
87
89
  logger.success(label: type, **({ duration: duration.round(2), meta: meta }.merge(arguments)))
88
-
89
90
  result
90
91
  end
92
+
93
+ def update_message_with_result(message)
94
+ arguments[:message] = "#{arguments[:message]} → #{message}"
95
+ end
91
96
  end
92
97
  end
data/lib/regent/tool.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Regent
4
+ class ToolError < StandardError; end
5
+
4
6
  class Tool
5
7
  def initialize(name:, description:)
6
8
  @name = name
@@ -13,6 +15,12 @@ module Regent
13
15
  raise NotImplementedError, "Tool #{name} has not implemented the execute method"
14
16
  end
15
17
 
18
+ def execute(*arguments)
19
+ call(*arguments)
20
+ rescue NotImplementedError, StandardError => e
21
+ raise ToolError, e.message
22
+ end
23
+
16
24
  def to_s
17
25
  "#{name} - #{description}"
18
26
  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.1"
4
+ VERSION = "0.3.3"
5
5
  end
data/lib/regent.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require 'faraday'
5
+ require 'json'
4
6
  require 'pastel'
5
7
  require 'tty-spinner'
6
8
  require 'zeitwerk'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: regent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Chaplinsky
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-12-29 00:00:00.000000000 Z
11
+ date: 2025-02-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -71,13 +71,17 @@ files:
71
71
  - lib/regent/concerns/dependable.rb
72
72
  - lib/regent/concerns/durationable.rb
73
73
  - lib/regent/concerns/identifiable.rb
74
+ - lib/regent/concerns/toolable.rb
75
+ - lib/regent/engine/base.rb
74
76
  - lib/regent/engine/react.rb
75
77
  - lib/regent/engine/react/prompt_template.rb
76
78
  - lib/regent/llm.rb
77
79
  - lib/regent/llm/anthropic.rb
78
80
  - lib/regent/llm/base.rb
79
81
  - lib/regent/llm/gemini.rb
82
+ - lib/regent/llm/ollama.rb
80
83
  - lib/regent/llm/open_ai.rb
84
+ - lib/regent/llm/open_router.rb
81
85
  - lib/regent/logger.rb
82
86
  - lib/regent/session.rb
83
87
  - lib/regent/span.rb