|
| 1 | +# RLM — the recursive-language-model runtime (`src/rlm/`) |
| 2 | + |
| 3 | +The `rlm` module (Cargo feature `rlm`) turns a code sandbox into a |
| 4 | +recursive-language-model harness: a **driver model** writes code cells, the |
| 5 | +cells execute in a **sandboxed interpreter**, and the only host surface the |
| 6 | +scripts see is a set of **capability calls back into the registry** — sub-LLM |
| 7 | +queries, tools, and sub-agent delegation. Because scripts can call models |
| 8 | +(and agents that call models), the loop is recursive by construction, which |
| 9 | +is the execution pattern of the RLM literature this crate is architected |
| 10 | +around (see the crate-level docs in `src/lib.rs`). |
| 11 | + |
| 12 | +```text |
| 13 | + ┌──────────────────────────────────────────────┐ |
| 14 | + task ───► │ RlmRunner: driver model ⇄ code cells ⇄ obs. │ ───► answer |
| 15 | + └──────────────┬───────────────────────────────┘ |
| 16 | + │ eval(code) |
| 17 | + ┌──────────────▼───────────────┐ |
| 18 | + │ RlmSession │ |
| 19 | + │ ┌──────────┐ HostCall │ |
| 20 | + │ │ RlmInter-│ ─────────────► │ RlmHost ──► CapabilityRegistry |
| 21 | + │ │ preter │ ◄───────────── │ (policy, counters, depth) |
| 22 | + │ └──────────┘ Value/error │ ├─ llm → ChatModel::invoke |
| 23 | + └──────────────────────────────┘ ├─ tool → Tool::call |
| 24 | + └─ agent → HarnessAgent::run |
| 25 | +``` |
| 26 | + |
| 27 | +## The three layers |
| 28 | + |
| 29 | +| Layer | Type | What it is | |
| 30 | +|---|---|---| |
| 31 | +| Interpreter | `RlmInterpreter` (trait) | Pluggable cell execution: `eval_cell`, `set_variable`, `usage_guide`, `shutdown`. State persists across cells like a notebook. | |
| 32 | +| Session | `RlmSession` | One interpreter bound to one `RlmHost`; the programmatic "interpreter as an API" surface. Enforces per-cell policy fail-closed. | |
| 33 | +| Runner | `RlmRunner` | The model-driven loop: template → system prompt, fenced code cells in, observations out, stop on `final_answer(...)`. | |
| 34 | + |
| 35 | +Every layer is usable on its own: an embedder that wants its own loop (or a |
| 36 | +notebook UI) drives `RlmSession::eval` directly and never touches the runner. |
| 37 | + |
| 38 | +## Interpreter backends |
| 39 | + |
| 40 | +- **`InterpreterSpec::Rhai`** (default) — the embedded Rhai engine. This is |
| 41 | + the only *hermetic* sandbox: no filesystem, network, or process access |
| 42 | + exists inside the engine; the registered capability closures are its whole |
| 43 | + world. Bounded by `max_operations`, the cell deadline (`on_progress` |
| 44 | + hook), and the blocking bridge around every capability call (the same |
| 45 | + fail-closed adapter as `repl::session`). |
| 46 | +- **`InterpreterSpec::Python { binary, args }`** — an external CPython child |
| 47 | + process (`python3` by default). A bootstrap prelude is injected via `-c`; |
| 48 | + cells run in a persistent exec namespace. |
| 49 | +- **`InterpreterSpec::Javascript { binary, args }`** — a Node.js child |
| 50 | + (`node` by default, prelude via `-e`); cells run in a persistent `vm` |
| 51 | + context. |
| 52 | +- **`InterpreterSpec::Command { binary, args }`** — any command that speaks |
| 53 | + the wire protocol itself (a containerized runner, a jailed interpreter, a |
| 54 | + different language). |
| 55 | + |
| 56 | +### The wire protocol (external backends) |
| 57 | + |
| 58 | +Line-delimited JSON on the child's stdin/stdout; calls are strictly |
| 59 | +sequential so no correlation ids are needed. Child → host: `ready`, |
| 60 | +`call {call: HostCall}`, `result {stdout, value, error}`, `var_set`. |
| 61 | +Host → child: `eval {code}`, `set_var {name, value}`, |
| 62 | +`call_result {ok, value|error}`, `shutdown`. `HostCall` is the shared, |
| 63 | +serde-stable call shape (`{"capability": "llm"|"tool"|"agent"|"final_answer", ...}`). |
| 64 | +Any runtime that speaks this protocol is a valid backend — that is the |
| 65 | +extension point for other harnesses (e.g. openhuman) to bring their own |
| 66 | +interpreter. |
| 67 | + |
| 68 | +### Sandboxing honesty |
| 69 | + |
| 70 | +The host enforces every policy limit fail-closed for **all** backends (a |
| 71 | +child that exceeds its deadline or trips a bound is killed, not asked). But |
| 72 | +an external child process has whatever OS access the environment grants it. |
| 73 | +For untrusted driver models, either stay on the embedded Rhai backend or run |
| 74 | +the external interpreter inside real isolation (container/jail/seccomp) via |
| 75 | +`InterpreterSpec::Command`. |
| 76 | + |
| 77 | +## The capability surface inside scripts |
| 78 | + |
| 79 | +Identical semantics across languages (spelling varies per usage guide): |
| 80 | + |
| 81 | +- `llm(prompt)` / `llm({model, prompt, system})` → string — a sub-LLM call; |
| 82 | + the unnamed form uses the session's default sub-model. |
| 83 | +- `tool(name, args)` → tool result (raw JSON when the tool provides it). |
| 84 | + Arguments are validated against the tool's schema at the host boundary. |
| 85 | +- `agent(name, input)` → string — delegates to a registered |
| 86 | + `HarnessAgent`; a full nested agent run with event fan-out and the shared |
| 87 | + recursion-depth guard (`RunConfig::checked_child_depth`). |
| 88 | +- `final_answer(text)` — ends the run with this answer. |
| 89 | +- `print(...)` / `console.log(...)` — captured and echoed back to the |
| 90 | + driver next turn, bounded by `max_output_bytes` (explicit truncation |
| 91 | + marker). |
| 92 | + |
| 93 | +### Error contract |
| 94 | + |
| 95 | +Script-visible failures (unknown tool, schema mismatch, tool error, provider |
| 96 | +error) surface *inside* the script as catchable exceptions (`RlmError` in |
| 97 | +Python/JS, `try`/`catch` in Rhai) so the driving model can adapt — that |
| 98 | +feedback loop is the point of an RLM. Policy violations (`LimitExceeded`, |
| 99 | +`Timeout`, `Cancelled`, `SubAgentDepth`) are **fatal**: the cell aborts (and |
| 100 | +an external child is killed) so scripts can never observe and route around |
| 101 | +their own resource limits. `rlm::is_fatal` is the classifier. |
| 102 | + |
| 103 | +## Config-driven runs |
| 104 | + |
| 105 | +Everything a run needs is one serde document — the integration surface for |
| 106 | +external harnesses: |
| 107 | + |
| 108 | +```json |
| 109 | +{ |
| 110 | + "interpreter": {"kind": "python", "binary": "/opt/venv/bin/python3"}, |
| 111 | + "driver_model": "openai", |
| 112 | + "sub_model": "openai", |
| 113 | + "template": "context-explorer", |
| 114 | + "policy": { |
| 115 | + "max_cells": 8, "max_llm_calls": 16, "max_tool_calls": 64, |
| 116 | + "max_agent_calls": 8, "max_depth": 8, |
| 117 | + "max_script_bytes": 65536, "max_output_bytes": 262144, |
| 118 | + "cell_timeout": 90000, "max_operations": 5000000 |
| 119 | + } |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +`RlmConfig::from_json` → `RlmRunner::from_config(config, registry, state)` → |
| 124 | +`runner.set_context(json)` (optional) → `runner.run(task)`. |
| 125 | + |
| 126 | +### Templates |
| 127 | + |
| 128 | +`TemplateSpec::Named` selects a built-in; `TemplateSpec::Inline` carries a |
| 129 | +custom `RlmTemplate` in the config document. Placeholders `{{language}}`, |
| 130 | +`{{usage}}`, `{{capabilities}}`, `{{limits}}` are substituted at run time |
| 131 | +from the live session (so the prompt always reflects the actual registry and |
| 132 | +policy). Built-ins: |
| 133 | + |
| 134 | +- `general` — solve the task with code; sub-LLMs for fuzzy subproblems. |
| 135 | +- `context-explorer` — the RLM long-context pattern: material injected as |
| 136 | + the `context` variable, probed programmatically, never printed whole. |
| 137 | +- `orchestrator` — decompose and delegate to registered sub-agents. |
| 138 | + |
| 139 | +## Policy (all fail-closed) |
| 140 | + |
| 141 | +`RlmPolicy`: `max_cells`, `max_script_bytes`, `max_output_bytes`, |
| 142 | +`max_llm_calls`, `max_tool_calls`, `max_agent_calls`, `max_depth`, |
| 143 | +`cell_timeout` (ms in JSON), `max_operations` (Rhai only). Counters are |
| 144 | +session-cumulative. `RlmCancelFlag` provides sticky external cancellation, |
| 145 | +observed mid-script (Rhai `on_progress`), mid-call (blocking bridge), and |
| 146 | +before each cell. |
| 147 | + |
| 148 | +## Runner loop details |
| 149 | + |
| 150 | +- The driver must reply with exactly one fenced code block; the first fence |
| 151 | + is extracted regardless of its info string (models mislabel languages). |
| 152 | +- A fence-less reply earns one nudge (it is often raw unfenced code); a |
| 153 | + second consecutive fence-less reply is accepted as a prose answer |
| 154 | + (`RlmStopReason::ModelAnswered`). |
| 155 | +- Observations echo captured stdout, the cell value, and any script error. |
| 156 | +- Stop reasons: `Answered` (a cell called `final_answer`), `ModelAnswered`, |
| 157 | + `CellBudgetExhausted`. The full per-cell trajectory is returned in |
| 158 | + `RlmOutcome::steps`. |
| 159 | + |
| 160 | +## Tests and examples |
| 161 | + |
| 162 | +- Unit: `src/rlm/test.rs` (config round-trips, templates, extraction, the |
| 163 | + embedded backend, runner loop against `ScriptedModel`). |
| 164 | +- E2E: `tests/e2e_rlm.rs` — sub-agent delegation from scripts, real |
| 165 | + `python3`/`node` protocol round-trips (skipped when the binary is |
| 166 | + missing), fail-closed timeout kill, config-driven runner. |
| 167 | +- Live: `tests/live_rlm.rs` — network-gated on `OPENAI_API_KEY`. |
| 168 | +- Examples: `examples/rlm_rhai.rs` (context-explorer over expense records), |
| 169 | + `examples/rlm_python.rs` (tools + a real sub-agent, external Python). |
| 170 | + Run with `cargo run --features rlm --example rlm_rhai`. |
| 171 | + |
| 172 | +## Relationship to `repl::session` |
| 173 | + |
| 174 | +`repl::session` is the `.ragsh` interactive orchestration surface: Rhai |
| 175 | +only, richer built-ins (graph authoring, batching), REPL-first. `rlm` is the |
| 176 | +model-*driven* counterpart: interpreter-pluggable, config-first, and shaped |
| 177 | +for embedding in other harnesses. They share design DNA deliberately — the |
| 178 | +fail-closed blocking bridge, reserved capability boundary, and policy |
| 179 | +posture are the same pattern. |
0 commit comments