Skip to content
Skip to lesson
Back to Topics
LearnAdvanced Agents & RetrievalRecursive Language Models (RLM)
📈HardReasoning & Scaling

Recursive Language Models (RLM)

Learn Recursive Language Models (RLMs): keep long context in a programmable environment, delegate targeted sub-calls, and release the design only after measured quality, cost, and safety checks.

33 min read
Learning path
Step 134 of 197 in the full curriculum

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Agent recovery introduced bounded tool loops and explicit failure states. Now consider finding every deprecated-endpoint exception in an API manual too large for one model request. A summary can drop the expiry date you need; retrieval can miss a relevant exception. A large advertised context window doesn't establish reliable coverage on this task. Long-context accuracy depends on the model, task, distractors, and length; “context rot” describes observed degradation rather than one proven mechanism of diffuse attention.[1]

Dense attention over an entire prompt has quadratic attention computation. With a conventional KV cache, each new decoding token attends to the existing context rather than recomputing every prior pair.[2] Output limits bound generated text, but they don't make every concise answer over a large manual impossible. The challenge is obtaining adequate evidence and producing the answer the task requires.

A changes how the system interacts with input data.[1] Instead of forcing the entire corpus into the model prompt, it stores the corpus in a programmable environment, such as a Python read-eval-print loop (REPL). The root model receives compact input metadata and writes code to inspect, slice, filter, and partition the data. It can construct targeted sub-calls: the library's llm_query makes a plain call, while rlm_query can start a nested controller with its own REPL namespace when enabled. A private namespace isn't necessarily a separate process or security sandbox. Each underlying model still has its own context window.

We'll count the work before launching sub-calls, run a bounded split-and-merge baseline, and inspect where the adaptive controller can fail. The examples test request counts, completion signals, and budget admission without calling a model. They make the control flow visible; they don't establish extraction accuracy.

The manual stays outside the model window

Think of a program inspecting a large file in pieces while storing intermediate results. The root prompt holds instructions and recent observations; the external environment holds the source and variables. This is a useful storage-versus-working-set analogy, not an exact account of CPU caches or a guarantee that a model follows memory pointers correctly.

The original MIT OASYS architecture initializes a Python REPL, assigns the full prompt to a variable in its namespace, and gives the root model compact metadata.[1] This is a Python variable, not a shell environment variable. Generated code can inspect slices, compute regex matches, store intermediate values, and construct sub-call prompts. Code can assemble a result longer than any individual model-generated output. Each underlying call still has finite context and output limits; the wrapper must budget its complete request and handle overlong inputs.

The root model sends inspection code to an external REPL and receives bounded observations. The REPL holds source data and variables and sends selected text to plain or recursive model calls, receiving their results. A separate executor policy must enforce isolation; a private namespace is not an OS sandbox.
The source and intermediate values live outside the root prompt. Inspection code, observations, selected text, and sub-call results cross different boundaries; the executor's actual isolation policy remains a separate requirement.

For an API manual, generated code might locate version headers, partition clauses by date, and dispatch sub-calls to evaluate exceptions. The controller can revisit a slice or change its filter. A predetermined summary tree doesn't make those adaptive decisions, but either approach can win on a particular workload.[3] Measure coverage and cost instead of inferring superiority from flexibility.

The paper combines externalized input, stateful intermediate variables, and programmatic sub-calls inside generated code.[1] Other agents can share these features; tool access alone doesn't define the pattern. Preserve an authoritative source snapshot outside writable interpreter state. Python strings are immutable values, but variables can be rebound, nested records mutated, and files overwritten. A vector retriever can also keep source data external and return bounded evidence; RLMs aren't the only way to avoid prompt flooding.

DimensionDirect long-context callRLM-style execution
Where input livesIn the model prompt, consuming context capacityIn the external environment; selected text is serialized into sub-call prompts
What inference runsPrompt prefill over all tokens followed by autoregressive decodingIterative root turns, code execution under the chosen executor policy, and targeted sub-calls
What can grow too largeInput tokens, system instructions, and output generation reserveTotal turn history, external heap memory, recursion call tree, and cumulative spend
What failure modes occurContext rot, missed needle evidence, and hallucinated synthesisThe same semantic errors, plus faulty code execution, runaway recursion, and budget breaches

External storage preserves availability, not semantic coverage. A buggy loop can skip an entire file; an overly aggressive filter can discard the decisive exception clause. Even bug-free exhaustive code can't guarantee that a sub-model extracts or classifies each clause accurately.

Count the relationships, not just the tokens

Three requests over the same 10,000-page manual demand distinctly different computational workloads:

  • “Which endpoint replaced /v1/search?” requires locating one fact. Once found, the semantic work is complete.
  • “List all deprecation exceptions” requires scanning every page to extract active clauses.
  • “Compare every unordered pair of policies for conflicts” creates 49,995,000 pairwise comparisons before making any semantic determination.

Run the combinatorics calculation before deciding whether a model should launch one call per pair:

count-pairwise-work.py
def unordered_pairs(items: int) -> int:
    if type(items) is not int or items < 0:
        raise ValueError("items must be a nonnegative integer")
    return items * (items - 1) // 2

