17 kB 285 lines
7e70aea0
⋯
packages/coding-agent/docs/rlm-runtime.md
download raw history
at main Markdown

RLM Runtime Architecture #

Prime Agent gives each agent session a persistent Python REPL kernel and a native recursive sub-agent interface. The Python rlm package is a model-facing shim; the TypeScript host owns child execution, persistence, usage accounting, and lifecycle.

Architecture #

ownsJSON lines over stdiorlm.run · rlm.* · goal.*host_request eventstyped dispatchAgentSession · TypeScriptPython REPL tool + hostrequest handlersReplKernelManager ·TypeScriptexecution + host-requestdispatchREPL runtime process ·Pythonprime-agent-runtimerlm module + Python skillsModel-executed Pythoncode

When the model delegates work:

handle = await rlm(
    "inspect the API",
    name="api-reviewer",
    model="provider/model",
    result={"schema": {"type": "object", "properties": {"status": {"type": "string"}}}, "required": False},
)
print(handle.rlm_child_id, handle.name, handle.session_dir, handle.model)

the call travels as a host_request event over the runtime's stdio protocol. ReplKernelManager dispatches request type rlm.run to the parent AgentSession, which starts a child through the same TypeScript agent machinery as the parent. The rlm.run handler is the fail-closed spawn boundary: it rejects a request that declares no result contract unless unguarded=True is passed explicitly. The call returns over the same bridge immediately after task admission with a child handle; it never waits for or returns the child's answer. Without unguarded=True, results arrive through explicit rlm.send_parent(...) replies or files. Contracted children submit through the host bridge and parents read an admitted projection.

The same bridge supports other typed host requests. Bundled Python skills such as goal call rlm.host_request("goal.get", ...); state and policy remain in the TypeScript host.

Delegation Flow #

loop[Child agent loop]Python tool callexecute await rlm("inspect the API", model="provider/model")host_request · rlm.runcheck depth and resolve modeladmit child task and update registryRLMSubagenttool outputPython resultcreate child runtime and promptstream model requestresponse or tool callexplicit rlm.send_parent replyordinary agent messageupdate registry and attribute usageParent modelParent AgentSessionPython kernelChild AgentSessionModel providerParent modelParent AgentSessionPython kernelChild AgentSessionModel provider

Component Ownership #

Component Responsibility
src/core/kernel/repl-manager.ts Runtime process, stdio protocol, execution, host-request dispatch, interrupt, and shutdown.
src/core/tools/ipython.ts Agent tool wrapper, lazy kernel provisioning, namespace bootstrap, and output shaping.
src/core/agent-session.ts RLM policy, child creation, registry, usage attribution, cancellation, and goal handlers.
src/core/rlm-runtime.ts Typed request/spawn-handle validation for rlm.run, model discovery, list, and delete.
prime-agent-runtime/src/rlm/ Python shim, handle types, callable rlm, and session-backed harness state.

The Python side does not call providers or implement an agent loop.

Kernel Lifecycle #

The kernel is created lazily on first Python REPL use. Python resolution is:

  1. PRIME_AGENT_KERNEL_PYTHON, when it has a current prime-agent-runtime;
  2. ~/.prime/agent/kernel-venv/bin/python, bootstrapped with uv; or
  3. the XDG data location when ~/.prime is not writable.

The managed environment includes Python 3.11, prime-agent-runtime, dill, and the default Python packages. A bootstrap marker detects stale environments.

Startup spawns python -m rlm.repl and exchanges newline-delimited JSON over stdio: the runtime announces itself with a single ready event, then requests and events flow one JSON object per line (see prime-agent-runtime/src/rlm/repl.md).

The manager owns the child process and a bounded stderr tail. Shutdown sends a shutdown request, waits for the process to exit, and terminates it as a fallback. Persistent sessions may snapshot the kernel namespace into their session artifact directory for revival.

Stdio Transport #

Requests flow to the runtime on stdin and events return on stdout, one JSON object per line:

requests  execute, interrupt, host_reply, snapshot, restore, list_names, shutdown
events    ready, stdout, stderr, result, display, host_request, error, done

Output events carry the id of the cell that was running when the bytes were produced; asyncio tasks keep their spawning cell's id even after that cell finishes, so detached work is attributed correctly.

Calls to ReplKernelManager.execute() are serialized. One kernel has one shared namespace and does not run two ordinary Python cells concurrently. RLM child agents can still run concurrently because each delegation uses a distinct host request and child runtime.

Host-Request Event Flow #

A running cell can await task admission:

