Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ default = []
sqlite = ["dep:rusqlite"]
# Embedded Rhai-backed `.ragsh` REPL session runtime (`repl::session`).
repl = ["dep:rhai"]
# Recursive-language-model runtime (`rlm`): a driver model writes code cells
# executed in a sandboxed interpreter (embedded Rhai, or an external Python /
# JavaScript process) whose only host surface is capability calls back into
# the registry (`llm`, `tool`, `agent`). `tokio/process` + `tokio/io-util`
# drive the external interpreter subprocesses.
rlm = ["dep:rhai", "tokio/process", "tokio/io-util"]
# Builtin generic tools (`harness::tools`) kept out of the default dependency
# graph so host applications can choose whether they want these implementations.
tools = ["dep:chrono", "dep:chrono-tz"]
Expand All @@ -78,3 +84,12 @@ futures = "0.3"
# The OpenAI provider is always compiled, so these build with a plain
# `cargo build --examples`; they only need a network call + `OPENAI_API_KEY`
# at run time.

# --- RLM examples (need the `rlm` feature) ---
[[example]]
name = "rlm_rhai"
required-features = ["rlm"]

[[example]]
name = "rlm_python"
required-features = ["rlm"]
179 changes: 179 additions & 0 deletions docs/modules/rlm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# RLM — the recursive-language-model runtime (`src/rlm/`)

The `rlm` module (Cargo feature `rlm`) turns a code sandbox into a
recursive-language-model harness: a **driver model** writes code cells, the
cells execute in a **sandboxed interpreter**, and the only host surface the
scripts see is a set of **capability calls back into the registry** — sub-LLM
queries, tools, and sub-agent delegation. Because scripts can call models
(and agents that call models), the loop is recursive by construction, which
is the execution pattern of the RLM literature this crate is architected
around (see the crate-level docs in `src/lib.rs`).

```text
┌──────────────────────────────────────────────┐
task ───► │ RlmRunner: driver model ⇄ code cells ⇄ obs. │ ───► answer
└──────────────┬───────────────────────────────┘
│ eval(code)
┌──────────────▼───────────────┐
│ RlmSession │
│ ┌──────────┐ HostCall │
│ │ RlmInter-│ ─────────────► │ RlmHost ──► CapabilityRegistry
│ │ preter │ ◄───────────── │ (policy, counters, depth)
│ └──────────┘ Value/error │ ├─ llm → ChatModel::invoke
└──────────────────────────────┘ ├─ tool → Tool::call
└─ agent → HarnessAgent::run
```

## The three layers

| Layer | Type | What it is |
|---|---|---|
| Interpreter | `RlmInterpreter` (trait) | Pluggable cell execution: `eval_cell`, `set_variable`, `usage_guide`, `shutdown`. State persists across cells like a notebook. |
| Session | `RlmSession` | One interpreter bound to one `RlmHost`; the programmatic "interpreter as an API" surface. Enforces per-cell policy fail-closed. |
| Runner | `RlmRunner` | The model-driven loop: template → system prompt, fenced code cells in, observations out, stop on `final_answer(...)`. |

Every layer is usable on its own: an embedder that wants its own loop (or a
notebook UI) drives `RlmSession::eval` directly and never touches the runner.

## Interpreter backends

- **`InterpreterSpec::Rhai`** (default) — the embedded Rhai engine. This is
the only *hermetic* sandbox: no filesystem, network, or process access
exists inside the engine; the registered capability closures are its whole
world. Bounded by `max_operations`, the cell deadline (`on_progress`
hook), and the blocking bridge around every capability call (the same
fail-closed adapter as `repl::session`).
- **`InterpreterSpec::Python { binary, args }`** — an external CPython child
process (`python3` by default). A bootstrap prelude is injected via `-c`;
cells run in a persistent exec namespace.
- **`InterpreterSpec::Javascript { binary, args }`** — a Node.js child
(`node` by default, prelude via `-e`); cells run in a persistent `vm`
context.
- **`InterpreterSpec::Command { binary, args }`** — any command that speaks
the wire protocol itself (a containerized runner, a jailed interpreter, a
different language).

### The wire protocol (external backends)

Line-delimited JSON on the child's stdin/stdout; calls are strictly
sequential so no correlation ids are needed. Child → host: `ready`,
`call {call: HostCall}`, `result {stdout, value, error}`, `var_set`.
Host → child: `eval {code}`, `set_var {name, value}`,
`call_result {ok, value|error}`, `shutdown`. `HostCall` is the shared,
serde-stable call shape (`{"capability": "llm"|"tool"|"agent"|"final_answer", ...}`).
Any runtime that speaks this protocol is a valid backend — that is the
extension point for other harnesses (e.g. openhuman) to bring their own
interpreter.

### Sandboxing honesty