small = unordered_pairs(5_000)
large = unordered_pairs(10_000)
print(f"pairs_at_5000={small:,}")
print(f"pairs_at_10000={large:,}")
print(f"doubling_ratio={large / small:.2f}x")
assert unordered_pairs(0) == unordered_pairs(1) == 0
Output
pairs_at_5000=12,497,500
pairs_at_10000=49,995,000
doubling_ratio=4.00x

The paper formalizes these information-processing workloads into constant, linear, and quadratic complexity categories to benchmark needle retrieval, OOLONG aggregation, and OOLONG-Pairs cross-document reasoning.[1] Here NN denotes the number of comparably sized records:

I(N)∈{Θ(1), Θ(N), Θ(N2)}.\mathcal{I}(N) \in \{\Theta(1),\ \Theta(N),\ \Theta(N^2)\}.

Θ\Theta characterizes asymptotic information-processing scaling, not wall-clock API latency. Constant answer-relevant information doesn't make finding it computationally free. Similarly, a pairwise evaluation task doesn't force you to execute 50 million separate LLM inferences: intelligent systems apply symbolic shortcuts, indexing, clustering, or candidate pre-filtering before invoking semantic sub-calls.

Illustrative constant, linear, and quadratic work curves at one to five times the starting record count, normalized to one unit at 1x. Pairwise comparisons grow quadratically, showing why externalizing memory to a REPL does not eliminate underlying O(N^2) complexity.
Illustrative answer-relevant work: a fixed needle, per-record aggregation, and explicit pairwise comparison. These curves aren't measured token bills or lookup runtimes. Finding a fixed needle can still require a linear scan; emitting every qualifying pair can require quadratic output.

The paper's scaling experiments reveal that language model accuracy degrades sharply as input length grows, even when the input fits well within the model's nominal context window.[1] That finding confirms the importance of analyzing task complexity before architecting the execution loop. It doesn't mean every aggregation task requires recursion, nor does it mean an RLM erases the computational burden of quadratic relationships.

Start with a bounded split-and-merge baseline

Suppose the manual has 40,000 source tokens. A binary split produces 16 leaves of about 2,500 tokens, followed by merge layers of 8, 4, 2, and 1 calls. That's 31 calls, provided each leaf and merge also fits its instruction overhead and reserved output within the model window. A 4,096-token window isn't a 4,096-token source allowance.

The executable version below uses characters, deliberately avoiding a character-to-token conversion. Its fake model produces a digest, not a summary. This makes the lesson's evidence boundary explicit: the tree runs and its requests fit the toy budget, but its output preserves no semantic facts.

bounded-split-and-merge.py
from dataclasses import dataclass
from hashlib import sha256

LEAF = "Summarize policy: "
MERGE = "Merge policy summaries: "

@dataclass
class DigestModel:
    window_chars: int = 512
    output_reserve: int = 32
    calls: int = 0
    max_input: int = 0

    def call(self, prompt: str) -> str:
        if len(prompt) + self.output_reserve > self.window_chars:
            raise ValueError("request exceeds toy window")
        self.calls += 1
        self.max_input = max(self.max_input, len(prompt))
        return sha256(prompt.encode()).hexdigest()[:16]

def summarize(text: str, model: DigestModel) -> str:
    if type(model.window_chars) is not int or type(model.output_reserve) is not int:
        raise ValueError("character budgets must be integers")
    if model.output_reserve < 16:
        raise ValueError("reserve cannot fit the fake output")
    payload_limit = model.window_chars - model.output_reserve - len(LEAF)
    if payload_limit < 1:
        raise ValueError("no room for a source character")
    if len(MERGE) + 16 + 1 + 16 + model.output_reserve > model.window_chars:
        raise ValueError("merge cannot fit")

    def visit(chunk: str) -> str:
        if len(chunk) <= payload_limit:
            return model.call(LEAF + chunk)
        midpoint = len(chunk) // 2
        left = visit(chunk[:midpoint])
        right = visit(chunk[midpoint:])
        return model.call(MERGE + left + "|" + right)

    return visit(text)

model = DigestModel()
result = summarize("deprecation policy " * 300, model)
print(f"source_chars={len('deprecation policy ' * 300)}")
print(f"model_calls={model.calls}")
print(f"largest_input_plus_reserve={model.max_input + model.output_reserve}")
print(f"output_is_digest={len(result) == 16}")
assert model.calls == 31
Output
source_chars=5700
model_calls=31
largest_input_plus_reserve=407
output_is_digest=True

A real summarizer needs the deployed tokenizer, semantic chunk boundaries, per-call output caps, and a policy for overlong merge inputs. Splitting words or clauses in half can lose meaning even when every character was visited. The fake's fixed-size digest hides that challenge; it doesn't solve it.

Fixed binary baseline: 16 leaf calls, 8 pair merges, 4 quarter merges, 2 half merges, and 1 root merge total 31 calls. Each bottom group combines two leaf calls and their pair merge; the drawing groups five logical call layers into four visual levels.
The missing layer matters: 16 leaves + 8 pair merges + 4 quarter merges + 2 half merges + 1 root merge = 31 calls. Bottom groups combine a pair's two leaves and its merge. Each actual request needs instruction overhead and an output reserve.

