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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5ca2748be57e491dbf33208cfaf09a8009716daf365845dac771c083c5de6d4
4
- data.tar.gz: 9bcb3ca59f00555aeffdfe27f53cf10bbfb32e0789e00eedd07cc1e6db0d7f0c
3
+ metadata.gz: a1c3bc1771d81f732f941e27dfc393ef56b6f6c42d6c448385202f065dfc5811
4
+ data.tar.gz: 8950e15664e538beffc54d5e0e92da76bc8390e7ddd20bc923914ad14d3613e3
5
5
  SHA512:
6
- metadata.gz: 3f9505c05aea8978afb583224616b4cc1851abad8e484d764be40833269adc4f57eab5d1437f4dfd3b47282a449a288048dcf21121a5d6d1b1361a918c6173d5
7
- data.tar.gz: aaa3d15807126bb4cf4a4d7e27f8b52ab550b5851157c6a5a988fbbe522505a63ee4ce3f7b35487b8826bc52a431d34ba55818e5d167bc454f8f1f1fffeb5aef
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
- **Regent** is library for building AI agents with Ruby.
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
- ## Install
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
- In order to operate an agent needs access to LLM (large language model). Regent provides a simple interface for interacting with LLMs. You can create an instance of any LLM provider by passing the model name to the `Regent::LLM.new` method:
55
+ Create your first agent:
47
56
 
48
57
  ```ruby
49
- llm = Regent::LLM.new("gpt-4o-mini")
50
- ```
58
+ # Initialize the LLM
59
+ model = Regent::LLM.new("gpt-4o")
51
60
 
52
- Agents are effective when they have tools that enable them to get new information:
53
-
54
- ```ruby
61
+ # Create a custom tool
55
62
  class WeatherTool < Regent::Tool
56
63
  def call(location)
57
- # implementation of a call to weather API
64
+ # Implement weather lookup logic
65
+ "Currently 72°F and sunny in #{location}"
58
66
  end
59
67
  end
60
68
 
61
- weather_tool = WeatherTool.new(name: "weather_tool", description: "Get the weather in a given location")
62
- ```
63
-
64
- Next, let's instantiate an agent passing agent's statement, LLM and a set of tools:
65
-
66
- ```ruby
67
- agent = Regent::Agent.new("You are a weather AI agent", llm: llm, tools: [weather_tool])
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
- Simply run an execute function, passing your query as an argument
83
+ ## Why Regent?
71
84
 
72
- ```ruby
73
- agent.execute("What is the weather in London today?")
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, llm:, tools: [], **options)
9
+ def initialize(context, model:, tools: [], engine: Regent::Engine::React, **options)
10
10
  super()
11
11
 
12
12
  @context = context
13
- @llm = llm
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, :llm, :tools
20
+ attr_reader :context, :sessions, :model, :tools
20
21
 
21
- def execute(task)
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
- react.reason(task)
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 react
51
- Regent::Engine::React.new(context, llm, tools, session, @max_iterations)
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, 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,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 - 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, 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, type: :success, message: content, duration: session.duration.round(2)) { content }
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
- 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
@@ -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: options[:model],
16
- stop_sequences: args[:stop] ? args[:stop] : nil,
15
+ model: model,
16
+ temperature: args[:temperature] || 0.0,
17
+ stop_sequences: args[:stop] || [],
17
18
  max_tokens: MAX_TOKENS
18
19
  })
19
- format_response(response)
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
@@ -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
@@ -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
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
- response = provider.invoke(messages, **args)
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
- def initialize
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("[")}#{green(":spinner")}#{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
@@ -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
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Regent
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
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.0
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: 2024-12-28 00:00:00.000000000 Z
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