Back to Publications
Article

Building ReAct-based AI Agent from Scratch in Ruby programming language

Alex Chaplinsky

Alex Chaplinsky

FWD AI

Jan 8, 202515 min read12 citationsMedium
ReAct
Ruby
AI Agent
agent architecture

Abstract

This article explains how to design and implement a simple AI agent architecture in Ruby, focusing on core concepts like perception, reasoning, decision-making, and action rather than just code. Using Ruby's clarity, it demonstrates how modular components come together to form a goal-driven agent that interacts with its environment in a structured, understandable way.

Building a ReAct agent from scratch in Ruby

This is not primarily an article about code. It's about the architecture and the patterns underneath AI agent development — the code is there to make them concrete. Ruby is the medium because its syntax gets out of the way: the reader's attention stays on the structure rather than on the language. The patterns transfer to any runtime.

What an AI agent is

An AI agent perceives its environment, reasons through a problem, makes decisions, and takes actions toward a goal. That definition is easy to state and does very little work, because "agentic AI system" has no settled meaning. Some definitions emphasize autonomy and decision-making; others foreground collaboration, adaptability, or a particular technical implementation.

The term spans a wide range of approaches and architectures. Rule-based systems run on predefined logic. Machine-learning-based agents adapt their behavior from training data. Published architectures run from single-agent frameworks to multi-agent systems. This article takes the simplest useful point on that spectrum: the ReAct (Reasoning and Action) architecture, implemented from scratch.

Agent architecture

The architecture of a first agent is simple, and it is common to most agentic systems. An Agent sits at the center. It holds a set of tools and an LLM, takes in information about its environment, reasons, and decides. Every action it takes is collected in a session, and individual operations within that session are stored as spans.

Basic agent architecture Figure 1. Agent, tools, LLM, and the session/span record of execution.

The reasoning mechanism inside the Agent is ReAct.

ReAct was introduced in October 2022, early in the LLM-agent era, by this paper. It closes the gap between reasoning and action. Where traditional systems separate planning from execution, ReAct interleaves them: the agent generates a thought, takes a task-specific action, observes the result, and continues. The consequence is that the plan is revisable mid-execution rather than fixed up front.

In practice this means the agent produces explicit reasoning traces, updates its action plan as it goes, handles situations that weren't anticipated at design time, and pulls information from external sources such as APIs or databases. Each decision is both recorded and actionable — which matters as much for debugging as it does for capability.

What follows is not a one-off agent but the foundation of a mini-framework (a Ruby gem) for implementing agents in Ruby applications. The design goal is modularity: components should be replaceable. Swapping the "engine" swaps the entire reasoning architecture and leaves the rest of the system intact. The point of building it by hand is that every piece stays legible.

The Agent

The Agent class is the entry point to the framework. It represents an entity that can be given a task, and it owns the lifecycle of the interaction.

High-level agent logic is deliberately separated from the low-level implementation of the loop that drives it. The Agent class should be usable regardless of the underlying architecture, and that architecture should be swappable with minimal effort. At its core an Agent is a generic entity combining a large language model and a collection of tools, both supplied at initialization.

It also maintains a list of sessions, since one agent instance may be invoked repeatedly with different tasks. Infinite loops are a standard failure mode of LLM-based agents, so an iteration limit is set at initialization, defaulting to 10.

  • execute: Accepts a task — a string representing a command or question from the user. This method initiates a session and forwards the task to the agent's engine.

  • running?: Checks if the last session is still in progress. This design supports sequential session execution. When parallel task execution is supported, this will evolve to check whether any session is running.

  • session: A getter that retrieves the last session.

class Agent
  include Concerns::Identifiable

  DEFAULT_MAX_ITERATIONS = 10

  def initialize(model:, tools: [], **options)
    super()
    @model = model
    @sessions = []
    @tools = tools.is_a?(Toolchain) ? tools : Toolchain.new(Array(tools))
    @max_iterations = options[:max_iterations] || DEFAULT_MAX_ITERATIONS
  end

  attr_reader :sessions, :model, :tools

  def execute(task)
    raise ArgumentError, "Task cannot be empty" if task.to_s.strip.empty?
    start_session
    react.reason(task)
  ensure
    complete_session
  end

  def running?
    session&.active? || false
  end

  def session
    @sessions.last
  end

  private

  def start_session
    complete_session
    @sessions << Session.new
    session.start
  end

  def complete_session
    session&.complete if running?
  end

  def react
    Regent::Engine::React.new(model, tools, session, @max_iterations)
  end
