REPL runtime protocol #
python -m rlm.repl starts a CPython REPL runtime that executes code cells in
one persistent __main__ namespace on a single asyncio event loop. The wire
format is newline-delimited JSON: one object per line, UTF-8, no other framing.
The current protocol version is 3; the runtime announces it in the ready
event.
Channels #
- Requests arrive on fd 0 (stdin).
- Events leave on a private dup of the original fd 1, made before anything else runs. Every frame is one locked write sequence, so frames never interleave.
- Python-level writes through
sys.stdout/sys.stderrare intercepted at write time, tagged with the writing context's cell id, and shipped to the protocol. While the writing cell is live they are batched: a batch ships when it passes 8 KiB or 50 ms, whichever comes first, and any other frame ships the pending batch first. Text order within the channel is preserved; only the frame count changes, so a print-heavy cell cannot flood the host with one frame perprint(). Writes from a user thread or from a task outliving its cell ship immediately, unbatched. - fds 1 and 2 are redirected into pipes at startup; pump threads read them and
ship the bytes as
stdout/stderrevents withid: null— raw fd bytes (os.write,sys.stdout.buffer.write, C extensions, subprocesses) are never attributed to a cell. Neither channel can corrupt protocol framing. Ordering is preserved within each channel, not across them. - fd 0 is rebound to
/dev/nullafter the reader thread takes it, so userinput()sees EOF instead of consuming protocol frames.
Requests #
| Request | Fields |
|---|---|
execute |
{"type":"execute","id":str,"code":str} |
interrupt |
{"type":"interrupt","id"?:str} — no reply |
host_reply |
{"type":"host_reply","id":str,"data":{"status":"ok","result":{...}}} or an error envelope — no reply |
snapshot |
{"type":"snapshot","id":str,"path":str,"manifest_path":str,"max_bytes"?:int,"max_variable_bytes"?:int,"prune_oversized"?:bool} |
restore |
{"type":"restore","id":str,"path":str} |
list_names |
{"type":"list_names","id":str} |
signatures |
{"type":"signatures","id":str,"skills":[str,...]} |
shutdown |
{"type":"shutdown","id"?:str} |
Requests other than interrupt and host_reply run strictly in order, one at
a time. A malformed line
produces {"event":"error","id":null,"ename":"ProtocolError",...} and the
runtime keeps serving. Closing stdin is equivalent to shutdown.
Events #
{"event":"ready","protocol":3,"python":"3.13.11","capabilities":["signatures"]}— sent once at startup; the handshake. No banner precedes it.capabilitiesis additive: a host readsprotocoland ignores the list, and it sends asignaturesrequest only when the list advertises it (an older runtime answers an unknown request type with aProtocolErrorevent and a faileddone).{"event":"stdout"|"stderr","id":str|null,"text":str}— captured output.idis the cell whose Python execution context performed the write; asyncio tasks inherit the spawning cell's id (even after that cell finished).nullfor user threads, raw fd writes (os.write, C extensions, subprocesses), and anything else without provable ownership — bytes read from the fd pipes are never attributed to a cell.{"event":"result","id":str,"text":str}—reprof the cell's trailing expression when the body ends in an expression whose value is notNone. The value is also bound to_in the namespace.{"event":"display","id":str|null,"data":{mime:payload,...}}— one dict of MIME type to JSON payload, shipped verbatim fromemit().idrides task context: an asyncio task spawned by a cell keeps that cell's id even after the cell finishes; user threads emitnull.{"event":"host_request","id":str,"data":{...}}— one typed request from runtime code to the host; the host answers with ahost_replyrequest carrying the same id.{"event":"error","id":str|null,"ename":str,"evalue":str,"traceback":[str,...]}— for a cell failure the runtime appends what it can generate: the published signature plus the nearest legal name for a Python call-shape error, and one repeat line when the identical cell failed before (see below).{"event":"done","id":str,"status":"ok"|"error"}— exactly one per id'd request, always after all of that request's other events. A snapshotdoneaddssaved,skipped,pruned,bytes; a restoredoneaddsrestored,failed; alist_namesdoneaddsnames; a failed snapshot/restore addsreason. Restoring a missing file reportsstatus:"ok"with emptyrestored/failedlists andreason:"snapshot not found".
Before a cell's done, the runtime drains both channels: the pending tagged
batch ships, and the fd pipes are fenced with a marker byte sequence awaited in
the pumps, so every byte the cell wrote synchronously — including direct fd
writes — precedes its done. Ordering between a cell's Python-level writes and
its raw fd writes is not guaranteed (two channels).
Execution #
Cells compile with PyCF_ALLOW_TOP_LEVEL_AWAIT and run as tasks on the
persistent event loop, so await works at top level and background tasks
created by a cell keep running between cells.
The boundary decides on the evaluated value, not on the compiled flags: a value
that is awaitable is awaited, so f(x) and await f(x) agree for a call the model
left bare as the cell's last expression. Two values are kept instead, because both
have already started and awaiting them would turn a background launch into a
blocking wait: a live bash() handle (awaiting it would also suppress the
completion notice) and a started asyncio.Task. The cell's trailing value is also
returned settled — await on it (or on the next cell's _) returns it unchanged
instead of raising TypeError. Each cell's source is registered
in linecache under <cell-N>, so tracebacks show the offending source line.
Tracebacks are plain traceback formatting with the runtime's own frames
stripped, keeping cell and library frames; no colors, no decoration.
Interrupt #
{"type":"interrupt"} raises KeyboardInterrupt in the running cell. Without
an id the interrupt applies to the running request, or — when none is running
yet — to the next queued one; with an id it applies to that request only.
An interrupt that arrives before its request starts executing is parked and
delivered the moment the request becomes active, so execute + interrupt
written back-to-back still interrupts the cell. A request stays
interrupt-targetable until its done event is emitted: this covers the
post-run trailing-expression repr and output drain. Interrupts for finished
or unknown requests are dropped.
Delivery: the reader thread sends SIGINT to the main thread (also the loop
thread); the handler asks asyncio which task's step the signal interrupted.
On Windows (no signal.pthread_kill) the reader instead cancels the active
cell task on the loop, so await-suspended cells interrupt normally but cells
blocked in synchronous code cannot be broken (best-effort parity):
- The active cell's own task is mid-step (sync bytecode such as a
time.sleeploop, or a blocking syscall such asselectors.select()woken by EINTR): the handler raisesKeyboardInterruptdirectly and it propagates out of the cell task. - The loop is idle in
select()(the cell is suspended at anawait) or a different task — a background task or the runtime itself — is mid-step: raising there would land in the wrong context, so the handler cancels the active cell task and the runtime reports the cancellation as aKeyboardInterrupt. When the mid-step task is a background task, the handler also raisesKeyboardInterruptinto it: a background task blocked in synchronous code occupies the only thread, so it receives theKeyboardInterrupt(and dies with it) to unblock the loop and let the cancel take effect. Limitation: the interrupt lands at the await point as a cancellation, so user code catchingKeyboardInterruptaround anawaitdoes not intercept it.
Both paths end with an error event (ename KeyboardInterrupt) and
done with status:"error"; the runtime keeps serving. When nothing is
running or queued, SIGINT and interrupt requests are ignored.
Display bridge #
from rlm.repl import emit inside a cell (or any user thread) ships a
display event. emit(data) takes one non-empty dict keyed by MIME type
strings; the dict is forwarded verbatim as the event's data.
Host bridge #
await rlm.repl.host_request(data) ships a host_request event with a
runtime-minted id and awaits the matching host_reply, returning its data
dict verbatim. Replies are routed on the reader thread like interrupt —
never through the request queue, since the awaiting cell is itself the
in-flight execute. Replies for unknown ids, or for a request whose awaiting
cell was cancelled, are dropped. rlm.repl.is_active() reports whether the
process is serving the protocol (importing the module does not count).
Snapshot / restore #
snapshot serializes the user namespace with dill (recurse mode), one name
at a time: _-prefixed names and
{rlm, mcp, bash, asyncio, In, Out, get_ipython, exit, quit, open} are always
skipped; a name whose pickle exceeds max_variable_bytes or would push the
total over max_bytes is skipped and reported. With prune_oversized, only
names exceeding the per-variable cap (max_variable_bytes) are also deleted
from the namespace and listed in pruned; names skipped for the aggregate
max_bytes cap are reported in skipped but kept in the namespace. The
payload is written atomically (tmp file + os.replace) and a JSON manifest
(version, savedNames, skipped, pruned, bytes, pythonVersion,
timestamp) is written to manifest_path. A manifest write failure fails the
snapshot (and nothing is pruned).
restore loads the payload and revives each name independently; a missing
file yields an ok empty restore with reason:"snapshot not found", a corrupt
file fails with a reason, and per-name failures are listed in failed.
Names In, Out, and get_ipython in a payload are never restored. dill is imported lazily; when unavailable, snapshot and restore
fail with status:"error" and a reason.
list_names replies with done carrying names: the sorted user-defined
top-level names under the same filter the snapshot applies.
signatures imports the host's skill names, indexes every published call, and
replies with done carrying calls (the number indexed), failed (a
[{name, reason}, ...] list for names that will not import) and digest (one
generated block: one line per published call, plus one shape line per result
type). The runtime keeps that index and renders the remedies it appends to later
cell errors from it, so an error can never name a signature the code has already
changed. Without a signatures request the runtime indexes the skill-shaped
modules in the cell namespace instead, which covers the wrapped skills the
bootstrap installs.
Shutdown #
shutdown (or stdin EOF) kills live rlm.bash child process groups, replies
done (when the request carried an id), stops the loop, and exits 0.