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 recursive language model (RLM) 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.

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.
| Dimension | Direct long-context call | RLM-style execution |
|---|---|---|
| Where input lives | In the model prompt, consuming context capacity | In the external environment; selected text is serialized into sub-call prompts |
| What inference runs | Prompt prefill over all tokens followed by autoregressive decoding | Iterative root turns, code execution under the chosen executor policy, and targeted sub-calls |
| What can grow too large | Input tokens, system instructions, and output generation reserve | Total turn history, external heap memory, recursion call tree, and cumulative spend |
| What failure modes occur | Context rot, missed needle evidence, and hallucinated synthesis | The 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:
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) == 0pairs_at_5000=12,497,500
pairs_at_10000=49,995,000
doubling_ratio=4.00xThe 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 denotes the number of comparably sized records:
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.

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.
Why doesn't moving a pairwise task into a REPL make it cheap?
Answer
External state avoids putting every record in one prompt, but it doesn't remove the relationships that must be checked. Doubling the record count creates roughly four times as many unordered pairs. Pruning must preserve the task's required evidence.
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.
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 == 31source_chars=5700
model_calls=31
largest_input_plus_reserve=407
output_is_digest=TrueA 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.

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:
# 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)exact_fact_recall=0.50
all_required_facts_correct=FalseThis 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:

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:
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 == 5invalid_states_rejected=5
candidate with source spansA 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:
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) == 800raw_chars=409
preview_chars=40
raw_still_available=True
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.
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}")candidate_steps=2
stopped=incomplete_or_step_budget
stopped=history_budgetThe 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 children over each parent's entire run, and maximum edge depth , run nodes total . 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.
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}")run_nodes=85
model_request_allowance=715Bound 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:
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)admitted_calls=3
reserved_usd=0.22
remaining_microdollars=30000Three 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:
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 <= capstale_admissions=4; promised_usd=0.60
shared_admissions=1; reserved_usd=0.15The 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:
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.concurrency=1: requests=8, ideal_waves=8
concurrency=2: requests=8, ideal_waves=4
concurrency=4: requests=8, ideal_waves=2Real 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 slice | Base | Selected comparator | RLM result |
|---|---|---|---|
| BrowseComp+ accuracy, GPT-5 | 0.0* | OpenCode with context offloading: 94.0 | Depth 1: 91.3 |
| OOLONG-Pairs F1, GPT-5 | 0.1 | CodeAct with sub-calls: 28.4 | Depth 1: 58.0; depth 3: 76.0 |
| OOLONG-Pairs F1, Qwen3-Coder | 0.1 | REPL-only depth 0: 17.3 | Depth 1: 23.1 |
| CodeQA accuracy, Qwen3-Coder | 20.0* | REPL-only depth 0: 66.0 | Depth 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]

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:
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)}")locked=True
host=False
open_egress=False
invalid_limit=False
missing_config=FalseThe 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:
- Neutral query formulation: Frame child tasks objectively (“Determine the operational status of
/v1/searchacross all versions, citing exact dates, deprecation notices, and conflicting records”). - Independent contradiction searches: Explicitly task a child sub-agent with seeking falsification (“Search the corpus for evidence showing that
/v1/searchremains active or supported”). - 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:
# 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'])}")three_echoes=needs_evidence
real_but_opposing_source=contradicted
conflicting_sources=conflict
matching_record=supported_in_fixtureThis 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 .
- Cost ceiling: p95 token cost must remain at or below \0.30$.
- Latency ceiling: p95 wall-clock latency must remain at or below .
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:
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"fixture_candidate=repl_only
no_eligible_candidate=hold
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
| Symptom | First check | Recovery |
|---|---|---|
| Calls multiply without new facts | Per-parent fanout, repeated spans, and shared budget reservations | Cap global calls; retry only when new evidence or a changed operation justifies it |
| Child answers a different question | Child mission, constraints, and source scope | Pass compact global requirements with the slice; reevaluate the same held-out cases |
| Root loses earlier findings | Complete serialized history size and compaction losses | Preserve mission and evidence pointers externally; stop or compact before another call |
| A returned string followed a limit | Typed stop reason and fallback path | Keep the result incomplete; require separate verification before use |
| A valid citation supports the opposite claim | Source text, dates, and claim-specific support | Mark contradiction or conflict; don't count source IDs as votes |
| A shallow run hangs | Cell execution and provider request deadlines | Enforce 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.