Visiting every chunk doesn't preserve every fact

A summary that contains the heading “sunset date” but gives the wrong date has failed. For a small synthetic manual, check exact structured facts rather than keyword presence:

check-retained-policy-facts.py
# Authored ground truth for this fixture, not a model-generated label set.
required = {
    "sunset_date": "2026-09-30",
    "legacy_token_exception": "audit logging required",
}
candidate = {
    "sunset_date": "2026-08-31",
    "legacy_token_exception": "audit logging required",
}

def fact_check(expected: dict[str, str], actual: dict[str, str]) -> tuple[float, bool]:
    if not expected:
        raise ValueError("missing ground truth")
    correct = sum(actual.get(key) == value for key, value in expected.items())
    return correct / len(expected), correct == len(expected)

recall, passes = fact_check(required, candidate)
print(f"exact_fact_recall={recall:.2f}")
print(f"all_required_facts_correct={passes}")
assert fact_check(required, required) == (1.0, True)
Output
exact_fact_recall=0.50
all_required_facts_correct=False

This checks two labeled fields only. It doesn't judge free-text entailment, detect every extra false claim, or estimate corpus-wide recall. On real tasks, label the required evidence on held-out questions, inspect unsupported claims separately, and keep raw source spans available when a merge drops a fact.

The baseline gives us a known call tree and a visible failure. An adaptive controller now has something concrete to improve: retrieve or revisit the missing evidence without spending an uncontrolled number of calls.

The adaptive loop and its completion contract

In the paper's ablations, depth 0 retains the REPL without model sub-calls, depth 1 permits plain model sub-calls, and higher depths permit nested controllers.[1] The author-maintained runtime exposes llm_query for plain completions and rlm_query for recursive execution when enabled; both provide batched variants.[4] A child has separate working variables, but process isolation depends on the backend. The local implementation can run children in threads of the host process, and the caller receives a response string rather than the child's entire interpreter state.

API details were rechecked on October 10, 2026 at runtime commit 854e688fbba9d8f8989e3da9989812e4b6dfe270. The latest main, d04208afbad29ca675ab13478c40ee8bebc84bfe, changes only the README's paper reference; the runtime below is unchanged. Don't map paper labels blindly onto library arguments: RLM(max_depth=0).completion(...) takes a plain-LM fallback, not the paper's REPL-only depth-0 ablation. A REPL-only experiment must disable model sub-calling while retaining the environment.[4]

The basic state machine is smaller than the implementation:

Diagram showing Store corpus in external state, Root selects an inspection, Execute within sandbox and budgets, and Bounded observation.
Store corpus in external state, Root selects an inspection, Execute within sandbox and budgets, and Bounded observation.

Completion isn't inferred from the last line printed. The paper's algorithm uses a Final variable, its experimental prompts use FINAL(...) / FINAL_VAR(...), and the pinned library captures answer["content"] when answer["ready"] becomes truthy.[1][4] Those are different versioned interfaces, not interchangeable syntax.

For our wrapper, require a dictionary, Boolean True, and a nonempty string. This is stricter than the pinned library's truthiness and string conversion:

accept-only-explicit-final-state.py
def extract_final(answer: object) -> str:
    if not isinstance(answer, dict):
        raise ValueError("candidate state must be a dictionary")
    if answer.get("ready") is not True:
        raise ValueError("missing Boolean ready signal")
    content = answer.get("content")
    if not isinstance(content, str) or not content.strip():
        raise ValueError("candidate must be a nonempty string")
    return content

bad_states = [
    None,
    [],
    {"ready": False, "content": "printed answer"},
    {"ready": "false", "content": "looks ready"},
    {"ready": True, "content": None},
]
rejected = 0
for state in bad_states:
    try:
        extract_final(state)
    except ValueError:
        rejected += 1
print(f"invalid_states_rejected={rejected}")
print(extract_final({"ready": True, "content": "candidate with source spans"}))
assert rejected == 5
Output
invalid_states_rejected=5
candidate with source spans

A valid shape still says nothing about factual support. When integrating a library that already converts values to strings, validate at the environment boundary before that conversion, and retain a typed stop reason. Inspecting the final string alone can't recover how the run ended.

Bound accumulated history as well as one return

A 40-character preview repeated thousands of times is still too much history. Count the complete serialized request, including code, metadata, wrappers, and output reserve, with the actual tokenizer before every model call. Compaction is lossy; keep the original mission and source pointers outside it.

First make the preview bound honest. The truncation marker must fit inside the limit:

truncate-repl-output-before-history.py
def preview_for_history(stdout: str, limit: int = 40) -> str:
    if type(limit) is not int or limit < 0:
        raise ValueError("limit must be a nonnegative integer")
    if len(stdout) <= limit:
        return stdout
    marker = "...[truncated]"
    if limit <= len(marker):
        return marker[:limit]
    return stdout[:limit - len(marker)] + marker