The host enforces every policy limit fail-closed for **all** backends (a
child that exceeds its deadline or trips a bound is killed, not asked). But
an external child process has whatever OS access the environment grants it.
For untrusted driver models, either stay on the embedded Rhai backend or run
the external interpreter inside real isolation (container/jail/seccomp) via
`InterpreterSpec::Command`.

## The capability surface inside scripts

Identical semantics across languages (spelling varies per usage guide):

- `llm(prompt)` / `llm({model, prompt, system})` → string — a sub-LLM call;
the unnamed form uses the session's default sub-model.
- `tool(name, args)` → tool result (raw JSON when the tool provides it).
Arguments are validated against the tool's schema at the host boundary.
- `agent(name, input)` → string — delegates to a registered
`HarnessAgent`; a full nested agent run with event fan-out and the shared
recursion-depth guard (`RunConfig::checked_child_depth`).
- `final_answer(text)` — ends the run with this answer.
- `print(...)` / `console.log(...)` — captured and echoed back to the
driver next turn, bounded by `max_output_bytes` (explicit truncation
marker).

### Error contract

Script-visible failures (unknown tool, schema mismatch, tool error, provider
error) surface *inside* the script as catchable exceptions (`RlmError` in
Python/JS, `try`/`catch` in Rhai) so the driving model can adapt — that
feedback loop is the point of an RLM. Policy violations (`LimitExceeded`,
`Timeout`, `Cancelled`, `SubAgentDepth`) are **fatal**: the cell aborts (and
an external child is killed) so scripts can never observe and route around
their own resource limits. `rlm::is_fatal` is the classifier.

## Config-driven runs

Everything a run needs is one serde document — the integration surface for
external harnesses:

```json
{
"interpreter": {"kind": "python", "binary": "/opt/venv/bin/python3"},
"driver_model": "openai",
"sub_model": "openai",
"template": "context-explorer",
"policy": {
"max_cells": 8, "max_llm_calls": 16, "max_tool_calls": 64,
"max_agent_calls": 8, "max_depth": 8,
"max_script_bytes": 65536, "max_output_bytes": 262144,
"cell_timeout": 90000, "max_operations": 5000000
}
}
```

`RlmConfig::from_json` → `RlmRunner::from_config(config, registry, state)` →
`runner.set_context(json)` (optional) → `runner.run(task)`.

### Templates

`TemplateSpec::Named` selects a built-in; `TemplateSpec::Inline` carries a
custom `RlmTemplate` in the config document. Placeholders `{{language}}`,
`{{usage}}`, `{{capabilities}}`, `{{limits}}` are substituted at run time
from the live session (so the prompt always reflects the actual registry and
policy). Built-ins:

- `general` — solve the task with code; sub-LLMs for fuzzy subproblems.
- `context-explorer` — the RLM long-context pattern: material injected as
the `context` variable, probed programmatically, never printed whole.
- `orchestrator` — decompose and delegate to registered sub-agents.

## Policy (all fail-closed)

`RlmPolicy`: `max_cells`, `max_script_bytes`, `max_output_bytes`,
`max_llm_calls`, `max_tool_calls`, `max_agent_calls`, `max_depth`,
`cell_timeout` (ms in JSON), `max_operations` (Rhai only). Counters are
session-cumulative. `RlmCancelFlag` provides sticky external cancellation,
observed mid-script (Rhai `on_progress`), mid-call (blocking bridge), and
before each cell.

## Runner loop details

- The driver must reply with exactly one fenced code block; the first fence
is extracted regardless of its info string (models mislabel languages).
- A fence-less reply earns one nudge (it is often raw unfenced code); a
second consecutive fence-less reply is accepted as a prose answer
(`RlmStopReason::ModelAnswered`).
- Observations echo captured stdout, the cell value, and any script error.
- Stop reasons: `Answered` (a cell called `final_answer`), `ModelAnswered`,
`CellBudgetExhausted`. The full per-cell trajectory is returned in
`RlmOutcome::steps`.

## Tests and examples

- Unit: `src/rlm/test.rs` (config round-trips, templates, extraction, the
embedded backend, runner loop against `ScriptedModel`).
- E2E: `tests/e2e_rlm.rs` — sub-agent delegation from scripts, real
`python3`/`node` protocol round-trips (skipped when the binary is
missing), fail-closed timeout kill, config-driven runner.
- Live: `tests/live_rlm.rs` — network-gated on `OPENAI_API_KEY`.
- Examples: `examples/rlm_rhai.rs` (context-explorer over expense records),
`examples/rlm_python.rs` (tools + a real sub-agent, external Python).
Run with `cargo run --features rlm --example rlm_rhai`.

## Relationship to `repl::session`

