Overview
Choose a module by the work it needs to do:
- Step-by-step reasoning with ChainOfThought
- Multiple attempts with BestOfN
- Iterative refinement with Refine
- Ensemble reasoning with MultiChainComparison
- Exact computation with ProgramOfThought (code generation)
- Hybrid agents with CodeAct (tools + code execution)
- Adaptive investigation of large or awkward R objects with RLM
- Implementation search across predictors, R logic, and tools with Flex
The sections below show each execution pattern and its tradeoffs.
ChainOfThought
ChainOfThought (CoT) asks the model for a reasoning field before the final answer.
Why Use ChainOfThought?
Use it when intermediate reasoning is useful to the task or evaluator. It adds tokens and does not make the reasoning inherently faithful, so evaluate the final output against a task-specific metric.
Basic Usage
The simplest way to use CoT is with
chain_of_thought():
# Create a CoT module
math_solver <- chain_of_thought("problem -> solution")
# Run it
result <- run(
math_solver,
problem = "If a train travels 120 miles in 2 hours, what is its average speed?",
.llm = chat_openai()
)
# Result includes both reasoning and answer
result$reasoning
#> "To find average speed, I need to divide total distance by total time.
#> Distance = 120 miles, Time = 2 hours.
#> Speed = 120 / 2 = 60 miles per hour."
result$solution
#> "60 miles per hour"Signature Transforms
Under the hood, chain_of_thought() uses
with_reasoning() to transform the signature. You can use
this directly for more control:
# Start with a regular signature
sig <- signature("question -> answer: string")
# Transform it to include reasoning
cot_sig <- with_reasoning(sig)
# The output now includes a reasoning field
names(cot_sig@output_type@properties)
#> [1] "reasoning" "answer"
# Check if a signature has reasoning
has_reasoning(cot_sig)
#> TRUE
has_reasoning(sig)
#> FALSECustom Reasoning Prefix
You can customize the reasoning prompt:
# Default: "Let's think step by step in order to"
math_cot <- with_reasoning(
"equation -> result",
prefix = "Let me solve this equation carefully:"
)
# For code tasks
code_cot <- with_reasoning(
"task -> code",
prefix = "Let me break down the implementation:"
)Removing Reasoning
For A/B testing CoT vs non-CoT performance:
cot_sig <- with_reasoning("question -> answer")
plain_sig <- without_reasoning(cot_sig)
has_reasoning(plain_sig)
#> FALSEBestOfN
BestOfN addresses output variance by running a module multiple times and selecting the best result based on a reward function.
Why Use BestOfN?
LLM outputs can be inconsistent. The same prompt might produce correct output 70% of the time. BestOfN increases reliability by: - Making multiple attempts - Scoring each attempt with a reward function - Returning the highest-scoring result - Optionally stopping early when a threshold is met
Basic Usage
# Create a QA module
qa <- module(signature("question -> answer"))
# Wrap with BestOfN (default N=3)
reliable_qa <- best_of_n(qa, N = 5)
# Run - internally makes up to 5 attempts
result <- run(
reliable_qa,
question = "What is the capital of France?",
.llm = chat_openai()
)Reward Functions
The power of BestOfN comes from custom reward functions that score outputs:
# Reward function signature: function(prediction, inputs) -> [0, 1]
# Example: Prefer single-word answers
one_word_reward <- function(pred, inputs) {
words <- strsplit(as.character(pred$answer), "\\s+")[[1]]
if (length(words) == 1) 1.0 else 0.0
}
# Example: Prefer confident answers
confidence_reward <- function(pred, inputs) {
# Check for hedging language
hedges <- c("maybe", "perhaps", "possibly", "might")
answer <- tolower(pred$answer)
if (any(sapply(hedges, grepl, answer))) 0.3 else 1.0
}
wrapper <- best_of_n(
qa,
N = 5,
reward_fn = one_word_reward,
threshold = 1.0 # Stop early if we get a one-word answer
)Using Metrics as Rewards
Convert existing metrics to reward functions with
as_reward_fn():
# When you have expected values in your inputs
wrapper <- best_of_n(
qa,
N = 3,
reward_fn = as_reward_fn(
metric_exact_match(field = "answer"),
expected_field = "expected_answer"
)
)
# Run with expected value for reward calculation
result <- run(
wrapper,
question = "What is 2+2?",
expected_answer = "4",
.llm = chat_openai()
)Inspecting Attempts
After running, you can examine all attempts:
# Get attempts from last run
attempts <- wrapper$get_attempts()
attempts
#> # A tibble: 3 x 4
#> run attempt prediction score
#> <int> <int> <list> <dbl>
#> 1 1 1 <named list [1]> 0
#> 2 1 2 <named list [1]> 1
#> 3 1 3 <named list [1]> 0
# Get all attempts across multiple runs
all_attempts <- wrapper$get_attempts(all = TRUE)Metadata
BestOfN tracks useful metadata. Use
.return_format = "structured" to access it:
# Use structured format to access metadata
result <- run(wrapper, question = "Test", .llm = llm, .return_format = "structured")
# Access metadata fields
result$metadata$n_attempts # How many attempts were made
result$metadata$best_score # Score of selected result
result$metadata$all_scores # Scores of all attempts
result$metadata$early_stopped # Did we hit threshold?
result$metadata$total_tokens # Tokens across all attempts, or NA if unknown
result$metadata$cost # Cost across all attempts, or NA if unknown
result$metadata$provider_calls # Provider calls across all attempts, or NA
# For batch operations with run_dataset(), use .metadata column:
# batch_result$.metadata[[1]]$n_attemptsRefine
Refine extends BestOfN with a feedback loop. After each failed attempt, it generates feedback explaining what was wrong and injects this into the next attempt.
Why Use Refine?
While BestOfN makes independent attempts, Refine learns from mistakes. Each iteration receives feedback about the previous attempt, allowing the model to correct specific issues.
Basic Usage
# Create module that accepts feedback
qa <- module(signature("question, feedback -> answer"))
# One-word answer reward
one_word_reward <- function(pred, inputs) {
words <- strsplit(as.character(pred$answer), "\\s+")[[1]]
if (length(words) == 1) 1.0 else 0.0
}
# Wrap with Refine
refined <- refine(
qa,
N = 3,
reward_fn = one_word_reward,
threshold = 1.0,
feedback_template = "Your answer '{prediction}' scored {score}. Please give a single word answer."
)
result <- run(
refined,
question = "What is the capital of France?",
.llm = chat_openai()
)Feedback Templates
Feedback templates use glue syntax with these variables: -
{score} - The score from the reward function -
prediction - The previous output (formatted as string) -
Any input field names from your signature
# Reference input fields
template <- "For the question '{question}', your answer '{prediction}' scored {score}. Try again."
# Be specific about what's wrong
template <- "Score: {score}. Your answer was too verbose. Give only the city name."
# Use conditional language
template <- "Previous attempt scored {score}/1.0. Focus on precision and brevity."Custom Feedback Field
By default, feedback is injected as a field called
feedback. You can customize this:
Feedback History
Track the feedback generated across iterations:
result <- run(refined, question = "Test", .llm = llm)
# Get feedback from last run
refined$get_feedback_history()
#> [1] "Your answer 'The capital is Paris' scored 0..."
#> [2] "Your answer 'Paris, France' scored 0..."
# Get all feedback across runs
refined$get_feedback_history(all = TRUE)MultiChainComparison
MultiChainComparison (MCC) runs several independent reasoning chains and asks a final model call to synthesize one answer.
Why Use MultiChainComparison?
MCC: - Generates M diverse reasoning attempts (using temperature for variation) - Compares all attempts in a synthesis step - Produces one answer from the compared attempts
Basic Usage
# Create MCC module
mcc <- multi_chain_comparison(
"question -> answer",
M = 3, # Number of reasoning chains
temperature = 0.7 # Higher = more diversity
)
result <- run(
mcc,
question = "What are the pros and cons of renewable energy?",
.llm = chat_openai()
)
# Result is synthesized from all chains
result$reasoning
result$answerUsing the Module Factory
MCC is also available via the module() factory:
mcc <- multi_chain_comparison(
signature("context, question -> answer"),
M = 5,
temperature = 0.8
)Custom Inner Module
By default, MCC uses ChainOfThought for the inner module. You can provide your own:
# Use a custom CoT module
cot <- chain_of_thought(
"question -> answer",
prefix = "Let me analyze this from multiple angles:"
)
mcc <- multi_chain_comparison(
"question -> answer",
inner_module = cot,
M = 5
)Custom Comparison Template
Customize how attempts are compared:
mcc <- multi_chain_comparison(
"question -> answer",
M = 3,
comparison_template = paste0(
"You have {M} expert analyses of the same question.\n\n",
"{attempts_text}\n\n",
"Synthesize these into a single authoritative answer. ",
"Note where experts agree and resolve any disagreements."
)
)Inspecting Chains
View the individual reasoning chains:
result <- run(mcc, question = "Complex question...", .llm = llm)
# Get all chain results
chains <- mcc$get_attempts()
chains
#> # A tibble: 3 x 3
#> run attempt prediction
#> <int> <int> <list>
#> 1 1 1 <named list [2]>
#> 2 1 2 <named list [2]>
#> 3 1 3 <named list [2]>
# Each prediction has reasoning and answer
chains$prediction[[1]]
#> $reasoning
#> [1] "First, let me consider..."
#> $answer
#> [1] "The answer is..."ProgramOfThought
ProgramOfThought addresses a fundamental LLM limitation: they’re unreliable at exact computation. Instead of asking the model to compute directly, it generates R code that R executes.
Why Use ProgramOfThought?
LLMs frequently make arithmetic errors, especially with multi-step calculations. ProgramOfThought solves this by: - Having the LLM generate R code to solve the problem - Executing that code in an isolated subprocess - If execution fails, feeding the error back for code repair - Extracting the final answer from the execution result
Setting Up Code Execution
Code execution requires explicit opt-in via a runner or interpreter factory. Here is the caller-owned form:
# Create a runner - this enables code execution
runner <- r_code_runner(
timeout = 30, # Max execution time
allowed_packages = c("base", "stats", "utils") # Allowed packages
)Security note: r_code_runner() provides
subprocess isolation but is not a security sandbox. For untrusted
generated code, use a fresh managed mcp_repl_runner() from
an interpreter factory or another runner with verified OS-level
sandboxing.
ProgramOfThought, CodeAct, and RLM accept exactly one execution
binding. Pass runner to retain a caller-owned runner object
that dsprrr reuses and never shuts down. Whether execution state
persists depends on the backend. Serialize a stateful runner, and reset
it between unrelated jobs when that backend supports
reset(). Alternatively, pass a zero-argument
interpreter_factory; dsprrr calls it once per invocation,
owns the fresh runner, and shuts it down exactly once when the
invocation ends, including after an error:
pot <- program_of_thought(
"question -> answer",
interpreter_factory = function() r_code_runner(timeout = 30)
)The factory form prevents state from crossing invocation boundaries.
A direct runner is caller-owned and sequential; never share a stateful
runner across concurrent calls. Supplying both forms is an error.
Factory-backed ProgramOfThought, CodeAct, and RLM support
run_async() and isolated mirai batch execution because
every invocation owns a fresh runner. Caller-owned runners remain
sequential. Specialized token streaming is still rejected before
provider or factory work; run_stream() without a matching
token listener preserves the ordinary synchronous forward()
path.
Basic Usage
# Create a ProgramOfThought module
pot <- program_of_thought("question -> answer", runner = runner)
# Run it - the LLM generates code, R executes it
result <- run(
pot,
question = "What is the sum of all prime numbers under 100?",
.llm = chat_openai()
)
# Result is the computed answer
result$answer
#> "1060"Automatic Error Recovery
If the generated code fails, ProgramOfThought automatically feeds the error back to the LLM for repair:
pot <- program_of_thought(
"question -> answer",
runner = runner,
max_iters = 3 # Try up to 3 times to get working code
)
# Even if first attempt has a bug, it may self-correct
result <- run(pot, question = "Calculate factorial of 10", .llm = llm)Accessing Execution History
Track the code generation and execution process:
# After running, inspect execution history
executions <- pot$get_executions()
executions[[1]]$iterations # List of code attempts
executions[[1]]$success # Whether it succeededUsing Context Data
Pass data to your code via the .context list:
pot <- program_of_thought("data, question -> answer", runner = runner)
result <- run(
pot,
data = mtcars,
question = "What is the correlation between mpg and hp?",
.llm = llm
)
# The LLM can generate: cor(.context$data$mpg, .context$data$hp)CodeAct
CodeAct combines declared host tools with generated R execution. Use it when a task needs both an external action and computation inside one bounded agent loop.
Why Use CodeAct?
Some tasks require multiple capabilities: - Search for information (tool calling) - Perform calculations on that information (code execution) - Iterate until the answer is found (agent loop)
CodeAct provides all of these in a single module.
Basic Usage
# Create tools
search_tool <- ellmer::tool(
function(query) search_api(query),
description = "Search for information",
arguments = list(query = ellmer::type_string())
)
# Create CodeAct agent with tools and code execution
runner <- r_code_runner(timeout = 30)
agent <- code_act(
"question -> answer",
tools = list(search = search_tool),
runner = runner
)
# The agent can search AND compute
result <- run(
agent,
question = "What is 10% of France's current population?",
.llm = chat_openai()
)
# Agent might: 1) Search for France's population, 2) Execute: 67000000 * 0.10Built-in Code Execution Tool
CodeAct automatically includes an execute_r_code tool
that the LLM can call:
agent <- code_act("question -> answer", runner = runner)
# The LLM sees this tool:
# execute_r_code(code): Execute R code in an isolated environment.
# The input data is available in the `.context` list.Controlling Iterations
agent <- code_act(
"question -> answer",
runner = runner,
# Caps outer agent iterations and inner tool calls; excess tool calls error.
max_iterations = 10
)Inspecting Agent Trajectory
Track the agent’s decision-making process:
result <- run(agent, question = "Complex question...", .llm = llm)
# Get the trajectory
trajectories <- agent$get_trajectories()
trajectories[[1]]$iterations # Number of iterations
trajectories[[1]]$trajectory # List of steps takenCombining with Custom Tools
# Create multiple tools
weather_tool <- ellmer::tool(
function(city) get_weather(city),
description = "Get current weather",
arguments = list(city = ellmer::type_string())
)
database_tool <- ellmer::tool(
function(query) run_sql(query),
description = "Query the database",
arguments = list(query = ellmer::type_string())
)
# CodeAct with multiple tools + code execution
agent <- code_act(
"question -> answer",
tools = list(weather = weather_tool, database = database_tool),
runner = runner
)Recursive Language Model (experimental)
Use an RLM when the answer is inside an R object but the useful slice and calculation are not known in advance. The model proposes one R operation, observes bounded output, and chooses the next operation. The full object does not enter every model prompt.
That is a different job from the other code-oriented modules:
| Need | Module |
|---|---|
| Execute a calculation whose steps are already known | program_of_thought() |
| Discover an exploration path for this invocation | rlm_module() |
| Learn a reusable implementation from labeled examples |
flex() with GEPA |
Investigate a large R object
The release-regression tutorial uses 40,000 session rows and 200 change records. A persistent callr runner stages those rich R objects once and keeps derived values between iterations:
incident <- rlm_module(
paste(
"sessions, changes, question ->",
"release: string, cohort: string, before_rate: number,",
"after_rate: number, drop_pp: number, change_id: string, evidence: string"
),
interpreter_factory = function() {
r_code_runner(timeout = 30, persistent = TRUE)
},
max_iterations = 8,
max_llm_calls = 0L,
max_output_chars = 10000
)
result <- run(
incident,
sessions = sessions,
changes = changes,
question = "Which cohort regressed, and which change best explains it?",
.llm = chat_openai(),
.return_format = "structured"
)This configuration is appropriate only when the fixture and generated
code are trusted. persistent = TRUE preserves one callr
process, but that process has the host user’s file, network, and
environment permissions.
The default max_output_chars = 10000 keeps a
head-and-tail excerpt from each execution in the next prompt. It bounds
model-visible evidence; it does not increase a runner’s transport
limit.
Recursive queries return values
sub_lm = NULL inherits the outer model passed to
run(). Generated code can therefore assign and use the
result of a focused query:
candidate <- subset(.context$changes, component == "checkout-auth")
interpretation <- llm_query(
"Which change could reduce successful token refreshes?",
paste(candidate$note, collapse = "\n")
)
SUBMIT(answer = interpretation)The guest emits a nonce-bound, schema-checked request, dsprrr calls the model in the host, then replays the same code evaluation with the returned value. One ordered ledger prevents query/tool replay from changing operation kind. It cannot roll back an external side effect performed directly by generated code before the query, so RLM code should keep pre-query work read-only.
SUBMIT() is checked against the signature. Missing
required, extra, or incompatible fields become a repairable observation
so the next iteration can correct the submission; optional fields may be
omitted. If the iteration budget ends first, the extraction predictor
attempts to produce the best typed answer supported by the trajectory;
provider or type-validation failure remains terminal.
The action and fallback extraction steps are graph-visible child predictors:
names(incident$graph_children())
#> [1] "generate_action" "extract"Structured results report whether the answer came from
SUBMIT() or fallback, and retain the bounded
trajectory:
result$output
result$metadata$output_source
result$metadata$repl_history
result$metadata$runner_policyChoose the execution boundary
The one-call helper creates a fresh managed MCP sandbox by default:
answer <- rlm(
"document, question -> answer",
document = "Owner: team-a\nCommitment: rotate signing keys quarterly",
question = "Which commitments have no owner?",
.llm = chat_openai(),
.max_iterations = 4L,
.max_llm_calls = 0L
)Managed mcp-repl requires the suggested R package
mcptools plus the external mcp-repl
executable. It disables network access and applies an OS sandbox, but
workspace writes remain allowed. Requests have a 7 KB wire bound and RLM
control frames have a 3,000-byte encoded bound. When a raw request is
too large, dsprrr first tries a gzip/base64 wrapper; the final JSON-RPC
request must still fit the wire bound. Oversized output may be rejected
by the runner before the module’s 10,000-character head-and-tail
formatter. Host tools run outside that sandbox with host permissions.
Use explicit persistent r_code_runner() for trusted large
data frames and fitted models; it is not a sandbox.
See Investigate a Release Regression with an RLM for the deterministic demo and How the RLM Works for the complete execution contract.
Flex (experimental)
Use flex() when the implementation strategy is the
search problem. GEPA can change which predictors run, add deterministic
R logic, or call a selected tool—not only rewrite instructions inside a
fixed module.
program <- flex("question -> answer")
program$module_srcUse a regular module when its shape is known, or an explicit pipeline when people should own the workflow. See Flex: Optimize the Whole Program for a deterministic GEPA replay that preserves router accuracy while removing unnecessary model calls.
Combining Modules
These modules can be composed when one execution pattern is not enough:
# ChainOfThought inside BestOfN
cot <- chain_of_thought("math_problem -> solution")
reliable_cot <- best_of_n(cot, N = 3, reward_fn = math_checker)
# Refine with CoT
cot_with_feedback <- module(
with_reasoning(signature("question, feedback -> answer"))
)
refined_cot <- refine(cot_with_feedback, N = 3, reward_fn = quality_score)
# MCC already uses CoT internally by defaultOptimization Support
Wrapper modules retain their underlying optimizable predictors. RLM
exposes separate generate_action and extract
children, but optimizer support is deliberately explicit:
| Optimizer | RLM support |
|---|---|
GEPA() |
Tunes both child predictors with end-to-end feedback;
metric_with_trace() can derive feedback from the bounded
RLM trajectory |
AutoResearch() / MetaHarness()
|
Discovers and applies both graph children |
MIPROv2() |
Tunes child instructions only when
max_bootstrapped_demos = 0L
|
BootstrapFewShot() /
BootstrapFewShotWithRandomSearch()
|
Programs containing Flex or an RLM are rejected; use GEPA, or instruction-only MIPROv2 for an RLM graph |
LabeledFewShot() |
Programs containing an RLM are rejected because root examples do not match child signatures |
Nested MIPRO demo bootstrapping fails with an actionable typed error
until RLM collects predictor-local child evidence. This avoids attaching
task-level demos to incompatible state -> ...
predictors.
# Grid search over wrapper parameters
wrapper <- best_of_n(qa, N = 3)
optimize_grid( wrapper,
data = dev_data,
metric = metric_exact_match(),
parameters = list(
N = c(3, 5, 7),
threshold = c(0.8, 0.9, 1.0)
)
)
# Teleprompter compilation
tp <- LabeledFewShot(k = 4)
compiled <- compile(wrapper, tp, trainset)Performance Considerations
Token Usage
Advanced modules trade additional calls for reasoning, retries, comparison, or exploration. The actual cost depends on early stopping, provider behavior, trajectory length, and recursive queries. Set explicit iteration and call budgets, then inspect returned metadata rather than relying on a fixed multiplier.
Cost Tracking
Structured results expose the usage and cost metadata available for the module:
result <- run(
mcc,
question = "Test",
.llm = llm,
.return_format = "structured"
)
result$metadata$cost
result$metadata$total_tokensSummary
These modules cover distinct execution strategies:
| Module | Best For | Trade-off |
|---|---|---|
chain_of_thought() |
Complex reasoning, math, logic | Longer model output |
best_of_n() |
High-variance tasks, critical outputs | Additional candidate calls |
refine() |
Tasks with clear failure modes | Iterative feedback calls |
multi_chain_comparison() |
Complex analysis, multiple valid approaches | Candidate and comparison calls |
program_of_thought() |
Exact computation, data analysis | Code execution overhead |
code_act() |
Tasks needing both tools AND computation | Agent loop overhead |
rlm_module() |
Adaptive exploration of large or irregular R objects | Experimental; iterative calls and an explicit execution boundary |
flex() |
Optimizing the choice among predictors, R logic, and tools | Experimental; executable source requires a sandbox |
Getting started: - Start with ChainOfThought for complex reasoning tasks - Add BestOfN when you need reliability - Use ProgramOfThought for exact computation (math, statistics) - Use CodeAct when you need tools AND code execution together - Use RLM when the exploration path is unknown for this input - Use Flex when the implementation strategy itself is the experiment
Further Reading
Tutorials: - Improving with Examples — Learn few-shot prompting - Finding Best Configuration — Grid search optimization - Investigate a Release Regression with an RLM — Explore a deterministic large object
How-to Guides: - Compile & Optimize — Full optimization workflow with advanced modules - Build RAG Pipelines — Use modules in retrieval workflows
Concepts: - Understanding Signatures & Modules — S7 vs R6 design choices - How Optimization Works — Teleprompter theory - How the RLM Works — Lifecycle, replay, typed submission, and runner boundaries
Reference: - Quick Reference — Syntax and patterns at a glance