regent 0.3.0 → 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +44 -30
- data/lib/regent/agent.rb +12 -7
- data/lib/regent/concerns/dependable.rb +5 -4
- data/lib/regent/engine/base.rb +64 -0
- data/lib/regent/engine/react/prompt_template.rb +3 -3
- data/lib/regent/engine/react.rb +18 -81
- data/lib/regent/llm/anthropic.rb +10 -14
- data/lib/regent/llm/base.rb +13 -32
- data/lib/regent/llm/gemini.rb +21 -15
- data/lib/regent/llm/open_ai.rb +10 -14
- data/lib/regent/llm.rb +25 -3
- data/lib/regent/logger.rb +14 -8
- data/lib/regent/span.rb +2 -1
- data/lib/regent/tool.rb +8 -0
- data/lib/regent/version.rb +1 -1
- data/lib/regent.rb +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a1c3bc1771d81f732f941e27dfc393ef56b6f6c42d6c448385202f065dfc5811
|
4
|
+
data.tar.gz: 8950e15664e538beffc54d5e0e92da76bc8390e7ddd20bc923914ad14d3613e3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3d6a447a3df128617b5ede55412752a331f6edcb556039ea09472357b258803889722331408baf1e7b8add8111c5addbf58016a6799b3d2d98d608a88b501e39
|
7
|
+
data.tar.gz: f496a6abdd8646342bb0d2fd34b59893d9770e74d7325d1fdf79d3d0d33751765b3dfd17a2dacf4636fd045edbc0c9661fadb143df24be206a281e6b7b2a356e
|
data/README.md
CHANGED
@@ -1,19 +1,38 @@
|
|
1
1
|
![regent_light](https://github.com/user-attachments/assets/62564dac-b8d7-4dc0-9b63-64c6841b5872)
|
2
2
|
|
3
|
+
<div align="center">
|
4
|
+
|
3
5
|
# Regent
|
4
6
|
|
5
|
-
|
7
|
+
[![Gem Version](https://badge.fury.io/rb/regent.svg)](https://badge.fury.io/rb/regent)
|
8
|
+
[![Build](https://github.com/alchaplinsky/regent/actions/workflows/main.yml/badge.svg)](https://github.com/alchaplinsky/regent/actions/workflows/main.yml)
|
9
|
+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
|
10
|
+
|
11
|
+
</div>
|
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.
|
6
14
|
|
7
15
|
> [!WARNING]
|
8
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.
|
9
17
|
|
18
|
+
## Key Features
|
19
|
+
|
20
|
+
- **ReAct Pattern Implementation**: Agents follow the Reasoning-Action pattern, making decisions through a clear thought process before taking actions
|
21
|
+
- **Multi-LLM Support**: Seamlessly works with:
|
22
|
+
- OpenAI (GPT models)
|
23
|
+
- Anthropic (Claude models)
|
24
|
+
- Google (Gemini models)
|
25
|
+
- **Extensible Tool System**: Create custom tools that agents can use to interact with external services, APIs, or perform specific tasks
|
26
|
+
- **Built-in Tracing**: Every agent interaction is traced and can be replayed, making debugging and monitoring straightforward
|
27
|
+
- **Clean Ruby Interface**: Designed to feel natural to Ruby developers while maintaining powerful capabilities
|
28
|
+
|
10
29
|
## Showcase
|
11
30
|
|
12
31
|
A basic Regnt Agent extended with a `price_tool` that allows for retrieving cryptocurrency prices from coingecko.com.
|
13
32
|
|
14
33
|
![screencast 2024-12-25 21-53-47](https://github.com/user-attachments/assets/4e65b731-bbd7-4732-b157-b705d35a7824)
|
15
34
|
|
16
|
-
##
|
35
|
+
## Quick Start
|
17
36
|
|
18
37
|
```bash
|
19
38
|
gem install regent
|
@@ -31,47 +50,42 @@ and run
|
|
31
50
|
bundle install
|
32
51
|
```
|
33
52
|
|
34
|
-
## Available LLMs
|
35
|
-
|
36
|
-
Regent currently supports LLMs from the following providers:
|
37
|
-
|
38
|
-
| Provider | Models | Supported |
|
39
|
-
| ------------- | :--------------------: | :-------: |
|
40
|
-
| OpenAI | `gpt-` based models | ✅ |
|
41
|
-
| Anthropic | `claude-` based models | ✅ |
|
42
|
-
| Google Gemini | `gemini-` based models | ✅ |
|
43
|
-
|
44
53
|
## Usage
|
45
54
|
|
46
|
-
|
55
|
+
Create your first agent:
|
47
56
|
|
48
57
|
```ruby
|
49
|
-
|
50
|
-
|
58
|
+
# Initialize the LLM
|
59
|
+
model = Regent::LLM.new("gpt-4o")
|
51
60
|
|
52
|
-
|
53
|
-
|
54
|
-
```ruby
|
61
|
+
# Create a custom tool
|
55
62
|
class WeatherTool < Regent::Tool
|
56
63
|
def call(location)
|
57
|
-
#
|
64
|
+
# Implement weather lookup logic
|
65
|
+
"Currently 72°F and sunny in #{location}"
|
58
66
|
end
|
59
67
|
end
|
60
68
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
+
)
|
78
|
+
|
79
|
+
# Execute a query
|
80
|
+
agent.run("What's the weather like in Tokyo?") # => "It is currently 72°F and sunny in Tokyo."
|
68
81
|
```
|
69
82
|
|
70
|
-
|
83
|
+
## Why Regent?
|
71
84
|
|
72
|
-
|
73
|
-
|
74
|
-
|
85
|
+
- **Transparent Decision Making**: Watch your agent's thought process as it reasons through problems
|
86
|
+
- **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
|
+
- **Ruby-First Design**: Takes advantage of Ruby's elegant syntax and conventions
|
75
89
|
|
76
90
|
## Development
|
77
91
|
|
data/lib/regent/agent.rb
CHANGED
@@ -6,23 +6,24 @@ module Regent
|
|
6
6
|
|
7
7
|
DEFAULT_MAX_ITERATIONS = 10
|
8
8
|
|
9
|
-
def initialize(context,
|
9
|
+
def initialize(context, model:, tools: [], engine: Regent::Engine::React, **options)
|
10
10
|
super()
|
11
11
|
|
12
12
|
@context = context
|
13
|
-
@
|
13
|
+
@model = model
|
14
|
+
@engine = engine
|
14
15
|
@sessions = []
|
15
16
|
@tools = tools.is_a?(Toolchain) ? tools : Toolchain.new(Array(tools))
|
16
17
|
@max_iterations = options[:max_iterations] || DEFAULT_MAX_ITERATIONS
|
17
18
|
end
|
18
19
|
|
19
|
-
attr_reader :context, :sessions, :
|
20
|
+
attr_reader :context, :sessions, :model, :tools
|
20
21
|
|
21
|
-
def
|
22
|
+
def run(task)
|
22
23
|
raise ArgumentError, "Task cannot be empty" if task.to_s.strip.empty?
|
23
24
|
|
24
25
|
start_session
|
25
|
-
|
26
|
+
reason(task)
|
26
27
|
ensure
|
27
28
|
complete_session
|
28
29
|
end
|
@@ -37,6 +38,10 @@ module Regent
|
|
37
38
|
|
38
39
|
private
|
39
40
|
|
41
|
+
def reason(task)
|
42
|
+
engine.reason(task)
|
43
|
+
end
|
44
|
+
|
40
45
|
def start_session
|
41
46
|
complete_session
|
42
47
|
@sessions << Session.new
|
@@ -47,8 +52,8 @@ module Regent
|
|
47
52
|
session&.complete if running?
|
48
53
|
end
|
49
54
|
|
50
|
-
def
|
51
|
-
|
55
|
+
def engine
|
56
|
+
@engine.new(context, model, tools, session, @max_iterations)
|
52
57
|
end
|
53
58
|
end
|
54
59
|
end
|
@@ -25,7 +25,7 @@ module Regent
|
|
25
25
|
|
26
26
|
super()
|
27
27
|
rescue Gem::LoadError
|
28
|
-
warn_and_exit(dependency,
|
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
|
67
|
-
|
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,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
|
@@ -11,9 +11,9 @@ module Regent
|
|
11
11
|
You are an AI agent reasoning step-by-step to solve complex problems.
|
12
12
|
Your reasoning process happens in a loop of Thought, Action, Observation.
|
13
13
|
Thought - a description of your thoughts about the question.
|
14
|
-
Action - pick a an action from available tools. If there are no tools that can help return an Answer saying you are not able to help.
|
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 -
|
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
|
24
|
+
Action: {"tool": "weather_tool", "args": ["London"]}
|
25
25
|
PAUSE
|
26
26
|
|
27
27
|
You will have a response form a user with Observation:
|
data/lib/regent/engine/react.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
27
|
-
content =
|
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
|
-
|
25
|
+
tool_name, arguments = parse_tool_signature(content)
|
26
|
+
tool = find_tool(tool_name)
|
33
27
|
return unless tool
|
34
|
-
|
35
|
-
|
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, 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
|
86
|
-
|
87
|
-
end
|
88
|
-
|
89
|
-
def error_answer(content)
|
90
|
-
session.exec(Span::Type::ANSWER, 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
|
-
|
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
|
-
|
117
|
-
|
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
|
data/lib/regent/llm/anthropic.rb
CHANGED
@@ -12,11 +12,18 @@ module Regent
|
|
12
12
|
response = client.messages(parameters: {
|
13
13
|
messages: format_messages(messages),
|
14
14
|
system: system_instruction(messages),
|
15
|
-
model:
|
16
|
-
|
15
|
+
model: model,
|
16
|
+
temperature: args[:temperature] || 0.0,
|
17
|
+
stop_sequences: args[:stop] || [],
|
17
18
|
max_tokens: MAX_TOKENS
|
18
19
|
})
|
19
|
-
|
20
|
+
|
21
|
+
result(
|
22
|
+
model: model,
|
23
|
+
content: response.dig("content", 0, "text"),
|
24
|
+
input_tokens: response.dig("usage", "input_tokens"),
|
25
|
+
output_tokens: response.dig("usage", "output_tokens")
|
26
|
+
)
|
20
27
|
end
|
21
28
|
|
22
29
|
private
|
@@ -32,17 +39,6 @@ module Regent
|
|
32
39
|
def format_messages(messages)
|
33
40
|
messages.reject { |message| message[:role].to_s == "system" }
|
34
41
|
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
42
|
end
|
47
43
|
end
|
48
44
|
end
|
data/lib/regent/llm/base.rb
CHANGED
@@ -2,55 +2,36 @@
|
|
2
2
|
|
3
3
|
module Regent
|
4
4
|
class LLM
|
5
|
-
|
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
|
35
|
-
|
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 :
|
24
|
+
attr_reader :model, :api_key, :options
|
41
25
|
|
42
|
-
def
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
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."
|
data/lib/regent/llm/gemini.rb
CHANGED
@@ -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({
|
12
|
-
|
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:
|
20
|
-
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
|
data/lib/regent/llm/open_ai.rb
CHANGED
@@ -10,10 +10,17 @@ module Regent
|
|
10
10
|
def invoke(messages, **args)
|
11
11
|
response = client.chat(parameters: {
|
12
12
|
messages: messages,
|
13
|
-
model:
|
14
|
-
|
13
|
+
model: model,
|
14
|
+
temperature: args[:temperature] || 0.0,
|
15
|
+
stop: args[:stop] || []
|
15
16
|
})
|
16
|
-
|
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
|
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,9 +11,11 @@ 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)
|
16
|
+
def initialize(model, strict_mode: true, **options)
|
15
17
|
@model = model
|
18
|
+
@strict_mode = strict_mode
|
16
19
|
@options = options
|
17
20
|
instantiate_provider
|
18
21
|
end
|
@@ -20,12 +23,20 @@ module Regent
|
|
20
23
|
attr_reader :model, :options
|
21
24
|
|
22
25
|
def invoke(messages, **args)
|
23
|
-
|
26
|
+
retries = 0
|
27
|
+
provider.invoke(messages, **args)
|
28
|
+
|
29
|
+
rescue Faraday::Error => error
|
30
|
+
if error.respond_to?(:retryable?) && error.retryable? && retries < DEFAULT_RETRY_COUNT
|
31
|
+
sleep(exponential_backoff(retries))
|
32
|
+
retry
|
33
|
+
end
|
34
|
+
handle_error(error)
|
24
35
|
end
|
25
36
|
|
26
37
|
private
|
27
38
|
|
28
|
-
attr_reader :provider
|
39
|
+
attr_reader :provider, :strict_mode
|
29
40
|
|
30
41
|
def instantiate_provider
|
31
42
|
provider_class = find_provider_class
|
@@ -41,5 +52,16 @@ module Regent
|
|
41
52
|
def create_provider(provider_class)
|
42
53
|
Regent::LLM.const_get(provider_class).new(**options.merge(model: model))
|
43
54
|
end
|
55
|
+
|
56
|
+
def handle_error(error)
|
57
|
+
message = provider.parse_error(error) || error.message
|
58
|
+
raise ApiError, message if strict_mode
|
59
|
+
Result.new(model: model, content: message, input_tokens: nil, output_tokens: nil)
|
60
|
+
end
|
61
|
+
|
62
|
+
def exponential_backoff(retry_count)
|
63
|
+
# Exponential backoff with jitter: 2^n * 100ms + random jitter
|
64
|
+
(2**retry_count * 0.1) + rand(0.1)
|
65
|
+
end
|
44
66
|
end
|
45
67
|
end
|
data/lib/regent/logger.rb
CHANGED
@@ -2,19 +2,25 @@
|
|
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
6
|
|
7
|
-
|
7
|
+
class << self
|
8
|
+
def warn_and_exit(message)
|
9
|
+
warn message
|
10
|
+
exit 1
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(output: $stdout)
|
8
15
|
@pastel = Pastel.new
|
9
|
-
@spinner = build_spinner(spinner_symbol)
|
10
|
-
@nested_spinner = build_spinner("#{dim(" ├──")}#{spinner_symbol}")
|
16
|
+
@spinner = build_spinner(spinner_symbol, output)
|
17
|
+
@nested_spinner = build_spinner("#{dim(" ├──")}#{spinner_symbol}", output)
|
11
18
|
end
|
12
19
|
|
13
20
|
attr_reader :spinner, :nested_spinner
|
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,11 +53,11 @@ module Regent
|
|
47
53
|
end
|
48
54
|
|
49
55
|
def spinner_symbol
|
50
|
-
"#{dim("[")}#{
|
56
|
+
"#{dim("[")}#{white(":spinner")}#{dim("]")}"
|
51
57
|
end
|
52
58
|
|
53
|
-
def build_spinner(spinner_format)
|
54
|
-
TTY::Spinner.new("#{spinner_format} :title", format: :dots)
|
59
|
+
def build_spinner(spinner_format, output)
|
60
|
+
TTY::Spinner.new("#{spinner_format} :title", format: :dots, output: output)
|
55
61
|
end
|
56
62
|
|
57
63
|
COLORS.each do |color|
|
data/lib/regent/span.rb
CHANGED
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
|
data/lib/regent/version.rb
CHANGED
data/lib/regent.rb
CHANGED
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.
|
4
|
+
version: 0.3.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Alex Chaplinsky
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2025-01-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: zeitwerk
|
@@ -71,6 +71,7 @@ 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/engine/base.rb
|
74
75
|
- lib/regent/engine/react.rb
|
75
76
|
- lib/regent/engine/react/prompt_template.rb
|
76
77
|
- lib/regent/llm.rb
|