end

Session

A Session represents a single interaction cycle, from receiving a task to delivering the final result. It is the agent's execution record: every step taken in service of the task is captured there. Sessions carry context, make performance analyzable, and make the agent's behavior over time inspectable rather than opaque.

Tracking actions with spans. Each session holds an array of spans — discrete operations performed during the session. Spans give a granular view of the workflow. The session records its own start and end times, which makes duration analysis possible. The key methods:

  • start: Records the start time of the session.

  • exec: Captures the execution of a single operation, adding it as a span, and returns the operation's output.

  • complete: Marks the end of the session, logs the end time, and returns the final output by reading the last span's output.

  • active?: Determines whether the session is still running by checking for a start time without an associated end time.

  • result: Retrieves the final output of the session.

  • replay: Re-emits the recorded spans without re-executing them, which is what makes a completed run reviewable after the fact.

Communication history. The messages attribute tracks the dialogue between the agent and the LLM, producing a full record of the interaction. This is what maintains context, and it is what makes the run debuggable after the fact.

Ownership. Sessions are created and held by the agent, and the engine receives the live session at construction. Everything the engine does — every model call, every tool execution — is written into that session rather than into the engine's own state. The session, not the agent, is the unit that carries a run's history.

By encapsulating the full lifecycle of a task execution, the Session class gives a structured and modular way to manage interactions. Whether the concern is performance tracking or debugging a workflow that went wrong, this is the layer that makes either possible.

class Session
  include Concerns::Identifiable
  include Concerns::Durationable

  class SessionError < StandardError; end
  class InactiveSessionError < SessionError; end
  class AlreadyStartedError < SessionError; end

  def initialize
    super()
    @spans = []
    @messages = []
    @start_time = nil
    @end_time = nil
  end

  attr_reader :id, :spans, :messages, :start_time, :end_time

  # Starts the session
  # @raise [AlreadyStartedError] if session is already started
  # @return [void]
  def start
    raise AlreadyStartedError, "Session already started" if @start_time
    @start_time = Time.now.freeze
  end

  # Executes a new span in the session
  # @param type [String] The type of span
  # @param options [Hash] Options for the span
  # @raise [InactiveSessionError] if session is not active
  # @return [String] The output of the span
  def exec(type, options = {}, &block)
    raise InactiveSessionError, "Cannot execute span in inactive session" unless active?
    @spans << Span.new(type: type, arguments: options)
    current_span.run(&block)
  end

  # Replays the recorded spans without re-executing them
  # @return [String] The result of the session
  def replay
    spans.each { |span| span.replay }
    result
  end

  # Completes the session and returns the result
  # @raise [InactiveSessionError] if session is not active
  # @return [String, nil] The output of the last span
  def complete
    raise InactiveSessionError, "Cannot complete inactive session" unless active?
    @end_time = Time.now.freeze
    result
  end

  # @return [Span, nil] The current span or nil if no spans exist
  def current_span
    @spans.last
  end

  # @return [String, nil] The output of the current span or nil if no spans exist
  def result
    current_span&.output
  end

  # @return [Boolean] Whether the session is currently active
  def active?
    start_time && end_time.nil?
  end

  # Adds a message to the session
  # @param message [Hash] The message to add
  # @raise [ArgumentError] if message is nil or empty
  def add_message(message)
    raise ArgumentError, "Message cannot be nil or empty" if message.nil? || message.empty?
    @messages << message
  end
end

Span

A Span is the smallest unit of work performed during a session — a single step in the execution of a task. Spans are what make the agent's behavior observable at every stage rather than only at the end.

The Span class tracks individual operations within a session. Each span has a type attribute defining the kind of operation it represents. The common types:

  • INPUT: Receiving input from the user or another system.

  • LLM_CALL: Communicating with the large language model.

  • TOOL_EXECUTION: Executing a specific tool or function.

  • MEMORY_ACCESS: Accessing or updating stored information.

  • ANSWER: Producing the final output for the user.

Spans record the start and end times of the operation, which makes execution time analyzable. They store the arguments passed and the resulting output, so each step is reconstructable in full.

  • run: Executes the given block, records timing, captures the output, and logs the operation. Errors are logged with the span's type and arguments, then re-raised — the span records the failure without swallowing it.

  • running? and completed?: Indicate whether a span is in progress or has finished.

  • replay: Re-emits the log for a completed span using its stored output, without executing anything.