raw = "\n".join(f"incident log line {index}" for index in range(20))
preview = preview_for_history(raw)
print(f"raw_chars={len(raw)}")
print(f"preview_chars={len(preview)}")
print(f"raw_still_available={len(raw) == 409}")
assert all(len(preview_for_history(raw, n)) <= n for n in range(50))
assert 20 * len(raw) == 8180 and 20 * len(preview) == 800
Output
raw_chars=409
preview_chars=40
raw_still_available=True
Illustrative history accounting for twenty returns of a 409-character log: full payloads contribute 8,180 characters, while forty-character previews contribute 800. Both accumulated histories grow with the number of returns; metadata, generated code, and wrappers are additional. The raw source remains available outside the prompt.
A per-return cap is not a constant total history. Twenty forty-character previews contribute 800 characters before metadata, code, and wrappers; the full-payload version contributes 8,180. Retain source access and check the complete next request.

The next fixture tests an outer loop without executing generated code. peek and finish are fixed actions, not a Python REPL or a substitute for one. Every step is accounted for, and exceeding the accumulated character budget stops the run rather than silently discarding earlier evidence.

bounded-controller-state-machine.py
import json

def run_fixture(actions: list[str], corpus: str, max_steps: int = 3,
                history_limit: int = 500) -> tuple[str, int]:
    if type(max_steps) is not int or max_steps < 1:
        raise ValueError("max_steps must be positive")
    if type(history_limit) is not int or history_limit < 1:
        raise ValueError("history_limit must be positive")
    history = [{"corpus_chars": len(corpus), "pointer": "corpus"}]
    candidate = None
    for action in actions[:max_steps]:
        if len(json.dumps(history)) > history_limit:
            raise RuntimeError("history_budget")
        if action == "peek":
            observation = corpus[:40]
        elif action == "finish":
            observation = "candidate stored"
            candidate = "fixture candidate, not a semantic answer"
        else:
            raise ValueError("unknown fixture action")
        history.append({"action": action, "observation": observation})
        if len(json.dumps(history)) > history_limit:
            raise RuntimeError("history_budget")
        if candidate is not None:
            return candidate, len(history) - 1
    raise RuntimeError("incomplete_or_step_budget")

candidate, steps = run_fixture(["peek", "finish"], "policy " * 1000)
print(f"candidate_steps={steps}")
for actions, limit in [(["peek"] * 4, 500), (["peek", "finish"], 80)]:
    try:
        run_fixture(actions, "policy " * 1000, history_limit=limit)
    except RuntimeError as error:
        print(f"stopped={error}")
Output
candidate_steps=2
stopped=incomplete_or_step_budget
stopped=history_budget

The pinned runtime defaults to 30 iterations, then makes an extra best-effort model call if the loop never finalizes. Its response string alone doesn't distinguish fallback from normal completion. Configured timeout, token, or budget breaches instead raise typed exceptions; don't collapse them into the iteration-exhaustion path.[4] Instrument the stop reason, preserve partial results as partial, and account for fallback calls. A returned string isn't automatically a verified answer.

persistent=True can retain an environment between completions, and RLMLogger captures trajectories.[4] Neither makes a live interpreter a durable distributed checkpoint. Store authorized input pointers, inspected spans, candidate facts, stop reasons, and budget usage. Scope persistent sessions to the user and corpus authorization; never reuse a previous tenant's variables.

Recursion needs shared budgets

For a full tree with at most b>1b>1 children over each parent's entire run, and maximum edge depth dd, run nodes total ∑k=0dbk=O(bd)\sum_{k=0}^{d} b^k = O(b^d). Four children down to depth three give 85 run nodes. That isn't necessarily 85 model requests: a controller may make many root turns, plain leaf calls, retries, or a fallback.

Predict the request count under this declared policy: levels 0 through 2 are controllers allowed 30 turns plus one fallback; level 3 has 64 plain leaf calls. Every controller launches at most four children in total. Compaction and transport retries are excluded.

count-runs-versus-model-requests.py
branching, depth, controller_requests = 4, 3, 31
controllers = sum(branching ** level for level in range(depth))
leaves = branching ** depth
run_nodes = controllers + leaves
requests = controllers * controller_requests + leaves
assert (controllers, leaves, run_nodes, requests) == (21, 64, 85, 715)
print(f"run_nodes={run_nodes}")
print(f"model_request_allowance={requests}")
Output
run_nodes=85
model_request_allowance=715

Bound depth, total children per parent, root turns, and global calls. A cap per batch alone doesn't bound how many batches can be launched. Choose limits for the task rather than treating depths one or two as a production standard. Shared atomic admission must cover root turns, leaves, compaction, retries, and fallback calls.

The pinned runtime checks token and cost totals after an iteration and elapsed time before another iteration. It passes remaining allowances to nested controllers, but these aren't atomic pre-call reservations or forced interruption of in-flight code. Missing cost metadata becomes 0.0 in its budget check; some helper failures become Error: ... response strings. A depth-capped sub-call takes its plain-completion branch before the nested child's remaining-budget checks. The depth cap therefore cannot replace shared admission before each model request.[4] An application must distinguish those failures from useful evidence and refuse new spending when it cannot account for usage safely.

