A recursive language model treats long context as an external environment instead of a prompt. The model decomposes a task, runs bounded inference over selected snippets, and composes the returned values. Autolith realizes this as a middle layer between a tool call and a child agent, so recursion becomes an ordinary Lisp operation:
(infer "List the invariants this file maintains."
:context (list #p"src/conversation/store.lisp")
:contract '(:type :object
:properties
(("invariants" (:type :array :items (:type :string))))
:required ("invariants"))
:budget (rlm-budget-create :calls 16 :tokens 80000 :depth 3))Autolith already has the outer level. Task children are persistent
agents with conversations, tool registries, and orchestrator
scheduling. RLM adds the two inner levels plus the paper-defining
root operation, rlm-complete, described below:
- An inference call. One bounded inference operation over explicit context. Returns a Lisp value. Never touches the caller’s conversation.
- An inference frame. One task with a private ephemeral conversation, a contract, and a budget subtree. A frame may recurse into child frames until the budget runs out. Only the frame’s value and a trace reference escape.
A frame never inherits the parent conversation, the agent identity, or unrestricted capabilities. It receives the task, the materialized context views, the contract, and the remaining budget. This containment is what makes recursion cheap and safe: a hundred frames cannot pollute the primary conversation or the prompt cache. The frame’s request puts the views first, then the answer shape, and the task last, so frames asking different questions of the same views share a cacheable prefix.
Context is passed explicitly, and the designator set is split by authority. Programmatic Lisp callers in the active image may pass:
- a string, passed verbatim,
- a pathname, read at call time,
- a plist
(:label ... :content ...)for labeled literals.
The model-visible tools never accept raw filesystem paths. Their
views carry literal text, a resource URI resolved through the
resource protocol under the caller’s authority (so restricted frames
stay confined to their readable scheme set), or a stored context
object reference as context:<sha256>. A resource view reads the
resource whole, up to *rlm-view-maximum-bytes* (4 MiB), so a
conversation: view carries the complete durable history rather than
the paged window resource.read returns. Each view materializes into a
labeled block with a content digest, so a trace records exactly what
the frame saw. Views are read-only and fixed for the frame’s
lifetime.
A contract names the shape of the value a frame must return. Contracts
reuse the task output schema language documented for task agent roles;
task-output-schema-normalize validates the definition and
task-output-schema-valid-p validates the value. Two contract forms
exist:
:text, the default. The frame returns its answer as a string.- a schema, for example
(:type :object :properties (("claims" (:type :array :items (:type :string)))) :required ("claims")). The frame is instructed to answer with exactly one JSON value of any type, object and array included, and the parsed, validated value is returned as portable tagged native data.
A malformed answer is repaired by re-asking within the remaining budget.
On exhaustion, infer signals rlm-partial-result, a subtype of
rlm-budget-exhausted. Read rlm-partial-result-observation for the explicit
:incomplete status, exhausted allowance, remaining budget, trace identifier,
and bounded recent assistant and tool outputs. These outputs are evidence,
not a validated answer. Read inference:<trace-id> for the complete record.
The tool response contains this observation rather than a bare failure.
Every inference runs under an rlm-budget: remaining calls, tokens,
and recursion depth, with thread-safe accounting. Recursive frames
share the parent’s counters and decrement depth. Every provider
request atomically reserves, in one critical section, one call and an
output token tranche; the tranche doubles as the request’s provider
output ceiling where the wire protocol accepts one (elsewhere it stays
an accounting reservation) and is settled against the reported usage
afterwards,
with failed or usage-less requests refunding the output tranche in
full while the admitted call stays consumed. Settlement discounts
prompt-cache reads: cached input tokens are repeated context served
from the provider cache, so a long run over a shared prefix drains the
pool only for its genuinely new work. A tranche takes
at most a share of the remaining pool (a quarter by default, capped
at 16000 tokens) and never more than the pool holds, so concurrent
siblings keep headroom and combined reservations cannot oversubscribe
the allocation; only input tokens stay post hoc, gating the next
reservation rather than the current one. Exhaustion stops further provider
calls; incomplete observations preserve the work already performed.
rlm-complete is the paper-faithful realization: the complete input
remains outside the root model context, and only selected bounded
slices are supplied to sub-inference contexts. The context designator
is interned as
an immutable content-addressed object under data/inferences/objects/
and the root model receives only its label, size, and digest. A
dedicated heap-isolated Lisp environment holds the content, every
intermediate value, and the recursion:
(rlm-complete "Summarize every incident in this log."
:context #p"/var/log/huge.log"
:budget (rlm-budget-create :calls 24 :tokens 240000
:depth 2))The root model drives the environment through the env.eval tool,
one form per call, observing only bounded printed values and output.
Inside the environment, (context-length), (context-slice start
end), and (context-search pattern) inspect the external object;
(infer ...), (rlm-map ...), and (rlm-run ...) proxy
sub-inferences back to the host over an authenticated loopback
endpoint, where budgets, contracts, and traces are enforced host-side
because environment code is model-authored and untrusted; the run
proxy also makes installed distilled decomposition policies reachable
from root runs; (finish value) records the final answer. Every proxied call descends the run’s shared budget subtree,
so model-authored loops can never outspend the root allocation.
Finish is terminal: the first finish wins, later operations are
refused, and the value must be portable readable data because it
crosses the environment boundary as printed text. The run returns
the recorded Lisp value plus the root trace identifier.
The model reaches the same operation through the rlm.complete tool.
Every proxied operation also appends one replay-safe :rlm-call
metadata record to the root trace, carrying the operation, the child
trace identifiers, each child’s settled billable token spend, and the
remaining budget, so cost reporting can split a conversation’s own
usage from its recursive spend. The root trace therefore
holds the run’s machine-readable invocation tree even when
environment code discards the trace identifiers it receives, which is
what trace-driven policy distillation reads.
The defining litmus property is tested end to end: an input larger than the provider window is processed almost completely, with the decomposition authored by the root model as Lisp, no provider request exceeding the window, intermediate values staying in the environment, and the exact answer returned.
After completion or budget exhaustion, the worker is retained for another run
over the same context under the same originating configuration. Per-call model
routing does not change that ownership. The next request includes earlier
names; inspect (environment-names) to see them live.
An incomplete rlm.complete response includes a context: URI and continuation
instructions. Call it again with that URI in context.object and a fresh budget
to continue from the retained variables. In Lisp, resolve the digest with
rlm-context-object-find and pass the object as :context.
Retention is in memory, not a saved image: at most two idle workers are kept. Eviction, shutdown, and other failures stop them. If a retained worker dies, the next run starts a fresh environment and reports the loss. Trace and context files remain available independently of the worker.
rlm-map fans a list of tasks out as frames sharing one budget
subtree, each task a string or a (:task ... :context ...) plist.
Results keep task order. Finished frames retain their validated values;
budget-exhausted frames carry :status :incomplete with their own evidence and
trace identifiers. Other failures carry :error. The model reaches the same
fan-out through the rlm.map tool.
Decomposition and synthesis are generic functions dispatching on a policy keyword, so live self modification can promote strategies distilled from successful traces as new eql-specialized methods:
rlm-decompose-inference-taskreturns subtask plists for a task, or NIL to run it directly.rlm-synthesize-inference-resultscomposes the final answer from the subtask results; the default method for every policy runs one more frame whose views are the results, failures included.
rlm-run drives one task through a policy: decompose, fan the
subtasks out through rlm-map, and synthesize under the caller’s
contract, all inside one budget subtree. The default :direct policy
never decomposes.
rlm-distill automates the promotion path from traces to policies. A
gate frame reads bounded head-and-tail trace views and prefers
declining, so an empty review stays cheap; an approved review runs a
planner frame whose proposal is structurally validated with evaluation
disabled: exactly one rlm-decompose-inference-task defmethod,
eql-specialized on a fresh policy keyword, never :direct, keeping
the generic’s parameter specializers. A rejected proposal is re-asked
with its rejection reason within a bounded attempt count. Distillation
installs nothing: the returned proposal goes through the ordinary
durable mutation pipeline, which journals, checks, commits, and can
discard it. The model reaches it through the rlm.distill tool,
available only to the primary agent.
Frames persist their private conversations under
data/inferences/ in the same append-only readable format as regular
conversations, one file per frame. The caller’s conversation only
ever sees the returned value. The trace answers “why did this
inference conclude that” after the fact: task, views with digests,
model parameters, raw exchanges, and usage are all in the file.
Traces stay addressable after the fact: inference:<trace-id> URIs
read a bounded trace log back through resource.read, and the
compaction instructions tell summaries to reference frames by trace
identifier instead of restating their content, so compaction keeps
evidence reachable instead of inlining it.
The lisp.* tools run in heap-isolated workers with no provider
access, so model-authored worker code cannot call infer. The
surfaces are:
- The
infer,rlm-map,rlm-run, andrlm-completefunctions in the active image, for user REPL forms and for self-modification code (policies, agenda automation). - The
rlm.infer,rlm.map, andrlm.completetools for the model, taking tasks, views or one external context, an optional JSON Schema contract for the frame tools, and call, token, and depth allowances.
Background RLM tool jobs appear in a separate RLM section above the prompt,
beside commands and child agents. Rows show the job ID, operation, queued or
running state, elapsed time, and latest phase. rlm.infer reports request counts
and remaining calls; rlm.map adds frame index and fan width. rlm.complete
reports environment startup and nested direct, map, and synthesis phases.
Provider stream arrivals produce content-free receiving updates at most once per second for each request.
Spinners animate between phase reports. The no update timer measures the
absence of real progress reports, not animation. These rows are transient;
task text and source contents belong in the private inference traces.
Both surfaces accept :capabilities (the tools’ capabilities
argument). A frame without capabilities is a pure call over its
views. A read frame runs as a restricted agent turn: it may use
resource.read (workspace scheme only), the search tools, and
nested rlm.infer and rlm.map sharing the same budget subtree,
with tool rounds bounded per round. Its last request keeps the same
tool definitions but disables calls, so the answer reuses the cached
prefix; a call returned anyway fails the frame. Inside a frame the
nested tools ignore allowance arguments and descend the enclosing
budget, so fan-out can never outspend the root allocation.
Settled trade-offs, recorded so review rounds do not relitigate them.
env.eval runs model-authored Lisp in a heap-isolated worker with the
user’s privileges, exactly like lisp.* always has; Autolith’s stance
is that sandboxing is no substitute for oversight. Opaque context
handles with host-proxied context-slice and context-search were
considered and rejected: they add a socket round trip per operation
while confining nothing, because arbitrary worker Lisp can still open
any file the user can. Confinement is why frames may not launch
environments, not a property handles would provide. The store is
protected differently: objects are read-only on disk, host-side
lookup re-hashes their content and rejects corruption, and
re-interning the original content repairs a corrupted object.
Revisit only alongside real OS sandboxing of the worker.
Admission shares one lock with the terminal state, so nothing starts after a finish commits, but a finish never waits for admitted operations: they only overlap when environment code spawned worker threads, they stay budget-bounded, and a blocking finish could deadlock the run. Endpoint shutdown likewise joins handlers briefly and then abandons stragglers; an abandoned handler is a budget-bounded provider call whose response write fails harmlessly, which is cheaper than any safe cancellation.
Interning and the environment’s context functions materialize whole objects in memory. This is a documented capacity bound, not a design error: the RLM property only requires the content to stay outside model contexts, and content addressing already provides stable identity. Streaming, mmap, or indexes arrive when a workload actually exceeds the heap, not before.
That heap bound is also the only size limit on a root context. The
rlm.complete tool reads a workspace file named by its context URI
whole, up to *rlm-context-maximum-bytes* (64 MiB), rather than
through the editing snapshot that resource.read keeps, so an input
far larger than the model’s window, and larger than an editing
snapshot, is still one context object.
Reservations bound what concurrent requests may produce, but exact input usage is only known after a response, so input tokens settle post hoc and gate the next reservation. Providers that report no usage consume reservations but settle as free; deterministic test providers depend on this, and real providers report usage.
- Budgets:
src/inference/budget.lisp. - Context views and materialization:
src/inference/view.lisp. - The inference frame and
infer:src/inference/frame.lisp, with the compact frame system prompts and trace persistence. - The
rlm.inferandrlm.maptools, frame registries, and the contract conversion from JSON Schema:src/inference/tools.lisp. - Parallel maps:
src/inference/map.lisp. - Policies and
rlm-run:src/inference/policy.lisp. - Trace-driven policy distillation and
rlm.distill:src/inference/distill.lisp. - The
inference:trace resource:src/inference/resource.lisp, with the trace reference guidance in the compaction instructions. - Content-addressed context objects:
src/inference/object.lisp. - The loopback host endpoint for environment calls:
src/inference/endpoint.lisp. - Root completions, the environment prelude, and
env.eval:src/inference/environment.lisp. - Model-decided auto-mode command permissions:
src/inference/permission.lisp.
Pass optional model and effort arguments to rlm.infer, rlm.map, or
rlm.complete to select a model and supported reasoning effort for the entire
subtree. For a map, this selection applies to every frame; for a root completion,
it applies to the environment and child inferences. Make this selection in the
outermost call. Nested calls use the enclosing selection without overrides.