diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..ae636f84 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,60 @@ +name: E2E + +on: + push: + branches: [main] + paths: + - "src/**" + - "tests/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/e2e.yml" + pull_request: + paths: + - "src/**" + - "tests/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/e2e.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e: + name: E2E (${{ matrix.mode }}) + # Secrets do not reach pull requests from forks. + if: >- + github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]') + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + mode: [local, stdio, tcp] + env: + PRIME_API_KEY: ${{ secrets.PRIME_API_KEY }} + PRIME_TEAM_ID: ${{ secrets.PRIME_TEAM_ID }} + RLM_E2E_MODEL: ${{ vars.RLM_E2E_MODEL }} + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + - name: Install dependencies + run: uv sync + - name: Check credentials + if: env.PRIME_API_KEY == '' + run: | + echo "::error::PRIME_API_KEY is not set; add it under Settings → Secrets and variables → Actions" + exit 1 + - name: Run end-to-end tests + run: uv run pytest tests/e2e -m e2e -k ${{ matrix.mode }} -v --durations=0 diff --git a/AGENTS.md b/AGENTS.md index 8b51b5eb..822e867a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,10 @@ - **Targeted comments**: don't explain your work process or reference old code. Use comments sparingly to clarify ambiguous logic or non-obvious constraints. - **No "this replaced that" comments**: never write comments or docstrings that reference code that used to exist. That context belongs in the PR, not in the file. Describe what the code does now, not what it isn't anymore. +## Docs + +- **Keep docs current**: user-facing behavior is documented in `docs/` (indexed from the README). A change to that behavior updates the page that describes it in the same PR. + ## Running code - **Always use uv**: run code with `uv run`, never raw `python`. diff --git a/README.md b/README.md index 0de17b09..d236fb17 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,21 @@ # nano-rlm -A minimal CLI coding agent with a persistent IPython execution environment and optional recursive sub-agents. For a full-fledged coding agent built on the same RLM principles, see [prime-agent](https://github.com/PrimeIntellect-ai/prime-agent). +A minimal recursive language-model agent. The model works through one persistent IPython kernel: Python, shell commands, file edits and orchestration all happen there, and the kernel's state survives across turns and context compaction. From that kernel the agent can spawn sub-agents, each with its own kernel, and coordinate them like any other Python object. -By default the model gets a single built-in tool, `ipython`: a persistent IPython kernel for Python, and Bash commands and background jobs via `rlm.shell.run`. File edits, shell work, and orchestration all go through it. The runtime contract's `builtin_tools` list can select a different tool set (`bash`, `edit`, `fetch`, `ipython`) for native tool-calling runs. +nano-rlm runs as an [Agent Client Protocol](https://agentclientprotocol.com/) agent and is built as a training harness: every session is configured by one explicit runtime contract, and every model call, message and tool result is recorded. For a full-fledged coding agent built on the same principles, see [prime-agent](https://github.com/PrimeIntellect-ai/prime-agent). -For convenience, rlm ships built-in Python tools that can be enabled per session via the runtime contract's `skills` list (off by default): `edit` (single-occurrence string replacement), `search` (web search via Serper, needs `SERPER_API_KEY`), and `fetch` (retrieve a URL as cleaned text). Enabled tools are pre-imported into the IPython kernel (see [Tools](#tools)), so the agent calls `await edit(path=..., old_str=..., new_str=...)`, `await search(query=...)`, or `await fetch(url=...)`. `fetch` also exists as a native builtin tool with the same semantics, for tool-calling runs (opt-in via the contract's `builtin_tools`). [Skills](#skills) are `SKILL.md` instructions that teach the agent workflows using these tools. - -Context compaction is on by default: the engine compacts when 16k tokens remain below an advertised model context window. Termination comes from the default tree-wide budget of 1M new tokens (`max_total_tokens`). The policy can set an explicit `summarize_at_tokens` threshold. The IPython kernel keeps running across compaction, so REPL state survives (see [Compaction](#compaction)). + + + An ACP client sends the runtime contract and prompts to rlm. The engine runs the agent loop against the model API and appends to the session ledger; its tool calls run in a persistent IPython kernel, whose rlm.* calls reach the supervisor; the supervisor owns child agents, each with its own engine, kernel and session directory. + -Inside IPython, the `rlm` package is available in the namespace. When recursion is allowed, `await rlm.agent.spawn(task=...)` returns a handle to a supervisor-owned agent that continues across cells. Python tool packages supplied by the host environment (see [Tools](#tools)) are importable directly by name, e.g. `import websearch`. +- **One persistent kernel.** By default the model has a single tool, `ipython`. Shell work goes through `rlm.shell.run` or `!cmd`, edits through the pre-imported `edit`, and variables, imports and loaded data persist from cell to cell. +- **Recursion from Python.** `await rlm.agent.spawn(task=...)` returns a handle to a sub-agent that keeps running across cells. Agents report to their parent's inbox, and the parent waits, steers, reads their history or cancels them. +- **Supervised background work.** Shell jobs, long-lived services and watches on agents, jobs and paths are owned by a supervisor, so they survive a lost variable or a kernel crash. +- **Unbounded sessions.** The engine compacts the context when it runs out of room into a staircase of summaries that keeps the whole session at decreasing resolution; the kernel keeps its state and the full history stays readable from Python. +- **Continual harness.** Prompt notes, memories, skill entries, sub-agent specs and past episodes persist beside the system prompt, written by the agent or by refinement passes, and authored skill packages persist across sessions. +- **Tools where they belong.** Native tool calls (`bash`, `edit`, `fetch`), Python tool packages, `SKILL.md` workflows, and MCP servers whose credentials never reach the kernel. +- **Split execution.** The agent loop and credentials can stay in the harness while the tools run in a separate workspace, over stdio or TCP. ## Install @@ -16,1177 +23,93 @@ Inside IPython, the `rlm` package is available in the namespace. When recursion git clone https://github.com/mannuch/nano-rlm.git cd nano-rlm uv sync -source .venv/bin/activate -``` - -## CLI - -rlm runs as an [Agent Client Protocol](https://agentclientprotocol.com/) agent: - -```bash -rlm --acp -``` - -There is no standalone prompt mode: every session is created by an ACP client that -supplies the full runtime configuration (see below). The only other commands are -operator utilities for a harness store that never run inside a session, such as -`rlm harness prune` (see [Episodes](#episodes)). Tool CLIs provided by the host -environment are on `$PATH` inside the kernel (e.g. `websearch --queries "..."` when -the `websearch` tool package is installed). - -## Agent Client Protocol - -Each ACP session owns one persistent RLM engine. Repeated `session/prompt` -requests retain both the model conversation and the live IPython kernel. The -agent accepts text prompts and stdio or streamable HTTP MCP servers, including -HTTP request headers. It supports cancellation and `session/close`; cancelling -a turn leaves its session available for the next prompt. It intentionally does -not advertise `session/load`: an arbitrary live Python kernel cannot be -reconstructed after the ACP process exits, so clients must keep the process -alive for the lifetime of a session. - -RLM advertises `_meta.steering.supported` during ACP initialization. Clients can -send `_session/steering` with `sessionId` and text `prompt` blocks while a turn is -running. Messages enter the conversation as user messages at the next model step, -after any tool results; tools are not interrupted. Steering wakes the native -`wait` tool so the next model step can consume the message. The request returns `injected` -when the message has entered the conversation, or `promptRequired` with reason -`noRunningTurn` when no active turn can consume it. Idle steering never starts a -new turn. Clients can set `_meta.steering.idleBehavior` to `promptRequired`. - -An optional `messageId` deduplicates retries within the session; reusing an ID with -different content fails. Injected messages survive prompt rollback and are kept in -the user-request history used by compaction. Queued messages that cannot be applied -remain unconsumed. Worlds uses this interface to deliver its attention inbox. - -RLM's ACP surface is a versioned training contract. `initialize` advertises the exact -`ai.prime.rlm/contract-v1` marker in its response `_meta`; clients must require -it, then provide one complete `ai.prime.rlm/runtime-v1` object in -`session/new._meta`. The runtime object contains the ACP session ID, model, -provider, execution policy, prompt configuration, enabled built-in Python tools, -optional builtin tool selection, -explicit kernel environment, and optional search credential. Nullable and -disabled values are sent explicitly as `null` or empty collections. Missing, -partial, unknown, or unsupported contracts are rejected; ACP sessions never -fall back to process environment configuration. - -Credentials travel over the private ACP stdio channel and are never echoed. -The `session/close` response carries one authoritative, credential-free -snapshot of cumulative usage, metrics, tool-call stats, supervisor counters, -and limits under `ai.prime.rlm/session-v1`, and the session's refinement passes -(applied or declined, with any judge verdict) under `ai.prime.rlm/refinements-v1`. - -When a limit cuts a prompt short, its ACP stop reason says so: -`max_turn_requests` when the snapshot's `last_stop_reason` is -`max_total_turns`, and `max_tokens` when it is `max_total_tokens`, -`token_budget` (the policy's `max_tokens`) or `compaction_failed`. Every other -prompt ends with `end_turn`. - -Every actual model call carries a standard HTTP `Idempotency-Key` header that -stays stable across SDK and outer retries (retry attempts are distinguished by -`x-stainless-retry-count`), so an inference proxy can deduplicate replayed -requests. Both header names are reserved and rejected in provider -configuration. - -Model calls also carry a private `X-ACP-Model-Request-ID` correlation header. -RLM publishes sparse, labeled relationships between those request IDs under -`ai.prime.acp/semantic-edges-v1`: `continuation`, `subagent_call`, -`subagent_return`, `compaction`, `refinement_attempt`, and `refinement`. `continuation` preserves same-agent causal -order even when a consumer's physical token-prefix graph splits. ACP consumers -can resolve the request IDs onto their own message nodes while harnesses that do -not understand the extension ignore it. - -## Python API (inside a session) - -`rlm.agent` exposes `spawn`, `list`, and `get` inside a running session. These async calls contact the session supervisor; spawning waits only for registration, not task completion. `history()` also supports explicit session-directory loading outside a running session. - -## Configuration - -All runtime configuration enters through the `ai.prime.rlm/runtime-v1` contract object -(model, provider credentials, execution policy, prompt configuration, Python tools in `skills`, builtin tools, kernel -environment, search credential, and an optional `harness` object; see [Continual harness](#continual-harness)). Prompt configuration is role-aware: optional -`subagent_append_to_system_prompt` (nodes that can still recurse) and -`leaf_append_to_system_prompt` (depth == max_depth) override `append_to_system_prompt` -for sub-agents, each falling back to the next-more-general tier. Recursive children inherit the parent's configuration -in-memory (`model_copy`); nothing is re-read from the process environment. - -`policy.max_concurrent_subagents` caps concurrent model calls across the entire -tree, including the root and compaction requests. Queued calls closer to the root -have priority; calls at the same depth are admitted FIFO. In-flight calls are not -preempted, so continuous shallow work can delay deeper requests. Shell/tool -execution, child waits, and outer transport retry backoff do not hold inference -slots; SDK-internal retries remain within a slot. `policy.max_depth` independently -limits recursion. This limit is per tree; a serving-wide cap across multiple trees -belongs in the eval runner or inference gateway. - -`policy.delegation_prompt` (default off) appends the delegation guidance — when to spawn children, how to brief, watch, collect and reconcile them — for every agent that can still delegate. - -`system_prompt_path` supplies task instructions in place of the default task role. -The runtime guide is always appended, including when a custom prompt file is used. -The role-appropriate append instructions are included between the task instructions -and runtime guide. This keeps tool/API documentation and lifecycle rules available -to root agents, persistent children, and leaves. Credentials are not included in -agent identity metadata. - -`prompt_overrides` replaces named runtime texts for every engine in the tree. The names -are the keys of `rlm.prompt.DEFAULT_PROMPTS`: the system prompt's `task` line and the -`repl_doctrine` and `delegation_doctrine` paragraphs; the reference sections they sit in -(`runtime_reference`, `delegation_reference`, `history`, `harness_api`); the compaction -texts `checkpoint`, `rollup` and `staircase_framing`; and the refinement planning text -`refine`. Unknown names and empty texts are rejected, as is a text that drops a marker -the runtime fills in (`` inside `runtime_reference`, `%(scope_policy)s` -in `refine`, ...). `system_prompt_path` still replaces the task role entirely. The -`session/close` snapshot lists the overridden names under `limits.prompt_overrides`. - -The generated guide distinguishes Python state from supervisor-owned resources, -shows inbox dictionaries versus handle/metadata objects, and explains waiting, -completion, history recovery, jobs, and subscriptions. Delegation instructions are -shown only when IPython and recursion are available. Tool descriptions follow the -same shell execution guidance. - -Checkpoint prompts preserve task requirements, evidence, outstanding assignments, -jobs, subscriptions, event actions, output cursors, and history references. Commands -and edits are included only when relevant. Compaction thresholds, summary validation, -and context retention policy are unchanged. - -The process environment configures only process infrastructure: - -| Variable | Default | Description | -| ---------- | -------- | ------------------------------------------------------------------ | -| `RLM_HOME` | `~/.rlm` | Root directory for sessions, data, and skills (`$RLM_HOME/skills`) | - -## Recursion - -The supervisor owns each agent's identity, task, runtime, and lifetime. Python variables hold handles, so losing a variable or ending a cell does not stop its agent. - -```python -researcher = await rlm.agent.spawn( - task="Check authentication behavior", name="researcher" -) -other = await rlm.agent.spawn(task="Check login behavior", name="login") - -agents = await rlm.agent.list() # Direct children, including completed agents -info = await researcher.info() # Fresh metadata snapshot -hist = await researcher.history() # Fresh conversation snapshot -researcher = await rlm.agent.get("researcher") # Recover by sibling name or ID -``` - -Names are unique among siblings and remain reserved for the session, including after completion. Immutable IDs are shown alongside names. Metadata includes the parent ID, initial task, status, persistence flag, creation time, elapsed lifetime in seconds, session directory, and any failure. `list(recursive=True)` includes descendants, but only direct children can be retrieved as handles or controlled through the supervisor. - -```python -result = await researcher.result(yield_after=60) -if result.running: - print("still working, answer so far:", result.answer) -else: - print(result.answer) -``` - -`result()` waits up to `yield_after` seconds (default 300) for the agent to finish and returns an `AgentResult` (`status`, `answer`, `usage`, `turns`, `session_dir`, `running`). `answer` is `None` while the agent is still working on its first answer; a persistent agent's latest answer stays available while it runs again. Terminal failure or cancellation raises. Use `info()` or `wait()` for current activity; a retained answer does not mean a follow-up has finished. `wait()` returns current metadata after an outcome or its timeout (default 30 seconds, range 0–300). It waits inside the Python cell and uses the cell's normal execution timeout. Cancelling or timing out a wait does not cancel the agent. - -`await researcher.cancel()` terminates the agent and its descendants and waits for cleanup. Ordinary agents release their kernels after answering. An agent spawned with `persistent=True` becomes idle after answering and retains its conversation and kernel; a parent instruction or new inbox event wakes it. Parent termination tears down all descendants, including persistent agents. Closing the ACP session tears down the tree. Cancelling an individual prompt or cell leaves its accepted children registered and recoverable. - -Status is `starting` while waiting for capacity, `running` during execution, `waiting` during a native event wait, `idle` for a persistent agent that has answered, or `completed`, `failed`, or `cancelled` after termination. Completed metadata, results, and history remain accessible for the supervisor's lifetime. Shared filesystem access is trusted; handle permissions are orchestration controls, not filesystem isolation. - -Depth, concurrency, total-spawn, and shared token/turn limits remain supervisor-enforced. Rejected spawns raise before registration. Persistent idle kernels release inference capacity but still count toward the session's total-spawn limit. A child return enters the ACP semantic trace when the parent retrieves its result, so background completion is not mistaken for result consumption. - -### Messages and event waiting - -```python -await researcher.send("Also check logout") # Deliver after its answer or native wait -await researcher.steer("Focus on login first") # Deliver at the next model/tool boundary - -# Inside the child: -await rlm.agent.send_to_parent("Found a missing permission check") - -# Inside its parent: -for event in await rlm.inbox.list(): - report = await rlm.inbox.read(event["id"]) - if report["type"] == "agent.message": - print(report["content"]["name"], report["content"]["text"]) -``` - -Parent instructions are pushed into the child's conversation. Steering does not interrupt a running model request or tool. Queued messages wait until the child answers or calls the native `wait` tool. Both operations wake an idle persistent child; sending to a terminated child raises. - -Reports and `agent.completed` events enter the parent's inbox at every depth. The model sees the unread count whenever it changes or new events arrive, and chooses when to retrieve payloads. `list()` returns metadata without marking events read; `read(id)` returns the payload and marks it read. `list(unread_only=False)` includes previously read events. Completion payloads identify the agent and status; retrieve the answer with its handle's `result()`. - -ACP dependency edges use `agent_message` for delivered instructions and explicitly read inbox events. Spawning uses `subagent_call`; retrieving an answer uses `subagent_return`. Delivery policy and event type remain in the event records. An unread-count notification alone creates no `agent_message` edge. - -The native `wait` tool suspends inference without occupying an IPython cell, until a new inbox arrival, parent instruction, or timeout (default 300 seconds, range 0–300). It also yields to queued instructions. Already-announced unread events do not repeatedly wake it. A final root answer returns control to the ACP caller; use `wait` to keep the current prompt available for events. - -The supervisor owns inboxes and instruction queues. Each agent's `inbox.jsonl` records arrivals, instruction delivery, and explicit reads; delivered messages and notifications also enter `messages.jsonl`. Message payloads are limited to 65,536 JSON-encoded UTF-8 bytes. Reports are rejected once an inbox contains 10,000 events, and pending instruction queues have the same limit; lifecycle events remain recordable. This state lasts while the supervisor lives. Restarting a crashed IPython kernel and restoring a crashed supervisor are separate features. - -## Compaction - -There is no model-driven compaction tool. Compaction is on by default and unlimited (the policy's `compaction` field turns it off, `max_compactions` caps it); the default 1M `max_total_tokens` tree budget keeps sessions bounded, since each compaction cycle itself spends new tokens. The engine reads the model context window from the provider's `/models` response and compacts when 16k tokens remain below it; small windows keep at least half. Set `summarize_at_tokens` to pin the threshold explicitly. Without a known window or explicit threshold, proactive compaction stays off, but a provider overflow still triggers it reactively. A tool result larger than 20KB is truncated to its head and tail before it enters the conversation, with a warning naming the original size. - -Compaction is tiered, after headlong's memory pyramid: instead of replacing the window with one summary that every later compaction re-summarizes, the engine keeps the whole session in context at decreasing resolution and keeps the current prompt and the newest messages verbatim. - -### Windows versus branches - -Two words come up throughout this section and they are easy to conflate. - -Think of the session as a long strip of paper: the ledger (`messages.jsonl`). Every message ever sent to or received from the model is written on it once, in order, and gets a number that never changes. Nothing is ever erased. - -A **window** is a frame laid over that strip: the set of messages the model can currently see. When something replaces the model's context (session start, compaction, rollback, or a harness refinement swapping the system prompt) the engine puts down a new frame and gives it the next number. Frames are how the ledger answers "what exactly did the model see at request 57?" - -A **branch** is a stretch of the strip between two compactions: the part of the story that one tier-1 block summarizes. Only compaction ends a branch. - -Most of the time one branch is one window: - -```text -ledger 0 ──── 1 ──── 2 ──── 3 ──── 4 ──── 5 ──── 6 ──── 7 ──── 8 - sys task call res call res |stair| call res - | A | -window ├──────────── window 0 ─────────────────┤├──── window 1 ──── -branch ├──────────── branch 0 ─────────────────┤├──── branch 1 ──── - ^ compaction -``` - -They differ in two situations. A harness refinement opens a new window without compacting, because the system prompt changed in place while every other message kept its number; the branch keeps going, so a block records a window *range*: - -```text -ledger 0 ─── 1 ─── 2 ─── 3 ─── 9 ─── 4 ─── 5 ─── 6 - sys task call res sys' call res ... -window ├── window 0 ─────────┤├──── window 1 ──────── -branch ├──────────────── branch 0 ─────────────────── - ^ refinement: new system message (9), same branch -``` - -And with the verbatim tail, a window after compaction *starts* with the last few messages of the branch before it. Those messages keep their numbers and belong to the next block, so the blocks tile the strip without gaps: - -```text -ledger 0 ─── 1 ─── 2 ─── 3 ─── 4 ─── 5 ─── 6 ─── 7 - sys task call res stair call res stair - └─ tail ─┘ -window 1 seeds: [0, 1, 4, 2, 3] (system, pinned task, staircase, then the tail by number) -block A covers 1-1 (the branch before the tail) -block B covers 2-4 (the tail, the staircase message, and what followed) -``` - -A rollback also opens a window, restoring the pre-prompt frame, and restores the branch bookkeeping with it. So: windows are how the ledger addresses context; branches are how compaction chunks time; a branch spans one or more windows, and a window can hold the end of one branch and the start of the next. - -### Branches and blocks - -The context since the last compaction is a *branch*. When a branch is compacted, the engine asks the model, in context, for a plain-text handoff summary of that branch (reasoning is never part of it) and records it as a tier-1 *block*. A block is immutable and is a pointer into the ledger: its header names the message, window, and turn ranges it summarizes, so the raw `messages.jsonl` stays the source of truth and the summary is an index into it. - -```text -[tier 1 | branch 3 | window 3 | messages 412-1180 | turns 88-131] -Ran the integration suite against the new parser; 3 failures remain in -tests/test_edges.py (see hist.messages[1104]). Fixed the tokenizer off-by-one in -src/lex.py. Open: the `--strict` flag is still unhandled. -``` - -The ranges are supplied by the engine, never by the model, so they are exact. - -### Rollups - -Every `compaction_fanout` (default 5, written `F` below) consecutive blocks of one tier are merged into one block of the next tier by a side call over those summaries alone: tier 2 summarizes `F` branches, tier 3 summarizes `F^2`, tier k summarizes `F^(k-1)`. A rollup never sees the live context, only the child blocks, so it costs about `F` summaries' worth of input regardless of how long the session is. - -| tier | one block summarizes | one block covers | built from | -| ---- | --------------------- | ------------------ | -------------- | -| 1 | the branch itself | 1 branch | the live context | -| 2 | `F` tier-1 blocks | `F` branches | tier 1 | -| 3 | `F` tier-2 blocks | `F^2` branches | tier 2 | -| k | `F` tier-(k-1) blocks | `F^(k-1)` branches | tier k-1 | - -A rollup seals as soon as its `F` children exist and is then never rebuilt; only the frontier is ever summarized, so the lifetime cost is about `N / (F - 1)` small calls for `N` compactions. A rolled-up range keeps its children's message range: a tier-3 block over branches 0-24 covers exactly the messages those branches covered. - -### The staircase - -The fresh window after a compaction is `[system, prompt, staircase, tail]`. The staircase lists blocks oldest first at decreasing resolution: the branch count `N` is decomposed in base `F`, and tier k contributes the aligned blocks just older than the finer tiers, at most `F - 1` of them. The coarsest block always starts at branch 0, so nothing in the session is unrepresented, and the whole staircase is bounded by `(F - 1) * log_F(N)` blocks. - -With `F = 2` the layout after each compaction is: - -```text -compactions staircase (oldest first) - 1 [t1: 0] - 2 [t2: 0-1] - 3 [t2: 0-1] [t1: 2] - 4 [t3: 0-3] - 5 [t3: 0-3] [t1: 4] - 6 [t3: 0-3] [t2: 4-5] - 7 [t3: 0-3] [t2: 4-5] [t1: 6] - 8 [t4: 0-7] - 9 [t4: 0-7] [t1: 8] -``` - -Read left to right as decreasing age and increasing resolution: the far past is a single paragraph, the middle is folded into pairs, and the most recent branch is a full summary. With the default `F = 5` a session's first four compactions show every branch summary verbatim; the fifth folds them into one tier-2 block. - -If a rollup is missing (it failed, or has not been attempted yet) the staircase shows its children instead, so coverage is never lost: at 4 compactions with `[t3: 0-3]` unsealed the staircase reads `[t2: 0-1] [t2: 2-3]`, and with one of those missing too it reads `[t1: 0] [t1: 1] [t2: 2-3]`. - -### The verbatim tail - -The staircase is followed by the newest run of messages that fits `compaction_tail_tokens` (default 12k, capped at a quarter of the compaction threshold; 0 keeps none), so the exact last tool results survive compaction instead of being paraphrased. The tail opens with an assistant or user message so no tool result is separated from its call, and never reaches the branch's first message. It is re-attached by ledger index rather than copied: the new `context_window` record lists those existing indices after the staircase message, exactly as a rollback re-lists restored messages. - -```text -ledger index 0 1 2 3 4 5 6 7 -record system task call result stairc. call result stairc. - -window 0 [0, 1, 2, 3] <- first branch -compaction 1 block A = messages 1-1, tail = [2, 3] -window 1 [0, 1, 4, 2, 3, 5, 6] <- system, pinned task, staircase, tail, new work -compaction 2 block B = messages 2-4, tail = [5, 6] -window 2 [0, 1, 7, 5, 6] -``` - -This toy session has one call per branch, so the tail takes nearly all of it; in practice a branch holds far more than its tail. The summary request is told how many messages will stay verbatim so it covers what precedes them; the branch block ends just before the tail, and the kept tail opens the next branch, so block message ranges stay gapless (block B above starts where block A's tail started). - -### The pinned prompt - -The current prompt (the latest `user` or parent instruction, not a supervisor notice) is re-attached by ledger index between the system message and the staircase whenever it fits `compaction_prompt_tokens` (default 4k estimated tokens; 0 never pins), so the task is never reconstructed from a summary and the `[system, prompt]` prefix stays stable across compactions. A larger prompt is referenced by ledger index in the staircase message instead (`hist.messages[i]`); a prompt already inside the kept tail is not duplicated. The `compaction` record names the `pinned_prompt_index`. - -```text -window after compaction: [system] [prompt] [staircase] [tail...] - ^ stable prefix ^ new ^ re-attached by index -``` - -### Drill-down - -The compaction message names the ledger path and the history API, and every block header is a range the model can expand: - -```python -from rlm import history - -hist = await history() -hist.blocks # [{"tier": 1, "branches": [0, 1], "messages": [1, 1], ...}, ...] -hist.expand(-1) # the messages the newest block summarizes -hist.expand(hist.blocks[0]) # a block record works too -``` - -`hist.blocks` lists the blocks in ledger order and excludes those of rolled-back prompt attempts; `hist.expand` returns the slice of `hist.messages` a block covers, which for a rolled-up block is everything its children covered. A `compaction` record carries its tier-1 `block`, the `rollups_sealed` in that cycle, and the `tail_message_indices` it kept; each higher-tier block is a `rollup` record. - -### Budgets and failure - -Rollups are best effort: one compaction spends at most `max_compaction_attempts` rollup calls in total (the usual cycle needs none or one), a rollup that yields no usable reply or does not fit that budget is skipped, and the next compaction retries it, newest ranges first, so a range that keeps failing cannot starve fresh ones. - -A provider overflow (a 400 or 413 naming a context limit) triggers the same compaction reactively from the current state. A rejected checkpoint request falls back to the last state that passed a threshold check - by definition a state with a full reserve of room - and an empty or tool-calling reply is resampled; after `max_compaction_attempts` failed attempts the run ends cleanly with what the conversation holds. An overflow with no history beyond the task propagates: the task alone approaches the window and there is nothing to reclaim. Summary and rollup calls are side requests: they spend tokens but are not work turns, and each carries a `compaction_attempt` semantic edge from the request that triggered the compaction, with the single `compaction` edge sourced from the branch summary. - -The IPython kernel keeps running across the compaction, so all variables, imports, and in-memory data are preserved. The model is told to mention important variable names in its summary so the resumed branch knows what is available. The same policy applies to the main agent and all recursive agents. - -### The ledger - -A session creates a fresh ledger and refuses to reopen an existing `messages.jsonl` for writing. Existing histories remain readable. The session owns active messages and their indices; the engine uses context snapshots. A ledger write or flush failure makes the writer unusable and prevents further prompts. Rollback restores in-memory context and counters even if its ledger write fails. Supervisor crash recovery is not supported. - -The working conversation remains in the session's append-only `messages.jsonl`, which the model can search and process from Python. Message records have stable event `id` fields, zero-based `message_index` values, and a `message` object containing the role, content, and tool-call fields. Full tool outputs and shortened context versions have separate indices; the shortened tool record links to its `source_message_index`. System messages and installed summaries are also logged; a `compaction` record carries its tier-1 `block` and a `rollup` record each higher-tier block. Checkpoint prompts and raw checkpoint responses are omitted: this ledger serves agent context recovery. Verifiers, the ACP consumer, records model requests/responses through its interception endpoint and receives semantic links and metrics over ACP, independently of this file. A `user` record begins a prompt attempt; `prompt_rollback.prompt_id` identifies a failed or cancelled attempt, whose records remain available as history. - -Each `context_window` record declares a zero-based `window` index, a `reason` (`start`, `compaction`, or `rollback`), and the ordered `message_indices` that seed that window. Subsequent message records with a `window` field append to it. Closed windows never change. Rollback opens a new window containing the restored context, preserving addresses in the failed window; a compaction window likewise re-lists the indices of its verbatim tail. The `turn` field is an execution-loop counter, not a message or window index. - -Each agent writes its own `messages.jsonl` in its session directory. Message and context-window indices are local to that agent. Agent discovery belongs to the supervisor; the history reader accepts an explicit session directory. - -The kernel can inspect its own history or a child's, including while the child is running: - -```python -from rlm import history - -hist = await history() # Defaults to $RLM_SESSION_DIR -requests = hist.user_messages() # Original inputs, including rolled-back attempts -message = hist.messages[3] # Session-wide message index -earlier = hist.windows[0].messages # Initial working context -child_history = history(session_dir="/path/to/child-session") -message = child_history.windows[4].messages[2] # If that child has reached window 4 -blocks = hist.blocks # Compaction blocks with the ranges they cover -originals = hist.expand(blocks[0]) # The messages the first block summarizes -``` - -Snapshots contain complete records as of the read; call `history(...)` again to observe new activity. `hist.events` exposes lifecycle records, including each child's spawn prompt and rollback markers. The compacted context points to this API so the model can retrieve omitted details without putting the whole transcript back in context. - -## Session Directory - -Every invocation writes to `$RLM_HOME/sessions//`. Nested session directories mirror the call tree. - -```text -.rlm/sessions/abc123/ -├── meta.json -├── messages.jsonl -├── harness/ -│ └── harness_state.json -├── sub-d4e5/ -│ ├── meta.json -│ ├── messages.jsonl -│ └── sub-f6g7/ -└── sub-h8i9/ -``` - -These artifacts are consumable for debugging, visualization, or training-data extraction. - -The only cross-session file is the optional global harness store, which lives wherever -`harness.global_dir` points, outside any session directory: - -```text -/ -├── harness_state.json # global prompt/memory/skill/subagent entries and, when -│ # record_episodes is on, one episode entry per root session -└── refinements.jsonl -``` - -An episode entry's `path` field (`episodes//…`) is a facet inside that JSON file, -not a directory; the entry points back at its session through `metadata.session_dir`, and -the session's `messages.jsonl` records the `global_dir` it published to. Nothing under -`sessions//` changes. - -## Continual harness - -The continual harness is durable state that supplements the immutable system prompt: `prompt` -notes (narrow behavioural policies), `memory` entries (facts, decisions, failures), `skill` -entries (how to use an importable Python tool, with a `reference` and an -`arguments` contract), `subagent` specs (reusable delegation roles) and, in a global store, -`episode` records of past sessions written by the engine (see [Episodes](#episodes)). Each agent's local -store lives at `/harness/harness_state.json`. A child reads its ancestors' local -stores read-only alongside its own. The contract's `harness` object controls the feature: - -```json -"harness": { - "enabled": true, - "global_dir": null, - "max_prompt_entries_per_kind": 6, - "max_prompt_content_chars": 180, - "max_prompt_refinements": 5, - "auto_refine": false, - "auto_refine_review": "judge", - "refine_turn_interval": 12, - "refine_cooldown_seconds": 300, - "max_refinements": null, - "max_refinement_attempts": 3, - "refine_judge": null, - "author_skills": false, - "record_episodes": false -} -``` - -`global_dir` names a store shared across sessions; `null` (the default) keeps every session -hermetic. The system prompt carries a compact `## Continual harness` block: per-kind counts, -the most relevant entries within the caps (ranked against the task text when a kind -overflows) and recent refinement events. Entries are displayed as `[local:id]`, -`[ancestor:id]` or `[global:id]`. - -Inside the kernel, `rlm.harness` is the synchronous Python API: - -```python -h = rlm.harness.harness() # the view this agent sees -print(h.overview()) -h.search("pytest venv") # ranked by term overlap -h.list("episode") # every visible entry of one kind -h.get("memory", "ancestor:parent_lesson") # ids exactly as displayed -h.create_memory("Project venv", "run tests with ./.venv/bin/python -m pytest") -h.create_skill("Release lookup", "query websearch with ' release'", - reference={"type": "python", "import": "websearch", "callable": "run", - "call_pattern": "await websearch(queries=[...])"}, - arguments={"queries": {"type": "array", "required": True}}) -h.update_memory("project_venv", "Project venv", "...", global_=True) # needs global_dir -``` - -`update_*`/`delete_*` take the bare id or a `local:`/`global:` prefix; ancestor entries raise -`PermissionError`. Every method carries a docstring, so `help(h)` (or `help(h.create_skill)`) is -the full reference; the system prompt only lists the essential calls. A `skill` entry describes how to use an already-importable Python tool; it is not a -package (see [Python tool package contract](#python-tool-package-contract) for the on-disk package contract). Like -`rlm.agent`, the kernel's view is served by the session supervisor through the broker: -each call runs on the calling agent's own stores and needs an active cell, and the kernel -never names a store. The store objects themselves (`h.local`, `h.layers()`) are not -available in the kernel. Stores are rewritten atomically under a file lock and reloaded -when another writer changed them, so the engine and the supervisor share one file safely. -`harness(session_dir=...)` loads a session's local store directly, for example outside a -running session. - -### Entry schema - -Every entry is one `HarnessEntry` record (a Pydantic model in `rlm.harness`) with the same -base fields for all kinds, and kind-specific structure inside three payload dicts that is -validated whenever an entry is created, updated, restored or loaded from disk: - -| field | type | notes | -| --- | --- | --- | -| `id`, `kind`, `title`, `content` | `str` | `id` is a slug of the title unless given; `kind` is one of `prompt`, `memory`, `skill`, `subagent`, `episode` | -| `path` | `str` | grouping facet, default `general`; not a filesystem path | -| `scope` | `local` \| `global` | the store the entry lives in | -| `reference`, `arguments`, `metadata` | `dict` | payloads, see below | -| `source` | `str` | `agent`, `refinement` or `engine` | -| `created_at`, `updated_at`, `version` | | maintained by the store | - -| kind | payload | model | -| --- | --- | --- | -| `skill` | `reference` | `SkillReference`: `type: "python"`, `import` (module), and `callable` and/or `call_pattern` | -| `skill` | `arguments` | `{name: SkillArgument}` with `type`, `required` (default false), `default`, `description` | -| `episode` | `metadata` | `EpisodeMetadata`: `session_dir`, `session_id`, `stop_reason`, `prompts`, `turns`, `prompt_tokens`, `completion_tokens`, `cwd`, `blocks` (`BlockRecord`s), `started_at`, `ended_at` | -| others | `metadata` | free-form | - -Payload models keep unknown keys, so a store written by a newer build still loads, and -fill in optional ones, so `entry.reference["call_pattern"]` is always present on a skill. -A violation raises `ValueError` naming the field (`arguments.queries: Input should be a -valid dictionary ...`) from the kernel API, is reported per edit by a refinement pass, and -fails `load()` with the store path and entry id when a state file is inconsistent. - -### Skill entries versus Python tools - -The harness works with three kinds of object. In the [Tools](#tools) and [Skills](#skills) -terms, an installed package is a Python tool, an authored package is a skill that ships a -Python tool, and a harness `skill` entry is a skill kept as harness state: - -| | Installed Python tool | Authored skill | Harness `skill` entry | -| --------------- | ------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| What it is | A Python package installed into rlm's venv | A Python package under `$RLM_HOME/skills`, on `sys.path` | A JSON record in `harness_state.json` | -| Contains | Code: `async def run(...)` | Code: `async def run(...)` | Text: title, when/how to call something, a `reference` (`import`, `callable`, `call_pattern`) and an `arguments` schema | -| Created by | A human, via `install.sh` at image build | The agent, mid-session, by writing files | The agent (`h.create_skill(...)`) or the refinement pass | -| Lives | The venv; every session | `$RLM_HOME/skills//`; sessions sharing `RLM_HOME` | One store: session-local, an ancestor's, or global | -| The kernel sees | `await websearch(...)` | `await greeter(...)` | Nothing executable; it is rendered into the system prompt | -| Refinement can | Never touch it | Never touch it | Create, update, delete, roll back | - -The relationship is pointer to target. An entry's `reference.import` names a module that -must already be importable (an installed Python tool, an authored skill's tool, a -built-in Python tool from the contract's `skills` list, or `rlm` itself); apply-time validation rejects an entry that points anywhere else. The -entry adds what code cannot carry: when this call is the right move, which arguments -matter for a kind of task, gotchas learned from use. One package can be described by -several entries, and a package with no entry is still callable, just not surfaced as a -routing hint. - -The split gives the two layers different lifecycles. Packages are code, so they go -through the main loop: the agent writes them, `load_skills()` imports them, it tests -them, and mistakes are observed and iterated on. Refinement, a one-shot JSON proposal -with `before`/`after` snapshots, is kept away from them because a file write cannot be -undone by replaying a snapshot and a side model call should not mint code that -auto-executes in future sessions. Entries are state, so they go through refinement: -small, validated, snapshotted, reversible; a wrong one costs a bad hint in the prompt, -not a broken kernel. The typical arc is: notice a repeated procedure, write a package, -`load_skills` and test it, record a `skill` entry pointing at it; future sessions see the -entry in their system prompt with the package already pre-imported. - -### Authored skills - -`author_skills` lets the agent write skills that survive the session into the skills root, -`$RLM_HOME/skills`, which also holds the host's [skills](#skills) and Python tool packages. -An authored skill is a `SKILL.md` that ships a Python tool package. The -[package contract](#python-tool-package-contract) applies, minus installation, with these -adjustments: - -- Required: `$RLM_HOME/skills//SKILL.md` and - `$RLM_HOME/skills//src//__init__.py` defining `async def run(...)`. The kernel - checks both; `run`'s docstring falls back to `SKILL.md` for `help()`. -- Optional: `pyproject.toml`; when present its `[project] name` must be `rlm-skill-` - so the package can be promoted to an installed Python tool unchanged. Nothing is installed, so - dependencies are limited to what is already in the kernel venv and there is no console - script: authored packages are IPython-only. - -At kernel start every authored package's `src/` goes on `sys.path` and the module is -pre-imported by name with the same `await (...)` wrapper as an installed Python tool. A -package that breaks the contract or fails to import binds a placeholder whose call raises -the reason, so a bad package never breaks the kernel. Names must not collide with installed -Python tools or the built-in Python tools the session generates from the contract's `skills` list. A package written or edited during the session is brought in -without a restart by `rlm.harness.load_skills("")` (no names = all), which -reloads and rebinds it exactly as kernel start does and returns `{name: reason}` with -`None` for a usable tool. The system prompt states the contract and this workflow; -`RLM_HARNESS_SKILLS_DIR` names the directory. - -The `## Skills` catalog scans the root each time a system prompt is built, so a skill -written mid-session, with `name` and `description` frontmatter, is listed for sub-agents -spawned afterwards, for later sessions, and for the authoring agent once a refinement -rebuilds its prompt. Until then, every running agent is told at its next model step: a -`` lists each skill added after its prompt was built, once, -in the catalog's line format. With `author_skills` on, it also says that a tool another -agent wrote is bound with `load_skills`. Compaction keeps the announced skills in the -continuation summary until the prompt is rebuilt. The prompt is not rebuilt just to list -them, which would discard the prompt cache. -A package that `install.sh` installed from the root, editable from its own folder, is an -installed Python tool rather than an authored one: it is pre-imported from the venv, -`load_skills` declines to rebind it, and edits take effect at the next kernel start. A -folder with no `src/`, `pyproject.toml` or `__init__.py` is an instruction-only skill and is -never loaded as a package. - -`author_skills` is off by default, and no authored packages load. - -### Refinement - -Refinement is a side model call, like compaction: the live conversation is extended with -one user message asking for a JSON proposal of create/update/delete edits, the proposal is -validated and applied under the store's lock, the system prompt is rebuilt in place (a new -`context_window` with `reason="harness"` that keeps every other message index), and a -`` notice tells the model what changed. Rejected edits -(unknown module in a `skill` reference, edits to the base system prompt, entries that -changed while planning, …) are recorded with an error and the rest still apply. Each -store's `refinements.jsonl` holds one full result per pass with per-edit `before`/`after` -snapshots; a rollback replays those snapshots in reverse and needs no model call. - -The planning call also decides whether to refine at all: a proposal with an empty `edits` -array declines the pass. A declined pass changes nothing (no store write, no rebuilt -window, no `refinement` edge) and is recorded as a `refinement_declined` ledger record with -the planner's rationale; only a kernel-requested pass tells the model, in context. An -applied pass is a `refinement` record. Both records carry the `trigger`, any compaction -`blocks` behind the pass and, when the TypeSafe judge ran, its verdict under `judge`. - -Three triggers, all of which run between model calls and never inside a cell: - -- **Kernel**: `await rlm.refine.run(instructions=None, global_=False, rollback_id=None)` - returns `{"scheduled": True}` (or a reason) and the pass runs at the next boundary; - `await rlm.refine.status()` reports `pending`/`in_flight`. -- **Host**: `session/prompt` may carry `ai.prime.rlm/refine-v1` in `_meta`: - `{"instructions": "...", "global": false, "rollback_id": null, "review": false, "focus": false}`. - The pass runs before the turn; with an empty prompt it is the whole turn and the notice - (or `[refinement declined: ]`) is the answer (`stop_reason` `refined`). - `review: true` lets the TypeSafe judge decline the pass before any model call; - `focus` has the judge write the plan's focus instructions, with any host `instructions` - appended after them. A rollback takes neither. The key is refused when the harness is - disabled, and `review` or `focus` is refused without `refine_judge`. -- **Auto** (`auto_refine`, off by default, root agent only): every `refine_turn_interval` - work turns and after each compaction, subject to `refine_cooldown_seconds`, the TypeSafe - judge reviews the pass (see below) and, unless it declines, a planning - call runs with an `` section: the pass was not requested, so edit - only on evidence useful to this session's future turns (a repeated failure, a reusable - tactic, a repeated delegation role, a durable fact or preference, a user correction) and - otherwise decline with no edits. After a compaction the plan also receives the blocks that - compaction produced (the branch summary and any rollups it sealed) as ``, with - the hint that a failure, tactic or fact recurring across blocks is evidence for a durable - entry while one branch's progress is not. The call runs with `tool_choice="none"`, so the - evidence is the block text itself, never a pointer to follow. - -#### TypeSafe review judge - -`refine_judge` puts TypeSafe's System One model (Jev) in front of the planning call. Jev -answers typed yes/no and choice questions with calibrated probabilities; the task model -still writes the plan and may still decline it. Automatic passes need the judge: -`auto_refine` without `refine_judge` fails validation at `session/new`. Only -`auto_refine_review: "planner"` lets the task model decide automatic passes alone, with -no judge call, e.g. as a baseline. For host passes the judge stays opt-in (`review`, -`focus`). It runs for the root agent only and sends a compact evidence state to the TypeSafe API: the messages since the last pass -(clipped, newest first, user turns kept ahead of the rest), exception counts, the -compaction blocks behind the pass, and the visible harness entries with the entries of -the store the pass writes first, alongside that store's refinement history. - -```json -"refine_judge": { - "api_key": "...", - "base_url": null, - "model": "jev-latest", - "mode": "gate", - "threshold": 0.7, - "veto_threshold": 0.8, - "home_confidence": 0.6, - "timeout_s": 30.0 -} ``` -A review is two calls. The **gate** asks one yes/no question per kind of lesson -(repeated failure, reusable tactic, delegation role, durable fact, user correction, and, -in a local pass a compaction started, working notes: state from the turns just -summarized away that the rest of the task still needs) and one per entry the pass could -fix: is it contradicted by the conversation? That covers -entries of the store the pass writes and, in a local pass, read-only global or ancestor -entries, which get a local override. Nothing at `threshold` declines after that one -call. The **focus** call is asked only about the lessons that fired, stated as -premises: per lesson, whether it is already recorded (a veto at `veto_threshold`) and -which kind should hold it; per entry of the store the pass writes, whether it covers a -lesson; per turn, whether it is direct evidence. Questions are worded -for the pass's scope: a local pass serves later work in this session (the rest of the -current task, which may continue after older turns are summarized away, and any later -tasks), a global pass (host `global: true`) future sessions. A fact or technique that -served only a step already finished doesn't count. Code turns the answers into deterministic plan -instructions naming the home kind (one kind when the choice's confidence reaches -`home_confidence`), the entries to update or delete, the read-only entries a local entry -should override, and quotes of the strongest turns. -They reach the planner as ``, the same slot host and kernel -instructions use. - -`mode: "gate"` lets the judge decline automatic passes before any model call; `"shadow"` -leaves the decision to the planner and only records the judge's verdict. The pass record's -`judge` holds the mode, the gate and focus probabilities, the evidence turns it chose and -TypeSafe token usage per call. A judge failure fails a gated pass; where the judge only -informs the plan (`focus`, `shadow`) the plan runs without it and the failure is recorded -as `judge.error`. `scripts/refine_eval/run.py` scores these pieces on labeled, steered -scenarios: decisions against labels, who declined, focus against expected kinds and -entries, and applied edits and follow-up probes per arm (`force`, `force+focus`, -`typesafe`, `none`). Scenarios can seed either store and run a global pass, covering a -stale global entry, a global entry a local pass must override, and a session-only fact -a global pass should decline. `scripts/refine_eval/RUNBOOK.md` covers running it and -reading the report. - -`max_refinements` caps applied passes per engine; `max_refinement_attempts` bounds how -often an unusable reply (truncated JSON, prose, a tool call) is resampled before the pass is -reported as failed in the conversation and the run continues. A pass is one planning -request (plus any resampled attempts), each carrying a `refinement_attempt` semantic edge -from the last work request; an applied plan becomes the source of a `refinement` edge into -the next work request, which also keeps its ordinary `continuation` edge. A declined plan -is a dead end, and a pass the judge declines makes no request at all. Refinement counts, -edit totals, declines by reason and the judge's reviews, errors, input tokens and -disagreements with the planner appear in the session metrics; the `session-v1` snapshot -carries per-scope entry counts under `harness`. - -### Episodes - -Trajectories are per agent directory and there is no global trajectory: `$RLM_HOME` is only -the default parent of `sessions//`, and no session discovers or reads another unless -it is handed a path. Episodes are the opt-in episodic half of cross-session memory, next to -the semantic entries above. With `record_episodes: true` **and** a `global_dir`, the root -engine writes one `episode` entry into the global store when the session closes, without -any model call: - -- `title`: the first line of the first prompt; the entry's `path` field (a facet, not a - directory): `episodes//
T-` from the session's start - time, so episodes list chronologically within the kind and a month or day is a - searchable term. -- `content`: `Prompts (n):` with the first line of every prompt (the first ten listed, - the rest counted), then the compaction staircase as it stood at close (the coarsest - blocks, or `(no compaction)` for a session that fit one window), then - `Outcome (): ` for the last prompt. -- `metadata`: `session_dir`, `session_id`, `stop_reason`, every prompt line under - `prompts`, `turns`, token totals, `cwd`, the block records, and start/end times. - -The entry is keyed by session id, so closing twice rewrites the same record. The system -prompt lists episodes like any other kind, ranked against the task text when there are more -than `max_prompt_entries_per_kind`; from the kernel, `h.list("episode")` lists them, -`h.search(query, kind="episode")` ranks them (an empty query returns the newest), and -`history(session_dir=h.get("episode", id).metadata["session_dir"])` opens its ledger for -`.blocks` / `.expand(i)`. Each session also logs an `episode` record naming what it -published. - -Episodes are engine-owned: `create`/`update`/`delete` of that kind raise `PermissionError` -from the kernel API and refinement edits of that kind are rejected, so nothing inside a -session can rewrite or remove another session's record. The engine never deletes them -either — a per-session setting must not prune a shared store — so trimming a long-lived -store is an operator command run outside any session: - -```bash -rlm harness prune --global-dir ~/.rlm/global --older-than 30d # dry run -rlm harness prune --global-dir ~/.rlm/global --older-than 30d --keep 200 --apply -``` - -`--older-than` (`30d`, `12h`, `4w`) selects by the session's start time and `--keep N` -drops everything beyond the N newest; without `--apply` the command only prints what would -go. `--global-dir` defaults to `$RLM_HARNESS_GLOBAL_DIR`. The removal runs under the store -lock and is recorded like a refinement — an `operator:prune` event in `harness_state.json` -and a result in `refinements.jsonl` with each removed entry's snapshot — so it is -inspectable and reversible by hand, while no in-session path can recreate an episode. Both gates default to off because a global store shared -across RL rollouts of one task would let a rollout read another's outcome. A store that -holds episodes cannot be read by builds that predate the kind. - -### In split execution - -With [separate tool execution](#separate-tool-execution) the harness keeps working as -described above, and its stores stay with the engine. - -The worker keeps a mirror of `$RLM_HOME/skills` in its session directory, filled before -the kernel starts and updated before each tool call, so every skill the prompt lists is -readable there. The prompt's skill paths, including `RLM_HARNESS_SKILLS_DIR`, name the -mirror. With `author_skills` on, the kernel binds and writes authored packages in the -mirror, and the files it changes return with each tool result to `$RLM_HOME/skills`. -They persist there, and the engine announces them to every agent. The receiving side -writes only regular files inside skill folders. It rejects absolute paths, `..`, and -paths through symlinks, and never imports what it receives. Files over 4 MiB and -bytecode are not mirrored. - -`history(session_dir)` and `harness(session_dir)` for a path the worker doesn't have ask -the supervisor. The supervisor serves the ledger in pages, or the session's local store -read-only, when the path is the `session_dir` of an episode in the agent's harness, and -refuses any other path. - -## Tools - -Tools are executable capabilities. They can be native model tool calls, Python callables in IPython, or shell commands. Skills are `SKILL.md` workflows that guide the agent's use of tools. - -`rlm` ships a small set of built-in Python tools enabled per session via the runtime contract's `skills` list (`edit`, `search`, `fetch`; see [MCP tools](#mcp-tools) for the related MCP path). `edit` runs in the kernel. Credentialed `search` runs in the supervisor through the capability broker, so `SERPER_API_KEY` is unavailable to IPython and its subprocesses; it returns title/URL/snippet for a single query (`await search(query="...")`). The contract's `skills` field selects Python tools; it does not select `SKILL.md` workflows. - -Additional Python tool packages are supplied by the host environment: before `install.sh` runs, the environment places packages under `$RLM_HOME/skills//`, and `install.sh` installs them alongside `rlm` so they're both importable and on `$PATH`. The prompt advertises pre-imported callables as "Python tools (pre-imported)" and packages with console commands as "Shell-enabled Python tools". +## Quickstart -From IPython, call a pre-imported tool's async `run(...)` entrypoint through its callable module: +`rlm --acp` serves one ACP agent over stdio. A client starts it, sends the runtime contract with `session/new`, and prompts it. This client works against any OpenAI-compatible endpoint: ```python -import websearch - -help(websearch) # signature + docstring -results = await websearch(queries=["latest jupyter_client release"]) -``` - -Installed `rlm-skill-*` tool packages also expose their declared console command. Built-in Python tools and MCP proxy callables are IPython-only: - -```bash -websearch --queries "latest jupyter_client release" -``` - -### Python tool package contract - -A Python tool package is laid out like this: - -```text -/ -├── SKILL.md # Optional workflow instructions -├── pyproject.toml -└── src/ - └── / - ├── __init__.py - └── .py -``` - -Required public surface: - -- async `run(...)`: the single entrypoint. Type-annotated parameters and a Google-style docstring (`Args:`, `Returns:`) drive both the Python API and the CLI. - -The `pyproject.toml` points its console script at the shared CLI entry: - -```toml -[project.scripts] - = "rlm.skill:cli" -``` - -`rlm.skill:cli` reads `sys.argv[0]`, imports the matching module, and uses [tyro](https://brentyi.github.io/tyro/) to build the argparse CLI from `run`'s signature. The return value of `run` is printed; a raise surfaces as a normal Python traceback. - -Naming expectations (all match): - -- package directory name: `` -- distribution name in `pyproject.toml`: `rlm-skill-` -- import name: `` -- console script name: `` - -Keyword arguments on `run(...)` and CLI flags line up automatically — `queries: list[str]` becomes `--queries` on the CLI. - -The distribution prefix `rlm-skill-` and CLI entrypoint `rlm.skill:cli` identify Python tool packages. Neither requires a `SKILL.md` file. Dependencies go in the package's own `pyproject.toml`; declare `rlm` there so the shared CLI entry resolves. Version conflicts between packages installed side-by-side are the user's responsibility. - -### Local development - -Place Python tool packages under `$RLM_HOME/skills/` (`~/.rlm/skills/` by default) before running `install.sh`. [The `say` fixture](tests/fixtures/skills/say) is a minimal package example. - -## Skills - -A skill is a folder holding a `SKILL.md` with `name` and `description` frontmatter. It contains instructions for a workflow and may include scripts, references, or a Python tool package. A skill's Markdown is read by the agent; its scripts and tools are executed through the available runtime tools. - -Every folder under `$RLM_HOME/skills` holding a `SKILL.md` is listed in a `## Skills` prompt section, one `- name: description (file: path)` line each (the codex convention). Only metadata and paths are included; the agent reads the full `SKILL.md` when its task matches. This discovery is independent of installed Python tool packages. A folder with both a `SKILL.md` and a tool package contributes workflow instructions to the skills catalog and a callable to the Python tools listing. - -## MCP tools - -A host harness can wire task-specific [MCP](https://modelcontextprotocol.io) tool servers to `rlm` through the ACP session (a standard `mcpServers` config shape). Streamable HTTP and stdio transports are supported: - -```json -{ - "mcpServers": { - "remote": { "url": "http://127.0.0.1:8000/mcp" }, - "local": { - "command": "/path/to/tool-server", - "args": ["--stdio"], - "env": { "API_KEY": "..." } +import asyncio +import os + +from acp import PROTOCOL_VERSION, spawn_agent_process, text_block + + +class Client: + async def session_update(self, session_id, update, **kwargs): + if getattr(update, "session_update", None) == "agent_message_chunk": + print(update.content.text) + + +async def main(): + runtime = { + "session_id": "quickstart", + "model": "your-model", + "provider": { + "base_url": "https://your-endpoint/v1", + "api_key": os.environ["API_KEY"], + "headers": {}, + }, + "policy": {}, + "system_prompt_path": None, + "append_to_system_prompt": None, + "skills": ["edit"], + "kernel_env": {}, + "search_api_key": None, } - } -} -``` - -Programmatically, pass `mcp_servers={"tools": "http://127.0.0.1:8000/mcp"}` to `RLMEngine`. An HTTP server may instead be `{"url": "...", "headers": {"Authorization": "..."}}` when it needs request headers; stdio servers use the same `command` / `args` / `env` shape shown above. - -At startup `rlm` connects to each server and indexes all pages of its raw MCP catalog in the supervisor. MCP listing includes input schemas; these stay in the supervisor's catalog. Startup and search do not create Python callables, public descriptors, broker handlers, or per-tool modules. The first `get(id)` creates and registers that tool's descriptor; subsequent loads reuse it. Only requested tools are converted into async callables in IPython, regardless of catalog size. + async with spawn_agent_process(Client(), "rlm", "--acp") as (agent, _process): + await agent.initialize(PROTOCOL_VERSION) + session = await agent.new_session( + cwd=os.getcwd(), mcp_servers=[], **{"ai.prime.rlm/runtime-v1": runtime} + ) + await agent.prompt( + session_id=session.session_id, + prompt=[text_block("How many Python files are in this directory?")], + ) + await agent.close_session(session.session_id) -The discovery guide appears in the system prompt only when IPython is active and at least one MCP tool was discovered. Server summaries include available descriptions from MCP initialization. From IPython, search by keywords, load a selected callable, and inspect its full JSON input schema before calling it: -```python -await rlm.tools.servers() # server names, tool counts and available descriptions -page = await rlm.tools.search("calendar events", limit=5, server="tools") -tool = await rlm.tools.get(page["matches"][0]["id"]) -print(tool.schema) # includes nested fields, enums and constraints -help(tool) # Python signature and description -result = await tool(day="monday", title="standup") # returns str +asyncio.run(main()) ``` -Search returns `matches` (at most 20 summaries per page: id, server and description, without schemas) and `next_cursor`. Retrieve another page using the same query and server filter; `next_cursor=None` means there are no more matches: +Run it with `uv run python quickstart.py`. The session's ledger lands in `~/.rlm/sessions/` (`$RLM_HOME` moves it). [The runtime contract](docs/runtime-contract.md) lists every field and policy. -```python -if page["next_cursor"] is not None: - page = await rlm.tools.search( - "calendar events", limit=5, server="tools", cursor=page["next_cursor"] - ) -``` - -Save loaded callables in variables to reuse them. Search uses keyword matching; try other keywords when no result fits. Cursors belong to the current session's catalog and query/server filter. Raw catalog metadata, including listing cursors, is limited to 64 MiB, with at most 1 MiB per tool; bounds are checked while pages are indexed. Discovery allows at most 4,096 pages per server. Individual tool names are omitted from the system prompt. +## Documentation -Loaded tools are async functions called with `await tool(...)`. Calls return text strings; when a tool returns JSON text, decode it before accessing fields. Prose and CSV remain text: - -```python -raw = await tool(...) -data = json.loads(raw) # only for tools that return JSON text -``` - -Each call connects using the configured transport, invokes the tool, and returns its text content (a tool-reported error is raised as `RuntimeError`). Discovery and invocation run in the session supervisor through the session-local capability broker. The kernel receives only a selected tool's public schema, description, name, and opaque capability; server URLs, headers, commands, and environment variables stay in the supervisor. MCP tools are IPython-only. - -## Kernel - -The IPython kernel always runs in rlm's own Python (`sys.executable`). `install.sh` puts `rlm` and all discovered Python tool packages into the same `uv tool install` environment, so their imports work natively from inside an IPython cell. Enabled built-in tools are also pre-imported by name, e.g. `edit`. - -The kernel inherits the supervisor's environment minus variables that would redirect its interpreter or config (`PYTHONHOME`, `UV_PYTHON`, `CONDA_PREFIX`, `PIP_TARGET`, …), provider and infrastructure prefixes (`OPENAI_`, `PRIME_`, `RLM_`, `AWS_`, `GITHUB_`, …) and anything that looks like a credential by name or by a `user:pass@` URL, plus the contract's explicit `kernel_env` mapping. Platform variables (`PATH`, home/user/shell, locale, temporary-directory, certificate, and `VIRTUAL_ENV`) always pass. It receives private Jupyter/IPython config directories. This de-ambients credentials as far as those patterns reach; it is not hostile-code containment because the kernel still shares the sandbox user, filesystem, process namespace, and network with the supervisor. - -To add a package to the kernel itself, install it into rlm's interpreter explicitly: `!uv pip install --python {sys.executable} `. A bare `uv pip install` picks its target from `VIRTUAL_ENV` or a nearby `.venv`, so under `uv tool install` (as in sandboxes) it finds no environment or, worse, the project's. - -To exercise packages from the target project's `.venv` (e.g. running its test suite), shell out from an IPython cell: `!./.venv/bin/python3 -m pytest`. The kernel itself stays isolated from whatever project venv the agent is working on — no cross-cell state involving sandbox packages. +| Page | What it covers | +|---|---| +| [Architecture](docs/architecture.md) | The engine, kernel, supervisor and session directory, and one turn through them | +| [Runtime contract](docs/runtime-contract.md) | The ACP surface: the runtime object, policy, prompts, steering, snapshots and tracing headers | +| [Python API](docs/python-api.md) | The `rlm` namespace in the kernel: agents, inbox, shell jobs, services, watches, history, hints | +| [Tools and skills](docs/tools.md) | Native tools, built-in Python tools, tool packages, `SKILL.md` skills, MCP servers, the kernel's environment | +| [Context and history](docs/context.md) | Compaction, tool-output truncation, the `messages.jsonl` ledger and reading it back | +| [Kernel and processes](docs/processes.md) | Timeouts, crash recovery, and which processes outlive a call, a restart or the session | +| [Split execution](docs/split-execution.md) | Running the tools in a separate workspace, over stdio or TCP | +| [Continual harness](docs/continual-harness.md) | Durable harness entries, authored skills, refinement passes and episodes | +| [Prompt optimization](docs/prompt-optimization.md) | Optimizing the runtime's prompt texts with GEPA | ## Developing -After `uv sync`, enable the pre-commit hooks: - -```bash -uv run pre-commit install -``` - -Lint and format manually: - -```bash -uv run ruff check --fix . -uv run ruff format . -``` - -### Prompt optimization - -`scripts/gepa/` optimizes the registry texts behind `prompt_overrides` with -[GEPA](https://github.com/gepa-ai/gepa): a candidate is a `{name: text}` dict, a rollout -is one nano-rlm session, and a reflection model rewrites one text at a time from the -session ledgers and their scores. Nothing here runs inside a session; it needs the -`gepa` dependency group (`uv sync --group gepa`). - -Tasks are question sessions about checked-out repositories whose answers are computed -from the AST or the filesystem (`tasks.py`: definition counts, parameter defaults, -callers, importers, decorator users, test locations, line counts) plus `api` questions -that can only be answered by using a documented runtime surface (`rlm.shell.run`, -`rlm.agent.spawn`, `h.create_memory`, the history API) and are checked against the -session ledger as well as the answer. The history question that quotes question 1 is -only asked once the session has compacted, since before that the question is still in -context. A session's score is the mean question score minus a small token penalty. -Rollouts run with a `summarize_at_tokens` far below production so sessions compact and -the compaction texts get evidence. - -```bash -uv run python scripts/gepa/workspace.py # pinned checkouts, one NAME=PATH per line -uv run python scripts/gepa/tasks.py --repo nano-rlm= --repo click= --per-repo 20 -RLM_API_KEY=... RLM_BASE_URL=... uv run --group gepa python scripts/gepa/optimize.py \ - --tasks scripts/gepa/tasks/tasks.jsonl --run-dir scripts/gepa/runs/first \ - --model deepseek/deepseek-v4.1-flash --reflection-model openai/gpt-6-astra \ - --max-metric-calls 300 -``` - -The first run optimizes `task`, `repl_doctrine`, `delegation_doctrine`, `checkpoint`, -`rollup` and `staircase_framing` (`--components` selects others). Every proposed -candidate is checked before any rollout is spent: a text that is empty, longer than its -allowance (twice the seed text or 1,200 characters, whichever is larger), or missing a -token the runtime depends on (`components.py`, e.g. -`hist.expand(` in `history`, `rlm.shell.run` in `runtime_reference`), or mentioning what -exists only in these sessions (scoring, checks, the `ANSWER:` format) scores zero with -feedback naming the problem. The component selector only proposes texts the minibatch -actually exercised. A run is resumable from its `--run-dir` and ends with -`best_prompt_overrides.json`, a `prompt_overrides` object any host can send, and -`report.md` with per-task validation scores and a diff per component. Landing a -winner in `rlm.prompt` is a reviewed change. `scripts/gepa/RUNBOOK.md` is the -step-by-step for a full run. Expect roughly 100-250k tokens per rollout -and a few hundred rollouts per run. Sessions run inside a checkout and may write to it, -so ask about this repository through its pinned workspace copy, never the live tree. - -## Testing - -Install dev dependencies and run the suite: - ```bash uv sync --group dev +uv run pre-commit install +uv run ruff check --fix . && uv run ruff format . uv run pytest tests/ ``` -Agent execution status and cleanup are separate: `info.cleanup_error` reports failed resource cleanup without hiding a completed result. Calling `cancel()` again retries unfinished cleanup. - -`send()` and `steer()` return acceptance IDs, not processing acknowledgements. They reject instructions when the tree budget is already exhausted. If an accepted instruction cannot be delivered because the budget runs out or the child terminates, the supervisor records `instruction_failed` in the child's inbox journal and sends the parent an `agent.delivery_failed` event containing `agent_id`, `message_id`, and `reason`. Failed instructions are removed from the pending queue. - -### Bash commands and background jobs - -For quick commands, wait for the result in the current cell: - -```python -result = await rlm.shell.run("git status --short") -print(result.text, result.exit_code) -``` - -`run(command, cwd=..., env=..., yield_after=10, timeout=None)` returns a `ShellJob`: a snapshot with `running`, -`ok`, combined stdout/stderr `text` (up to 16 KiB: the first and last 8 KiB around an -omitted-range marker when longer), `exit_code`, `id`, `truncated`, `error` and `timed_out`, plus the -methods `result()`, `read()`, `info()` and `cancel()`. `yield_after` is how long `run()` -waits before yielding a handle instead of a result: 10 s by default, 0 returns at once, capped -at 300. A command still going then comes back with `running=True` and the output so far while -the job continues; nothing is killed by yielding. `timeout`, if given, kills the process group after that many seconds and -sets `timed_out`. `await job.result(yield_after=...)` waits (up to 300 s per call) and returns -the finished snapshot, again with `running=True` if the job is still not done; it is -repeatable and never consumes output. The command is a Bash string or an argv list. Nonzero -exit codes are returned; startup/capture errors populate `error`. If output is truncated, -`await job.read(cursor=..., max_bytes=...)` reads the retained output (16 MiB per job). -Cancelling the waiting cell leaves the job running and discoverable with `shell.list()`; -`shell.get(job_id)` recovers it. Only a job handed back with `running=True` publishes a quiet -`shell.completed` inbox event (job ID, status, exit code, completeness flags and the last -4 KiB of output); quiet means it wakes the native `wait` tool but is not counted in the unread -notice, and `result()` marks it read. `await rlm.shell.setenv(NAME='value')` sets variables -for every later `run()` of the agent (`getenv()` reads the overlay; per-call `env=` wins). -The kernel and Bash jobs inherit the launching process's environment (in a sandbox, the -image's ENV) minus a blocklist: credential-looking names, values with URL-embedded -credentials, variables that would redirect the kernel's own interpreter or venv (PYTHONHOME, -UV_PYTHON, ...), and agent/daemon sockets. +The end-to-end suite in `tests/e2e` gives a hosted model real tasks through `rlm --acp` +and checks what the agent leaves in its workspace, with tools in the agent's process +tree, in a [split-execution](docs/split-execution.md) worker over stdio, and in one +over TCP. It runs only when asked for and needs a Prime Inference key (`PRIME_TEAM_ID` +too for a team's models): -For long commands or work that should run alongside other tasks, pass `yield_after=0` and -collect with `result()`: - -```python -job = await rlm.shell.run("uv run pytest tests/", cwd="/workspace/project", yield_after=0) -# ... other work ... -res = await job.result() -print(res.exit_code, res.text) - -# Several jobs at once, and recovery of a job in a later cell: -a, b = await asyncio.gather(job_a.result(), job_b.result()) -job = await rlm.shell.get(job_id) -chunk = await job.read(cursor=0, max_bytes=16384) # continue from chunk.next_cursor -``` - -Use the native `wait` tool when there is no other work and you hold no running job; a held -job is collected with `result()`. A nonzero Bash exit code is a completed process; -startup/capture errors have status `failed`. - -`await job.cancel()` terminates its process group. Jobs survive cell completion -and lost handle variables; owner termination cancels them. Commands run in -`/bin/bash --noprofile --norc -c`, with closed stdin and combined stdout/stderr. -Relative working directories resolve against the agent's working directory. -The task environment and Git-history policy also apply to these commands. - -The supervisor permits 32 active jobs and 1,024 total jobs per session tree. -Each job retains up to 16 MiB in `/jobs//output.bin`; excess output -is drained and discarded. Reads default to 16 KiB and allow at most 64 KiB. -Cursors count bytes, and text decodes as UTF-8 with replacement. Terminal metadata -is saved alongside output in `meta.json`. Output files remain after job cleanup. - -Normal completion waits for stdout/stderr EOF. If descendants keep the pipe open -more than two seconds after Bash exits, capture stops and reports -`output_complete=False` and `output_truncated=True`. Remaining processes in the -job's process group are killed when the job ends. Keep Bash alive until its work -finishes; detached processes that escape the process group are not managed. - -There is no PTY, stdin API, or redirection of IPython's `!` / `%%bash` magics. - -### IPython crash recovery - -If the kernel exits during a cell, the harness starts a fresh kernel under the -same agent identity and resumes the conversation with a recovery notice. A dead -kernel discovered before a cell starts is also restarted; that cell is returned -unexecuted so the model can first reconstruct its Python state. - -The conversation, history, child agents, shell jobs, and inbox (including read -state) remain owned by the live supervisor. Recover handles with -`rlm.agent.list/get` and `rlm.shell.list/get`. Python variables, imports, and -in-kernel tasks are lost. The interrupted cell is never replayed automatically: -its file writes or accepted spawn/send/job requests may already have happened. -Inspect those resources before retrying an operation. - -Recovery notices are separate `kernel_recovery` history records and active -conversation messages, so truncating tool output cannot hide them. A kernel -restart required after an unresponsive timeout/interrupt produces the same notice. -A successful interrupt that preserves the kernel does not claim variables were lost. - -There are at most three recovery attempts without a subsequently completed cell. -A failed startup reports failure; a later IPython call may retry within that -budget. Exhausting the budget leaves IPython unavailable, while the agent can -still respond or use native waiting. Supervisor crash recovery is not supported. -Kernel death is detected during execution or at the next IPython call; this does -not introduce a background kernel-health subscription. - -### Subscriptions - -Watch future activity without keeping a Python cell running: - -```python -activity = await rlm.watch.agent(researcher) -output = await rlm.watch.job(job) -files = await rlm.watch.path("/workspace/results", recursive=False) - -subscriptions = await rlm.watch.list() -watch = await rlm.watch.get(subscriptions[0].id) -await watch.cancel() +```bash +PRIME_API_KEY=... uv run pytest tests/e2e -m e2e ``` -Agent watches observe direct children after complete assistant/tool steps, -including final answers. Job watches observe newly captured stdout/stderr from -jobs owned by the caller. Path watches observe existing files or directories; -relative paths resolve against the agent's working directory. Hidden files are -included, and directory recursion is opt-in. Child/job completion already -notifies the owner automatically and needs no subscription. - -Events enter the same pull-based inbox and carry `subscription_id`. Their -`content` contains a `target` (agent ID, job ID, or absolute path) plus: - -| Event | References | -| -------------- | ----------------------------------------------------------------------------------------------- | -| `watch.agent` | `start` and exclusive `end` message indices: `(await researcher.history()).messages[start:end]` | -| `watch.job` | `start` and exclusive `end` byte cursors for `job.read(cursor=start)` | -| `watch.path` | Changed `paths` and a `truncated` flag | -| `watch.failed` | An `error` explaining why the subscription stopped | - -Subscriptions start with future activity, not historical replay. Agent and job -ranges can be coalesced across several steps/chunks; changes are batched over -200 ms. Agent ranges exclude records written before registration. Filesystem -registration waits until the backend is watching before returning the handle. -Observed removal of the watched path ends the subscription with a failure event; -register a new watch after recreating it. - -Handles and subscriptions survive kernel restart. Cancellation stops future -events and drops any unpublished batch; existing inbox events remain readable. -Owner termination cancels its subscriptions. Registration and state transitions -are journaled in the owner's `inbox.jsonl`; supervisor restart recovery remains -out of scope. - -The tree permits 64 active subscriptions and 1,024 total registrations. Changed -path lists are capped at 32 KiB of JSON with explicit truncation. A subscription -stops with a failure event when the inbox event limit is reached. There are no -model-authored callbacks, event selectors, or output predicates. - -Activity subscriptions become `completed` after their target permanently terminates, flushing pending activity and releasing their active slot. Watches of idle persistent agents remain active. Watching an already-finished target returns a completed subscription. - -### Separate tool execution - - - - Colocated, the agent loop, model credentials and the agent's tools share one machine. Split, the harness keeps the loop, credentials, supervisor and ledger, and a worker in the workspace runs the tools over one authenticated connection. - - -The ACP runtime metadata accepts `execution_command` (a trusted argv launching -`python -m rlm.execution`) and `execution_cwd` (the task workspace). For example, -an orchestrator can start the worker with `docker exec -i -/path/to/python -m rlm.execution`. Both processes must have the same nano-rlm -version installed. The orchestrator owns container provisioning and teardown. - -The engine, model credentials, supervisor, and authoritative conversation logs -remain in the harness process. Native tools, persistent IPython kernels, and builtin skills run -in the worker environment. Recursive agents inherit the execution destination, -keep separate kernels, and share the task workspace. With the stdio transport, -communication and recursive callbacks use the worker's stdio, so a task container -can have networking disabled. -The command itself defines the isolation boundary; launching a local Python -worker alone provides process separation, not filesystem isolation. - -ACP initialization advertises `ai.prime.rlm/execution-v1` with the supported -transports (`stdio`, `tcp`) and tools (`ipython`, `bash`, `edit`, `fetch`). Tool -calls cross the boundary as a name and arguments; filesystem operations happen -inside the worker. `ExecutionTransport` supplies `connect()` (reader and writer) -and `close()` independently of tool dispatch. - -For a trusted private TCP network, launch `python -m rlm.execution --listen -0.0.0.0 --port 8765` with a random `RLM_EXECUTION_TOKEN` of at least 32 characters. -With `--port 0` the kernel picks a free port, which the worker prints on stderr as -`listening on HOST:PORT` once it accepts connections. -Set `execution_tcp` to `{"host": "worker-address", "port": 8765, "token": "..."}` -instead of `execution_command`. Each connection owns a separate execution session. -The token authenticates connections; this transport does not encrypt traffic, so -use a private network or an encrypted tunnel. The worker removes its token from -the environment before starting tools. The orchestrator owns listener teardown. - -Supervisor shell jobs (`rlm.shell`), services (`rlm.services`) and path watches -(`rlm.watch.path`) run in the worker's environment, beside the kernels; the -supervisor keeps their records and inbox events, so the agent gets the same -runtime prompt and API as in local execution. The current agent's history is a -local snapshot refreshed before each cell; `await child.history()` reads a child's -ledger from the supervisor in pages. Frames are limited to 16 MiB: a command or -cell keeps the first and last 512 KiB of its output, and a long ledger syncs in -chunks. Kernel recovery preserves the existing no-replay behavior. Worker -disconnects fail the session. - - - - One agent step in split mode: the harness sends the tool call with the new ledger records, the cell runs in the workspace and calls back for rlm.* operations, and the result returns to the harness. A tool call costs one round trip, each rlm.* call one more. - - -Each tool call costs one round trip between harness and worker, each `rlm.*` -call one more, and `rlm.shell.run` two. - -A session that closes stops what is still attached to its kernels, as a closing -terminal does; daemons and bash background jobs keep running, as they do in local -execution, so the orchestrator stops what remains when the rollout ends. +`RLM_E2E_MODEL` picks the model (default `internal/glm-5.3-fast`) and `RLM_E2E_BASE_URL` +the endpoint. ## Acknowledgements diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..78278806 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,38 @@ +# Architecture + +nano-rlm gives a model one place to work, a persistent IPython kernel, and lets it extend itself with sub-agents and background work from there. Everything that has to outlive a single cell, such as agents, jobs, services, inbox events and the conversation itself, belongs to processes the kernel cannot lose: the engine and the supervisor. + + + + An ACP client sends the runtime contract and prompts to rlm. The engine runs the agent loop against the model API and appends to the session ledger; its tool calls run in a persistent IPython kernel, whose rlm.* calls reach the supervisor; the supervisor owns child agents, each with its own engine, kernel and session directory. + + +## Components + +**ACP agent.** `rlm --acp` serves the [Agent Client Protocol](https://agentclientprotocol.com/) over stdio. Each ACP session owns one engine for its lifetime. The session is configured entirely by the [runtime contract](runtime-contract.md) the client sends; nothing is read from the process environment except `RLM_HOME`. + +**Engine** (`RLMEngine`). The agent loop: it sends the conversation to the model, runs the tool calls the model makes, appends every message to the ledger, and compacts the context when it runs out of room. Model credentials live here and in the supervisor, never in the kernel. + +**IPython kernel.** One persistent Jupyter kernel per agent, running in rlm's own Python. Variables, imports and loaded data persist across cells, across turns and across compaction. The `rlm` package is importable in it, and enabled built-in Python tools (`edit`, `fetch`, ...) and installed tool packages are pre-imported by name. The kernel starts from a minimal environment, so ambient credentials do not reach it ([details](tools.md#the-kernels-environment)). + +**Supervisor.** Owns everything that must outlive a cell: child agents and their inboxes, shell jobs, services, watches, muted hints, and the admission of model calls across the agent tree. The kernel reaches it through `rlm.*` calls on a session-local capability broker. The broker also runs credentialed tools, such as `search` and MCP servers, on the kernel's behalf, so their keys and URLs stay out of the kernel. + +**Child agents.** `rlm.agent.spawn(...)` asks the supervisor for a child: another engine with its own kernel and session directory, configured from the parent's contract one level deeper. Children can spawn their own children up to `max_depth`. The parent holds a handle; losing the handle does not stop the child. + +**Session directory.** Each agent writes `$RLM_HOME/sessions//`, with children nested under it as `sub-/`. Its `messages.jsonl` is the append-only ledger of the conversation, which the agent itself can read back from Python ([Context and history](context.md)). + +## One turn + +1. The client sends `session/prompt`. The engine appends the prompt to the ledger and asks the supervisor for a model-call slot (`max_concurrent_subagents` bounds calls across the whole tree). +2. The model answers with text or tool calls. For an `ipython` call the engine runs the cell in the kernel; native `bash`, `edit` and `fetch` calls run beside it. +3. While the cell runs, its `rlm.*` calls (spawn an agent, read the inbox, start a job) go to the supervisor and return into the cell. +4. The tool result enters the conversation, truncated to its first and last 10 KB when longer ([details](context.md#tool-output)). The engine loops until the model answers without tool calls, a budget runs out, or the client cancels. +5. The answer goes back to the client as an agent message, and the ACP response carries a credential-free snapshot of usage, metrics and limits. + +Between turns, the kernel and every supervisor-owned resource stay alive; the next prompt continues the same conversation. + +## Trust boundaries + +- **Credentials.** The model's provider key travels over the private ACP stdio channel and stays in the engine. Search and MCP credentials stay in the supervisor. The kernel's environment is rebuilt from an allowlist plus the contract's `kernel_env`. +- **Not containment.** In local execution the kernel shares the user, filesystem, process namespace and network with the engine. Filesystem access between agents is trusted; agent handles are orchestration controls, not isolation. +- **Real separation** comes from [split execution](split-execution.md): the tools run in a separate workspace, such as a container or VM, and only tool calls cross between them. diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 00000000..22304778 --- /dev/null +++ b/docs/context.md @@ -0,0 +1,208 @@ +# Context and history + +A session has no fixed length. When the conversation outgrows the model's context window, the engine compacts it into a handoff summary and carries on, while the kernel keeps every variable and the full record stays on disk, readable from Python. The model loses nothing it cannot get back. + +## Compaction + +Compaction is on by default and unlimited: `compaction: false` turns it off and `max_compactions` caps it. The default `max_total_tokens` budget still bounds the session, since each compaction spends new tokens too. + +**When.** The engine reads the model's context window from the provider's `/models` response and compacts once 16k tokens remain; small windows keep at least half. `summarize_at_tokens` pins the threshold instead. Without a known window or explicit threshold, compaction happens only when the provider reports an overflow (a 400 or 413 naming a context limit), which also triggers it with a threshold set. + +**How.** Compaction is tiered, after headlong's memory pyramid: instead of replacing the window with one summary that every later compaction re-summarizes, the engine keeps the whole session in context at decreasing resolution and keeps the current prompt and the newest messages verbatim. Each compaction summarizes the context since the previous one; the sections below walk through the pieces. + +The kernel keeps running through compaction, so variables, imports and in-memory data survive it. The same policy applies to every agent in the tree. + +### Windows versus branches + +Two words come up throughout this section and they are easy to conflate. + +Think of the session as a long strip of paper: the ledger (`messages.jsonl`). Every message ever sent to or received from the model is written on it once, in order, and gets a number that never changes. Nothing is ever erased. + +A **window** is a frame laid over that strip: the set of messages the model can currently see. When something replaces the model's context (session start, compaction, rollback, or a harness refinement swapping the system prompt) the engine puts down a new frame and gives it the next number. Frames are how the ledger answers "what exactly did the model see at request 57?" + +A **branch** is a stretch of the strip between two compactions: the part of the story that one tier-1 block summarizes. Only compaction ends a branch. + +Most of the time one branch is one window: + +```text +ledger 0 ──── 1 ──── 2 ──── 3 ──── 4 ──── 5 ──── 6 ──── 7 ──── 8 + sys task call res call res |stair| call res + | A | +window ├──────────── window 0 ─────────────────┤├──── window 1 ──── +branch ├──────────── branch 0 ─────────────────┤├──── branch 1 ──── + ^ compaction +``` + +They differ in two situations. A harness refinement opens a new window without compacting, because the system prompt changed in place while every other message kept its number; the branch keeps going, so a block records a window *range*: + +```text +ledger 0 ─── 1 ─── 2 ─── 3 ─── 9 ─── 4 ─── 5 ─── 6 + sys task call res sys' call res ... +window ├── window 0 ─────────┤├──── window 1 ──────── +branch ├──────────────── branch 0 ─────────────────── + ^ refinement: new system message (9), same branch +``` + +And with the verbatim tail, a window after compaction *starts* with the last few messages of the branch before it. Those messages keep their numbers and belong to the next block, so the blocks tile the strip without gaps: + +```text +ledger 0 ─── 1 ─── 2 ─── 3 ─── 4 ─── 5 ─── 6 ─── 7 + sys task call res stair call res stair + └─ tail ─┘ +window 1 seeds: [0, 1, 4, 2, 3] (system, pinned task, staircase, then the tail by number) +block A covers 1-1 (the branch before the tail) +block B covers 2-4 (the tail, the staircase message, and what followed) +``` + +A rollback also opens a window, restoring the pre-prompt frame, and restores the branch bookkeeping with it. So: windows are how the ledger addresses context; branches are how compaction chunks time; a branch spans one or more windows, and a window can hold the end of one branch and the start of the next. + +### Branches and blocks + +The context since the last compaction is a *branch*. When a branch is compacted, the engine asks the model, in context, for a plain-text handoff summary of that branch (reasoning is never part of it) and records it as a tier-1 *block*. The checkpoint prompt asks for only the work since the previous block, well under 2,500 characters and most critical first: the current request, the best answer so far, conclusions from the evidence, and ongoing orchestration (children, jobs, subscriptions, pending events, output cursors and history references). The model is told to name the important kernel variables so the resumed branch knows what exists. A block is immutable and is a pointer into the ledger: its header names the message, window, and turn ranges it summarizes, so the raw `messages.jsonl` stays the source of truth and the summary is an index into it. + +```text +[tier 1 | branch 3 | window 3 | messages 412-1180 | turns 88-131] +Ran the integration suite against the new parser; 3 failures remain in +tests/test_edges.py (see hist.messages[1104]). Fixed the tokenizer off-by-one in +src/lex.py. Open: the `--strict` flag is still unhandled. +``` + +The ranges are supplied by the engine, never by the model, so they are exact. + +### Rollups + +Every `compaction_fanout` (default 5, written `F` below) consecutive blocks of one tier are merged into one block of the next tier by a side call over those summaries alone: tier 2 summarizes `F` branches, tier 3 summarizes `F^2`, tier k summarizes `F^(k-1)`. A rollup never sees the live context, only the child blocks, so it costs about `F` summaries' worth of input regardless of how long the session is. + +| tier | one block summarizes | one block covers | built from | +| ---- | --------------------- | ------------------ | -------------- | +| 1 | the branch itself | 1 branch | the live context | +| 2 | `F` tier-1 blocks | `F` branches | tier 1 | +| 3 | `F` tier-2 blocks | `F^2` branches | tier 2 | +| k | `F` tier-(k-1) blocks | `F^(k-1)` branches | tier k-1 | + +A rollup seals as soon as its `F` children exist and is then never rebuilt; only the frontier is ever summarized, so the lifetime cost is about `N / (F - 1)` small calls for `N` compactions. A rolled-up range keeps its children's message range: a tier-3 block over branches 0-24 covers exactly the messages those branches covered. + +### The staircase + +The fresh window after a compaction is `[system, prompt, staircase, tail]`. The staircase lists blocks oldest first at decreasing resolution: the branch count `N` is decomposed in base `F`, and tier k contributes the aligned blocks just older than the finer tiers, at most `F - 1` of them. The coarsest block always starts at branch 0, so nothing in the session is unrepresented, and the whole staircase is bounded by `(F - 1) * log_F(N)` blocks. + +With `F = 2` the layout after each compaction is: + +```text +compactions staircase (oldest first) + 1 [t1: 0] + 2 [t2: 0-1] + 3 [t2: 0-1] [t1: 2] + 4 [t3: 0-3] + 5 [t3: 0-3] [t1: 4] + 6 [t3: 0-3] [t2: 4-5] + 7 [t3: 0-3] [t2: 4-5] [t1: 6] + 8 [t4: 0-7] + 9 [t4: 0-7] [t1: 8] +``` + +Read left to right as decreasing age and increasing resolution: the far past is a single paragraph, the middle is folded into pairs, and the most recent branch is a full summary. With the default `F = 5` a session's first four compactions show every branch summary verbatim; the fifth folds them into one tier-2 block. + +If a rollup is missing (it failed, or has not been attempted yet) the staircase shows its children instead, so coverage is never lost: at 4 compactions with `[t3: 0-3]` unsealed the staircase reads `[t2: 0-1] [t2: 2-3]`, and with one of those missing too it reads `[t1: 0] [t1: 1] [t2: 2-3]`. + +### The verbatim tail + +The staircase is followed by the newest run of messages that fits `compaction_tail_tokens` (default 12k, capped at a quarter of the compaction threshold; 0 keeps none), so the exact last tool results survive compaction instead of being paraphrased. The tail opens with an assistant or user message so no tool result is separated from its call, and never reaches the branch's first message. It is re-attached by ledger index rather than copied: the new `context_window` record lists those existing indices after the staircase message, exactly as a rollback re-lists restored messages. + +```text +ledger index 0 1 2 3 4 5 6 7 +record system task call result stairc. call result stairc. + +window 0 [0, 1, 2, 3] <- first branch +compaction 1 block A = messages 1-1, tail = [2, 3] +window 1 [0, 1, 4, 2, 3, 5, 6] <- system, pinned task, staircase, tail, new work +compaction 2 block B = messages 2-4, tail = [5, 6] +window 2 [0, 1, 7, 5, 6] +``` + +This toy session has one call per branch, so the tail takes nearly all of it; in practice a branch holds far more than its tail. The summary request is told how many messages will stay verbatim so it covers what precedes them; the branch block ends just before the tail, and the kept tail opens the next branch, so block message ranges stay gapless (block B above starts where block A's tail started). + +### The pinned prompt + +The current prompt (the latest `user` or parent instruction, not a supervisor notice) is re-attached by ledger index between the system message and the staircase whenever it fits `compaction_prompt_tokens` (default 4k estimated tokens; 0 never pins), so the task is never reconstructed from a summary and the `[system, prompt]` prefix stays stable across compactions. A larger prompt is referenced by ledger index in the staircase message instead (`hist.messages[i]`); a prompt already inside the kept tail is not duplicated. The `compaction` record names the `pinned_prompt_index`. + +```text +window after compaction: [system] [prompt] [staircase] [tail...] + ^ stable prefix ^ new ^ re-attached by index +``` + +### Drill-down + +The compaction message names the ledger path and the history API, and every block header is a range the model can expand: + +```python +from rlm import history + +hist = await history() +hist.blocks # [{"tier": 1, "branches": [0, 1], "messages": [1, 1], ...}, ...] +hist.expand(-1) # the messages the newest block summarizes +hist.expand(hist.blocks[0]) # a block record works too +``` + +`hist.blocks` lists the blocks in ledger order and excludes those of rolled-back prompt attempts; `hist.expand` returns the slice of `hist.messages` a block covers, which for a rolled-up block is everything its children covered. A `compaction` record carries its tier-1 `block`, the `rollups_sealed` in that cycle, and the `tail_message_indices` it kept; each higher-tier block is a `rollup` record. + +### Budgets and failure + +Rollups are best effort: one compaction spends at most `max_compaction_attempts` rollup calls in total (the usual cycle needs none or one), a rollup that yields no usable reply or does not fit that budget is skipped, and the next compaction retries it, newest ranges first, so a range that keeps failing cannot starve fresh ones. + +A provider overflow (a 400 or 413 naming a context limit) triggers the same compaction reactively from the current state. A rejected checkpoint request falls back to the last state that passed a threshold check - by definition a state with a full reserve of room - and an empty or tool-calling reply is resampled; after `max_compaction_attempts` failed attempts the run ends cleanly with what the conversation holds. An overflow with no history beyond the task propagates: the task alone approaches the window and there is nothing to reclaim. Summary and rollup calls are side requests: they spend tokens but are not work turns, and each carries a `compaction_attempt` semantic edge from the request that triggered the compaction, with the single `compaction` edge sourced from the branch summary. + +## Tool output + +A tool result longer than 20 KB (`max_tool_output_bytes`) enters the conversation as its first and last 10 KB around a notice with the whole output's token estimate, line count and omitted bytes. The full result stays in the ledger. + +A command or cell always runs to its end, however much it prints. Its result keeps at most the first and last 512 KiB of output (bash divides that between stdout and stderr) and records how much more the whole output measured, so the notice reports the whole output's size either way. The ledger keeps the retained ends with a marker where the middle was dropped. `rlm.shell` jobs keep up to 16 MiB each in their own files ([Shell jobs](python-api.md#shell-jobs)). + +## The ledger + +Each agent appends its conversation to `messages.jsonl` in its session directory. The ledger serves the agent's own recovery: it is what `history()` reads and what compacted contexts point to. Clients that trace model calls record requests and responses at their own proxy and receive metrics and semantic edges over ACP, independent of this file. + +- **Messages.** Message records carry a stable event `id`, a zero-based `message_index` and a `message` with the role, content and tool calls. System messages and installed summaries are recorded; a `compaction` record carries its tier-1 `block` and a `rollup` record each higher-tier block. Checkpoint prompts and raw checkpoint responses are not. A tool result truncated for the context has a separate record linking back through `source_message_index`. +- **Windows.** A `context_window` record opens a window with a zero-based `window` index, a `reason` (`start`, `compaction`, `rollback`, or `harness` for a [refinement](continual-harness.md#refinement)) and the ordered `message_indices` that seed it. Later records with a `window` field append to it, and closed windows never change. A failed or cancelled prompt rolls back by opening a new window with the restored context, so addresses in the failed window stay valid; a compaction window likewise re-lists the indices of its verbatim tail. +- **Prompts and turns.** A `user` record begins a prompt attempt, and `prompt_rollback.prompt_id` marks one that failed or was cancelled; its records remain as history. The `turn` field counts loop iterations and is not an index. +- **Other records.** Lifecycle records such as `kernel_recovery`, child spawns and delivered inbox notifications are in the same file. + +Indices are local to each agent. A session opens a fresh ledger and refuses to reopen an existing `messages.jsonl` for writing. A failed write or flush makes the session refuse further prompts; rollback restores the in-memory context even if its own record cannot be written. + +## Reading history + +```python +from rlm import history + +hist = await history() # this agent's ledger ($RLM_SESSION_DIR) +requests = hist.user_messages() # original inputs, rolled-back attempts included +message = hist.messages[3] # by message index +earlier = hist.windows[0].messages # the first context window +other = await history(session_dir="/path/to/session") +message = other.windows[4].messages[2] # if that session has reached window 4 +blocks = hist.blocks # compaction blocks with the ranges they cover +originals = hist.expand(blocks[0]) # the messages the first block summarizes +``` + +A snapshot holds the complete records as of the read; read again to see new activity. `hist.events` holds the lifecycle records, including each child's spawn prompt and rollback markers. For a child, `await child.history()` goes through the supervisor ([Agents](python-api.md#agents)). Compacted contexts point to this API, so the model can recover details without putting the transcript back in context. + +## Session directory + +Every agent writes `$RLM_HOME/sessions//`, and children nest under their parent, mirroring the call tree: + +```text +~/.rlm/sessions/abc123/ +├── meta.json +├── messages.jsonl +├── inbox.jsonl +├── jobs//{output.bin, meta.json} +├── harness/ +│ └── harness_state.json +├── sub-d4e5/ +│ ├── meta.json +│ ├── messages.jsonl +│ └── sub-f6g7/ +└── sub-h8i9/ +``` + +These files are meant for debugging, visualization and extracting training data. `harness/` is the agent's local [continual harness](continual-harness.md) store; the optional global store lives outside any session directory ([The global store](continual-harness.md#the-global-store)). diff --git a/docs/continual-harness.md b/docs/continual-harness.md new file mode 100644 index 00000000..4a76873b --- /dev/null +++ b/docs/continual-harness.md @@ -0,0 +1,367 @@ +# Continual harness + +The continual harness is durable state that supplements the immutable system prompt: `prompt` +notes (narrow behavioural policies), `memory` entries (facts, decisions, failures), `skill` +entries (how to use an importable Python tool, with a `reference` and an +`arguments` contract), `subagent` specs (reusable delegation roles) and, in a global store, +`episode` records of past sessions written by the engine (see [Episodes](#episodes)). Each agent's local +store lives at `/harness/harness_state.json`. A child reads its ancestors' local +stores read-only alongside its own. The contract's `harness` object controls the feature: + +```json +"harness": { + "enabled": true, + "global_dir": null, + "max_prompt_entries_per_kind": 6, + "max_prompt_content_chars": 180, + "max_prompt_refinements": 5, + "auto_refine": false, + "auto_refine_review": "judge", + "refine_turn_interval": 12, + "refine_cooldown_seconds": 300, + "max_refinements": null, + "max_refinement_attempts": 3, + "refine_judge": null, + "author_skills": false, + "record_episodes": false +} +``` + +`global_dir` names a store shared across sessions; `null` (the default) keeps every session +hermetic. The system prompt carries a compact `## Continual harness` block: per-kind counts, +the most relevant entries within the caps (ranked against the task text when a kind +overflows) and recent refinement events. Entries are displayed as `[local:id]`, +`[ancestor:id]` or `[global:id]`. + +Inside the kernel, `rlm.harness` is the synchronous Python API: + +```python +h = rlm.harness.harness() # the view this agent sees +print(h.overview()) +h.search("pytest venv") # ranked by term overlap +h.list("episode") # every visible entry of one kind +h.get("memory", "ancestor:parent_lesson") # ids exactly as displayed +h.create_memory("Project venv", "run tests with ./.venv/bin/python -m pytest") +h.create_skill("Release lookup", "query websearch with ' release'", + reference={"type": "python", "import": "websearch", "callable": "run", + "call_pattern": "await websearch(queries=[...])"}, + arguments={"queries": {"type": "array", "required": True}}) +h.update_memory("project_venv", "Project venv", "...", global_=True) # needs global_dir +``` + +`update_*`/`delete_*` take the bare id or a `local:`/`global:` prefix; ancestor entries raise +`PermissionError`. Every method carries a docstring, so `help(h)` (or `help(h.create_skill)`) is +the full reference; the system prompt only lists the essential calls. A `skill` entry describes how to use an already-importable Python tool; it is not a +package (see [Python tool packages](tools.md#python-tool-packages) for the on-disk package contract). Like +`rlm.agent`, the kernel's view is served by the session supervisor through the broker: +each call runs on the calling agent's own stores and needs an active cell, and the kernel +never names a store. The store objects themselves (`h.local`, `h.layers()`) are not +available in the kernel. Stores are rewritten atomically under a file lock and reloaded +when another writer changed them, so the engine and the supervisor share one file safely. +`harness(session_dir=...)` loads a session's local store directly, for example outside a +running session. + +## The global store + +The only cross-session file is the optional global store, which lives wherever +`harness.global_dir` points, outside any session directory: + +```text +/ +├── harness_state.json # global prompt/memory/skill/subagent entries and, when +│ # record_episodes is on, one episode entry per root session +└── refinements.jsonl +``` + +An episode entry's `path` field (`episodes//…`) is a facet inside that JSON file, +not a directory; the entry points back at its session through `metadata.session_dir`, and +the session's `messages.jsonl` records the `global_dir` it published to. Nothing under +`sessions//` changes. + +## Entry schema + +Every entry is one `HarnessEntry` record (a Pydantic model in `rlm.harness`) with the same +base fields for all kinds, and kind-specific structure inside three payload dicts that is +validated whenever an entry is created, updated, restored or loaded from disk: + +| field | type | notes | +| --- | --- | --- | +| `id`, `kind`, `title`, `content` | `str` | `id` is a slug of the title unless given; `kind` is one of `prompt`, `memory`, `skill`, `subagent`, `episode` | +| `path` | `str` | grouping facet, default `general`; not a filesystem path | +| `scope` | `local` \| `global` | the store the entry lives in | +| `reference`, `arguments`, `metadata` | `dict` | payloads, see below | +| `source` | `str` | `agent`, `refinement` or `engine` | +| `created_at`, `updated_at`, `version` | | maintained by the store | + +| kind | payload | model | +| --- | --- | --- | +| `skill` | `reference` | `SkillReference`: `type: "python"`, `import` (module), and `callable` and/or `call_pattern` | +| `skill` | `arguments` | `{name: SkillArgument}` with `type`, `required` (default false), `default`, `description` | +| `episode` | `metadata` | `EpisodeMetadata`: `session_dir`, `session_id`, `stop_reason`, `prompts`, `turns`, `prompt_tokens`, `completion_tokens`, `cwd`, `blocks` (`BlockRecord`s), `started_at`, `ended_at` | +| others | `metadata` | free-form | + +Payload models keep unknown keys, so a store written by a newer build still loads, and +fill in optional ones, so `entry.reference["call_pattern"]` is always present on a skill. +A violation raises `ValueError` naming the field (`arguments.queries: Input should be a +valid dictionary ...`) from the kernel API, is reported per edit by a refinement pass, and +fails `load()` with the store path and entry id when a state file is inconsistent. + +## Skill entries versus Python tools + +The harness works with three kinds of object. In [Tools and skills](tools.md) +terms, an installed package is a Python tool, an authored package is a skill that ships a +Python tool, and a harness `skill` entry is a skill kept as harness state: + +| | Installed Python tool | Authored skill | Harness `skill` entry | +| --------------- | ------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| What it is | A Python package installed into rlm's venv | A Python package under `$RLM_HOME/skills`, on `sys.path` | A JSON record in `harness_state.json` | +| Contains | Code: `async def run(...)` | Code: `async def run(...)` | Text: title, when/how to call something, a `reference` (`import`, `callable`, `call_pattern`) and an `arguments` schema | +| Created by | A human, via `install.sh` at image build | The agent, mid-session, by writing files | The agent (`h.create_skill(...)`) or the refinement pass | +| Lives | The venv; every session | `$RLM_HOME/skills//`; sessions sharing `RLM_HOME` | One store: session-local, an ancestor's, or global | +| The kernel sees | `await websearch(...)` | `await greeter(...)` | Nothing executable; it is rendered into the system prompt | +| Refinement can | Never touch it | Never touch it | Create, update, delete, roll back | + +The relationship is pointer to target. An entry's `reference.import` names a module that +must already be importable (an installed Python tool, an authored skill's tool, a +built-in Python tool from the contract's `skills` list, or `rlm` itself); apply-time validation rejects an entry that points anywhere else. The +entry adds what code cannot carry: when this call is the right move, which arguments +matter for a kind of task, gotchas learned from use. One package can be described by +several entries, and a package with no entry is still callable, just not surfaced as a +routing hint. + +The split gives the two layers different lifecycles. Packages are code, so they go +through the main loop: the agent writes them, `load_skills()` imports them, it tests +them, and mistakes are observed and iterated on. Refinement, a one-shot JSON proposal +with `before`/`after` snapshots, is kept away from them because a file write cannot be +undone by replaying a snapshot and a side model call should not mint code that +auto-executes in future sessions. Entries are state, so they go through refinement: +small, validated, snapshotted, reversible; a wrong one costs a bad hint in the prompt, +not a broken kernel. The typical arc is: notice a repeated procedure, write a package, +`load_skills` and test it, record a `skill` entry pointing at it; future sessions see the +entry in their system prompt with the package already pre-imported. + +## Authored skills + +`author_skills` lets the agent write skills that survive the session into the skills root, +`$RLM_HOME/skills`, which also holds the host's [skills](tools.md#skills) and Python tool packages. +An authored skill is a `SKILL.md` that ships a Python tool package. The +[package contract](tools.md#python-tool-packages) applies, minus installation, with these +adjustments: + +- Required: `$RLM_HOME/skills//SKILL.md` and + `$RLM_HOME/skills//src//__init__.py` defining `async def run(...)`. The kernel + checks both; `run`'s docstring falls back to `SKILL.md` for `help()`. +- Optional: `pyproject.toml`; when present its `[project] name` must be `rlm-skill-` + so the package can be promoted to an installed Python tool unchanged. Nothing is installed, so + dependencies are limited to what is already in the kernel venv and there is no console + script: authored packages are IPython-only. + +At kernel start every authored package's `src/` goes on `sys.path` and the module is +pre-imported by name with the same `await (...)` wrapper as an installed Python tool. A +package that breaks the contract or fails to import binds a placeholder whose call raises +the reason, so a bad package never breaks the kernel. Names must not collide with installed +Python tools or the built-in Python tools the session generates from the contract's `skills` list. A package written or edited during the session is brought in +without a restart by `rlm.harness.load_skills("")` (no names = all), which +reloads and rebinds it exactly as kernel start does and returns `{name: reason}` with +`None` for a usable tool. The system prompt states the contract and this workflow; +`RLM_HARNESS_SKILLS_DIR` names the directory. + +The `## Skills` catalog scans the root each time a system prompt is built, so a skill +written mid-session, with `name` and `description` frontmatter, is listed for sub-agents +spawned afterwards, for later sessions, and for the authoring agent once a refinement +rebuilds its prompt. Until then, every running agent is told at its next model step: a +`` lists each skill added after its prompt was built, once, +in the catalog's line format. With `author_skills` on, it also says that a tool another +agent wrote is bound with `load_skills`. Compaction keeps the announced skills in the +continuation summary until the prompt is rebuilt. The prompt is not rebuilt just to list +them, which would discard the prompt cache. +A package that `install.sh` installed from the root, editable from its own folder, is an +installed Python tool rather than an authored one: it is pre-imported from the venv, +`load_skills` declines to rebind it, and edits take effect at the next kernel start. A +folder with no `src/`, `pyproject.toml` or `__init__.py` is an instruction-only skill and is +never loaded as a package. + +`author_skills` is off by default, and no authored packages load. + +## Refinement + +Refinement is a side model call, like compaction: the live conversation is extended with +one user message asking for a JSON proposal of create/update/delete edits, the proposal is +validated and applied under the store's lock, the system prompt is rebuilt in place (a new +`context_window` with `reason="harness"` that keeps every other message index), and a +`` notice tells the model what changed. Rejected edits +(unknown module in a `skill` reference, edits to the base system prompt, entries that +changed while planning, …) are recorded with an error and the rest still apply. Each +store's `refinements.jsonl` holds one full result per pass with per-edit `before`/`after` +snapshots; a rollback replays those snapshots in reverse and needs no model call. + +The planning call also decides whether to refine at all: a proposal with an empty `edits` +array declines the pass. A declined pass changes nothing (no store write, no rebuilt +window, no `refinement` edge) and is recorded as a `refinement_declined` ledger record with +the planner's rationale; only a kernel-requested pass tells the model, in context. An +applied pass is a `refinement` record. Both records carry the `trigger`, any compaction +`blocks` behind the pass and, when the TypeSafe judge ran, its verdict under `judge`. + +Three triggers, all of which run between model calls and never inside a cell: + +- **Kernel**: `await rlm.refine.run(instructions=None, global_=False, rollback_id=None)` + returns `{"scheduled": True}` (or a reason) and the pass runs at the next boundary; + `await rlm.refine.status()` reports `pending`/`in_flight`. +- **Host**: `session/prompt` may carry `ai.prime.rlm/refine-v1` in `_meta`: + `{"instructions": "...", "global": false, "rollback_id": null, "review": false, "focus": false}`. + The pass runs before the turn; with an empty prompt it is the whole turn and the notice + (or `[refinement declined: ]`) is the answer (`stop_reason` `refined`). + `review: true` lets the TypeSafe judge decline the pass before any model call; + `focus` has the judge write the plan's focus instructions, with any host `instructions` + appended after them. A rollback takes neither. The key is refused when the harness is + disabled, and `review` or `focus` is refused without `refine_judge`. +- **Auto** (`auto_refine`, off by default, root agent only): every `refine_turn_interval` + work turns and after each compaction, subject to `refine_cooldown_seconds`, the TypeSafe + judge reviews the pass (see below) and, unless it declines, a planning + call runs with an `` section: the pass was not requested, so edit + only on evidence useful to this session's future turns (a repeated failure, a reusable + tactic, a repeated delegation role, a durable fact or preference, a user correction) and + otherwise decline with no edits. After a compaction the plan also receives the blocks that + compaction produced (the branch summary and any rollups it sealed) as ``, with + the hint that a failure, tactic or fact recurring across blocks is evidence for a durable + entry while one branch's progress is not. The call runs with `tool_choice="none"`, so the + evidence is the block text itself, never a pointer to follow. + +### TypeSafe review judge + +`refine_judge` puts TypeSafe's System One model (Jev) in front of the planning call. Jev +answers typed yes/no and choice questions with calibrated probabilities; the task model +still writes the plan and may still decline it. Automatic passes need the judge: +`auto_refine` without `refine_judge` fails validation at `session/new`. Only +`auto_refine_review: "planner"` lets the task model decide automatic passes alone, with +no judge call, e.g. as a baseline. For host passes the judge stays opt-in (`review`, +`focus`). It runs for the root agent only and sends a compact evidence state to the TypeSafe API: the messages since the last pass +(clipped, newest first, user turns kept ahead of the rest), exception counts, the +compaction blocks behind the pass, and the visible harness entries with the entries of +the store the pass writes first, alongside that store's refinement history. + +```json +"refine_judge": { + "api_key": "...", + "base_url": null, + "model": "jev-latest", + "mode": "gate", + "threshold": 0.7, + "veto_threshold": 0.8, + "home_confidence": 0.6, + "timeout_s": 30.0 +} +``` + +A review is two calls. The **gate** asks one yes/no question per kind of lesson +(repeated failure, reusable tactic, delegation role, durable fact, user correction, and, +in a local pass a compaction started, working notes: state from the turns just +summarized away that the rest of the task still needs) and one per entry the pass could +fix: is it contradicted by the conversation? That covers +entries of the store the pass writes and, in a local pass, read-only global or ancestor +entries, which get a local override. Nothing at `threshold` declines after that one +call. The **focus** call is asked only about the lessons that fired, stated as +premises: per lesson, whether it is already recorded (a veto at `veto_threshold`) and +which kind should hold it; per entry of the store the pass writes, whether it covers a +lesson; per turn, whether it is direct evidence. Questions are worded +for the pass's scope: a local pass serves later work in this session (the rest of the +current task, which may continue after older turns are summarized away, and any later +tasks), a global pass (host `global: true`) future sessions. A fact or technique that +served only a step already finished doesn't count. Code turns the answers into deterministic plan +instructions naming the home kind (one kind when the choice's confidence reaches +`home_confidence`), the entries to update or delete, the read-only entries a local entry +should override, and quotes of the strongest turns. +They reach the planner as ``, the same slot host and kernel +instructions use. + +`mode: "gate"` lets the judge decline automatic passes before any model call; `"shadow"` +leaves the decision to the planner and only records the judge's verdict. The pass record's +`judge` holds the mode, the gate and focus probabilities, the evidence turns it chose and +TypeSafe token usage per call. A judge failure fails a gated pass; where the judge only +informs the plan (`focus`, `shadow`) the plan runs without it and the failure is recorded +as `judge.error`. `scripts/refine_eval/run.py` scores these pieces on labeled, steered +scenarios: decisions against labels, who declined, focus against expected kinds and +entries, and applied edits and follow-up probes per arm (`force`, `force+focus`, +`typesafe`, `none`). Scenarios can seed either store and run a global pass, covering a +stale global entry, a global entry a local pass must override, and a session-only fact +a global pass should decline. `scripts/refine_eval/RUNBOOK.md` covers running it and +reading the report. + +`max_refinements` caps applied passes per engine; `max_refinement_attempts` bounds how +often an unusable reply (truncated JSON, prose, a tool call) is resampled before the pass is +reported as failed in the conversation and the run continues. A pass is one planning +request (plus any resampled attempts), each carrying a `refinement_attempt` semantic edge +from the last work request; an applied plan becomes the source of a `refinement` edge into +the next work request, which also keeps its ordinary `continuation` edge. A declined plan +is a dead end, and a pass the judge declines makes no request at all. Refinement counts, +edit totals, declines by reason and the judge's reviews, errors, input tokens and +disagreements with the planner appear in the session metrics; the `session-v1` snapshot +carries per-scope entry counts under `harness`. + +## Episodes + +Trajectories are per agent directory and there is no global trajectory: `$RLM_HOME` is only +the default parent of `sessions//`, and no session discovers or reads another unless +it is handed a path. Episodes are the opt-in episodic half of cross-session memory, next to +the semantic entries above. With `record_episodes: true` **and** a `global_dir`, the root +engine writes one `episode` entry into the global store when the session closes, without +any model call: + +- `title`: the first line of the first prompt; the entry's `path` field (a facet, not a + directory): `episodes//
T-` from the session's start + time, so episodes list chronologically within the kind and a month or day is a + searchable term. +- `content`: `Prompts (n):` with the first line of every prompt (the first ten listed, + the rest counted), then the compaction staircase as it stood at close (the coarsest + blocks, or `(no compaction)` for a session that fit one window), then + `Outcome (): ` for the last prompt. +- `metadata`: `session_dir`, `session_id`, `stop_reason`, every prompt line under + `prompts`, `turns`, token totals, `cwd`, the block records, and start/end times. + +The entry is keyed by session id, so closing twice rewrites the same record. The system +prompt lists episodes like any other kind, ranked against the task text when there are more +than `max_prompt_entries_per_kind`; from the kernel, `h.list("episode")` lists them, +`h.search(query, kind="episode")` ranks them (an empty query returns the newest), and +`history(session_dir=h.get("episode", id).metadata["session_dir"])` opens its ledger for +`.blocks` / `.expand(i)`. Each session also logs an `episode` record naming what it +published. + +Episodes are engine-owned: `create`/`update`/`delete` of that kind raise `PermissionError` +from the kernel API and refinement edits of that kind are rejected, so nothing inside a +session can rewrite or remove another session's record. The engine never deletes them +either — a per-session setting must not prune a shared store — so trimming a long-lived +store is an operator command run outside any session: + +```bash +rlm harness prune --global-dir ~/.rlm/global --older-than 30d # dry run +rlm harness prune --global-dir ~/.rlm/global --older-than 30d --keep 200 --apply +``` + +`--older-than` (`30d`, `12h`, `4w`) selects by the session's start time and `--keep N` +drops everything beyond the N newest; without `--apply` the command only prints what would +go. `--global-dir` defaults to `$RLM_HARNESS_GLOBAL_DIR`. The removal runs under the store +lock and is recorded like a refinement — an `operator:prune` event in `harness_state.json` +and a result in `refinements.jsonl` with each removed entry's snapshot — so it is +inspectable and reversible by hand, while no in-session path can recreate an episode. Both gates default to off because a global store shared +across RL rollouts of one task would let a rollout read another's outcome. A store that +holds episodes cannot be read by builds that predate the kind. + +## In split execution + +With [split execution](split-execution.md) the harness keeps working as +described above, and its stores stay with the engine. + +The worker keeps a mirror of `$RLM_HOME/skills` in its session directory, filled before +the kernel starts and updated before each tool call, so every skill the prompt lists is +readable there. The prompt's skill paths, including `RLM_HARNESS_SKILLS_DIR`, name the +mirror. With `author_skills` on, the kernel binds and writes authored packages in the +mirror, and the files it changes return with each tool result to `$RLM_HOME/skills`. +They persist there, and the engine announces them to every agent. The receiving side +writes only regular files inside skill folders. It rejects absolute paths, `..`, and +paths through symlinks, and never imports what it receives. Files over 4 MiB and +bytecode are not mirrored. + +`history(session_dir)` and `harness(session_dir)` for a path the worker doesn't have ask +the supervisor. The supervisor serves the ledger in pages, or the session's local store +read-only, when the path is the `session_dir` of an episode in the agent's harness, and +refuses any other path. diff --git a/docs/images/overview-dark.svg b/docs/images/overview-dark.svg new file mode 100644 index 00000000..a885ac72 --- /dev/null +++ b/docs/images/overview-dark.svg @@ -0,0 +1 @@ +ACP clientverifiers, an editor,your own scriptone rlm session (rlm --acp)Model APIenginethe agent loop: model turns,tool calls, compactionRLMEngineIPython kernelvariables and imports that persistthe rlm.* namespace, edit, fetch,your Python tool packagessession directorymessages.jsonlinbox.jsonl · jobs/ · meta.jsonone per agentsupervisoragents · inbox · shell jobs · serviceswatches · model-call admissioncapability broker: search, MCPchild agentseach with its own engine, kernel and session directory, owned by the supervisorcontract, prompts →← answers, snapshotstool callsresultsappendsrlm.* callsspawns, owns diff --git a/docs/images/overview.svg b/docs/images/overview.svg new file mode 100644 index 00000000..4e0b0664 --- /dev/null +++ b/docs/images/overview.svg @@ -0,0 +1 @@ +ACP clientverifiers, an editor,your own scriptone rlm session (rlm --acp)Model APIenginethe agent loop: model turns,tool calls, compactionRLMEngineIPython kernelvariables and imports that persistthe rlm.* namespace, edit, fetch,your Python tool packagessession directorymessages.jsonlinbox.jsonl · jobs/ · meta.jsonone per agentsupervisoragents · inbox · shell jobs · serviceswatches · model-call admissioncapability broker: search, MCPchild agentseach with its own engine, kernel and session directory, owned by the supervisorcontract, prompts →← answers, snapshotstool callsresultsappendsrlm.* callsspawns, owns diff --git a/docs/processes.md b/docs/processes.md new file mode 100644 index 00000000..9d9d17c7 --- /dev/null +++ b/docs/processes.md @@ -0,0 +1,40 @@ +# Kernel and processes + +nano-rlm's workspace behaves like a Linux box an agent is logged into. A command can be stopped when it hangs, a crashed kernel comes back without losing the conversation, and what the agent deliberately leaves running keeps running after it is done, so a task that asks for a server gets graded with the server up. + +## Timeouts and cancellation + +| | Times out after | Then | +|---|---|---| +| `ipython` cell | `exec_timeout` (300 s), or the call's `timeout`, at most 600 s | The kernel is interrupted and the output so far is returned with a notice. A kernel that does not respond is restarted ([below](#kernel-crash-recovery)) | +| `bash` command | `exec_timeout` | Its process group is killed and the output so far is returned with the error | +| `rlm.shell` job | its own `timeout`, if given | Its process group is killed and `timed_out` is set | + +A cancelled turn stops a running `bash` command the same way. A cancelled `ipython` call interrupts its cell, and its kernel is recovered if needed. + +`bash` commands read an empty stdin, so a command that waits for input sees end-of-file at once rather than hanging or reading the harness's own streams. `rlm.shell` jobs have stdin closed too. + +## Which processes outlive what + +| Process | Kernel restart | Timeout or cancelled turn | Session end | +|---|---|---|---| +| Started from a cell and still attached to the kernel (`!cmd &`, `subprocess.Popen`) | stopped | the cell is interrupted | stopped | +| A `bash` tool command | unaffected | stopped, partial output kept | stopped | +| A `bash` tool command's background job (`cmd &`, `nohup cmd &`) | runs on | runs on | runs on | +| An `rlm.shell` job and the processes in its group | runs on | runs on (its own `timeout` applies) | stopped with its owner | +| An `rlm.services` service | runs on | runs on | runs on, until `stop()` | +| A daemon in a session of its own (`setsid`, `start_new_session=True`) | runs on | runs on | runs on | + +- **The kernel's session.** The kernel leads a session of its own. A restart or the end of the session stops every process still in that session, the way a closing terminal does, while processes that started a session of their own are left alone. +- **Bash commands.** Each `bash` command, whether a tool call or `await bash(...)` from a cell, leads its own process group. A timeout or cancellation kills that group while the command is still running. Once bash itself has exited, a job it put in the background outlives the call, even one that kept the call's output open until the timeout. A `bash` command run from a cell is in the kernel's session, so a restart stops it with the kernel. +- **Shell jobs and services.** The supervisor owns these. A job's leftover processes are killed when the job ends, and its owner's end ends it. A service runs in its own session and only `stop()` ends it ([Services](python-api.md#services)). + +What keeps running after a session is the orchestrator's to clean up, for example by tearing down the container or VM. + +## Kernel crash recovery + +If the kernel dies during a cell, rlm starts a fresh kernel under the same agent and resumes the conversation with a recovery notice. A dead kernel found before a cell starts is restarted too, and that cell is returned unexecuted so the model can rebuild its Python state first. A kernel restarted after an unresponsive timeout or interrupt produces the same notice; an interrupt that leaves the kernel alive does not claim variables were lost. + +Python variables, imports and in-kernel tasks are lost. The conversation, history, child agents, shell jobs, services, watches and inbox, read state included, belong to the supervisor and stay; `rlm.agent.list`/`get` and `rlm.shell.list`/`get` recover their handles. The interrupted cell is never replayed: its file writes or accepted spawns, sends and jobs may already have happened, so the model inspects them before retrying. + +Recovery notices are `kernel_recovery` records in the history and messages in the context, so truncated tool output cannot hide them. rlm makes at most three recovery attempts without a completed cell in between. A failed start is reported, and a later `ipython` call may retry within that budget; once it is spent, IPython is unavailable but the agent can still answer or wait. Kernel death is noticed during a cell or at the next `ipython` call; nothing polls the kernel in the background. A crashed supervisor is not recovered. diff --git a/docs/prompt-optimization.md b/docs/prompt-optimization.md new file mode 100644 index 00000000..72d0d15e --- /dev/null +++ b/docs/prompt-optimization.md @@ -0,0 +1,43 @@ +# Prompt optimization + +`scripts/gepa/` optimizes the registry texts behind [`prompt_overrides`](runtime-contract.md#prompts) with +[GEPA](https://github.com/gepa-ai/gepa): a candidate is a `{name: text}` dict, a rollout +is one nano-rlm session, and a reflection model rewrites one text at a time from the +session ledgers and their scores. Nothing here runs inside a session; it needs the +`gepa` dependency group (`uv sync --group gepa`). + +Tasks are question sessions about checked-out repositories whose answers are computed +from the AST or the filesystem (`tasks.py`: definition counts, parameter defaults, +callers, importers, decorator users, test locations, line counts) plus `api` questions +that can only be answered by using a documented runtime surface (`rlm.shell.run`, +`rlm.agent.spawn`, `h.create_memory`, the history API) and are checked against the +session ledger as well as the answer. The history question that quotes question 1 is +only asked once the session has compacted, since before that the question is still in +context. A session's score is the mean question score minus a small token penalty. +Rollouts run with a `summarize_at_tokens` far below production so sessions compact and +the compaction texts get evidence. + +```bash +uv run python scripts/gepa/workspace.py # pinned checkouts, one NAME=PATH per line +uv run python scripts/gepa/tasks.py --repo nano-rlm= --repo click= --per-repo 20 +RLM_API_KEY=... RLM_BASE_URL=... uv run --group gepa python scripts/gepa/optimize.py \ + --tasks scripts/gepa/tasks/tasks.jsonl --run-dir scripts/gepa/runs/first \ + --model deepseek/deepseek-v4.1-flash --reflection-model openai/gpt-6-astra \ + --max-metric-calls 300 +``` + +The first run optimizes `task`, `repl_doctrine`, `delegation_doctrine`, `checkpoint`, +`rollup` and `staircase_framing` (`--components` selects others). Every proposed +candidate is checked before any rollout is spent: a text that is empty, longer than its +allowance (twice the seed text or 1,200 characters, whichever is larger), or missing a +token the runtime depends on (`components.py`, e.g. +`hist.expand(` in `history`, `rlm.shell.run` in `runtime_reference`), or mentioning what +exists only in these sessions (scoring, checks, the `ANSWER:` format) scores zero with +feedback naming the problem. The component selector only proposes texts the minibatch +actually exercised. A run is resumable from its `--run-dir` and ends with +`best_prompt_overrides.json`, a `prompt_overrides` object any host can send, and +`report.md` with per-task validation scores and a diff per component. Landing a +winner in `rlm.prompt` is a reviewed change. `scripts/gepa/RUNBOOK.md` is the +step-by-step for a full run. Expect roughly 100-250k tokens per rollout +and a few hundred rollouts per run. Sessions run inside a checkout and may write to it, +so ask about this repository through its pinned workspace copy, never the live tree. diff --git a/docs/python-api.md b/docs/python-api.md new file mode 100644 index 00000000..3a49ab73 --- /dev/null +++ b/docs/python-api.md @@ -0,0 +1,170 @@ +# Python API + +Inside the kernel, the `rlm` package is how an agent reaches beyond its own cell: it starts and steers sub-agents, runs shell jobs and services, watches for activity, and reads its own history. These are async calls to the [supervisor](architecture.md#components), so what they create outlives the cell, the variable holding it, and even a kernel crash. + +| Module | For | +|---|---| +| [`rlm.agent`](#agents) | Spawning sub-agents and steering, waiting for and cancelling them | +| [`rlm.inbox`](#inbox-and-waiting) | Reports, completions and watch events, pulled when the agent chooses | +| [`rlm.shell`](#shell-jobs) | Bash commands, short or in the background | +| [`rlm.services`](#services) | Processes that must keep running after the agent finishes | +| [`rlm.watch`](#watches) | Notifications about an agent's steps, a job's output or changes under a path | +| [`rlm.history`](#history) | Reading this agent's or a child's conversation | +| `rlm.harness`, `rlm.refine` | The continual harness's entries and refinement passes ([Continual harness](continual-harness.md)) | +| [`rlm.hints`](#hints) | Muting the supervisor's one-line usage hints | +| `rlm.tools` | Searching and loading MCP tools ([Tools](tools.md#mcp-tools)) | + +## Agents + +Agents are available when recursion is allowed (`max_depth` above the agent's depth). A Python variable holds a handle; the supervisor owns the agent, so ending a cell or losing the variable does not stop it. + +```python +researcher = await rlm.agent.spawn(task="Check authentication behavior", name="researcher") +other = await rlm.agent.spawn(task="Check login behavior", name="login") + +agents = await rlm.agent.list() # direct children, finished ones included +info = await researcher.info() # a fresh metadata snapshot +hist = await researcher.history() # a fresh conversation snapshot +researcher = await rlm.agent.get("researcher") # recover a handle by name or ID +``` + +`spawn` returns once the child is registered, not when it finishes; a rejected spawn (depth, spawn or budget limits) raises before registration. Names are unique among siblings and stay reserved for the session. `list(recursive=True)` includes descendants, but only direct children can be retrieved as handles and controlled. Metadata includes the ID, name, parent, task, status, persistence, creation time, elapsed seconds, session directory and any failure. + +**Results.** + +```python +result = await researcher.result(yield_after=60) +if result.running: + print("still working, answer so far:", result.answer) +else: + print(result.answer) +``` + +`result()` waits up to `yield_after` seconds (default 300) and returns an `AgentResult` with `status`, `answer`, `usage`, `turns`, `session_dir` and `running`. `answer` is `None` while the agent works on its first answer; a persistent agent's latest answer stays available while it runs again. A failed or cancelled agent raises. `wait(timeout=30)` returns current metadata after an outcome or its timeout (0 to 300 seconds); it waits inside the cell, under the cell's own timeout. Neither cancels the agent. + +**Status** is `starting` while waiting for capacity, `running`, `waiting` during a native wait, `idle` for a persistent agent that has answered, and `completed`, `failed` or `cancelled` once it has ended. Finished agents' metadata, results and history stay readable while the session lives. + +**Lifetime.** An agent releases its kernel after answering. One spawned with `persistent=True` goes `idle` instead, keeping its conversation and kernel, and wakes on a parent instruction or a new inbox event. `await researcher.cancel()` ends an agent and its descendants and waits for cleanup; calling it again retries cleanup that did not finish, and `info.cleanup_error` reports a cleanup failure without hiding a completed result. A parent's end ends all its descendants, persistent ones included, and closing the ACP session ends the tree. Cancelling a single prompt or cell leaves its accepted children registered and recoverable. Idle persistent agents hold no inference slot but count toward `max_subagent_calls`. + +**Messages.** + +```python +await researcher.send("Also check logout") # delivered after its answer or at a native wait +await researcher.steer("Focus on login first") # delivered at its next model/tool boundary + +# inside the child: +await rlm.agent.send_to_parent("Found a missing permission check") +``` + +Parent instructions enter the child's conversation; neither kind interrupts a running model call or tool. Both wake an idle persistent child, and sending to an agent that has ended raises. `send` and `steer` return acceptance IDs, not acknowledgements, and refuse when the tree's budget is spent. An accepted instruction that cannot be delivered (the budget ran out, or the child ended) is recorded as `instruction_failed` in the child's inbox journal and reported to the parent as an `agent.delivery_failed` event with `agent_id`, `message_id` and `reason`. + +## Inbox and waiting + +Reports, completions and watch events arrive in the agent's inbox. The model sees the unread count whenever it changes and decides when to read: + +```python +for event in await rlm.inbox.list(): # metadata, unread only by default + report = await rlm.inbox.read(event["id"]) # the payload, now marked read + if report["type"] == "agent.message": + print(report["content"]["name"], report["content"]["text"]) +``` + +`list(unread_only=False)` includes events already read. A child's `agent.completed` event names the agent and its status; its answer comes from the handle's `result()`. + +The native **`wait`** tool suspends inference without holding a cell, until a new inbox event, a parent instruction or its timeout (default 300 seconds). Events already announced do not wake it again. An agent with nothing else to do waits instead of polling; a final root answer instead returns control to the client. + +Inboxes and instruction queues belong to the supervisor. Each agent's `inbox.jsonl` journals arrivals, deliveries and reads, and delivered messages and notifications also enter its `messages.jsonl`. Message payloads are limited to 65,536 JSON bytes. An inbox takes up to 10,000 events and a pending instruction queue as many; lifecycle events are always recorded. + +## Shell jobs + +`rlm.shell.run` runs a Bash command under the supervisor. A short command returns its result in the same cell: + +```python +result = await rlm.shell.run("git status --short") +print(result.text, result.exit_code) +``` + +A long one keeps running in the background while the cell moves on: + +```python +job = await rlm.shell.run("uv run pytest tests/", cwd="/workspace/project", yield_after=0) +# ... other work ... +res = await job.result() +print(res.exit_code, res.text) + +a, b = await asyncio.gather(job_a.result(), job_b.result()) # several at once +job = await rlm.shell.get(job_id) # recover in a later cell +chunk = await job.read(cursor=0, max_bytes=16384) # continue from chunk.next_cursor +``` + +`run(command, cwd=..., env=..., yield_after=10, timeout=None)` takes a Bash string or an argv list and returns a `ShellJob` snapshot: `id`, `running`, `ok`, `exit_code`, `text` (combined stdout and stderr, up to 16 KiB: the first and last 8 KiB around a marker when longer), `truncated`, `timed_out` and `error`. + +- **Yielding.** `run` waits `yield_after` seconds (default 10, 0 returns at once, at most 300) before handing back a job that is still running, with `running=True` and its output so far. Yielding never stops the job. `await job.result(yield_after=...)` waits up to 300 seconds per call and returns the finished snapshot, or the job again if it is still running; it is repeatable and never consumes output. +- **Timeout and cancel.** `timeout` kills the job's process group after that many seconds and sets `timed_out`. `await job.cancel()` terminates the process group. +- **Output.** Each job keeps up to 16 MiB in `/jobs//output.bin`, readable with `job.read(cursor, max_bytes)` (16 KiB by default, 64 KiB at most; cursors count bytes, text decodes as UTF-8 with replacement). A finished job's metadata is kept beside it. +- **Completion events.** A job handed back while still running publishes a quiet `shell.completed` event when it ends, with its ID, status, exit code, completeness flags and last 4 KiB of output. Quiet events wake `wait` but are not counted as unread, and `result()` marks them read. +- **Environment.** `await rlm.shell.setenv(NAME="value")` sets variables for every later `run` of this agent; `getenv()` reads them back, and a call's own `env=` wins. Jobs inherit the agent's process environment minus credential-looking names, URLs with embedded credentials, variables that would redirect rlm's interpreter or virtual environment, and agent sockets. +- **Execution.** Commands run in `/bin/bash --noprofile --norc -c`, with stdin closed and stdout and stderr combined. Relative directories resolve against the agent's working directory, and the Git guard applies. A nonzero exit code is a completed job; failing to start or capture is status `failed`, with `error` set. There is no PTY or stdin API, and IPython's `!` and `%%bash` do not go through `rlm.shell`. + +Jobs survive the end of their cell, lost variables and kernel restarts; `rlm.shell.list()` finds them. A job ends with its owner, and when it ends the processes left in its process group are killed. A job counts as finished when stdout and stderr close: if descendants keep them open more than two seconds after Bash exits, capture stops with `output_complete=False`. Processes that leave the job's process group are not managed; anything that must keep running is a [service](#services). A session tree allows 32 running jobs and 1,024 in total. + +## Services + +A service is a process that must keep running after the agent finishes, such as a server or daemon a task asks to leave up. + +```python +svc = await rlm.services.start("api", "python -m http.server 8000", cwd="/workspace/site") +print(await svc.tail()) # the last lines of its log +svc = await rlm.services.get("api") +await svc.stop(grace=5) # SIGTERM to its process group, SIGKILL after the grace period +``` + +`start(name, command, cwd=..., env=...)` returns at once; check that the service came up with `tail()` or by connecting to it. The service runs in a session of its own, with stdin from `/dev/null` and stdout and stderr appended to its log, and only `stop()` ends it. Names are 1 to 64 letters, digits, `.`, `_` or `-`; starting a name that is still running raises. The environment and default working directory are those of `rlm.shell.run`. `get(name)` returns a fresh snapshot (`name`, `pid`, `command`, `cwd`, `log`, `running`, `exit_code`, `started_at`) and works after kernel restarts; `list()` returns every service started in this workspace, running or not. + +## Watches + +A watch turns future activity into inbox events, without keeping a cell running: + +```python +activity = await rlm.watch.agent(researcher) # its completed steps, final answers included +output = await rlm.watch.job(job) # newly captured output +files = await rlm.watch.path("/workspace/results", recursive=False) + +subscriptions = await rlm.watch.list() +watch = await rlm.watch.get(subscriptions[0].id) +await watch.cancel() +``` + +Agent watches cover direct children; job watches cover the caller's own jobs; path watches cover an existing file or directory, hidden files included, with recursion opt-in. A child's or job's completion already notifies its owner without a watch. + +Each event carries its `subscription_id`, and its `content` names the `target` (an agent ID, job ID or absolute path) plus: + +| Event | References | +|---|---| +| `watch.agent` | `start` and exclusive `end` message indices: `(await researcher.history()).messages[start:end]` | +| `watch.job` | `start` and exclusive `end` byte cursors for `job.read(cursor=start)` | +| `watch.path` | The changed `paths` and a `truncated` flag | +| `watch.failed` | An `error` saying why the watch stopped | + +Watches see only activity after registration; there is no replay. Agent and job ranges can merge several steps or chunks, and path changes are batched over 200 ms. A path watch returns once the backend is watching; removing the watched path ends it with `watch.failed`. Watches survive kernel restarts, end with their owner, and complete once their target has ended (flushing pending activity); a watch on an idle persistent agent stays active, and one on an already finished target returns completed. Cancelling drops any unpublished batch but keeps delivered events. Registrations and state changes are journaled in the owner's `inbox.jsonl`. A tree allows 64 active watches and 1,024 registrations, changed-path lists are capped at 32 KiB of JSON, and a watch stops with `watch.failed` when its owner's inbox is full. + +## History + +An agent can read its own conversation, or a child's, as it stands: + +```python +from rlm import history + +hist = await history() # this agent's ledger +requests = hist.user_messages() # original inputs, rolled-back attempts included +message = hist.messages[3] # by session-wide message index +earlier = hist.windows[0].messages # the first context window +child = await researcher.history() # a child's, even while it runs +originals = hist.expand(hist.blocks[-1]) # the messages the newest compaction block summarizes +``` + +Each call returns a complete snapshot as of the read; call again to see new activity. `hist.events` holds the lifecycle records too, such as each child's spawn prompt and rollback markers. `history(session_dir=...)` reads any session directory, also outside a session; in split execution, a past [episode](continual-harness.md#episodes) the workspace doesn't have comes from the supervisor. [Context and history](context.md#the-ledger) describes the records. + +## Hints + +The supervisor attaches a one-line hint to the next turn when something worth knowing happened, for example a blocking `run()` that detached into a background job. Each hint names a tag; `await rlm.hints.mute("tag")` stops that hint for this agent, `unmute` restores it, and `muted()` lists the muted tags. diff --git a/docs/runtime-contract.md b/docs/runtime-contract.md new file mode 100644 index 00000000..0f00bf65 --- /dev/null +++ b/docs/runtime-contract.md @@ -0,0 +1,115 @@ +# Runtime contract + +nano-rlm is configured by its client, once per session, through one versioned object. There is no config file and no environment fallback: a training run gets exactly the agent it asked for, and the response tells it exactly what that agent spent. + +`rlm --acp` is an [Agent Client Protocol](https://agentclientprotocol.com/) agent over stdio. There is no standalone prompt mode. The only other commands are operator utilities for a harness store that never run inside a session, such as `rlm harness prune` ([Episodes](continual-harness.md#episodes)). + +## Handshake + +1. `initialize` returns `ai.prime.rlm/contract-v1: true` in its response `_meta`. Clients must require it. The same `_meta` advertises steering (`steering.supported`) and split execution (`ai.prime.rlm/execution-v1`, with its transports and tools). +2. `session/new` carries one complete `ai.prime.rlm/runtime-v1` object in its `_meta`. Nullable and disabled values are sent explicitly as `null` or empty collections. Missing, partial, unknown or unsupported contracts are rejected. +3. `session/prompt` runs a turn. Repeated prompts continue the same conversation and the same live kernel. Cancelling a turn leaves the session available for the next prompt. Its `_meta` may carry `ai.prime.rlm/refine-v1` to request a [refinement pass](continual-harness.md#refinement) before the turn. +4. `session/close` tears down the agent tree and returns the final [session snapshot](#session-snapshot). + +The agent accepts text prompts, and stdio or streamable-HTTP MCP servers (with HTTP headers) in `session/new`. It does not advertise `session/load`: a live Python kernel cannot be rebuilt after the process exits, so a client keeps the process alive for the session's lifetime. + +## The runtime object + +| Field | Type | Meaning | +|---|---|---| +| `session_id` | string | The client's ID for the session, echoed in snapshots (`[A-Za-z0-9._:-]`, at most 128 characters) | +| `model` | string | Model name sent to the provider | +| `provider` | object | `base_url`, `api_key`, `headers`, `max_retries` (default 5). `Idempotency-Key`, `x-stainless-retry-count` and the ACP tracing headers are reserved | +| `policy` | object | Budgets, recursion, compaction and timeouts, see [Policy](#policy). Omitted fields take their defaults | +| `system_prompt_path` | string or null | A file whose instructions replace the default task role | +| `append_to_system_prompt` | string or null | Instructions appended for every agent | +| `subagent_append_to_system_prompt` | string or null | Override for sub-agents that can still recurse | +| `leaf_append_to_system_prompt` | string or null | Override for agents at `max_depth` | +| `skills` | list | Built-in Python tools to pre-import: `bash`, `edit`, `fetch`, `search` ([Tools](tools.md#built-in-python-tools)) | +| `builtin_tools` | list or null | Native tools; null means `["ipython"]` ([Tools](tools.md#native-tools)) | +| `kernel_env` | object | Variables added to the kernel's minimal environment | +| `search_api_key` | string or null | Serper key for `search`, held by the supervisor | +| `network_block` | list | Domains, wildcard hosts or URL origins that `search` results must not point to | +| `execution_command` | list or null | A trusted argv starting `python -m rlm.execution`, for [split execution](split-execution.md) over stdio | +| `execution_cwd` | string or null | The tools' working directory in split execution | +| `execution_tcp` | object or null | `host`, `port` and `token` of a worker for split execution over TCP | +| `harness` | object or null | The [continual harness](continual-harness.md) settings; null takes the defaults | +| `prompt_overrides` | object or null | Replacement texts for named runtime prompts, see [Prompts](#prompts) | + +Recursive children inherit their parent's configuration in memory, one level deeper; nothing is re-read. + +The only process environment variable is `RLM_HOME` (default `~/.rlm`): the root for session directories and host-supplied tool packages (`$RLM_HOME/skills`). + +## Policy + +| Field | Default | Meaning | +|---|---|---| +| `max_total_tokens` | 1,000,000 | New tokens across the whole tree: each call's completion plus uncached prompt. Once reached, every engine stops before its next model call and no more agents are spawned. The default terminator | +| `max_total_turns` | null | Model calls across the tree, compaction excluded | +| `max_depth` | 1 | How deep agents can recurse; 0 disables `rlm.agent.spawn` | +| `max_concurrent_subagents` | 4 | Concurrent model calls across the tree, root and compaction calls included | +| `max_subagent_calls` | null | Total spawns across the tree | +| `delegation_prompt` | false | Add guidance on when and how to delegate, for agents that can | +| `exec_timeout` | 300 | Seconds a tool call may run; an `ipython` call may ask for up to 600 | +| `max_tokens` | null | Completion tokens one agent may spend; reaching it ends the turn | +| `max_tool_output_bytes` | null (20 KB) | How much of one tool result enters the conversation | +| `compaction` | true | Compact the context when it runs out of room, and recover from provider overflows | +| `summarize_at_tokens` | null | Compaction threshold; null derives it from the provider's advertised context window | +| `max_compactions` | null | Compactions per session | +| `max_compaction_attempts` | 5 | Summary attempts within one compaction, and rollup calls per compaction | +| `compaction_fanout` | 5 | Blocks of one tier merged into one block of the next ([Rollups](context.md#rollups)) | +| `compaction_tail_tokens` | 12,000 | Newest messages kept verbatim after a compaction, capped at a quarter of the threshold; 0 keeps none ([The verbatim tail](context.md#the-verbatim-tail)) | +| `compaction_prompt_tokens` | 4,000 | Largest current prompt kept verbatim after a compaction; 0 never keeps it ([The pinned prompt](context.md#the-pinned-prompt)) | +| `allow_git` | false | Lift the guard that refuses Git commands reaching beyond the current branch (other refs, branch and tag listings, `clone`, `fetch`, `pull`) | +| `network_guidance` | true | Keep the prompt guidance that assumes outbound network access: installing extra kernel packages, and the online-solutions clause of the Git guard. Set it to false on a box with no network | + +**Model-call admission.** `max_concurrent_subagents` bounds calls across the tree. Queued calls closer to the root go first, FIFO within a depth, and calls in flight are not preempted, so continuous shallow work can delay deeper agents. Tools, child waits and the backoff between outer retries hold no slot; the SDK's own retries stay within one. The limit is per tree: a cap across trees belongs in the runner or inference gateway. + +## Prompts + +`system_prompt_path` supplies task instructions in place of the default task role. The runtime guide is always appended, so root agents, persistent children and leaves all get the tool and API documentation and the lifecycle rules. The role's append instructions sit between the task instructions and the guide; sub-agents and leaves fall back to the next more general tier when their own is unset. + +The guide distinguishes Python state from supervisor-owned resources and explains waiting, completion, history, jobs and subscriptions. Delegation guidance appears only when IPython and recursion are both available. + +`prompt_overrides` replaces named runtime texts for every engine in the tree. The names are the keys of `rlm.prompt.DEFAULT_PROMPTS`: the system prompt's `task` line and the `repl_doctrine` and `delegation_doctrine` paragraphs; the reference sections they sit in (`runtime_reference`, `delegation_reference`, `history`, `harness_api`); the compaction texts `checkpoint`, `rollup` and `staircase_framing`; and the refinement planning text `refine`. Unknown names and empty texts are rejected, as is a text that drops a marker the runtime fills in (`` inside `runtime_reference`, `%(scope_policy)s` in `refine`, ...). `system_prompt_path` still replaces the task role entirely. The `session/close` snapshot lists the overridden names under `limits.prompt_overrides`. [Prompt optimization](prompt-optimization.md) searches for better texts. + +## Steering + +During a running turn, `_session/steering` (with `sessionId` and text `prompt` blocks) adds a user message at the next model step, after any tool results; running tools are not interrupted. It wakes the native `wait` tool. The request returns `injected` once the message is in the conversation, or `promptRequired` with reason `noRunningTurn` when no turn can take it; idle steering never starts a turn. Clients can set `_meta.steering.idleBehavior` to `promptRequired`. + +An optional `messageId` deduplicates retries within the session; reusing an ID with different content fails. Injected messages survive prompt rollback and stay in the user-request history that compaction keeps. Queued messages that cannot be applied stay unconsumed. + +## Session snapshot + +Prompt responses and the `session/close` response carry one credential-free snapshot under `ai.prime.rlm/session-v1`: the session ID and last stop reason, the model, turns, cumulative token usage, metrics (compactions, sub-agent calls, programmatic tool calls, ...), supervisor counters and the effective limits, and per-scope harness entry counts under `harness`. The `session/close` snapshot is authoritative. The `session/close` response also lists the session's refinement passes (applied or declined, with any judge verdict) under `ai.prime.rlm/refinements-v1`. + +The ACP stop reason says when a limit cut a prompt short; the snapshot's `last_stop_reason` names the exact cause: + +| `last_stop_reason` | Cause | ACP stop reason | +|---|---|---| +| `done` | The agent answered | `end_turn` | +| `max_total_turns` | The tree reached `max_total_turns` | `max_turn_requests` | +| `max_total_tokens` | The tree reached `max_total_tokens` | `max_tokens` | +| `token_budget` | The agent reached its `max_tokens` | `max_tokens` | +| `compaction_failed` | A compaction failed with the context full | `max_tokens` | +| `cancelled` | The client cancelled the prompt | `cancelled` | +| `refined` | A host [refinement](continual-harness.md#refinement) with an empty prompt was the whole turn | `end_turn` | + +## Tracing headers + +Every model call carries a standard `Idempotency-Key` header that stays stable across SDK and outer retries (attempts differ in `x-stainless-retry-count`), so an inference proxy can deduplicate replays. + +Each call also carries a private `X-ACP-Model-Request-ID`. Responses publish sparse, labeled edges between those request IDs under `ai.prime.acp/semantic-edges-v1`: + +| Edge | Meaning | +|---|---| +| `continuation` | The same agent's next call, in causal order even where a consumer's token-prefix graph splits | +| `subagent_call` | A spawn | +| `subagent_return` | The parent retrieving a child's result | +| `agent_message` | A delivered instruction, or an inbox event the agent explicitly read; an unread count alone creates none | +| `compaction` | A compaction call, sourced from the branch summary | +| `compaction_attempt` | Each summary or rollup call of a compaction, from the request that triggered it | +| `refinement_attempt` | Each planning call of a refinement pass, from the last work request | +| `refinement` | An applied refinement plan into the next work request | + +Clients that do not understand the extension ignore it. diff --git a/docs/split-execution.md b/docs/split-execution.md new file mode 100644 index 00000000..a450a35b --- /dev/null +++ b/docs/split-execution.md @@ -0,0 +1,66 @@ +# Split execution + +By default the agent's tools run beside the harness: the agent loop, the model credentials, the supervisor and the ledger share a filesystem and a process tree with every command the agent runs. A command can then read the credentials, kill or starve the loop, or rewrite the ledger, and the harness's own install is part of the environment being graded. + +Split execution moves the boundary to the tool call. The harness keeps the loop, the credentials, the supervisor and the authoritative conversation, and a worker in a separate workspace, such as a container or VM, runs the tools. The model sees the same tools, prompt and results. + + + + Colocated, the agent loop, model credentials and the agent's tools share one machine. Split, the harness keeps the loop, credentials, supervisor and ledger, and a worker in the workspace runs the tools over one authenticated connection. + + +## How it works + +- **Tool calls cross the boundary.** `ipython`, `bash`, `edit` and `fetch` travel as a name and arguments to `python -m rlm.execution` in the workspace, which hosts the persistent kernels, the built-in Python tools, interruption and kernel recovery. File operations happen in the workspace. Recursive agents use the same worker, each with a kernel of its own, and share the workspace. +- **`rlm.*` calls come back.** A cell's calls into the supervisor return over the same connection, scoped to the agent that made them. The supervisor's shell jobs, services and path watches run in the workspace, beside the kernels, while the supervisor keeps their records and inbox events. The agent gets the same runtime prompt and API as in local execution. +- **The ledger follows the cell.** Before each cell the worker receives the conversation's new records, in chunks that fit a frame, so `history()` reads locally in the workspace. A child's history comes from the supervisor in pages. +- **Bounded frames.** Messages are length-prefixed JSON of at most 16 MiB. A command or cell keeps the first and last 512 KiB of its output, so any result fits. + + + + One agent step in split mode: the harness sends the tool call with the new ledger records, the cell runs in the workspace and calls back for rlm.* operations, and the result returns to the harness. A tool call costs one round trip, each rlm.* call one more. + + +## Configuring it + +Both sides need the same nano-rlm version. The runtime contract takes one of two transports: + +**stdio.** `execution_command` is a trusted argv that starts the worker, and `execution_cwd` is the tools' working directory in the workspace: + +```json +{ + "execution_command": ["docker", "exec", "-i", "task-container", "/opt/rlm/bin/python", "-m", "rlm.execution"], + "execution_cwd": "/workspace" +} +``` + +Calls and callbacks share the worker's stdio, so the workspace can have networking disabled. The command defines the isolation boundary: a worker started as a local process gives process separation, not filesystem isolation. + +**TCP.** For a trusted private network, start the worker with a random token of at least 32 characters: + +```bash +RLM_EXECUTION_TOKEN=... python -m rlm.execution --listen 0.0.0.0 --port 0 +``` + +With `--port 0` the kernel picks a free port, and the worker prints `listening on HOST:PORT` on stderr once it accepts connections. Then set `execution_tcp` instead of `execution_command`: + +```json +{ + "execution_tcp": {"host": "10.0.0.5", "port": 41234, "token": "..."}, + "execution_cwd": "/workspace" +} +``` + +Each connection is a separate execution session. The token authenticates connections, and the worker removes it from its environment before starting tools. The transport does not encrypt, so use a private network or an encrypted tunnel. + +`initialize` advertises `ai.prime.rlm/execution-v1` with the supported transports (`stdio`, `tcp`) and tools (`ipython`, `bash`, `edit`, `fetch`). In code, `ExecutionTransport` supplies `connect()` (a reader and writer) and `close()`, independently of tool dispatch. + +## What it costs + +A tool call adds one round trip between harness and worker, each `rlm.*` call one more, and `rlm.shell.run` two. The worker holds a running job's new output for up to 50 ms, so a short command's output and exit arrive together, and both ends set `TCP_NODELAY`. On a cloud provider's private network a round trip is typically tens to a few hundred milliseconds. + +## Responsibilities + +The orchestrator provisions the workspace, starts the worker or listener, and tears both down. A worker that disconnects fails the session. Process lifetimes are those of [local execution](processes.md#which-processes-outlive-what): a closing session stops what is attached to its kernels, while daemons, services and `bash` background jobs keep running, so stopping what remains when a rollout ends is the orchestrator's job. Kernel recovery works as locally, including never replaying an interrupted cell. + +The prompt lists Python tool packages from the harness's installation, but the agent imports them in the workspace, so the workspace needs the same packages installed. The worker keeps a mirror of `$RLM_HOME/skills`, so `SKILL.md` skills are readable there and the prompt's skill paths name the mirror; with the continual harness, authored skills persist back to the harness ([details](continual-harness.md#in-split-execution)). Built-in Python tools work as in local execution, and MCP calls still run in the harness's supervisor. diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 00000000..7ed15968 --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,116 @@ +# Tools and skills + +nano-rlm keeps the model's tool list short and puts everything else in Python. By default the model has one native tool, `ipython`, and reaches shell commands, edits, web pages, MCP servers and installed packages as callables in its kernel. Native tool calling is available for runs that want it, and skills teach workflows on top of either. + +| Kind | Selected by | The model uses it as | +|---|---|---| +| [Native tools](#native-tools) | `builtin_tools` in the contract | a tool call | +| [Built-in Python tools](#built-in-python-tools) | `skills` in the contract | `await edit(...)` in a cell | +| [Python tool packages](#python-tool-packages) | installed by the host under `$RLM_HOME/skills` | `await websearch(...)`, or a shell command | +| [Skills](#skills) | any folder with a `SKILL.md` under `$RLM_HOME/skills` | instructions it reads when relevant | +| [MCP tools](#mcp-tools) | `mcpServers` in `session/new` | `await tool(...)` after searching the catalog | + +## Native tools + +`builtin_tools` selects the model's tools; null selects `["ipython"]`. + +| Tool | Does | +|---|---| +| `ipython` | Runs a cell in the agent's persistent kernel, with an optional `timeout` up to 600 seconds | +| `bash` | Runs one `bash -c` command in the working directory and returns stdout, stderr and a nonzero exit code | +| `edit` | Replaces one exact occurrence of a string in a file | +| `fetch` | Retrieves a URL as cleaned text | + +Whenever `ipython` is active, the model also gets the native `wait` tool ([Inbox and waiting](python-api.md#inbox-and-waiting)). [Kernel and processes](processes.md) describes timeouts, cancellation and what a command's processes outlive. + +## Built-in Python tools + +The contract's `skills` list pre-imports these into the kernel by name: + +| Name | Call | +|---|---| +| `bash` | `await bash("...")`: the `bash` tool's semantics from a cell | +| `edit` | `await edit(path=..., old_str=..., new_str=...)`: a single-occurrence replacement | +| `fetch` | `await fetch(url=...)`: a URL as cleaned text | +| `search` | `await search(query=...)`: web search through Serper, returning titles, URLs and snippets | + +`search` runs in the supervisor through the capability broker, so `search_api_key` never reaches the kernel or its subprocesses; `network_block` filters its results. The others run in the kernel. + +## Python tool packages + +A host can supply more tools as Python packages. Before running `install.sh`, it places them under `$RLM_HOME/skills//`; `install.sh` installs them beside rlm, so each is importable in the kernel and its command is on `$PATH`. The prompt lists pre-imported callables as "Python tools (pre-imported)" and packages with console commands as "Shell-enabled Python tools". + +```python +import websearch + +help(websearch) # signature and docstring +results = await websearch(queries=["latest jupyter_client release"]) +``` + +```bash +websearch --queries "latest jupyter_client release" +``` + +A package looks like this: + +```text +/ +├── SKILL.md # optional workflow instructions +├── pyproject.toml +└── src/ + └── / + ├── __init__.py + └── .py +``` + +Its one public entry point is an async `run(...)` with type-annotated parameters and a Google-style docstring (`Args:`, `Returns:`), which drive both the Python call and the CLI. The console script points at rlm's shared entry: + +```toml +[project.scripts] + = "rlm.skill:cli" +``` + +`rlm.skill:cli` reads `sys.argv[0]`, imports the matching module, and builds the CLI from `run`'s signature with [tyro](https://brentyi.github.io/tyro/): `queries: list[str]` becomes `--queries`. It prints the return value; an exception surfaces as a traceback. The directory, import name and console script are all ``, and the distribution is `rlm-skill-`; that prefix and the entry point identify a tool package, with or without a `SKILL.md`. Declare `rlm` among the package's dependencies so the entry point resolves; version conflicts between packages installed side by side are the host's to resolve. [The `say` fixture](../tests/fixtures/skills/say) is a minimal example. + +Built-in Python tools and MCP callables exist only in the kernel; tool packages also have their command. + +## Skills + +A skill is a folder holding a `SKILL.md` with `name` and `description` frontmatter: instructions for a workflow, possibly with scripts, references or a tool package. Every such folder under `$RLM_HOME/skills` gets one line in the prompt's `## Skills` section, `- name: description (file: path)`, and the agent reads the full file when its task matches. Its scripts and tools run through the agent's usual tools. A folder with both a `SKILL.md` and a tool package contributes to both listings. The contract's `skills` field selects built-in Python tools, not `SKILL.md` skills. With the continual harness's `author_skills`, the agent can also write skills that ship a Python tool into `$RLM_HOME/skills` ([Authored skills](continual-harness.md#authored-skills)). + +## MCP tools + +A client can give a session [MCP](https://modelcontextprotocol.io) servers in `session/new`, over streamable HTTP or stdio: + +```json +{ + "mcpServers": { + "remote": {"url": "http://127.0.0.1:8000/mcp"}, + "local": {"command": "/path/to/tool-server", "args": ["--stdio"], "env": {"API_KEY": "..."}} + } +} +``` + +Programmatically, pass `mcp_servers={"tools": "http://127.0.0.1:8000/mcp"}` to `RLMEngine`, or `{"url": ..., "headers": {...}}` for a server that needs request headers. + +At startup the supervisor connects to each server and indexes its whole catalog, schemas included, without creating anything in the kernel. The model finds tools by keyword and loads only what it uses: + +```python +await rlm.tools.servers() # names, tool counts, descriptions +page = await rlm.tools.search("calendar events", limit=5, server="tools") +tool = await rlm.tools.get(page["matches"][0]["id"]) +print(tool.schema) # the full JSON input schema +help(tool) # Python signature and description +result = await tool(day="monday", title="standup") # returns text +data = json.loads(result) # when the tool returns JSON +``` + +`search` returns up to 20 `matches` per page (ID, server and description) and a `next_cursor`, which continues the same query and server filter; `None` means there are no more. Search is keyword matching, so try other words when nothing fits. Tool names are not listed in the prompt, which shows the discovery guide only when IPython is active and at least one tool was found. + +Each call connects with the configured transport, runs the tool and returns its text; a tool-reported error raises `RuntimeError`. Discovery and calls run in the supervisor through the capability broker: the kernel receives only a tool's name, description, public schema and an opaque capability, never server URLs, headers, commands or environment. A catalog is limited to 64 MiB of metadata, 1 MiB per tool and 4,096 pages per server. + +## The kernel's environment + +The kernel runs in rlm's own Python (`sys.executable`), in the same environment `install.sh` installs rlm and the tool packages into, so their imports work from a cell. To use a project's own packages, shell out to its interpreter: `!./.venv/bin/python -m pytest`. To add a package to the kernel itself, install it into rlm's interpreter explicitly: `!uv pip install --python {sys.executable} `. A bare `uv pip install` picks its target from `VIRTUAL_ENV` or a nearby `.venv`, so under `uv tool install` (as in sandboxes) it finds no environment or, worse, the project's. + +The kernel inherits rlm's environment minus variables that would redirect its interpreter or configuration (`PYTHONHOME`, `UV_PYTHON`, `CONDA_PREFIX`, `PIP_TARGET`, ...), provider and infrastructure prefixes (`OPENAI_`, `PRIME_`, `RLM_`, `AWS_`, `GITHUB_`, ...) and anything that looks like a credential by name or by a `user:pass@` URL, plus the contract's `kernel_env`, with private Jupyter and IPython configuration directories. Platform variables (`PATH`, home, user, shell, locale, temporary directory, certificate and `VIRTUAL_ENV`) always pass. This keeps credentials out of reach of the agent's code as far as those patterns reach. This is not containment: in local execution the kernel shares rlm's user, filesystem, processes and network. [Split execution](split-execution.md) moves the tools out. diff --git a/pyproject.toml b/pyproject.toml index 7980b14d..9f839fbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,8 @@ extend-exclude = ["scripts/gepa/workspace"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +addopts = "-m 'not e2e'" +markers = ["e2e: runs a hosted model end to end (opt in with -m e2e)"] [build-system] requires = ["hatchling"] diff --git a/scripts/gepa/RUNBOOK.md b/scripts/gepa/RUNBOOK.md index 50b9c83e..8ed1f479 100644 --- a/scripts/gepa/RUNBOOK.md +++ b/scripts/gepa/RUNBOOK.md @@ -1,7 +1,7 @@ # Running a prompt optimization -Everything runs from the repository root on `main`. See the "Prompt optimization" -section of the README for what is being optimized and how sessions are scored. +Everything runs from the repository root on `main`. See +[docs/prompt-optimization.md](../../docs/prompt-optimization.md) for what is being optimized and how sessions are scored. ## 1. Sync diff --git a/scripts/refine_eval/RUNBOOK.md b/scripts/refine_eval/RUNBOOK.md index cf23d524..97ab1d74 100644 --- a/scripts/refine_eval/RUNBOOK.md +++ b/scripts/refine_eval/RUNBOOK.md @@ -2,8 +2,8 @@ Everything runs from the repository root. The eval checks whether harness refinement works: whether a pass refines when it should, targets the right thing and writes the -right edits. See "Refinement" and "TypeSafe review judge" in the README for the -machinery under test. +right edits. See "Refinement" and "TypeSafe review judge" in +[docs/continual-harness.md](../../docs/continual-harness.md) for the machinery under test. Each case is one live session: diff --git a/src/rlm/acp.py b/src/rlm/acp.py index 0c3d7089..e0c18348 100644 --- a/src/rlm/acp.py +++ b/src/rlm/acp.py @@ -143,6 +143,7 @@ class _LimitsSnapshot(_ContractModel): compaction_tail_tokens: int = Field(ge=0) compaction_prompt_tokens: int = Field(ge=0) allow_git: bool + network_guidance: bool harness_enabled: bool harness_global: bool auto_refine: bool diff --git a/src/rlm/config.py b/src/rlm/config.py index 975b9b28..71fbffa0 100644 --- a/src/rlm/config.py +++ b/src/rlm/config.py @@ -186,6 +186,10 @@ class ExecutionPolicy(_ConfigModel): """Tree-total cap on sub-agent spawns. None (default) = uncapped; ``max_total_tokens`` still bounds the tree.""" allow_git: bool = False + network_guidance: bool = True + """Include prompt guidance that assumes outbound network access (installing extra + kernel packages, the online-solutions clause of the Git guard). Disable it for + boxes with no network.""" def validate_prompt_overrides(overrides: dict[str, str]) -> dict[str, str]: diff --git a/src/rlm/engine.py b/src/rlm/engine.py index a534e51f..ffc85e10 100644 --- a/src/rlm/engine.py +++ b/src/rlm/engine.py @@ -307,6 +307,7 @@ def __init__( self.max_depth = config.policy.max_depth self.depth = config.invocation.depth self.allow_git = config.policy.allow_git + self.network_guidance = config.policy.network_guidance self.harness_config = config.harness self._harness: HarnessView | None = None self._skills_dir = ( @@ -1972,6 +1973,7 @@ def execution_snapshot(self) -> dict: "compaction_tail_tokens": self.compaction_tail_tokens, "compaction_prompt_tokens": self.compaction_prompt_tokens, "allow_git": self.runtime_config.policy.allow_git, + "network_guidance": self.runtime_config.policy.network_guidance, "harness_enabled": self.harness_config.enabled, "harness_global": self.harness_config.global_dir is not None, "auto_refine": self.harness_config.auto_refine, @@ -2422,6 +2424,7 @@ def _load_system_prompt( allow_recursion=self.depth < self.max_depth, delegation_prompt=self.runtime_config.policy.delegation_prompt, allow_git=self.allow_git, + network_guidance=self.network_guidance, active_tools=active_tools, shell_skills=get_installed_skills(), skill_docs=[self._kernel_skill_doc(doc) for doc in skill_docs], diff --git a/src/rlm/prompt.py b/src/rlm/prompt.py index 12a257fa..93271b8f 100644 --- a/src/rlm/prompt.py +++ b/src/rlm/prompt.py @@ -34,15 +34,21 @@ ) SHELL_TOOL_NAMES = frozenset({"ipython", "bash"}) -GIT_HISTORY_GUARD_PROMPT = ( - "Do not cheat by using online solutions or hints specific to this task, or " - "by copying or inferring solutions from other branches, tags, remotes, " +_GIT_HISTORY_GUARD_ONLINE = ( + "using online solutions or hints specific to this task, or by " +) +_GIT_HISTORY_GUARD_LOCAL = ( + "copying or inferring solutions from other branches, tags, remotes, " "reflogs, or broad git history in the project, or by cloning other " "repositories to read their history. Git commands that reach beyond the " "current branch are refused: `git log --all` and similar history options, " "history subcommands given another ref (`git show origin/main`), listing " "branches, tags, remotes or the reflog, and `clone`, `fetch` or `pull`." ) +GIT_HISTORY_GUARD_PROMPT = ( + "Do not cheat by " + _GIT_HISTORY_GUARD_ONLINE + _GIT_HISTORY_GUARD_LOCAL +) +OFFLINE_GIT_HISTORY_GUARD_PROMPT = "Do not cheat by " + _GIT_HISTORY_GUARD_LOCAL PROJECT_ENV_PROMPT = ( "The ipython kernel is an isolated venv without the project's packages — " "never import project modules there. Everything that executes project code " @@ -53,8 +59,11 @@ "`await job.result()` collects any job. Use `rlm.services.start` for a server or daemon that must keep " "running after you finish. " + PROJECT_ENV_PROMPT ) +KERNEL_PREINSTALLED_PROMPT = ( + "Pre-installed in the kernel venv: " + ", ".join(BASE_TOOLKIT) + "." +) KERNEL_PACKAGES_PROMPT = ( - "Pre-installed in the kernel venv: " + ", ".join(BASE_TOOLKIT) + ". " + KERNEL_PREINSTALLED_PROMPT + " " "Install extra packages with `!uv pip install --python {sys.executable} ` " "in a code cell: `--python` targets the kernel venv (a uv-managed venv with no " "pip module), which uv would not otherwise find." @@ -562,6 +571,7 @@ def build_system_prompt( allow_recursion: bool, delegation_prompt: bool = False, allow_git: bool, + network_guidance: bool = True, active_tools: list[BuiltinTool], shell_skills: list[str] | None = None, skill_docs: list["SkillDoc"] | None = None, @@ -634,7 +644,9 @@ def build_system_prompt( ), texts["history"], IPYTHON_CONTROL_PROMPT, - KERNEL_PACKAGES_PROMPT, + KERNEL_PACKAGES_PROMPT + if network_guidance + else KERNEL_PREINSTALLED_PROMPT, ] ) if can_delegate: @@ -682,7 +694,11 @@ def build_system_prompt( + ". Discover CLI usage with ` --help`. Other listed Python tools are IPython-only." ) if _should_include_git_history_guard(active_tools, allow_git): - parts.append(GIT_HISTORY_GUARD_PROMPT) + parts.append( + GIT_HISTORY_GUARD_PROMPT + if network_guidance + else OFFLINE_GIT_HISTORY_GUARD_PROMPT + ) return "\n\n".join(parts) diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..9f182685 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,364 @@ +"""End-to-end runs against a hosted model. + +Each test starts `rlm --acp` the way an orchestrator does, gives it a task in a +throwaway workspace, and checks what the agent left there: files, its ledger and the +session snapshot. Where the model could get an answer without the feature under test, +the answer exists only behind that feature (a stamp only a tool call returns, a +codeword only a skill holds, a token only a sub-agent's history shows), so a pass proves +the feature ran. The suite runs only with `-m e2e`, needs `PRIME_API_KEY` (plus +`PRIME_TEAM_ID` for a team's models), and skips without it. `RLM_E2E_MODEL` and +`RLM_E2E_BASE_URL` choose the model. + +Every test runs in three placements: `local` (tools in the agent's process tree), +`stdio` (a worker the agent starts, over its stdin and stdout) and `tcp` (a worker +over TCP, started on its own with no credentials in its environment). The last two +are split execution. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import re +import secrets +import subprocess +import sys +from collections.abc import AsyncIterator, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest +from acp import PROTOCOL_VERSION, spawn_agent_process, text_block + +from rlm.acp import ( + ACP_SEMANTIC_EDGES_METADATA_KEY, + RUNTIME_METADATA_KEY, + SESSION_METADATA_KEY, +) + +MODEL = os.environ.get("RLM_E2E_MODEL") or "internal/glm-5.3-fast" +BASE_URL = os.environ.get("RLM_E2E_BASE_URL") or "https://api.pinference.ai/api/v1" +MODES = ("local", "stdio", "tcp") +PROMPT_TIMEOUT = 600 + + +def pytest_collection_modifyitems(items): + for item in items: + if "tests/e2e/" in item.nodeid.replace(os.sep, "/"): + item.add_marker(pytest.mark.e2e) + + +@pytest.fixture(scope="session", autouse=True) +def install_fixture_skills(): + """The agent sees the skills a real install has, not the unit tests' fixtures.""" + yield + + +@pytest.fixture(params=MODES) +def mode(request) -> str: + return request.param + + +@dataclass +class Turn: + stop_reason: str + answer: str + meta: dict[str, Any] + + +class _Client: + def __init__(self) -> None: + self.messages: list[str] = [] + + async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: + content = getattr(update, "content", None) + if getattr(update, "session_update", None) == "agent_message_chunk" and content: + self.messages.append(getattr(content, "text", "")) + + +class AgentSession: + """One ACP session: prompt, steer or cancel it while it lives, then read what it + left behind.""" + + def __init__( + self, + connection: Any, + session_id: str, + client: _Client, + workspace: Path, + home: Path, + ) -> None: + self._connection = connection + self._client = client + self.session_id = session_id + self.workspace = workspace + self.home = home + self.turns: list[Turn] = [] + self.running: asyncio.Task[Turn] | None = None + self._calls_before_running = 0 + self.closed_meta: dict[str, Any] | None = None + + async def prompt(self, text: str) -> Turn: + """Run one prompt to its end.""" + self._client.messages.clear() + response = await asyncio.wait_for( + self._connection.prompt( + session_id=self.session_id, prompt=[text_block(text)] + ), + PROMPT_TIMEOUT, + ) + turn = Turn( + response.stop_reason, + "".join(self._client.messages), + response.field_meta or {}, + ) + self.turns.append(turn) + return turn + + def start(self, text: str) -> asyncio.Task[Turn]: + """Start a prompt and return while it runs.""" + self._calls_before_running = len(self.tool_calls()) + self.running = asyncio.create_task(self.prompt(text)) + return self.running + + async def called(self, name: str, timeout: float = 300) -> None: + """Wait until the running prompt's agent has called the tool `name`.""" + deadline = asyncio.get_running_loop().time() + timeout + while name not in self.tool_calls()[self._calls_before_running :]: + assert self.running is not None and not self.running.done(), ( + f"the prompt ended without calling {name}: {self.describe()}" + ) + assert asyncio.get_running_loop().time() < deadline, ( + f"no {name} call within {timeout}s: {self.describe()}" + ) + await asyncio.sleep(0.2) + + async def steer(self, text: str) -> dict[str, Any]: + return await self._connection.ext_method( + "session/steering", + {"sessionId": self.session_id, "prompt": [{"type": "text", "text": text}]}, + ) + + async def cancel(self) -> None: + await self._connection.cancel(session_id=self.session_id) + + @property + def session_dir(self) -> Path: + (session_dir,) = (self.home / "sessions").iterdir() + return session_dir + + @property + def records(self) -> list[dict[str, Any]]: + """The root agent's ledger as written so far.""" + return self.ledger(self.session_dir) + + def children(self) -> list[Path]: + """Session directories of the root agent's sub-agents.""" + return sorted( + path for path in self.session_dir.iterdir() if path.name.startswith("sub-") + ) + + @staticmethod + def ledger(session_dir: Path) -> list[dict[str, Any]]: + path = session_dir / "messages.jsonl" + if not path.exists(): + return [] + records = [] + for line in path.read_text().splitlines(): + with contextlib.suppress( + json.JSONDecodeError + ): # a line still being written + records.append(json.loads(line)) + return records + + @property + def snapshot(self) -> dict[str, Any]: + """The authoritative snapshot `session/close` returned.""" + assert self.closed_meta is not None, "the session is still open" + return self.closed_meta[SESSION_METADATA_KEY] + + @property + def answer(self) -> str: + return self.turns[-1].answer if self.turns else "" + + @property + def stop_reason(self) -> str: + return self.turns[-1].stop_reason if self.turns else "" + + def read(self, name: str) -> str: + """A file the agent left in the workspace, stripped; empty when it left none.""" + path = self.workspace / name + return path.read_text().strip() if path.exists() else "" + + def tool_calls(self) -> list[str]: + """Names of the tools the root agent called, in order.""" + return [call["function"]["name"] for call in self.tool_call_records()] + + def tool_call_records(self) -> list[dict[str, Any]]: + return [ + call + for record in self.records + if record.get("type") == "assistant" + for call in record["message"].get("tool_calls") or () + ] + + def tool_results(self) -> list[str]: + """What the root agent's tool calls returned, in order.""" + return [ + record["content"] + for record in self.records + if record.get("type") == "tool_result" + ] + + def edges(self) -> set[str]: + """Types of the semantic edges the session published.""" + metas = [turn.meta for turn in self.turns] + [self.closed_meta or {}] + return { + edge["type"] + for meta in metas + for edge in (meta.get(ACP_SEMANTIC_EDGES_METADATA_KEY) or {}).get( + "edges", () + ) + } + + def describe(self) -> str: + stops = [turn.stop_reason for turn in self.turns] + return ( + f"stops {stops}; tools {self.tool_calls()}; answer {self.answer[-500:]!r}" + ) + + +def _provider() -> dict[str, Any]: + headers = {} + if os.environ.get("PRIME_TEAM_ID"): + headers["X-Prime-Team-ID"] = os.environ["PRIME_TEAM_ID"] + return { + "base_url": BASE_URL, + "api_key": os.environ["PRIME_API_KEY"], + "headers": headers, + "max_retries": 5, + } + + +def _start_tcp_worker(workspace: Path, token: str) -> subprocess.Popen: + """A worker the way a remote workspace runs one: its own process, its own + environment, none of the agent's credentials.""" + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("PRIME_", "RLM_E2E_")) + } + env["RLM_EXECUTION_TOKEN"] = token + return subprocess.Popen( + [sys.executable, "-m", "rlm.execution", "--listen", "127.0.0.1", "--port", "0"], + cwd=workspace, + env=env, + stderr=subprocess.PIPE, + text=True, + ) + + +async def _listening_port(worker: subprocess.Popen) -> int: + line = await asyncio.wait_for(asyncio.to_thread(worker.stderr.readline), 60) + match = re.search(r"listening on \S+:(\d+)", line) + if not match: + raise RuntimeError(f"execution worker did not start: {line!r}") + return int(match.group(1)) + + +@pytest.fixture +def agent(tmp_path, mode): + """Open a session on a fresh workspace, in this test's placement. Closing it + records the final snapshot.""" + if not os.environ.get("PRIME_API_KEY"): + pytest.skip("needs PRIME_API_KEY") + workers: list[subprocess.Popen] = [] + + @contextlib.asynccontextmanager + async def open_session( + *, + files: dict[str, str] | None = None, + builtin_tools: list[str] | None = None, + skills: tuple[str, ...] = ("edit",), + skill_docs: dict[str, str] | None = None, + mcp_servers: Sequence[Any] = (), + policy: dict[str, Any] | None = None, + ) -> AsyncIterator[AgentSession]: + workspace = tmp_path / "workspace" + workspace.mkdir() + for name, text in (files or {}).items(): + (workspace / name).write_text(text) + home = tmp_path / "rlm" + for name, text in (skill_docs or {}).items(): + (home / "skills" / name).mkdir(parents=True) + (home / "skills" / name / "SKILL.md").write_text(text) + runtime: dict[str, Any] = { + "session_id": "e2e", + "model": MODEL, + "provider": _provider(), + "policy": {"max_depth": 1, "exec_timeout": 120, **(policy or {})}, + "system_prompt_path": None, + "append_to_system_prompt": None, + "skills": list(skills), + "builtin_tools": builtin_tools, + "kernel_env": {}, + "search_api_key": None, + } + if mode == "stdio": + runtime["execution_command"] = [sys.executable, "-m", "rlm.execution"] + runtime["execution_cwd"] = str(workspace) + elif mode == "tcp": + token = secrets.token_hex(32) + worker = _start_tcp_worker(workspace, token) + workers.append(worker) + port = await _listening_port(worker) + runtime["execution_tcp"] = { + "host": "127.0.0.1", + "port": port, + "token": token, + } + runtime["execution_cwd"] = str(workspace) + client = _Client() + executable = str(Path(sys.executable).parent / "rlm") + env = {**os.environ, "RLM_HOME": str(home)} + async with spawn_agent_process(client, executable, "--acp", env=env) as ( + connection, + _process, + ): + await connection.initialize(PROTOCOL_VERSION) + created = await connection.new_session( + cwd=str(workspace), + mcp_servers=list(mcp_servers), + **{RUNTIME_METADATA_KEY: runtime}, + ) + session = AgentSession( + connection, created.session_id, client, workspace, home + ) + try: + yield session + finally: + if session.running is not None and not session.running.done(): + session.running.cancel() + await asyncio.gather(session.running, return_exceptions=True) + closed = await connection.close_session(created.session_id) + session.closed_meta = closed.field_meta + + yield open_session + for worker in workers: + worker.kill() + worker.wait() + + +@pytest.fixture +def run_agent(agent): + """Run one prompt to its end in a fresh session.""" + + async def run(prompt: str, **options: Any) -> AgentSession: + async with agent(**options) as session: + await session.prompt(prompt) + return session + + return run diff --git a/tests/e2e/fixtures/desk_mcp.py b/tests/e2e/fixtures/desk_mcp.py new file mode 100644 index 00000000..0e15cd94 --- /dev/null +++ b/tests/e2e/fixtures/desk_mcp.py @@ -0,0 +1,25 @@ +"""A stdio MCP server whose results only a real call produces: `echo` stamps what it +returns, and `lookup` fails with a token in its error.""" + +from mcp.server.fastmcp import FastMCP + +ECHO_STAMP = "ok-7f3" +ERROR_TOKEN = "err-5c2" + +server = FastMCP("desk") + + +@server.tool() +def echo(message: str) -> str: + """Echo the message back, stamped so the caller can prove the tool ran.""" + return f"{message} [{ECHO_STAMP}]" + + +@server.tool() +def lookup(order_id: str) -> str: + """Look up an order by its id.""" + raise ValueError(f"Order {order_id} not found [{ERROR_TOKEN}]") + + +if __name__ == "__main__": + server.run() diff --git a/tests/e2e/test_agents.py b/tests/e2e/test_agents.py new file mode 100644 index 00000000..57d93a74 --- /dev/null +++ b/tests/e2e/test_agents.py @@ -0,0 +1,67 @@ +"""The supervisor's API from a cell: sub-agents, their history, persistent agents, and +background jobs the agent waits on.""" + +from __future__ import annotations + +import json +import re + + +async def test_subagent_does_part_of_the_work(run_agent): + outcome = await run_agent( + "Spawn a sub-agent with rlm.agent.spawn and have it write the sum of the integers " + "from 1 to 100 to child.txt. Wait for it to finish, then write the word done to " + "answer.txt." + ) + assert outcome.read("child.txt") == "5050", outcome.describe() + assert outcome.read("answer.txt") == "done", outcome.describe() + assert outcome.children(), outcome.describe() + assert "subagent_call" in outcome.edges(), outcome.edges() + + +async def test_parent_reads_a_childs_history(run_agent): + outcome = await run_agent( + "Spawn a sub-agent whose task is: 'Run `import secrets; print(secrets.token_hex(4))` " + "in ipython, then answer with only the word done.' Its answer will not contain the " + "token, so once it finishes, read its conversation with `await child.history()` and " + "write the token it printed to answer.txt." + ) + (child,) = outcome.children() + printed = { + token + for record in outcome.ledger(child) + if record.get("type") == "tool_result" + for token in re.findall(r"\b[0-9a-f]{8}\b", record["content"]) + } + assert outcome.read("answer.txt") in printed, (printed, outcome.describe()) + assert any( + "history(" in json.loads(call["function"]["arguments"]).get("code", "") + for call in outcome.tool_call_records() + if call["function"]["name"] == "ipython" + ), outcome.describe() + + +async def test_persistent_agent_takes_a_second_instruction(run_agent): + outcome = await run_agent( + "Spawn a persistent sub-agent with `rlm.agent.spawn(task=..., name='scribe', " + "persistent=True)` whose task is to write the word alpha to a.txt, and wait for its " + "result. Then give the same agent a second instruction with `await scribe.send(...)`: " + "write the word beta to b.txt. Wait for that result too, then write the word done " + "to answer.txt." + ) + assert outcome.read("a.txt") == "alpha", outcome.describe() + assert outcome.read("b.txt") == "beta", outcome.describe() + assert outcome.read("answer.txt") == "done", outcome.describe() + assert len(outcome.children()) == 1, outcome.children() + + +async def test_wait_returns_when_a_background_job_ends(run_agent): + outcome = await run_agent( + "Start the command `sleep 10; echo finished` as a background job with " + "`await rlm.shell.run(command, yield_after=0)`. Do not poll it: call the wait tool, " + "which returns when the job completes. Then write the job's output to answer.txt." + ) + assert outcome.read("answer.txt") == "finished", outcome.describe() + assert ( + "New supervisor events or instructions are available." in outcome.tool_results() + ), outcome.tool_results() diff --git a/tests/e2e/test_session.py b/tests/e2e/test_session.py new file mode 100644 index 00000000..38e0e58e --- /dev/null +++ b/tests/e2e/test_session.py @@ -0,0 +1,94 @@ +"""The session as a client drives it: several prompts on one kernel, steering into a +waiting agent, cancelling a turn, tool timeouts, and the budgets that end a run.""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + + +async def test_second_prompt_continues_the_session(agent): + async with agent(files={"secret.txt": "marmalade\n"}) as session: + await session.prompt( + "In ipython, store the contents of secret.txt, stripped, in a variable named " + "word, then delete secret.txt. Do not print word." + ) + assert not (session.workspace / "secret.txt").exists(), session.describe() + await session.prompt("Write the value of your variable word to answer.txt.") + assert session.read("answer.txt") == "marmalade", session.describe() + assert [turn.stop_reason for turn in session.turns] == ["end_turn", "end_turn"] + assert "continuation" in session.edges(), session.edges() + + +async def test_steering_wakes_a_waiting_agent(agent): + async with agent() as session: + running = session.start( + "Before doing anything else, call the wait tool and keep waiting until the user " + "sends you a message. It contains a code word: write the code word to answer.txt." + ) + await session.called("wait") + assert await session.steer("The code word is heron-913.") == { + "outcome": "injected" + } + await running + assert session.read("answer.txt") == "heron-913", session.describe() + assert "A new user message is available." in session.tool_results() + + +async def test_cancelled_turn_leaves_the_session_usable(agent): + async with agent(policy={"exec_timeout": 300}) as session: + running = session.start( + "Run `import time; time.sleep(300)` in one ipython call." + ) + await session.called("ipython") + await asyncio.sleep(3) # let the cell start sleeping + started = time.monotonic() + await session.cancel() + assert (await running).stop_reason == "cancelled" + await session.prompt("Write the word ok to answer.txt.") + elapsed = time.monotonic() - started + assert session.read("answer.txt") == "ok", session.describe() + # Left to run, the cell would hold the kernel for the rest of its 300 s. + assert elapsed < 120, f"cancel and second prompt took {elapsed:.0f}s" + + +async def test_tool_timeout_is_reported_and_work_goes_on(run_agent): + outcome = await run_agent( + "Run `import time; time.sleep(60)` in one ipython call, without a timeout " + "argument. When the call comes back, write the word continued to answer.txt.", + policy={"exec_timeout": 5}, + ) + assert outcome.read("answer.txt") == "continued", outcome.describe() + assert any("timed out after 5s" in result for result in outcome.tool_results()), ( + outcome.tool_results() + ) + + +CAPS = [ + pytest.param( + {"max_total_turns": 1}, + "max_total_turns", + "max_turn_requests", + id="max-total-turns", + ), + pytest.param( + {"max_total_tokens": 1}, "max_total_tokens", "max_tokens", id="max-total-tokens" + ), + pytest.param({"max_tokens": 1}, "token_budget", "max_tokens", id="max-tokens"), +] + + +@pytest.mark.parametrize("cap,last_stop_reason,acp_stop_reason", CAPS) +async def test_budget_stops_the_run(run_agent, cap, last_stop_reason, acp_stop_reason): + """Each budget ends a task that wants three model calls after the first.""" + outcome = await run_agent( + "Use three separate ipython calls: write 1 to a.txt, then 2 to b.txt, then 3 " + "to c.txt.", + policy=cap, + ) + assert outcome.snapshot["last_stop_reason"] == last_stop_reason, outcome.describe() + assert outcome.stop_reason == acp_stop_reason, outcome.describe() + assert outcome.snapshot["turns"] == 1, outcome.snapshot + assert not outcome.read("b.txt"), outcome.describe() diff --git a/tests/e2e/test_tasks.py b/tests/e2e/test_tasks.py new file mode 100644 index 00000000..84ddfc5b --- /dev/null +++ b/tests/e2e/test_tasks.py @@ -0,0 +1,108 @@ +"""Tasks a hosted model completes with nano-rlm, each checked by what it leaves behind.""" + +from __future__ import annotations + + +async def test_writes_files(run_agent): + outcome = await run_agent( + "Write the prime numbers below 50 to primes.txt, one per line, in ascending order." + ) + assert outcome.read("primes.txt").split() == [ + "2", + "3", + "5", + "7", + "11", + "13", + "17", + "19", + "23", + "29", + "31", + "37", + "41", + "43", + "47", + ], outcome.describe() + + +async def test_kernel_keeps_state_between_calls(run_agent): + outcome = await run_agent( + "Use two separate ipython tool calls. In the first, read the integer in seed.txt " + "into a variable named seed and print nothing else. In the second, without " + "reading any file, write seed * 3 to answer.txt.", + files={"seed.txt": "1234\n"}, + ) + assert outcome.read("answer.txt") == "3702", outcome.describe() + assert outcome.tool_calls().count("ipython") >= 2, outcome.describe() + + +async def test_edit_skill_fixes_a_bug(run_agent): + outcome = await run_agent( + "calc.py has a bug: add() should return the sum of its arguments. Fix it with " + "the edit skill, changing only the broken line.", + files={ + "calc.py": "def add(a, b):\n return a - b\n\n\ndef double(x):\n return 2 * x\n" + }, + ) + source = outcome.read("calc.py") + namespace: dict = {} + exec(source, namespace) + assert namespace["add"](2, 3) == 5, outcome.describe() + assert "def double(x):\n return 2 * x" in source + + +async def test_native_bash_tool(run_agent): + outcome = await run_agent( + "With the bash tool, count the lines of data.csv that contain 'error' and write " + "the count to answer.txt.", + files={ + "data.csv": "".join( + f"{i},{'error' if i % 7 == 0 else 'ok'}\n" for i in range(1, 101) + ) + }, + builtin_tools=["ipython", "bash", "edit"], + ) + assert outcome.read("answer.txt") == "14", outcome.describe() + assert "bash" in outcome.tool_calls(), outcome.describe() + + +async def test_long_output_shows_its_end(run_agent): + outcome = await run_agent( + "Run `seq 1 300000` in a shell and look at its output. Write the last number in " + "that output to answer.txt." + ) + assert outcome.read("answer.txt") == "300000", outcome.describe() + + +async def test_service_answers_requests(run_agent): + outcome = await run_agent( + "Serve the working directory over HTTP with `python -m http.server` on a free " + "port, as a service that keeps running, then fetch /hello.txt from it and write " + "the response body to answer.txt.", + files={"hello.txt": "hello from the workspace\n"}, + ) + assert outcome.read("answer.txt") == "hello from the workspace", outcome.describe() + + +async def test_kernel_recovers_after_it_dies(run_agent): + outcome = await run_agent( + "Run `import os; os._exit(1)` in the ipython tool; it kills the kernel. After " + "the kernel restarts, write the word recovered to answer.txt." + ) + assert outcome.read("answer.txt") == "recovered", outcome.describe() + assert any(record.get("type") == "kernel_recovery" for record in outcome.records), ( + outcome.describe() + ) + + +async def test_work_continues_past_a_compaction(run_agent): + outcome = await run_agent( + "Use three separate tool calls. First read the code word in secret.txt. Then run " + "`seq 1 3000` in a shell and print all of its output. Then write the code word " + "to answer.txt.", + files={"secret.txt": "marmalade\n"}, + policy={"summarize_at_tokens": 8000}, + ) + assert outcome.read("answer.txt") == "marmalade", outcome.describe() + assert outcome.snapshot["metrics"]["num_compactions"] >= 1, outcome.describe() diff --git a/tests/e2e/test_tools.py b/tests/e2e/test_tools.py new file mode 100644 index 00000000..e5edb345 --- /dev/null +++ b/tests/e2e/test_tools.py @@ -0,0 +1,95 @@ +"""What a client equips the agent with: MCP servers, `SKILL.md` skills and the native +fetch tool. Each answer exists only behind the feature.""" + +from __future__ import annotations + +import http.server +import sys +import threading +from pathlib import Path + +import pytest +from acp.schema import McpServerStdio + +from .fixtures.desk_mcp import ECHO_STAMP, ERROR_TOKEN + +DESK = McpServerStdio( + name="desk", + command=sys.executable, + args=[str(Path(__file__).parent / "fixtures" / "desk_mcp.py")], + env=[], +) + +CODEWORD = "amber-falcon-482" +CODEWORD_SKILL = f"""--- +name: project-codeword +description: Look up the project codeword. Use this whenever you are asked for the project codeword. +--- + +# Project codeword + +The project codeword is `{CODEWORD}`. Reply with it verbatim when asked. +""" + +PAGE_STAMP = "pg-41c" + + +async def test_mcp_tool(run_agent): + outcome = await run_agent( + "Find the echo tool of the desk MCP server with rlm.tools, call it with the " + "message 'hello world', and write exactly what it returns to answer.txt.", + mcp_servers=[DESK], + ) + answer = outcome.read("answer.txt") + assert "hello world" in answer and ECHO_STAMP in answer, outcome.describe() + + +async def test_mcp_tool_error_reaches_the_model(run_agent): + outcome = await run_agent( + "Call the lookup tool of the desk MCP server with the order id A-17, and write " + "the error message it gives to answer.txt.", + mcp_servers=[DESK], + ) + assert ERROR_TOKEN in outcome.read("answer.txt"), outcome.describe() + + +async def test_skill_instructions(run_agent): + outcome = await run_agent( + "What is the project codeword? Look it up with your project-codeword skill and " + "write it to answer.txt.", + skill_docs={"project-codeword": CODEWORD_SKILL}, + ) + assert outcome.read("answer.txt") == CODEWORD, outcome.describe() + + +@pytest.fixture +def notice_url(): + """A page on this machine, so the fetch reaches it from any placement.""" + + class Notice(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = f"