In a synthetic concurrency example, four children each read a balance of $0.20, then spend $0.15 each: total $0.60. A shared atomic reservation prevents that specific admission race when estimates bound actual usage. Enforce deadlines outside generated code and retain unknown provider outcomes for reconciliation. One host-side coordinator can own admission; a separate network service isn't required by the architecture.

For a simple reservation calculation, integer microdollars avoid floating-point currency surprises. One million microdollars equals one US dollar. These are authored worst-case call estimates, not provider prices or measured spend:

reserve-before-dispatch.py
def admit(estimates: list[int], cap: int) -> tuple[int, int]:
    if type(cap) is not int or cap < 0:
        raise ValueError("cap must be nonnegative integer microdollars")
    if any(type(value) is not int or value < 0 for value in estimates):
        raise ValueError("estimates must be nonnegative integer microdollars")
    reserved = 0
    count = 0
    for estimate in estimates:
        if reserved + estimate > cap:
            break
        reserved += estimate
        count += 1
    return count, reserved

count, reserved = admit([50_000, 80_000, 90_000, 120_000], 250_000)
print(f"admitted_calls={count}")
print(f"reserved_usd={reserved / 1_000_000:.2f}")
print(f"remaining_microdollars={250_000 - reserved}")
assert admit([250_000, 1], 250_000) == (1, 250_000)
Output
admitted_calls=3
reserved_usd=0.22
remaining_microdollars=30000

Three calls reserve $0.22; the fourth would exceed $0.25. This serial arithmetic isn't a concurrent budget service. Admission control must atomically reserve against one shared total before dispatch, reconcile actual usage afterward, and fail closed on unknown cost. A true cap also needs valid upper bounds from token limits and the applicable rate schedule. An underestimated reservation doesn't bound the bill.

The race comes from separating the balance check from the reservation. A barrier below makes all four workers read the same stale balance before any can proceed. The second path places the check and update under one shared lock:

reproduce-and-prevent-concurrent-budget-admission.py
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Lock

cap, estimate = 200_000, 150_000  # Integer microdollars, not provider prices.
all_checked = Barrier(4)

def stale_admission(_):
    observed_available = cap
    all_checked.wait(timeout=5)
    return estimate <= observed_available

with ThreadPoolExecutor(max_workers=4) as pool:
    naive_count = sum(pool.map(stale_admission, range(4)))

lock = Lock()
reserved = 0

def shared_admission(_):
    global reserved
    with lock:
        if reserved + estimate > cap:
            return False
        reserved += estimate
        return True

with ThreadPoolExecutor(max_workers=4) as pool:
    admitted_count = sum(pool.map(shared_admission, range(4)))

print(f"stale_admissions={naive_count}; promised_usd={naive_count * estimate / 1_000_000:.2f}")
print(f"shared_admissions={admitted_count}; reserved_usd={reserved / 1_000_000:.2f}")
assert naive_count == 4 and naive_count * estimate > cap
assert admitted_count == 1 and reserved == estimate <= cap
Output
stale_admissions=4; promised_usd=0.60
shared_admissions=1; reserved_usd=0.15

The lock protects admission in this one-process thread fixture. Separate worker processes or hosts need a coordinator or transactional store with the same check-and-reserve invariant. No provider call happens here. Reservations remain outstanding when a request's billed outcome is unknown; freeing them on an ambiguous timeout can admit spending that the original call still incurs.

Concurrent dispatch isn't fewer model requests

There are two different optimizations. Combining several records into one prompt can reduce request overhead but changes context, failure, and output-parsing behavior. Dispatching several independent prompts concurrently overlaps waiting but retains the requests and token work. A helper named “batched” doesn't establish a provider discount or a single billed completion.

The runtime's llm_query_batched handles plain calls; rlm_query_batched can launch child RLMs, with a max_concurrent_subcalls setting for those children.[4] A per-parent concurrency limit is not a global limit across an expanding tree.

This scheduling model assumes each call occupies one equal-duration slot. It counts idealized waves, not measured network round trips:

count-concurrent-waves.py
def schedule(requests: int, concurrency: int) -> tuple[int, int]:
    if type(requests) is not int or requests < 0:
        raise ValueError("requests must be a nonnegative integer")
    if type(concurrency) is not int or concurrency < 1:
        raise ValueError("concurrency must be a positive integer")
    return requests, (requests + concurrency - 1) // concurrency

for concurrency in (1, 2, 4):
    requests, waves = schedule(8, concurrency)
    print(f"concurrency={concurrency}: requests={requests}, ideal_waves={waves}")
assert schedule(0, 4) == (0, 0)
assert schedule(2**60 + 1, 2)[1] == 2**59 + 1  # Avoid lossy float division.
Output
concurrency=1: requests=8, ideal_waves=8
concurrency=2: requests=8, ideal_waves=4
concurrency=4: requests=8, ideal_waves=2

Real requests have unequal durations, rate limits, queueing, and retries. Measure those. Use plain calls when one classification suffices; allocate child RLMs when a subtask needs iterative inspection and private working state. Neither choice supplies factual verification automatically.