The type is deliberately metadata rather than behavior. A span doesn't know how to call an LLM or run a tool; it wraps whatever block it's handed and records what happened. That is what keeps one class sufficient for every kind of step, and what makes adding a new step type a matter of adding a constant rather than a subclass.

Breaking the agent's actions into atomic units is what makes it possible to:

  • Identify bottlenecks in execution.

  • Analyze the agent's decision-making process.

  • Debug interactions against a record rather than a guess.

class Span
  include Concerns::Identifiable
  include Concerns::Durationable

  class InvalidSpanType < StandardError; end

  module Type
    INPUT = 'INPUT'.freeze
    LLM_CALL = 'LLM'.freeze
    TOOL_EXECUTION = 'TOOL'.freeze
    MEMORY_ACCESS = 'MEMO'.freeze
    ANSWER = 'ANSWER'.freeze
    def self.all
      constants.map { |c| const_get(c) }
    end
    def self.valid?(type)
      all.include?(type)
    end
  end

  # @param type [String] The type of span (must be one of Type.all)
  # @param arguments [Hash] Arguments for the span
  # @param logger [Logger] Logger instance
  def initialize(type:, arguments:, logger: Logger.new)
    super()
    validate_type!(type)
    @logger = logger
    @type = type
    @arguments = arguments
    @meta = nil
    @output = nil
  end

  attr_reader :arguments, :output, :type, :start_time, :end_time

  # @raise [ArgumentError] if block is not given
  # @return [String] The output of the span
  def run
    raise ArgumentError, "Span requires a block to execute" unless block_given?

    @output = log_operation do
      yield
    rescue StandardError => e
      logger.error(label: type, message: e.message, **arguments)
      raise
    end
  end

  # @return [String] The output of the span
  def replay
    log_operation(live: false) { @output }
  end

  # @return [Boolean] Whether the span is currently running
  def running?
    @start_time && @end_time.nil?
  end

  # @return [Boolean] Whether the span is completed
  def completed?
    @start_time && @end_time
  end

  # @param value [String] The meta value to set
  def set_meta(value)
    @meta = value.freeze
  end

  private

  attr_reader :logger, :meta

  def validate_type!(type)
    raise InvalidSpanType, "Invalid span type: #{type}" unless Type.valid?(type)
  end

  def log_operation(live: true, &block)
    @start_time = live ? Time.now.freeze : @start_time
    logger.start(label: type, **arguments)
    result = yield
    @end_time = live ? Time.now.freeze : @end_time
    logger.success(label: type, **({ duration: duration.round(2), meta: meta }.merge(arguments)))
    result
  end
end

Tools

Tools are how an agent reaches past its own logic to do specialized work. The Tool class represents a single external capability; the Toolchain manages the collection available to the agent.

class Tool
  def initialize(name:, description:)
    @name = name
    @description = description
  end

  attr_reader :name, :description

  def call(argument)
    raise NotImplementedError, "Tool #{name} has not implemented the call method"
  end

  def to_s
    "#{name} - #{description}"
  end
end
class Toolchain
  def initialize(tools)
    @tools = tools
  end

  attr_reader :tools

  def find(name)
    return nil if name.nil?

    tools.find { |tool| tool.name.downcase == name.downcase }
  end

  def to_s
    tools.map(&:to_s).join("\n")
  end
end

Decoupling specialized tasks into tools keeps the core agent logic stable. An agent adapts to a different scenario by updating the toolchain, not by changing the agent.

For example:

  • A chatbot agent could include tools for fetching weather updates, retrieving database records, or generating reports.

  • Tools can be added, removed, or replaced as requirements change, without touching the reasoning loop.

Tool and Toolchain are a small amount of machinery for a large amount of range: what the agent can do becomes a configuration decision rather than an architectural one.

The Engine

The Engine orchestrates reasoning, acting, and observing. Keeping it separate from the Agent is what allows:

  • Experimenting with different architectures without altering the rest of the framework.

  • Debugging interactions through sessions and spans.

  • Extending functionality by integrating custom tools or modifying the reasoning loop.

The default implementation, Regent::Engine::React, follows the ReAct pattern, but the design is open: a different class can be swapped in to explore another agent architecture while keeping the same API, replayability, and debuggability through sessions and spans.