The notice stamp is {PAGE_STAMP}.

" + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(body.encode()) + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Notice) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_address[1]}/notice.html" + server.shutdown() + thread.join() + + +async def test_fetch_tool(run_agent, notice_url): + outcome = await run_agent( + f"Read {notice_url} with the fetch tool and write the notice stamp it shows to " + "answer.txt.", + builtin_tools=["ipython", "fetch"], + ) + assert outcome.read("answer.txt") == PAGE_STAMP, outcome.describe() + assert "fetch" in outcome.tool_calls(), outcome.describe() diff --git a/tests/test_acp.py b/tests/test_acp.py index 8c5b34eb..0336457c 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -60,6 +60,7 @@ def _runtime_metadata(**overrides: Any) -> dict[str, Any]: "max_concurrent_subagents": 4, "max_subagent_calls": None, "allow_git": False, + "network_guidance": False, }, "system_prompt_path": None, "append_to_system_prompt": None, @@ -163,6 +164,7 @@ def execution_snapshot(self) -> dict[str, Any]: "compaction_tail_tokens": 12000, "compaction_prompt_tokens": 4000, "allow_git": False, + "network_guidance": False, "harness_enabled": True, "harness_global": False, "auto_refine": False, diff --git a/tests/test_compaction.py b/tests/test_compaction.py index 3f09e32c..8d046493 100644 --- a/tests/test_compaction.py +++ b/tests/test_compaction.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import json import re import sys @@ -87,6 +88,11 @@ def test_overflow_detection_is_status_gated(): assert is_context_overflow(error) +# Discovered context windows are cached per base URL for the whole process, and an +# id() can be reused once its object is freed, so each client gets a fresh number. +_client_numbers = itertools.count() + + class _ScriptedClient(DummyClient): def __init__( self, @@ -97,7 +103,7 @@ def __init__( super().__init__([]) self.actions = list(actions) self.max_model_len = max_model_len - self.base_url = f"http://scripted-{id(self)}" + self.base_url = f"http://scripted-{next(_client_numbers)}" @property def models(self): diff --git a/tests/test_config.py b/tests/test_config.py index 1980a890..e263d87c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,6 +23,7 @@ def test_runtime_config_redacts_secrets(): max_compactions=3, max_compaction_attempts=7, allow_git=True, + network_guidance=False, ), skills=("search", "edit"), kernel_env=(("TASK_TOKEN", "task-secret"),), @@ -44,6 +45,7 @@ def test_runtime_config_redacts_secrets(): max_concurrent_subagents=4, max_subagent_calls=None, allow_git=True, + network_guidance=False, ) assert config.skills == ("search", "edit") assert config.kernel_env == (("TASK_TOKEN", "task-secret"),) diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 6f88ae1e..b0ec3bb2 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -13,7 +13,10 @@ EDIT_SKILL_PROMPT, GIT_HISTORY_GUARD_PROMPT, IPYTHON_CONTROL_PROMPT, + KERNEL_PACKAGES_PROMPT, + KERNEL_PREINSTALLED_PROMPT, MCP_DISCOVERY_PROMPT, + OFFLINE_GIT_HISTORY_GUARD_PROMPT, SEARCH_SKILL_PROMPT, build_system_prompt, resolve_prompts, @@ -30,6 +33,7 @@ def _prompt( *, installed_skills: list[str] | None = None, allow_git: bool = False, + network_guidance: bool = True, mcp_servers: list[dict] | None = None, ) -> str: return build_system_prompt( @@ -37,6 +41,7 @@ def _prompt( installed_skills or [], allow_recursion=False, allow_git=allow_git, + network_guidance=network_guidance, active_tools=active_tools, mcp_servers=mcp_servers, ) @@ -79,6 +84,19 @@ def test_git_history_guard_prompt_included_for_shell_tools(): assert "`git log --all`" in prompt and "`clone`" in prompt +def test_network_guidance_off_drops_only_network_dependent_sentences(): + online = _prompt([_Tool("ipython")]) + offline = _prompt([_Tool("ipython")], network_guidance=False) + + assert KERNEL_PACKAGES_PROMPT in online and GIT_HISTORY_GUARD_PROMPT in online + assert KERNEL_PREINSTALLED_PROMPT in offline + assert OFFLINE_GIT_HISTORY_GUARD_PROMPT in offline + assert "uv pip install" not in offline + assert "online solutions" not in offline + assert "Do not cheat by copying or inferring solutions" in offline + assert "`git log --all`" in offline and "`clone`" in offline + + def test_git_history_guard_prompt_omitted_when_unrestricted(): assert GIT_HISTORY_GUARD_PROMPT not in _prompt([_Tool("ipython")], allow_git=True)