Evidence for where recursion helps

The following results are from paper v3, May 11, 2026, not an October 2026 comparison of current model products.[1] The main table uses GPT-5 with GPT-5-mini sub-calls, plus a separate Qwen3-Coder-480B-A35B setup. Its tasks include CodeQA at 23K to 4.2M tokens, BrowseComp+ with 1,000 documents at 6M to 11M tokens, OOLONG at 131K, and OOLONG-Pairs at 32K.

These selected comparisons show why the REPL-only ablation matters. The comparator column is not a claim to list the strongest method in every row:

Paper sliceBaseSelected comparatorRLM result
BrowseComp+ accuracy, GPT-50.0*OpenCode with context offloading: 94.0Depth 1: 91.3
OOLONG-Pairs F1, GPT-50.1CodeAct with sub-calls: 28.4Depth 1: 58.0; depth 3: 76.0
OOLONG-Pairs F1, Qwen3-Coder0.1REPL-only depth 0: 17.3Depth 1: 23.1
CodeQA accuracy, Qwen3-Coder20.0*REPL-only depth 0: 66.0Depth 1: 56.0

Scores use a 0 to 100 scale; * marks runs that sometimes encountered input-window limits. A context-limit failure is not a clean measurement of in-window reasoning ability. These tasks also use different metrics and sample sizes, so don't average their displayed scores as if they were one homogeneous test.[1]

More recursion isn't monotonic improvement. GPT-5 CodeQA rises from 58.0 at depth 0 to 66.0 at depth 2, then falls to 58.0 at depth 3. On LongCoT-mini, a separate compositional reasoning experiment, GPT-5.2 scores 38.7 overall and depth-1 RLM scores 50.6, yet MATH falls from 26.0 to 5.6. Decomposition hints lift overall RLM performance to 65.6.[1] The program must divide the actual problem correctly.

Cost is also task-specific. Table 1 reports historical average API costs of $0.11 for GPT-5 RLM depth 1 versus $0.13 for the base on CodeQA, but $0.33 versus $0.16 on OOLONG-Pairs. CodeQA includes window-limited base runs, so its cost difference isn't an equal-quality saving. These are paper accounting figures, not current quotes or total service costs including sandbox infrastructure.[1]

The paper reports wide cost/runtime variation and used blocking, sequential model calls in these experiments. It cautions that runtime depends on implementation and provider latency.[1] Reproduce quality and tails on your own workload; don't turn a benchmark average into an operational guarantee.

What post-training adds

The authors also train a smaller root controller, RLM-Qwen3-8B. In v3, the main text reports a 28.3% relative median improvement over base Qwen3-8B across four evaluation tasks. This is not 28.3 percentage points and not proof of general reasoning superiority.[1]

Their main text rounds the data to 1,000 filtered trajectories. Appendix A describes 2,250 candidates from 750 English LongBenchPro tasks, then 1,072 after dropping zero-score and one-turn trajectories. Root turns become supervised examples, with additional overlong-turn filtering and protocol corrections. Training used 300 steps, batch size 64, and 48 H100-hours.[1]

Diagram showing Trajectory collection, Root-controller supervision, 2,250 candidates, and 1,072 after filters.
Trajectory collection, Root-controller supervision, 2,250 candidates, and 1,072 after filters.

These results suggest that inspecting state, making useful sub-calls, and following the completion protocol can be learned. The same architecture still needs a safe executor and bounded requests. A separate v3 experiment trains a Qwen3-4B-Instruct-0527 RLM on 32K to 64K-token MRCRv2 inputs with two needles and evaluates length generalization on 512K to 1M-token inputs with eight needles.[1] That specific result doesn't establish arbitrary-length generalization.

Keep generated code behind an enforced boundary

The pinned repository defaults to local execution using Python exec in the host process. Its limited namespace is not an operating-system sandbox. The README also lists Docker and cloud execution backends, but choosing a backend name doesn't establish an adequate security policy.[4]

Treat all model-generated code as untrusted, including code influenced by documents in a private corpus. An injected document can request secrets, mutate files, or use an allowed tool to exfiltrate content. Enforce filesystem isolation, resource limits, constrained egress, and narrowly scoped tool credentials outside the model. Children must not gain broader permissions than the parent. See code-generation sandboxing for implementation choices.

This configuration fixture admits only a deliberately narrow policy. It neither launches nor verifies a real sandbox:

check-declared-execution-policy.py
def policy_meets_minimum(policy: dict[str, object]) -> bool:
    allowed = {
        "isolation": {"container", "microvm"},
        "network": {"deny"},
        "secrets": {"none"},
        "filesystem": {"ephemeral"},
    }
    for key, choices in allowed.items():
        value = policy.get(key)
        if not isinstance(value, str) or value not in choices:
            return False
    for key, ceiling in [("cpu_seconds", 30), ("memory_mib", 2048)]:
        value = policy.get(key)
        if type(value) is not int or not 0 < value <= ceiling:
            return False
    return True