The reason method drives the engine's workflow. It:

  • Takes the user's task as input.

  • Pushes user input to the session and initializes message history with the system prompt and the user message.

  • Enters a loop where it reasons, acts, and observes — using an LLM to generate answers, execute tools, and produce the final response.

This method is the interface every engine must implement to serve as a replacement engine and extend the framework.

Two failure modes get handled explicitly rather than left to chance, because both are things a model does routinely: naming a tool that doesn't exist, and emitting an action with no argument. Each terminates the session with a recorded failure answer instead of propagating a NoMethodError out of the loop. A parser for model output is a parser for untrusted input, and it should say what it rejected.

module Engine
  class React
    SEQUENCES = {
      answer: "Answer:",
      action: "Action:",
      observation: "Observation:",
      stop: "PAUSE"
    }.freeze

    def initialize(llm, toolchain, session, max_iterations)
      @llm = llm
      @toolchain = toolchain
      @session = session
      @max_iterations = max_iterations
    end

    attr_reader :llm, :toolchain, :session, :max_iterations

    def reason(task)
      initialize_session(task)

      max_iterations.times do
        content = get_llm_response
        session.add_message({ role: :assistant, content: content })
        return extract_answer(content) if answer_present?(content)

        if action_present?(content)
          tool, argument = parse_action(content)
          return session.result unless tool

          process_tool_execution(tool, argument)
        end
      end

      error_answer("Max iterations reached without finding an answer.")
    end

    private

    def initialize_session(task)
      session.add_message({ role: :system, content: Regent::Engine::React::PromptTemplate.system_prompt(toolchain.to_s) })
      session.add_message({ role: :user, content: task })
      session.exec(Span::Type::INPUT, message: task) { task }
    end

    def get_llm_response
      session.exec(Span::Type::LLM_CALL, type: llm.model, message: session.messages.last[:content]) do
        result = llm.invoke(session.messages, stop: [SEQUENCES[:stop]])

        session.current_span.set_meta("#{result.usage.input_tokens} → #{result.usage.output_tokens} tokens")
        result.content
      end
    end

    def extract_answer(content)
      answer = content.split(SEQUENCES[:answer])[1]&.strip
      success_answer(answer)
    end

    def parse_action(content)
      sanitized_content = content.gsub(SEQUENCES[:stop], "")
      lookup_tool(sanitized_content)
    end

    def process_tool_execution(tool, argument)
      result = session.exec(Span::Type::TOOL_EXECUTION, { type: tool.name, message: argument }) do
        tool.call(argument)
      end

      session.add_message({ role: :user, content: "#{SEQUENCES[:observation]} #{result}" })
    end

    def answer_present?(content)
      content.include?(SEQUENCES[:answer])
    end

    def action_present?(content)
      content.include?(SEQUENCES[:action])
    end

    def success_answer(content)
      session.exec(Span::Type::ANSWER, type: :success, message: content, duration: session.duration.round(2)) { content }
    end

    def error_answer(content)
      session.exec(Span::Type::ANSWER, type: :failure, message: content, duration: session.duration.round(2)) { content }
    end

    def lookup_tool(content)
      tool_name, argument = parse_tool_signature(content)
      tool = toolchain.find(tool_name)

      unless tool
        error_answer("No matching tool found for: #{tool_name}")
        return [nil, nil]
      end

      if argument.nil?
        error_answer("No argument provided for tool: #{tool_name}")
        return [nil, nil]
      end

      [tool, argument]
    end

    def parse_tool_signature(content)
      action = content.split(SEQUENCES[:action])[1]&.strip
      return [nil, nil] unless action

      tool_name, argument = action.split('|', 2).map(&:strip)
      argument = argument&.gsub('"', '')

      # Handle cases where argument is nil, empty, or only whitespace
      argument = nil if argument.nil? || argument.empty?

      [tool_name, argument]
    end
  end
end

A loop alone doesn't produce decisions. The component that turns it into an agent is the system prompt: the instructions that make the LLM behave in a way the loop can act on. The React engine uses a prompt template that:

  • Provides the LLM with explicit instructions on how to behave.

  • Includes examples of interactions between the user, LLM, and tools, which is what holds the output format stable enough to parse.

