Building Your First ReAct Agent in Ruby
Step-by-step guide to creating tool-using AI agents with DSPy.rb. Learn how to build agents that can reason about their actions and solve complex multi-step problems.
Vicente Reig
Fractional Engineering Lead • • 12 min read
ReAct (Reasoning and Acting) agents are the workhorses of AI applications. They can use tools, reason about their actions, and solve complex multi-step problems. Today, I’ll show you how to build one from scratch.
What is a ReAct Agent?
ReAct agents follow a simple loop:
- Reason about what to do next
- Act by calling a tool
- Observe the result
- Repeat until the task is complete
Let’s build a research assistant that can search the web, calculate statistics, and generate reports.
Step 1: Define Your Tools
In DSPy.rb, tools are just Ruby objects that respond to call
:
# A simple web search tool
class WebSearchTool
def call(query:)
# In production, this would call a real search API
case query.downcase
when /ruby programming/
[
{ title: "Ruby Programming Language", url: "https://ruby-lang.org", snippet: "A dynamic, open source programming language..." },
{ title: "Ruby on Rails", url: "https://rubyonrails.org", snippet: "Rails is a web application framework..." }
]
when /climate change/
[
{ title: "IPCC Report 2024", url: "https://ipcc.ch", snippet: "Latest findings on global climate..." },
{ title: "NASA Climate Data", url: "https://climate.nasa.gov", snippet: "Real-time climate monitoring..." }
]
else
[{ title: "No results found", url: "", snippet: "Try a different query" }]
end
end
end
# A calculator tool using Ruby's capabilities
class CalculatorTool
def call(expression:)
# Safe evaluation of mathematical expressions
allowed_methods = %w[+ - * / ** % sin cos tan log sqrt]
# Validate the expression contains only allowed operations
tokens = expression.scan(/[a-zA-Z_]+/)
unauthorized = tokens - allowed_methods
if unauthorized.any?
{ error: "Unauthorized operations: #{unauthorized.join(', ')}" }
else
{ result: eval(expression) }
end
rescue => e
{ error: e.message }
end
end
# A data analysis tool
class DataAnalysisTool
def call(data:, operation:)
case operation
when "mean"
{ result: data.sum.to_f / data.size }
when "median"
sorted = data.sort
mid = sorted.size / 2
{ result: sorted.size.odd? ? sorted[mid] : (sorted[mid-1] + sorted[mid]) / 2.0 }
when "std_dev"
mean = data.sum.to_f / data.size
variance = data.map { |x| (x - mean) ** 2 }.sum / data.size
{ result: Math.sqrt(variance) }
else
{ error: "Unknown operation: #{operation}" }
end
end
end
Step 2: Define Your Agent’s Signature
The signature defines what your agent does:
class ResearchAssistant < DSPy::Signature
description "Research a topic and provide a comprehensive summary with statistics"
input do
const :topic, String, description: "The topic to research"
const :depth, String, description: "How detailed the research should be (basic, detailed, comprehensive)"
end
output do
const :summary, String, description: "A comprehensive summary of the findings"
const :key_statistics, T::Array[String], description: "Important numbers and facts"
const :sources, T::Array[String], description: "URLs of sources used"
const :confidence, Float, description: "Confidence level in the findings (0-1)"
end
end
Step 3: Create the ReAct Agent
Now let’s put it all together:
require 'dspy'
# Configure DSPy
DSPy.configure do |c|
c.lm = DSPy::LM.new('openai/gpt-4o-mini', api_key: ENV['OPENAI_API_KEY'])
end
# Initialize tools
tools = {
search: WebSearchTool.new,
calculate: CalculatorTool.new,
analyze: DataAnalysisTool.new
}
# Create the agent
research_agent = DSPy::ReAct.new(ResearchAssistant, tools: tools, max_iters: 5)
# Use the agent
result = research_agent.forward(
topic: "Ruby programming language adoption trends",
depth: "detailed"
)
puts "Summary: #{result.summary}"
puts "Key Statistics: #{result.key_statistics.join(', ')}"
puts "Sources: #{result.sources.join(', ')}"
puts "Confidence: #{result.confidence}"
Step 4: Understanding the Agent’s Reasoning
One of the best features of ReAct agents is transparency. You can see exactly how they think:
# Enable detailed instrumentation
DSPy.configure do |c|
c.instrumentation.enabled = true
c.instrumentation.logger.level = :info
end
result = research_agent.forward(
topic: "Climate change impact on agriculture",
depth: "comprehensive"
)
# Access the reasoning trace
result.react_iterations.each_with_index do |iteration, i|
puts "\n=== Iteration #{i + 1} ==="
puts "Thought: #{iteration.thought}"
if iteration.tool_name
puts "Action: #{iteration.tool_name}(#{iteration.tool_args})"
puts "Observation: #{iteration.observation}"
end
end
This might output:
=== Iteration 1 ===
Thought: I need to search for information about climate change impacts on agriculture
Action: search({"query": "climate change impact agriculture 2024"})
Observation: [{"title": "IPCC Report 2024", "snippet": "Agricultural yields expected to decline by 10-25% by 2050..."}]
=== Iteration 2 ===
Thought: I found that yields may decline 10-25%. Let me calculate the impact for different scenarios
Action: calculate({"expression": "1000000 * 0.25"})
Observation: {"result": 250000}
=== Iteration 3 ===
Thought: A 25% decline on 1 million tons of production means 250,000 tons lost. Let me search for adaptation strategies
Action: search({"query": "climate adaptation strategies agriculture"})
Observation: [{"title": "Sustainable Farming Practices", "snippet": "Drought-resistant crops, precision irrigation..."}]
Advanced: Custom Tool Creation
Let’s create a more sophisticated tool that integrates with Rails:
# app/tools/database_query_tool.rb
class DatabaseQueryTool
def initialize(allowed_models: [])
@allowed_models = allowed_models
end
def call(model:, query:, limit: 10)
# Security: Only allow whitelisted models
unless @allowed_models.include?(model)
return { error: "Model #{model} is not allowed" }
end
# Get the actual model class
model_class = model.constantize
# Parse the query into ActiveRecord methods
case query
when /count where (\w+) = ['"]([^'"]+)['"]/
field, value = $1, $2
{ result: model_class.where(field => value).count }
when /average (\w+) where (\w+) = ['"]([^'"]+)['"]/
avg_field, where_field, value = $1, $2, $3
{ result: model_class.where(where_field => value).average(avg_field) }
when /recent (\d+)/
count = $1.to_i
records = model_class.order(created_at: :desc).limit(count)
{ result: records.map { |r| r.attributes.slice('id', 'name', 'created_at') } }
else
{ error: "Query pattern not recognized" }
end
rescue => e
{ error: e.message }
end
end
# Use it in an agent
analytics_agent = DSPy::ReAct.new(
DataAnalytics,
tools: {
db_query: DatabaseQueryTool.new(allowed_models: ['User', 'Order', 'Product'])
}
)
result = analytics_agent.forward(
question: "What's the average order value for customers who signed up in the last month?"
)
Error Handling and Retries
ReAct agents can gracefully handle tool failures:
class ResilientSearchTool
def initialize
@attempt = 0
end
def call(query:)
@attempt += 1
# Simulate API failures
if @attempt == 1
{ error: "API rate limit exceeded" }
else
# Normal search results
[{ title: "Result", snippet: "Found after retry" }]
end
end
end
# The agent will automatically retry with different strategies
agent = DSPy::ReAct.new(SearchTask, tools: { search: ResilientSearchTool.new })
Production Best Practices
1. Tool Timeouts
class TimeoutTool
def call(**args)
Timeout.timeout(5) do
# Your tool logic here
end
rescue Timeout::Error
{ error: "Tool execution timed out" }
end
end
2. Caching Results
class CachedSearchTool
def call(query:)
Rails.cache.fetch(["search", query], expires_in: 1.hour) do
# Expensive search operation
perform_search(query)
end
end
end
3. Async Tool Execution
class AsyncTool
include Sidekiq::Worker
def call(job_params:)
# Queue the job and return immediately
job_id = SecureRandom.uuid
AsyncToolJob.perform_async(job_id, job_params)
{ status: "processing", job_id: job_id }
end
end
4. Tool Authorization
class AuthorizedTool
def initialize(user)
@user = user
end
def call(resource_id:)
resource = Resource.find(resource_id)
unless can?(@user, :read, resource)
return { error: "Unauthorized" }
end
{ data: resource.attributes }
end
end
# Pass user-specific tools to the agent
agent = DSPy::ReAct.new(
Task,
tools: {
resource: AuthorizedTool.new(current_user)
}
)
Debugging ReAct Agents
When things go wrong, here’s how to debug:
# 1. Enable verbose logging
DSPy.configure do |c|
c.instrumentation.logger.level = :debug
end
# 2. Add tool instrumentation
class InstrumentedTool
def call(**args)
started_at = Time.now
Rails.logger.info "Tool called with: #{args.inspect}"
result = perform_operation(**args)
duration = Time.now - started_at
Rails.logger.info "Tool completed in #{duration}s: #{result.inspect}"
result
rescue => e
Rails.logger.error "Tool failed: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
{ error: e.message }
end
end
# 3. Inspect failed iterations
result = agent.forward(task: "Complex task")
if result.failure?
result.react_iterations.each do |iter|
if iter.error?
puts "Failed at: #{iter.thought}"
puts "Tool error: #{iter.observation[:error]}"
end
end
end
ReAct vs CodeAct: A Practical Comparison
Here’s the same task implemented with both approaches:
# Task: Analyze sales data and create a report
# ReAct approach - using predefined tools
sales_tool = SalesDataTool.new
stats_tool = StatisticsTool.new
report_tool = ReportGeneratorTool.new
react_agent = DSPy::ReAct.new(
SalesAnalysis,
tools: { sales: sales_tool, stats: stats_tool, report: report_tool }
)
# CodeAct approach - writing custom analysis code
codeact_agent = DSPy::CodeAct.new
# ReAct is better here because:
# 1. Tools can access live database
# 2. Report generation follows company templates
# 3. More predictable and auditable
Next Steps
Now that you’ve built your first ReAct agent, try these challenges:
- Multi-Agent System: Create multiple agents that collaborate
- Tool Composition: Build tools that use other tools
- State Management: Add memory to your agents
- Custom Reasoning: Override the reasoning prompts
Here’s a starter for a multi-agent system:
class ResearchAgent < DSPy::Module
def initialize
@web_agent = DSPy::ReAct.new(WebResearch, tools: { search: SearchTool.new })
@analyst_agent = DSPy::ReAct.new(DataAnalysis, tools: { analyze: AnalysisTool.new })
@writer_agent = DSPy::ReAct.new(ReportWriting, tools: { format: FormatterTool.new })
end
def forward(topic:)
# Research phase
research = @web_agent.forward(query: topic)
# Analysis phase
analysis = @analyst_agent.forward(data: research.findings)
# Writing phase
report = @writer_agent.forward(
research: research.summary,
analysis: analysis.insights
)
report
end
end
Conclusion
ReAct agents are powerful tools for building AI applications that can interact with the real world. They provide transparency, reliability, and flexibility that makes them perfect for production use.
The key is to start simple - build basic tools, test them thoroughly, and gradually increase complexity. Remember that the best AI applications combine the reasoning power of language models with the reliability of well-crafted tools.
Ready to build your own ReAct agent? Check out the complete documentation or share your creations in our GitHub discussions.