handle = await rlm("subtask", model="provider/model")

The runtime ships the call to the host as a host_request event and keeps its event loop free while awaiting the reply. The host dispatches the typed request and answers with a host_reply request carrying the same id, so a cell can block on admission without stalling other runtime work. Child answers do not use this response path; they arrive later through explicit rlm.send_parent(...) replies or files.

Python API #

prime-agent-runtime exports:

rlm
run(prompt: str, **kwargs)
find_models(query: str = "", limit: int = 8)
list_subagents()
family()
send_parent(message)
send_sibling(name, message)
send_child(name, message)
observe(target, limit=8, max_chars=800)
info(target)
delete_subagent(target)
submit_result(value)
get_result(target, fields=None, generation=None)
host_request(request_type: str, payload: dict | None = None)
RLMModel
RLMSubagent

The kernel bootstrap places the callable rlm object in the user namespace, so these are equivalent:

await rlm("subtask", model="provider/model")
await rlm.run("subtask", model="provider/model")

rlm(...), rlm.list_subagents(), and rlm.delete_subagent() all return the same mutable RLMSubagent handle. It contains rlm_child_id, name, session_dir, model, and status, plus result_path when a result contract is present. It confirms admission only and never contains the child's answer; lifecycle methods (refresh, send, observe, info, result, delete) issue their own host requests and merge state in place.

Supported rlm.run options are:

  • name: a unique readable child session name;
  • model: an exact provider/model selector from rlm.find_models(); and
  • thinking: an explicit child reasoning level; must be valid for the resolved child model, defaults to the parent level (clamped to the child model).
  • result: an optional host-bound JSON Schema, file sink, and required-result flag.
  • context: independent terminal, direct-message, observation, and admitted-result-field policies.

Contracted children call await rlm.submit_result(value). Parents call await rlm.get_result(child, fields=[...]); the host validates the stored result and returns only fields admitted by the spawn contract. These policies are composable fields, not named modes or presets. Omitted contracts preserve the native lifecycle.

Every new child also receives an internal .rlm-contract.json lifecycle journal. It records parent follow-up completion, reply attribution, and durable notice admission even when no public result or context contract was requested. This internal state is not added to model context and does not change the spawn handle or messaging API.

Unknown options fail instead of being ignored. Every spawn must select an exact provider/model; the parent model is never inherited implicitly. Model search is bounded to active, non-expired credentials. If the selection is unavailable or fails auth preflight, spawn fails instead of silently falling back to another model.

Child Execution #

AgentSession.runRlmChild() performs the following sequence:

  1. Check RLM_DEPTH < RLM_MAX_DEPTH.
  2. Require and validate the requested exact provider/model selector.
  3. Create a sub-xxxxxxxx child directory under the parent artifact directory.
  4. Admit the task into the parent registry and return its RLMSubagent handle.
  5. In detached work, create a child SessionManager, Agent, and AgentSession.
  6. Reuse provider hooks, resource loader, model registry, tools, transport, retry settings, and thinking configuration.
  7. Run the child prompt, retain its session, and update lifecycle state independently of the admission call.
  8. Attribute child usage to the parent assistant turn and persist the attribution.

Children receive incremented RLM_DEPTH, the inherited maximum depth, and their own RLM_SESSION_DIR. The default maximum depth is 2, so root sessions may create children and grandchildren; grandchildren may not create another generation unless the limit is configured higher.

Independent Delegation #

Each direct call admits an independent child and returns its handle immediately:

api_review = await rlm("review the API", name="api-reviewer", model="provider/model")
test_review = await rlm("review the tests", name="test-reviewer", model="provider/model")
audit = await rlm("slow independent audit", name="audit-reviewer", model="provider/model")

End the turn instead of waiting for completion. Children send requested answers with await rlm.send_parent(message), and replies arrive as ordinary agent messages over later turns. A child may instead write results to files for the parent to read. The host runs each admitted child as an independent AgentSession; daemon-backed children can be retained as independently addressable session workers.

Parent-Scoped Sub-Agent Registry #

The TypeScript parent maintains the authoritative direct-child registry. await rlm.list_subagents() returns stable child IDs, active-session IDs when daemon-backed, session IDs, names, directories, and running/completed status.

This registry survives kernel restart, compaction, and parent restore. Successfully completed daemon-backed children are rehydrated from the parent artifact registry. Inline children remain inspectable in the current process but have no active-session ID.