locked = {
    "isolation": "container", "network": "deny", "secrets": "none",
    "filesystem": "ephemeral", "cpu_seconds": 30, "memory_mib": 1024,
}
for name, policy in [
    ("locked", locked),
    ("host", {**locked, "isolation": "host"}),
    ("open_egress", {**locked, "network": "open"}),
    ("invalid_limit", {**locked, "cpu_seconds": -1}),
    ("missing_config", {}),
]:
    print(f"{name}={policy_meets_minimum(policy)}")
Output
locked=True
host=False
open_egress=False
invalid_limit=False
missing_config=False

The real deployment must prove that the declared limits are applied: denied host mounts, blocked egress, absent secrets, enforced memory limits, and termination of a hanging cell. A trusted host-side model proxy can perform authorized calls without placing provider credentials in generated-code memory. That proxy still needs tenant authorization, input/output bounds, and shared budgets.

A sandbox limits side effects. It doesn't make tool output truthful or make a child model's interpretation correct. That requires a separate evidence check.

Don't let children certify the parent's guess

Suppose a controller sends three children the leading question “Why was /v1/search removed in 2024?” without evidence of that removal. The children may inherit the premise and return plausible rationales. If the controller mistakes agreement for verification, it can reinforce the original error. Different model calls aren't automatically independent sources of evidence.

Three checks can reduce this risk without guaranteeing independent or correct conclusions:

  1. Neutral query formulation: Frame child tasks objectively (“Determine the operational status of /v1/search across all versions, citing exact dates, deprecation notices, and conflicting records”).
  2. Independent contradiction searches: Explicitly task a child sub-agent with seeking falsification (“Search the corpus for evidence showing that /v1/search remains active or supported”).
  3. Citation provenance and lineage tracking: Record source document IDs and parentage. Repeated descendants of one claim aren't three independent confirmations. A shared prompt is no supporting evidence by itself; inspect whether the reports supply distinct applicable sources and what each actually establishes.

The next fixture checks a claim against trusted structured records, not against worker-written supported=True flags:

verify-claim-against-trusted-records.py
# Authored fixture records stand in for reviewed source evidence.
registry = {
    "doc:registry": {"endpoint": "/v1/search", "status": "active"},
    "doc:old-copy": {"endpoint": "/v1/search", "status": "removed"},
}

def adjudicate(claim: dict[str, str], source_ids: list[str]) -> str:
    records = [registry[key] for key in set(source_ids) if key in registry]
    relevant = [r for r in records if r["endpoint"] == claim.get("endpoint")]
    if not relevant or claim.get("status") not in {"active", "removed"}:
        return "needs_evidence"
    supports = any(r["status"] == claim["status"] for r in relevant)
    contradicts = any(r["status"] != claim["status"] for r in relevant)
    if supports and contradicts:
        return "conflict"
    return "contradicted" if contradicts else "supported_in_fixture"

claim = {"endpoint": "/v1/search", "status": "removed"}
print(f"three_echoes={adjudicate(claim, [])}")
print(f"real_but_opposing_source={adjudicate(claim, ['doc:registry'] * 3)}")
print(f"conflicting_sources={adjudicate(claim, ['doc:registry', 'doc:old-copy'])}")
print(f"matching_record={adjudicate(claim, ['doc:old-copy'])}")
Output
three_echoes=needs_evidence
real_but_opposing_source=contradicted
conflicting_sources=conflict
matching_record=supported_in_fixture

This is exact-field comparison in an invented registry, not natural-language verification. Real evidence checks need source text, claim scope, applicability dates, and conflict resolution. Preserve unknown and conflicting states. A model judge can help triage them, but its confidence isn't independent corroboration. Deduplicate source identity and track lineage so repeated descendants don't become extra votes.

Choose a route from held-out results

A direct call, REPL-only controller, and recursive route are candidate designs, not automatically members of a Pareto frontier. Compare them on the same held-out tasks and account for unsupported claims as well as required-fact recall. The authored fixture below supplies invented summary statistics to demonstrate this policy; it runs no models and contains no measured held-out results:

  • Quality gate: Fact recall must reach at least 0.900.90.
  • Cost ceiling: p95 token cost must remain at or below \0.30$.
  • Latency ceiling: p95 wall-clock latency must remain at or below 12.0 s12.0\text{ s}.

Higher recall can't buy forgiveness for a cost or latency breach. If a candidate violates any binding gate, it's disqualified. If no candidate clears all three gates, the system returns an explicit hold status rather than deploying a non-compliant model.

The selection rule chooses the lowest-cost eligible route, breaking cost ties by latency. Different tasks may use different validated policies:

select-a-route-from-fixture-results.py
from math import isfinite

results = [
    {"route": "direct", "recall": 0.73, "p95_cost": 0.08, "p95_seconds": 4.1},
    {"route": "repl_only", "recall": 0.92, "p95_cost": 0.19, "p95_seconds": 8.7},
    {"route": "recursive", "recall": 0.95, "p95_cost": 0.58, "p95_seconds": 24.0},
]

