A workflow follows branches written in Ruby. A ReAct agent gives the model a smaller, bounded decision: choose the next tool, inspect its result, and either continue or finish.
This tutorial builds a research assistant with typed tools and a five-iteration limit. DSPy.rb constructs the provider prompts from the signature, tool schemas, and accumulated history. You define the task and the operations the model may select.
The ReAct loop
DSPy::ReAct repeats four steps:
- Generate a thought and select an action.
- Validate the selected tool and its input.
- Call the Ruby tool and record its observation.
- Ask the model whether to use another tool or finish.
The model chooses actions inside that loop. The application defines the available tools, input types, maximum iterations, and permitted side effects.
Define typed tools
Tools inherit from DSPy::Tools::Base. The Sorbet signature on call becomes the tool’s input schema:
class WebSearchTool < DSPy::Tools::Base
extend T::Sig
tool_name 'web_search'
tool_description 'Searches the web for information on a given topic'
class SearchResult < T::Struct
const :title, String
const :url, String
const :snippet, String
end
sig { params(query: String).returns(T::Array[SearchResult]) }
def call(query:)
case query.downcase
when /ruby programming/
[
SearchResult.new(
title: "Ruby Programming Language",
url: "https://ruby-lang.org",
snippet: "A dynamic, open source programming language..."
)
]
when /climate change/
[
SearchResult.new(
title: "NASA Climate Data",
url: "https://climate.nasa.gov",
snippet: "Climate data and research..."
)
]
else
[]
end
end
end
class DataAnalysisTool < DSPy::Tools::Base
extend T::Sig
tool_name 'data_analysis'
tool_description 'Calculates the mean or median of numerical data'
sig { params(data: T::Array[Float], operation: String).returns(Float) }
def call(data:, operation:)
raise ArgumentError, "Data cannot be empty" if data.empty?
case operation
when "mean"
data.sum / data.length
when "median"
sorted = data.sort
middle = sorted.length / 2
sorted.length.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0
else
raise ArgumentError, "Unsupported operation: #{operation}"
end
end
end
The schema constrains tool arguments and lets DSPy.rb coerce them before invocation. Safety belongs inside the operation: a database, HTTP, shell, or payment tool still needs authorization, timeouts, rate limits, and input policy.
Define the task
The signature describes the inputs and the final typed result. The ReAct loop chooses the order of tool calls:
class ResearchDepth < T::Enum
enums do
Basic = new('basic')
Detailed = new('detailed')
Comprehensive = new('comprehensive')
end
end
class ResearchAssistant < DSPy::Signature
description "Research a topic and summarize findings from the available tools."
input do
const :topic, String, description: "The topic to research"
const :depth, ResearchDepth, description: "Requested research depth"
end
output do
const :summary, String, description: "Summary supported by the tool observations"
const :key_statistics, T::Array[String], description: "Relevant numbers and facts"
const :sources, T::Array[String], description: "Source URLs used"
const :confidence, Float, description: "Confidence from 0.0 to 1.0"
end
end
Descriptions still matter. They provide task and field information that DSPy.rb includes in the generated prompt. The difference from prompt-template programming is that the signature remains the application interface while adapters handle provider formatting.
Construct and run the agent
require 'dspy'
DSPy.configure do |config|
config.lm = DSPy::LM.new(
'openai/gpt-4o-mini',
api_key: ENV['OPENAI_API_KEY']
)
end
research_agent = DSPy::ReAct.new(
ResearchAssistant,
tools: [WebSearchTool.new, DataAnalysisTool.new],
max_iterations: 5
)
result = research_agent.call(
topic: "Ruby programming language adoption trends",
depth: ResearchDepth::Detailed
)
puts result.summary
puts result.key_statistics
puts result.sources
puts result.confidence
Five iterations is both the explicit limit above and the current default. If the model fails to finish, DSPy::ReAct emits a react.max_iterations event and raises DSPy::ReAct::MaxIterationsError instead of returning a valid typed answer.
Inspect what the agent did
The returned result includes history and iterations. The agent uses DSPy::HistoryEntry objects internally, then exposes each public history entry as a hash with symbol keys:
result.history.each do |entry|
puts "Step: #{entry[:step]}"
puts "Thought: #{entry[:thought]}"
puts "Action: #{entry[:action]}"
puts "Tool input: #{entry[:tool_input].inspect}"
puts "Observation: #{entry[:observation].inspect}"
end
puts "Total iterations: #{result.iterations}"
This history records the model’s reported thought, selected action, typed input, and tool result. It is useful execution evidence, but it is not proof that the model’s natural-language thought faithfully explains its internal reasoning.
DSPy.rb also emits spans and events for the module, LM calls, tool executions, invalid actions, and maximum-iteration exits. Configure an OpenTelemetry backend when you need traces outside the process.
Put tools behind real boundaries
A tool is part of the application’s authority surface. Keep its interface narrow:
class AccountLookupTool < DSPy::Tools::Base
extend T::Sig
tool_name 'account_lookup'
tool_description 'Returns support-safe account status for one account ID'
class AccountStatus < T::Struct
const :plan, String
const :active, T::Boolean
end
sig { params(account_id: Integer).returns(AccountStatus) }
def call(account_id:)
account = Account.find(account_id)
AccountStatus.new(plan: account.plan_name, active: account.active?)
end
end
Do not expose arbitrary SQL when the agent only needs an account status. Check authorization inside the tool, apply query limits, and separate read tools from tools that mutate data. max_iterations limits loop length; it does not limit the damage from one overpowered tool call.
Test decisions and boundaries
Unit-test tools as ordinary Ruby objects. For the agent, use recorded provider responses or stubs to cover:
- A valid tool call followed by
finish. - Unknown actions and malformed tool input.
- Tool exceptions.
- Maximum-iteration behavior.
- Final output coercion against the original signature.
Evaluate complete trajectories when tool choice matters. A correct final string can hide an unnecessary call, an unsafe argument, or a route that costs too much.
ReAct or a workflow?
Use ReAct when the next operation depends on an observation and the model has a useful bounded choice among tools. Use a deterministic workflow when every step must run, the branch graph is already known, or audit requirements demand explicit transitions.
A workflow can contain a ReAct handler. Use one when the model needs to choose the next operation; keep the surrounding Ruby workflow when the sequence and branches are already known.