The parent can continue a retained daemon child with await child.send(...) or await rlm.send_child(name, message). rlm.delete_subagent() accepts a handle, an exact child ID, active-session ID, session ID, or unique name. Deletion cancels or closes the runtime, writes a durable tombstone, and removes the child from messaging and observation. It does not erase the transcript or artifacts on disk.

Registry scope follows the parent transcript. An unrelated new parent session does not inherit children.

Usage and Cost Attribution #

The admission handle does not contain usage or completion data. Prime Agent asynchronously folds the child's assistant usage and cost into the parent assistant turn that launched it.

The parent transcript persists a child_usage_attributed entry containing:

  • the target parent assistant message ID;
  • the child usage being attributed; and
  • the resulting aggregate usage.

On reload, the aggregate is reapplied to the parent message. Context-tree reporting subtracts attributed child usage when showing each node's own usage, so tree-wide own usage and root aggregate totals remain reconcilable. Child work increases billable session totals but does not inflate the parent model's context-window measurement.

Continual Harness State #

rlm.harness is a persisted state ledger for prompt notes, memories, reusable skill descriptions, sub-agent specifications, and refinement events. It is not a second execution engine.

Session-local state lives in the session artifact directory under harness/harness_state.json. Explicitly global entries live under ~/.prime/agent/harness/. The Python store reloads after external modification so host-side /refine writes and kernel writes do not overwrite each other.

/refine runs a dedicated review over the current trajectory and applies small create/update/delete edits. Rollback uses recorded before/after snapshots. The base system prompt remains immutable; refinements are supplemental state.

Goal Requests #

The bundled goal Python skill is a thin host-bridge client:

await goal.get()
await goal.create("ship the release", token_budget=200000)
await goal.complete()

Goal state, persistence, token and wall-clock accounting, and continuation prompting live in AgentSession. When goals are disabled, the skill and goal.* host handlers are not registered.

Session Artifacts #

For a persisted root session, the relevant layout is:

~/.prime/agent/
  sessions/
    <root-session-id>.jsonl
  session-artifacts/
    <root-session-id>/
      kernel-state.dill
      kernel-state.json
      scheduled-jobs.json
      harness/
        harness_state.json
      sub-xxxxxxxx/
        <child-session-id>.jsonl
        sub-yyyyyyyy/

Exact artifact files are created only when their features are used. Non-persistent sessions place RLM directories under the OS temporary directory and do not gain revivable session artifacts.

Background bash() completion #

bash(command) returns immediately with a live BashHandle. Save the handle and end the turn for slow commands. When an unobserved background handle finishes, the kernel emits one host event and the owning session admits one durable internal turn:

checks = bash("npm test")
checks.pid

The completion message contains only the handle PID, exit code, and duration. Output stays behind checks.tail(), checks.output(), and checks.poll(). Awaiting the handle, polling a completed result, inspecting completed output, or killing the handle marks it observed. An observation event removes a queued completion turn before it can invoke the provider. Duplicate completion events use the runtime-minted handle id and do not create another turn.

Trust Boundary #

The REPL runtime process executes model-generated Python and bash() commands with the worker's OS permissions. The process boundary isolates protocol and lifecycle concerns; it is not a security sandbox. Installed Python packages, skills, and extensions are trusted code. Use an external sandbox or restricted execution environment when the workspace or generated code is untrusted.

Provider credentials are resolved by the TypeScript host. The bounded model catalog crosses into Python as metadata; the full auth store does not.

Failure Modes #

Failure Behavior
Managed runtime is missing Kernel bootstrap rebuilds it; a custom PRIME_AGENT_KERNEL_PYTHON without a current prime-agent-runtime is rejected at kernel startup.
Depth limit reached The host rejects the rlm.run request; the error reply raises in Python.
Unsupported options Host rejects the request.
Requested model unavailable Spawn fails instead of substituting another model.
Host connection closed Pending host_request calls fail with RuntimeError so awaiting cells unblock.
Child cancellation Host aborts the child and removes failed/cancelled registry entries.
Parent teardown Active descendants are cancelled and their runtimes are closed.
Daemon restart during an incomplete parent follow-up The persisted turn is closed as failed with a before/after durable-prompt phase, its parent notice is replayed, and the interrupted provider turn is not executed twice.

Focused Validation #

From the repository root, the implementation is covered by focused kernel, recursion, context-tree, daemon RLM, and runtime tests. When changing child creation or accounting, include agent-session-recursion.test.ts; when changing the stdio runtime protocol, include the repl-kernel-*.test.ts suites; when changing daemon retention, include the daemon RLM lifecycle tests.