Modules
DSPy::Module is the reusable execution boundary in DSPy.rb. Predict, ChainOfThought, and ReAct are modules; applications can subclass DSPy::Module to compose them with Ruby.
Choose a Module Boundary
A module can own:
- Execution strategies through built-in or custom modules
- Configuration at the instance, fiber, or global level
- Composition through explicit Ruby method calls and control flow
- Instrumentation around each module call
Subclass DSPy::Module
Define forward
class SentimentSignature < DSPy::Signature
description "Analyze sentiment of text"
input do
const :text, String
end
output do
const :sentiment, String
const :confidence, Float
end
end
class SentimentAnalyzer < DSPy::Module
def initialize
super
# Create the predictor
@predictor = DSPy::Predict.new(SentimentSignature)
end
def forward(text:)
@predictor.call(text: text)
end
end
# Usage
analyzer = SentimentAnalyzer.new
result = analyzer.call(text: "I love this product!")
puts result.sentiment # => "positive"
puts result.confidence # => 0.9
Inject Module Configuration
class ClassificationSignature < DSPy::Signature
description "Classify text into categories"
input do
const :text, String
end
output do
const :category, String
const :reasoning, String
end
end
class ConfigurableClassifier < DSPy::Module
def initialize
super
# Create predictor
@predictor = DSPy::ChainOfThought.new(ClassificationSignature)
end
def forward(text:)
@predictor.call(text: text)
end
end
# Usage
classifier = ConfigurableClassifier.new
result = classifier.call(text: "This is a technical document")
puts result.reasoning
Override the Language Model at Runtime
See Module Runtime Context for fiber-local language model overrides and lifecycle callbacks.
Manual Module Composition
Sequential Processing
class DocumentProcessor < DSPy::Module
def initialize
super
# Create sub-modules
@classifier = DocumentClassifier.new
@summarizer = DocumentSummarizer.new
@extractor = KeywordExtractor.new
end
def forward(document:)
# Step 1: Classify document type
classification = @classifier.call(content: document)
# Step 2: Generate summary
summary = @summarizer.call(content: document)
# Step 3: Extract keywords
keywords = @extractor.call(content: document)
# Return combined results
{
document_type: classification.document_type,
summary: summary.summary,
keywords: keywords.keywords
}
end
end
Conditional Processing
class AdaptiveAnalyzer < DSPy::Module
def initialize
super
@content_detector = ContentTypeDetector.new
@technical_analyzer = TechnicalAnalyzer.new
@general_analyzer = GeneralAnalyzer.new
end
def forward(content:)
# Determine content type
content_type = @content_detector.call(content: content)
# Route to appropriate analyzer based on result
if content_type.type.downcase == 'technical'
@technical_analyzer.call(content: content)
else
@general_analyzer.call(content: content)
end
end
end
Compose Predictor Types
Module Using Chain of Thought
class ClassificationSignature < DSPy::Signature
description "Classify text into categories"
input do
const :text, String
end
output do
const :category, String
# Note: ChainOfThought automatically adds a :reasoning field
# Do NOT define your own :reasoning field when using ChainOfThought
end
end
class ReasoningClassifier < DSPy::Module
def initialize
super
# ChainOfThought enhances the signature with automatic reasoning
@predictor = DSPy::ChainOfThought.new(ClassificationSignature)
end
def forward(text:)
# The result will include both :category and :reasoning fields
@predictor.call(text: text)
end
end
# Usage
classifier = ReasoningClassifier.new
result = classifier.call(text: "This is a technical document")
puts result.category # => "technical"
puts result.reasoning # => "The document mentions APIs and code examples..."
Module Using ReAct for Tool Integration
require "dspy"
require "dspy/tools/text_processing_toolset"
class ResearchSignature < DSPy::Signature
description "Answer a question about supplied text"
input do
const :text, String
const :question, String
end
output do
const :answer, String
end
end
class ResearchAssistant < DSPy::Module
MAX_TEXT_BYTES = 100_000
TOOL_NAMES = %w[text_wc text_extract_lines].freeze
def initialize
super
text_tools = DSPy::Tools::TextProcessingToolset.to_tools.select do |tool|
TOOL_NAMES.include?(tool.name)
end
@predictor = DSPy::ReAct.new(
ResearchSignature,
tools: text_tools,
max_iterations: 5
)
end
def forward(text:, question:)
raise ArgumentError, "text exceeds 100,000 bytes" if text.bytesize > MAX_TEXT_BYTES
@predictor.call(text: text, question: question)
end
end
Bounded Code Analysis Agent
This ReAct module exposes only the portable count and line-range operations. It caps application input and bounds model-directed steps:
require "dspy"
require "dspy/tools/text_processing_toolset"
class CodeAnalysisSignature < DSPy::Signature
description "Analyze source code and answer questions about it"
input do
const :source_code, String
const :question, String
end
output do
const :answer, String
const :relevant_lines, T::Array[String]
end
end
class CodeAnalyzer < DSPy::Module
MAX_SOURCE_BYTES = 100_000
TOOL_NAMES = %w[text_wc text_extract_lines].freeze
def initialize
super
text_tools = DSPy::Tools::TextProcessingToolset.to_tools.select do |tool|
TOOL_NAMES.include?(tool.name)
end
@agent = DSPy::ReAct.new(
CodeAnalysisSignature,
tools: text_tools,
max_iterations: 5
)
end
def forward(source_code:, question:)
raise ArgumentError, "source exceeds 100,000 bytes" if source_code.bytesize > MAX_SOURCE_BYTES
@agent.call(source_code: source_code, question: question)
end
end
# Usage
analyzer = CodeAnalyzer.new
result = analyzer.call(
source_code: File.read("app/models/user.rb"),
question: "What validations are defined?"
)
puts result.answer
puts result.relevant_lines
These examples deliberately exclude text_grep, text_rg, and text_filter_lines: those helpers accept command or regular-expression patterns and do not provide process deadlines or input/output caps. Tool arguments remain untrusted even when the module input was validated, so use an application-owned wrapper when provider output limits do not satisfy the same byte budget. max_iterations limits agent steps; it does not cancel a running tool.
Building a GitHub Issue Triage Agent
The core feature is DSPy::Tools::GitHubCLIToolset, loaded with require "dspy/tools/github_cli_toolset". Do not pass its entire authenticated proxy set to a triage agent: that would expose broader repository and arbitrary GET API access than issue listing requires. Wrap only the required read operation with an application-owned repository allowlist, credential scope, command timeout, output cap, and failure redaction before giving it to ReAct.
See Toolsets to define and export tools.
Module Using CodeAct for Dynamic Programming
CodeAct is available via the dspy-code_act gem. The complete Think-Code-Observe module example now lives in lib/dspy/code_act/README.md, alongside guidance on safety, observability, and advanced usage.
Define a Custom Execution Strategy
Create a Custom Module
Subclass DSPy::Module when the built-in modules do not define the required execution pattern, as DSPy::ReAct (core) and DSPy::CodeAct (optional gem) do. A custom module can compose predictors, agents, application services, and Ruby control flow behind one typed call boundary.
class CustomAgentSignature < DSPy::Signature
description "Custom agent for specialized tasks"
input do
const :task, String
const :context, T::Hash[String, T.untyped]
end
output do
const :result, String
const :reasoning, String
end
end
class CustomAgent < DSPy::Module
def initialize
super
# Initialize your custom inference components
@planner = DSPy::ChainOfThought.new(PlanningSignature)
@executor = DSPy::CodeAct.new(ExecutionSignature) # Requires the dspy-code_act gem
@validator = DSPy::Predict.new(ValidationSignature)
end
def forward(task:, context: {})
# Implement your custom inference logic
plan = @planner.call(task: task, context: context)
execution = @executor.call(
plan: plan.plan,
context: context
)
validation = @validator.call(
result: execution.solution,
original_task: task
)
{
result: execution.solution,
reasoning: plan.reasoning,
confidence: validation.confidence
}
end
end
# Usage
agent = CustomAgent.new
result = agent.call(
task: "Analyze data and generate insights",
context: { data_source: "database", format: "json" }
)
Testing Modules
Basic Module Testing
# In your test file (using RSpec)
describe SentimentAnalyzer do
let(:analyzer) { SentimentAnalyzer.new }
it "analyzes sentiment" do
result = analyzer.call(text: "I love this!")
expect(result).to respond_to(:sentiment)
expect(result).to respond_to(:confidence)
expect(result.sentiment).to be_a(String)
expect(result.confidence).to be_a(Float)
end
it "handles empty input" do
expect {
analyzer.call(text: "")
}.not_to raise_error
end
end
Testing Module Composition
describe DocumentProcessor do
let(:processor) { DocumentProcessor.new }
it "processes documents through all stages" do
document = "Sample document content..."
result = processor.call(document: document)
expect(result).to have_key(:document_type)
expect(result).to have_key(:summary)
expect(result).to have_key(:keywords)
end
end
Keep Module Boundaries Narrow and Typed
1. Give a Module One Execution Job
# Good: Focused responsibility
class EmailClassifier < DSPy::Module
def initialize
super
# Only handles email classification
end
def forward(email:)
# Single, clear purpose
end
end
# Good: Separate concerns through composition
class EmailProcessor < DSPy::Module
def initialize
super
@classifier = EmailClassifier.new
@spam_detector = SpamDetector.new
end
def forward(email:)
classification = @classifier.call(email: email)
spam_result = @spam_detector.call(email: email)
{
classification: classification,
spam_score: spam_result.score
}
end
end
2. Declare Interfaces with Signatures
class DocumentAnalysisSignature < DSPy::Signature
description "Analyze document content"
input do
const :content, String
end
output do
const :main_topics, T::Array[String]
const :word_count, Integer
end
end
class DocumentAnalyzer < DSPy::Module
def initialize
super
@predictor = DSPy::Predict.new(DocumentAnalysisSignature)
end
def forward(content:)
@predictor.call(content: content)
end
end
Instruction Update Contract
Optimizers such as GEPA and MIPROv2 expect predictors to expose immutable update hooks so they can safely swap instructions and few-shot examples. When you build a custom module that participates in optimization:
- Implement
with_instruction(new_instruction)and return a new instance configured with the provided instruction. - Implement
with_examples(few_shot_examples)when your module supports few-shot updates, also returning a new instance.
You can include DSPy::Mixins::InstructionUpdatable to signal this capability and surface helpful default errors during development:
class SentimentPredictor < DSPy::Module
include DSPy::Mixins::InstructionUpdatable
def initialize
super
@predictor = DSPy::Predict.new(SentimentSignature)
end
def with_instruction(instruction)
clone = self.class.new
clone.instance_variable_set(:@predictor, @predictor.with_instruction(instruction))
clone
end
def with_examples(examples)
clone = self.class.new
clone.instance_variable_set(:@predictor, @predictor.with_examples(examples))
clone
end
end
If a module omits these hooks, optimizers raise DSPy::InstructionUpdateError instead of mutating instance variables directly.
Expose Predictor Updates to Optimizers
Modules can work with the optimization framework through their underlying predictors:
# Create your module
classifier = SentimentAnalyzer.new
# Use with basic optimization if available
# (Advanced optimization features are limited)
training_examples = [
DSPy::FewShotExample.new(
input: { text: "I love this!" },
output: { sentiment: "positive", confidence: 0.9 }
)
]
# Basic evaluation
result = classifier.call(text: "Test input")