def choose(rows: list[dict]) -> str:
    eligible = []
    for row in rows:
        if not isinstance(row.get("route"), str) or not row["route"].strip():
            continue
        values = [row.get(key) for key in ("recall", "p95_cost", "p95_seconds")]
        if not all(type(v) in (int, float) and (type(v) is int or isfinite(v)) for v in values):
            continue
        recall, cost, seconds = values
        if 0.90 <= recall <= 1 and 0 <= cost <= 0.30 and 0 <= seconds <= 12:
            eligible.append(row)
    if not eligible:
        return "hold"
    return min(eligible, key=lambda r: (r["p95_cost"], r["p95_seconds"]))["route"]

print(f"fixture_candidate={choose(results)}")
print(f"no_eligible_candidate={choose([results[0], results[2]])}")
assert choose([]) == "hold"
assert choose([{**results[1], "p95_cost": 10**1000}]) == "hold"
assert choose([{**results[1], "recall": float("nan")}]) == "hold"
assert choose([{**results[1], "recall": True}]) == "hold"
Output
fixture_candidate=repl_only
no_eligible_candidate=hold
Authored selection fixture for three routes on the same task: direct misses the 0.90 recall gate; REPL-only clears recall, cost, and latency gates and is selected; recursive exceeds the cost and latency ceilings and is ineligible. The whole decision becomes hold only when no route qualifies. These figures are not measurements.
Invented values test a selection rule: REPL-only qualifies, so the decision is REPL-only rather than hold. The recursive candidate is ineligible. A real comparison needs the same tasks, measured outcomes, and uncertainty.

A p95 threshold is a population statistic, not a per-request hard cap. Estimate it from enough attempts, report uncertainty, and retain runtime limits for individual runs. If a run exhausts its budget, return an explicit partial or abstaining state. Don't silently replace an evidence-demanding task with a lossy summary and call it success.

Diagnose the failed boundary

SymptomFirst checkRecovery
Calls multiply without new factsPer-parent fanout, repeated spans, and shared budget reservationsCap global calls; retry only when new evidence or a changed operation justifies it
Child answers a different questionChild mission, constraints, and source scopePass compact global requirements with the slice; reevaluate the same held-out cases
Root loses earlier findingsComplete serialized history size and compaction lossesPreserve mission and evidence pointers externally; stop or compact before another call
A returned string followed a limitTyped stop reason and fallback pathKeep the result incomplete; require separate verification before use
A valid citation supports the opposite claimSource text, dates, and claim-specific supportMark contradiction or conflict; don't count source IDs as votes
A shallow run hangsCell execution and provider request deadlinesEnforce cancellation outside generated code; depth limits alone can't stop it

Practice: preserve the exception through a merge

Use the two-field policy fixture. Change the candidate date to the correct date, then delete the exception field. Predict the recall each time before running the check. Next add an extra false field and observe that required-fact recall doesn't decrease. Explain why unsupported-claim checking is a separate metric.

For the tree, change window_chars from 512 to 256 and count calls again. Compare the measured count with the layer diagram; include instruction and output reserve when calculating the leaf allowance. Finally set the window so the merge can't fit. The program should reject the configuration, not recurse forever.

Check the expected outcomes

Correcting the date yields recall 1.0. Removing the exception leaves 0.5. Extra unsupported fields don't affect this recall function, which is exactly why it isn't a full answer verifier. With the supplied 5,700-character input and a 256-character window, the baseline has 32 leaves and 63 total calls. A window of 64 can't fit the merge plus its output reserve and fails before making calls.

The real-model follow-up is a separate experiment: compare direct, retrieval-assisted, REPL-only, and recursive routes on labeled manual questions. Report actual model calls, tokens, retries, failures, and source-grounded accuracy. Passing the deterministic examples above is preparation for that experiment, not a substitute for it.

RLM reorganizes inference around an external environment. Post-training can improve the controller, but the operating contract still belongs to the application: authorized access, bounded work, explicit completion, and checked evidence.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A task examines each unordered pair in a 10,000-item set. What planning estimate follows?
2.What should an RLM place in the outer prompt when the full dataset is available through a controlled REPL?
3.An RLM has selected 500 records. Most need one independent classification call; a small subset needs iterative search, code, and private working state. Which execution plan matches the recursion boundary?
4.A 40,000-token manual uses fixed binary splitting. Each 2,500-token leaf and each merge fits after instruction and output reserves. What call budget follows?
5.How should this lesson's wrapper mark a candidate ready for evidence checking?
6.Eight independent prompts use concurrency four instead of one. Under equal-duration calls with no rate limiting, what changes?
7.Which declared execution policy satisfies this lesson's deliberately narrow minimum?
8.A full four-way tree has 85 run nodes. Its 21 internal controllers each permit 31 requests, and its 64 leaves each permit one. What request allowance follows?
9.In the authored routing fixture, the gates are recall >= 0.90, p95 cost <= 0.30, and p95 latency <= 12 seconds. Direct is 0.73/0.08/4.1, REPL is 0.92/0.19/8.7, and recursive is 0.95/0.58/24. Which candidate meets all three?
10.A premise-verification task receives several model answers that all repeat the same unsupported claim. What aggregation rule reduces correlated-echo errors?

10 questions remaining.

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.