`repl::session` is the `.ragsh` interactive orchestration surface: Rhai
only, richer built-ins (graph authoring, batching), REPL-first. `rlm` is the
model-*driven* counterpart: interpreter-pluggable, config-first, and shaped
for embedding in other harnesses. They share design DNA deliberately — the
fail-closed blocking bridge, reserved capability boundary, and policy
posture are the same pattern.
135 changes: 135 additions & 0 deletions examples/rlm_python.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! A live RLM run over an **external Python interpreter**, with a real tool
//! and a real sub-agent registered.
//!
//! The driver model writes Python cells; the child `python3` process executes
//! them, and every capability call (`llm`, `tool`, `agent`, `final_answer`)
//! travels back over the wire protocol to the host, which enforces the policy
//! and lowers to the harness runtime. The `agent("summarizer", ...)` call
//! drives a *complete nested agent run* on its own OpenAI-backed harness —
//! scripts calling agents calling models.
//!
//! Run with (needs `OPENAI_API_KEY` and `python3` on PATH):
//!
//! ```text
//! cargo run --features rlm --example rlm_python
//! ```

use std::sync::Arc;

use async_trait::async_trait;
use serde_json::json;

use tinyagents::harness::providers::openai::OpenAiModel;
use tinyagents::harness::runtime::AgentHarness;
use tinyagents::harness::tool::{Tool, ToolCall, ToolResult, ToolSchema};
use tinyagents::registry::CapabilityRegistry;
use tinyagents::rlm::{RlmConfig, RlmRunner};
use tinyagents::{HarnessSubAgent, Result, SubAgent};

/// A deterministic metrics tool the script can query.
struct MetricsTool;

#[async_trait]
impl Tool<()> for MetricsTool {
fn name(&self) -> &str {
"service_metrics"
}

fn description(&self) -> &str {
"Returns latency and error-rate metrics for a named service."
}

fn schema(&self) -> ToolSchema {
ToolSchema::new(
"service_metrics",
"Returns latency and error-rate metrics for a named service.",
json!({
"type": "object",
"properties": { "service": { "type": "string" } },
"required": ["service"],
"additionalProperties": false
}),
)
}

async fn call(&self, _state: &(), call: ToolCall) -> Result<ToolResult> {
let service = call
.arguments
.get("service")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let metrics = match service {
"checkout" => json!({"p99_ms": 2140, "error_rate": 0.031, "deploys_today": 3}),
"search" => json!({"p99_ms": 180, "error_rate": 0.002, "deploys_today": 0}),
"auth" => json!({"p99_ms": 95, "error_rate": 0.001, "deploys_today": 1}),
other => json!({"error": format!("unknown service `{other}`")}),
};
let mut result = ToolResult::text(call.id, call.name, metrics.to_string());
result.raw = Some(metrics);
Ok(result)
}
}

#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();

let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new();
registry.register_model("openai", Arc::new(OpenAiModel::from_env()?))?;
registry.register_tool(Arc::new(MetricsTool))?;

// A real sub-agent on its own harness: scripts delegate to it by name.
let mut child: AgentHarness<()> = AgentHarness::new();
child
.register_model("openai", Arc::new(OpenAiModel::from_env()?))
.set_default_model("openai");
let summarizer = Arc::new(
SubAgent::new(
"summarizer",
"Writes a crisp two-sentence incident summary from raw findings.",
Arc::new(child),
)
.with_system_prompt(
"You summarize incident findings for executives: two sentences, plain language, \
lead with impact.",
),
);
registry.register_agent(Arc::new(HarnessSubAgent::new(summarizer)))?;

let config = RlmConfig::from_json(
r#"{
"interpreter": {"kind": "python"},
"driver_model": "openai",
"template": "general",
"policy": {"max_cells": 8, "cell_timeout": 90000}
}"#,
)?;

let mut runner = RlmRunner::from_config(config, Arc::new(registry), Arc::new(()))?;
let outcome = runner
.run(
"Check the service_metrics tool for the services checkout, search, and auth. \
Identify which service looks unhealthy and why. Then delegate to the `summarizer` \
agent to produce an executive summary of your findings, and return that summary \
as the final answer.",
)
.await?;

for (i, step) in outcome.steps.iter().enumerate() {
println!("── cell {} ──\n{}\n", i + 1, step.code);
if !step.outcome.stdout.is_empty() {
println!("stdout:\n{}", step.outcome.stdout);
}
if let Some(error) = &step.outcome.error {
println!("error: {error}");
}
}
println!("── answer ({:?}) ──", outcome.stop_reason);
println!("{}", outcome.answer.as_deref().unwrap_or("(none)"));
println!(
"\ndriver calls: {}, sub-llm calls: {}, tool calls: {}, agent calls: {}",
outcome.driver_calls, outcome.sub_llm_calls, outcome.tool_calls, outcome.agent_calls
);
runner.shutdown().await?;
Ok(())
}
Loading