module Engine
  class React
    module PromptTemplate
      def self.system_prompt(tool_names)
        <<~PROMPT
          You are an assistant reasoning step-by-step to solve complex problems.
          Your reasoning process happens in a loop of Thought, Action, Observation.
          Thought - a description of your thoughts about the question.
          Action - pick an action from available tools. If there are no tools that can help, return an Answer saying you are not able to help.
          Observation - is the result of running a tool.

          ## Available tools:
          #{tool_names}

          ## Example session
          Question: What is the weather in London today?
          Thought: I need to get the weather in London
          Action: weather_tool | "London"
          PAUSE

          You will have a response with Observation:
          Observation: It is 32 degrees and Sunny

          ... (this Thought/Action/Observation can repeat N times)

          Thought: I know the final answer
          Answer: It is 32 degrees and Sunny in London
        PROMPT
      end
    end
  end
end

The prompt directs the model to produce a thought first — reasoning about how to approach the problem. Based on that reasoning it either returns a direct answer, if it has enough information, or identifies missing data. In the latter case it specifies an action to perform with one of the provided tools.

The agent executes that action with the given parameters, calls the tool, and returns the result to the model as an observation. The model responds with a new thought, followed by either an answer or the next action. The cycle continues until the model reaches a solution or hits the iteration limit.

The Logger

The Logger implementation is on GitHub, linked at the end of this article, and isn't reproduced here. Its role is to make each step of a session visible with enough detail to follow the agent's reasoning as it happens — which is what the examples below depend on.

The agent in practice

The first test targets a specific capability: answering a question about frequently changing information, so that retrieval is being tested rather than the LLM's parametric knowledge. Real-time cryptocurrency prices serve that purpose well.

The PriceTool class queries the CoinGecko API for the latest prices. USD is hardcoded as the return currency to keep the example focused.

class PriceTool < Tool
  def call(query)
    fetch_crypto_price(query, "usd")
  end

  private

  def fetch_crypto_price(crypto_id, currency)
    url = "https://api.coingecko.com/api/v3/simple/price"
    response = HTTP.get(url, params: { ids: crypto_id.downcase, vs_currencies: currency })

    if response.status.success?
      data = response.parse
      price = data.dig(crypto_id.downcase, currency)
      return "$#{price}"
    else
      raise "Error fetching data: #{response.status}"
    end
  end
end

Next, an LLM is instantiated through the Regent::LLM class, which looks up an API key in the environment based on the model passed as an argument. This example uses gpt-4o-mini, so OPENAI_API_KEY must be set.

model = Regent::LLM.new(model: "gpt-4o-mini")

Then the price-fetching tool:

price_tool = PriceTool.new(name: "price_tool", description: "Get cryptocurrencies prices")

And the agent, composed from the model and the tool:

agent = Agent.new(model: model, tools: [price_tool])

With setup complete, the first question is the price of Bitcoin:

Agent using gpt-4o-mini and price_tool to answer about Bitcoin price Figure 2. The agent resolves a single-tool query: one thought, one action, one observation, one answer.

The next case asks for two prices at once — Ethereum and Dash — with a deliberate spelling error introduced to test whether the agent recovers:

Agent using gpt-4o-mini and price_tool to answer about Ethereum and Dash price Figure 3. Multiple tool calls in a single session, with a misspelled input handled by the model.

A harder case: the price is not the goal. The question is which of several currencies is most or least expensive, which requires the agent to gather prices and then reason over them rather than report a lookup.

Agent using gpt-4o-mini and price_tool to determine the most expensive crypto from the list Figure 4. Retrieval followed by comparison — the observation feeds reasoning rather than the answer directly.

Finally, a task the agent has no tool for: the weather in London.

Agent response when asked about weather Figure 5. Out-of-scope request. The agent declines rather than fabricating.

With no weather tool in the toolchain, that outcome is the correct one — and declining is a behavior worth verifying explicitly, not an afterthought.

These examples are deliberately small, and the implementation has room to improve and bugs still to surface. The point they establish is narrow but real: the agent understands a request in natural language and performs the actions required to satisfy it, with every step of that process recorded and inspectable.

This is not AGI and it isn't trying to be. It's a specialized agent scoped to a specific set of tasks, and the scope is the design, not a limitation to apologize for.

Availability

These components are assembled into a Ruby gem, Regent, which supports building basic AI agents and extending them with custom instructions and new tools.

Regent on GitHub

Issues, feedback, and contributions are welcome.

References

  1. Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models (2022).
  2. Regent — the Ruby gem assembling the components described in this article.
  3. CoinGecko API — price data source used in the worked examples.