Showcase / Built with Affordance
We built a Recursive Language Model as one Affordance file
Give a model a very long document and ask it to count something in it, and it often gets the count wrong or never finishes. Alex Zhang's Recursive Language Model, or RLM, is a way around that.
In an RLM the model never reads the long text. The text sits in a Python session, and the model writes code to look at it. We built one as a single Affordance file of 116 lines and gave it 17 questions. It got 12 right, and its prompt never went past 26,033 tokens.
The problem: some questions need every line
Here are three lines from a log of customer messages. The log in our largest test has 4,000 of them.
Date: 2026-05-03 || User: 121 || Message: Quick note: Will the printer get the update this month? Date: 2026-02-17 || User: 123 || Message: Your support line kept me on hold for 27 minutes and then hung up. Date: 2026-06-08 || User: 102 || Message: Hey, Could not be happier with the speaker, honestly.
The question is: how many of the messages are complaints? No line says "complaint". Something has to read each message and decide. Pasted into a prompt, the 4,000 lines are 121,314 tokens. A token is a piece of text about three quarters of a word long.
How an RLM answers it
The log is loaded into a running Python session, in a variable called context. The model gets one tool, which runs Python code in that session. It sees only what its code prints.
Code can do the exact parts, like splitting the log into lines and counting. For the parts that need reading, the code can call a function named llm_batch. It sends a list of small prompts to other calls of the model at the same time and gets their replies back as a list. Those helper calls are where "recursive" comes from: a model call that makes more model calls.
In one of our runs on the 4,000-line log, on the question of which user praised the product most, the model printed the size of the text and a few lines of it. Then it counted how often each sentence appeared, and saw that the 4,000 messages were built from a few dozen sentences. It sorted those few dozen sentences by hand instead of reading 4,000 lines, and answered after 7 calls.
What it took to build in Affordance
An Affordance app is a file that says what the model reads and what it is allowed to do. Affordance calls the model, runs commands on a machine for the app, and runs the app's code when the model acts. The RLM needs three things from the app.
The Python tool. In Affordance, something the model is allowed to do is called a tool. This one has a name, a description the model reads, the shape of its input, and the function to run.
const app = <aff tool_name="python" description="Run Python code in the persistent REPL; returns what it prints (and its last expression)."
args_schema={{type: "object", properties: {code: {type: "string"}}, required: ["code"]}}
onPython={python}>{"[python]\n"}</aff>;Answering the helper calls. When the Python code calls llm_batch, the session pauses and hands the prompts to the app. The app answers all of them at once with Promise.all, where each model(...) is one call to the model, and then lets the Python code continue. The helper calls are ordinary model calls made by the app, so they are recorded and counted like any other.
while (r.status == "llm") {
// A sub-LLM call that fails (after the harness's retries) answers with the error, so the
// cell sees it and goes on; a cell is never left parked.
const replies = await Promise.all(r.prompts.map(async p => {
try { return (await model(<Sub template={sub_template || template} prompt={p}/>)).text; }
catch (e) { return `[sub-LLM error: ${e}]`; }
}));
calls += replies.length;
r = await repl("rlm_repl resume", JSON.stringify(replies));
}Ending with an answer. The model finishes by setting a variable in Python. When the app sees it, it stops and reports the answer.
if (r.answer.ready) {
if (onDone) onDone({answer: r.answer.content, turns: harness.turn + 1, subcalls: calls});
harness.stop({answer: r.answer.content});
}The Python session is a helper program of 161 lines that comes with the example. Here is the whole app:
rlm.tsx, the whole file (116 lines)
import { useLog } from "../../lib/log.tsx";
// A Recursive Language Model (after Prime Intellect's RLM, primeintellect.ai/blog/rlm), as an
// instance tree. The root model never reads the input: it lives in a persistent Python REPL on the
// agent's machine as `context`, and the model works on it with code (`python`). Code can call
// sub-LLMs, `llm_batch(prompts)` (in parallel) or `llm_query(prompt)`, and it answers by setting
// `answer["content"]` and `answer["ready"] = True`. What the model sees of a cell is its output,
// capped at 8192 characters.
//
// The REPL (tools/rlm_repl, a daemon per machine home) makes no model calls: `llm_batch` parks the
// cell, the `python` handler answers every prompt with a model call on `Sub` (model(<Sub/>)) (all at once,
// Promise.all), and resumes the cell. So sub-LLM calls are this instance's own model calls: in its
// journal, under its budget, replayed on resume.
//
// The default export is a small bench: per task, an `RLM` and (with `direct`) a `Direct` baseline
// that reads the whole context in its prompt; the root grades both (`@results`).
import { TurnStrategy } from "../../strategies/turn_strategy.tsx";
import { useLoop } from "../../lib/loop.tsx";
import { MessagesApp } from "../../template_apps/messages.tsx";
import { GLM53App } from "../../template_apps/glm53.tsx";
const SYSTEM = `You are tasked with answering a query about a context that is too large to read directly. You work in a persistent Python REPL through the \`python\` tool, where these are defined:
1. \`context\`: the input (a string or JSON value). Look at its type, size and a few samples before you process it; never print it whole.
2. \`llm_batch(prompts)\`: sends a list of prompts to sub-LLMs at once and returns the list of their replies, in order (a reply that failed reads "[sub-LLM error: ...]"). A sub-LLM reads about 100,000 characters well. Use it to read and classify parts of \`context\` you cannot handle with code alone, e.g. chunk the context and ask a sub-LLM about each chunk in one batch. \`llm_query(prompt)\` sends one.
3. \`answer\`: a dict {"content": "", "ready": False}. Put your final answer in answer["content"] and set answer["ready"] = True when you are certain; that ends the task.
Variables persist between calls. You see what a cell prints (and its last expression), up to 8192 characters, so print summaries, not data. Use code for what code does exactly (splitting, counting, filtering) and sub-LLMs for what needs reading (meaning, classification). Make sub-LLM prompts self-contained and ask for output that is easy to parse.`;
const SUB_SYSTEM = "You are a careful assistant. Follow the instructions exactly and reply in the format asked.";
function Template({ template, system, log, children }) {
if (template == "glm53") return <GLM53App system={system}><TurnStrategy strategy="unrolled" log={log}>{children}</TurnStrategy></GLM53App>;
return <MessagesApp system={system}><TurnStrategy strategy="unrolled" log={log.map(r => ({...r, ids: null}))}>{children}</TurnStrategy></MessagesApp>;
}
// A sub-LLM call: one prompt, one reply.
function Sub({ template, prompt }) {
return <Template template={template} system={SUB_SYSTEM} log={[]}>{prompt}</Template>;
}
export function RLM({ question, context, template = "messages", sub_template, onDone }) {
const log = useLog();
useLoop(log);
const [loaded, setLoaded] = useState(false, "loaded");
const [subcalls, setSubcalls] = useState(0, "subcalls");
const repl = async (cmd, stdin) => {
const r = await harness.machine.call({cmd, stdin, timeout_s: 600});
const last = r.output.trim().split("\n").at(-1) || "";
try { return JSON.parse(last); } catch (e) { return {status: "error", error: r.output.slice(-2000)}; }
};
const python = async ({ code }) => {
// The input goes to the machine once, before the first cell, to a fresh REPL.
if (!loaded) {
await harness.machine.call({cmd: "rlm_repl stop >/dev/null; mkdir -p ~/.rlm && cat > ~/.rlm/context.json", stdin: JSON.stringify(context)});
setLoaded(true);
}
let r = await repl("rlm_repl start ~/.rlm/context.json >/dev/null && rlm_repl exec", code);
let calls = subcalls;
while (r.status == "llm") {
// A sub-LLM call that fails (after the harness's retries) answers with the error, so the
// cell sees it and goes on; a cell is never left parked.
const replies = await Promise.all(r.prompts.map(async p => {
try { return (await model(<Sub template={sub_template || template} prompt={p}/>)).text; }
catch (e) { return `[sub-LLM error: ${e}]`; }
}));
calls += replies.length;
r = await repl("rlm_repl resume", JSON.stringify(replies));
}
setSubcalls(calls);
if (r.status != "done") return "error: " + r.error;
if (r.answer.ready) {
if (onDone) onDone({answer: r.answer.content, turns: harness.turn + 1, subcalls: calls});
harness.stop({answer: r.answer.content});
}
return r.output || "(no output)";
};
const app = <aff tool_name="python" description="Run Python code in the persistent REPL; returns what it prints (and its last expression)."
args_schema={{type: "object", properties: {code: {type: "string"}}, required: ["code"]}}
onPython={python}>{"[python]\n"}</aff>;
const task = `Query: ${question}\n\nThe context is loaded in the REPL as \`context\`. Answer the query with the REPL.`;
return <Template template={template} system={SYSTEM} log={[{kind: "input", content: task}, ...log.records]}>{app}</Template>;
}
// The baseline: the whole context in the prompt.
export function Direct({ question, context, template = "messages", onDone }) {
const log = useLog();
useLoop(log);
const text = typeof context == "string" ? context : JSON.stringify(context);
const app = <aff tool_name="answer" description="Give your final answer."
args_schema={{type: "object", properties: {text: {type: "string"}}, required: ["text"]}}
onAnswer={({ text }) => { if (onDone) onDone({answer: text, turns: harness.turn}); harness.stop({answer: text}); return "done"; }}>{"[answer]\n"}</aff>;
const task = `Context:\n${text}\n\nQuery: ${question}\n\nRead the context and call \`answer\` with your final answer.`;
// A reply cut off before it answered is dropped, so a retry costs one context, not a growing log.
const kept = log.records.filter((r, i, t) => !r.cut_off && !(r.kind == "screen" && t[i + 1]?.cut_off));
return <Template template={template} system={SUB_SYSTEM} log={[{kind: "input", content: task}, ...kept]}>{app}</Template>;
}
// Exact match on the answer's first number or word, as Oolong scores counts and ids.
function grade(answer, expect) {
const m = String(answer).match(/-?\d+(\.\d+)?/);
return (m ? m[0] : String(answer).trim()) == expect;
}
export default function Bench({ tasks, template = "messages", sub_template, direct = false }) {
const [results, setResults] = useState({}, "results");
const record = (key, expect) => (r) => setResults({...results, [key]: {...r, expect, correct: grade(r.answer, expect)}});
// Each RLM's REPL runs on one of the root's machines (in its own home); Direct needs none.
return <>{tasks.map((t, i) => <>
<Mount key={`${t.id}-rlm`} model="default" machine={harness.machines[i % harness.machines.length]}>
<RLM question={t.question} context={t.context} template={template} sub_template={sub_template} onDone={record(`${t.id}-rlm`, t.expect)}/>
</Mount>
{direct ? <Mount key={`${t.id}-direct`} model="default">
<Direct question={t.question} context={t.context} template={template} onDone={record(`${t.id}-direct`, t.expect)}/>
</Mount> : ""}
</>)}</>;
}The test: the same questions, two ways
We made logs of 500, 1,500 and 4,000 messages, in the style of the Oolong benchmark. Each question needs every line: how many messages are complaints, which user praised the product most, how many of one user's messages ask a question.
The same model answered each question two ways. As an RLM, it worked on the log through Python. Reading directly, it got the whole log pasted into its prompt, which is the usual way.
| Log | Pasted into a prompt | Right as an RLM | Right reading directly |
|---|---|---|---|
| 500 messages, 51,162 characters | 15,301 tokens | 3 of 5 | 3 of 5 |
| 1,500 messages, 153,667 characters | 45,654 tokens | 4 of 6 | 2 of 6 (2 never answered) |
| 4,000 messages, 408,893 characters | 121,314 tokens | 5 of 6 | 0 of 6 (6 never answered) |
On the smallest log the two did equally well. On the largest, reading directly, the model wrote until it hit its limit of 32,768 tokens for a reply without giving an answer. We let it try each question 2 to 4 times and then stopped the run.
Tokens in the model's prompt
Reading directly, this is the whole log. As an RLM, it is the largest prompt the model saw while working, averaged over the questions.
The RLM pays for its small prompt with more calls. It made 20 model calls per question on the smallest log and 88 on the largest, counting the helper calls. Its wrong answers were mostly on the questions about one user, which both ways found hardest.
This was one run, with a DeepSeek-V4.1-Flash variant served by vLLM on our own machine, and it is a small test. Alex Zhang's post has results across more tasks and models.
Try it
The example is in the repository at views/demo/rlm, and its page in the docs says how to make the tasks and point it at a model. The idea is from Alex Zhang's post on Recursive Language Models. Our app follows the variant in Prime Intellect's post, where the model answers by setting a variable.