13 kB 233 lines
94f25d4f
⋯
packages/coding-agent/docs/rlm.md
download raw history
at main Markdown

RLM Programming Model #

Prime Agent is built around a recursive language model (RLM) runtime: the model works inside a persistent Python control environment and composes capabilities as code. Provider calls, session persistence, child lifecycles, scheduling, and safety policy remain in the TypeScript host; the Python REPL is the model-facing programming surface.

RLM Loop #

Python callinspect · search · transformcall functionsspawn focused workagent messages · structuredresults · filesadmission handleTask + working contextParent modelPersistent Python kernelFiles · data · shellcommandsPython-backed skillsrlm(...) child agentsAnswer or next turn

The parent keeps its own context focused while Python holds working state and child agents receive only the context needed for their subtasks.

Core Invariants #

1. Execution is programmatic #

The default RLM runtime exposes one built-in model tool: ipython. Reading and editing files, running project commands, transforming results, invoking skills, and delegating work all begin from that persistent kernel instead of separate built-in tool calls.

Python state survives across tool calls and compaction. Variables, imports, functions, parsed results, and task handles remain available on later turns:

from pathlib import Path

config_files = list(Path(".").rglob("*.toml"))
large_files = [path for path in config_files if path.stat().st_size > 10_000]

Run a project's normal commands through its own environment with bash():

result = await bash("npm run check")
print(result.output)

Each bash() call is its own process, while Python state, os.chdir(...), and os.environ[...] changes persist in the kernel and apply to later bash() calls. Prime Agent extensions may intentionally add custom tools, but the built-in RLM design does not require a separate model tool for every capability.

2. Subagents are native RLM calls #

The callable rlm object is preloaded in the kernel. Every spawn declares how the child reports back with a result contract; a schema-less spawn is an error unless the caller passes the explicit opt-out unguarded=True. Spawn a child with a direct call:

handle = await rlm(
    "Review the authentication flow for security issues",
    name="auth-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 returns immediately after task admission with a child handle; it never waits for or returns the child's answer. The TypeScript host creates a normal child AgentSession with an independent context and session directory. Every spawn must select an exact provider/model; the parent model is never inherited implicitly. Provider configuration, skills, tools, retry policy, and the resource loader still follow the parent session.

Spawn independent children in separate calls and end the turn instead of awaiting completion:

api_review = await rlm("Review the public API", name="api-reviewer", model="provider/model")
test_review = await rlm("Review the test coverage", name="test-reviewer", model="provider/model")
integration_audit = await rlm(
    "Run the slow integration audit",
    name="integration-audit",
    model="provider/model",
)

With unguarded=True, the legacy freeform path applies: results arrive through explicit replies or files, never as an rlm() return value, and children can reply when an answer is needed. Guarded children instead submit their only voice through rlm.submit_result:

await rlm.submit_result({"status": "approved"})   # from inside a guarded child
await api_review.send("Check the newly added regression test.")

Composable result and context contracts #

A child can receive independent result and context policies without selecting a named mode or preset:

schema = {
    "type": "object",
    "properties": {
        "status": {"type": "string", "maxLength": 32},
        "finding_ids": {"type": "array", "items": {"type": "string"}},
        "private_notes": {"type": "string", "maxLength": 4000},
    },
    "required": ["status", "finding_ids", "private_notes"],
    "additionalProperties": False,
}
result_fields = ["status", "finding_ids"]

review = await rlm(
    "Review the change and submit the contracted result.",
    model="provider/model",
    result={"schema": schema, "sink": {"kind": "file"}, "required": True},
    context={
        "terminal": {"payload": False, "notify": True},
        "direct_messages": {"payload": False, "notify": True},
        "observe": False,
        "result": {"fields": result_fields},
    },
)

The host binds the contract to the admitted child identity. With required=True, the child submits exactly one value per task turn:

await rlm.submit_result({
    "status": "approved",
    "finding_ids": [],
    "private_notes": "not admitted to parent context",
})

The parent reads the schema-validated projection admitted at spawn. Omitting fields returns the admitted set; pass fields only to request a narrower projection:

result = await rlm.get_result(review)
print(result.value)

A projected read must stay under 32 KiB. For larger evidence, make the child return a compact summary or have a bounded fan-in child read the artifact and submit a smaller schema.

Set required=False when a task turn may finish without a structured result. The child prompt marks submission as optional. An omitted result creates no ready generation and leaves the latest settled result unchanged. Any submitted value must still match the schema.

Each context field acts independently. payload=False prevents child-authored terminal or direct-message text from reaching the recipient; notify=True retains a fixed host-authored notice. observe=False removes transcript previews and recent messages when a parent or sibling observes the contracted child. context.result.fields is the maximum result projection the parent can request.

Guarded is the default. When result is present and context is omitted, the host applies a guarded context: terminal and direct-message payloads are withheld behind fixed notices and observation previews are stripped. The result file is still validated, but no result fields enter parent model context unless context.result.fields admits them. Open a channel explicitly with context={"terminal": {"payload": True}} or context={"direct_messages": {"payload": True}}. The only schema-less escape is unguarded=True, which restores legacy freeform children. A result contract is required for every spawn; omitting it without unguarded=True fails closed at the spawn boundary.

The host still keeps an internal per-child follow-up journal so daemon restart cannot lose an unadmitted completion notice; this journal is not model-visible and does not add fields to the public handle.

Schemas use a bounded JSON Schema subset. External references, unsupported keywords, oversized schemas and results, invalid values, integers outside JavaScript's safe range, duplicate submissions, and field requests outside the admission list fail closed. Contracts contain model context; they do not restrict same-UID filesystem access or make admitted string fields semantically trustworthy.

Child handles and lifecycle #

rlm(...) and rlm.list_subagents() return the same mutable RLMSubagent handle. It contains rlm_child_id, name, session_dir, model, and status; contracted results also add result_path. Child usage is attributed to the parent session while remaining distinguishable in context-tree reporting.

The handle owns the whole lifecycle:

await child.refresh()                # merge the latest registry state in place
await child.send("Follow-up note.")  # direct message to the child
await child.observe()                # bounded recent-message previews
await child.result(fields=["status"])  # admitted structured result, when contracted
await child.delete()                 # remove the child when no longer needed

Module-level equivalents exist on rlm itself: rlm.send_parent(message), rlm.send_sibling(name, message), rlm.send_child(name, message), rlm.family(), rlm.observe(name), rlm.info(name), and rlm.delete_subagent(child_or_id).

The parent-scoped child registry survives compaction, kernel restart, and parent restoration:

children = await rlm.list_subagents()
for child in children:
    print(child.name, child.status, child.active_session_id)

Successfully completed daemon-backed children remain addressable while their parent session is open. Delete a child only when its context is no longer needed:

await children[0].delete()

The default recursion depth allows a root agent to create children. Raising the configured depth allows descendants to recurse further.

Root session controls #

Top-level sessions can inspect and control other top-level daemon sessions through a separate namespace. This is intentionally not part of rlm: RLM child handles carry result-contract state and are not root-session handles.

from agent import sessions

roots = await sessions.list()
target = await sessions.get(active_session_id="active-session-id")
recent = await target.recent_turns(count=3, max_chars=6000)
receipt = await target.send("continue the task", delivery="follow_up")

agent.sessions resolves a target by active session ID, durable session ID, display name, or an unambiguous ID suffix. recent_turns() returns bounded user/assistant/tool-call summaries and never exposes an unbounded transcript. send() uses the native daemon steer or follow_up queue. stop() terminates and archives a target; restart() asks the daemon supervisor to rehydrate its worker. A root cannot stop or restart itself, and RLM child kernels cannot access this namespace. Use await target.refresh() after a worker restart to update a retained handle.

Root session handles are separate from RLMSubagent: they have no result contract, result(), or child deletion semantics. The host remains authoritative for session identity, transcript access, worker routing, and lifecycle transitions.

3. Skills add programmatic capability #

Prime Agent supports the Agent Skills markdown format and extends it with Python-backed skills. Both use SKILL.md for discovery, routing, and instructions. A Python-backed skill also contains a Python package that Prime Agent installs into the kernel environment and exposes by import name.

For a skill named release-audit, the model can call:

report = await release_audit(repository=".", target_version="0.4.0")

This makes Python-backed skills a superset of instruction-only skills: they can provide guidance, scripts, references, dependencies, typed callables, and optional shell commands. They may also call rlm(...) themselves when a capability needs recursive delegation.

Only skill metadata is placed in the startup prompt. The agent loads the full SKILL.md when the task matches, then inspects and calls the documented Python API. See Skills for discovery, packaging, and the built-in skill-creation workflow.

4. State is designed to outlive one turn #

The RLM programming model assumes useful work may take many turns or continue after the terminal UI closes:

  • automatic compaction summarizes older context while preserving recent messages and kernel state;
  • daemon-backed workers keep active sessions running after clients detach;
  • child registries and session artifacts make subagents recoverable;
  • heartbeats and scheduled prompts re-enter a session later;
  • persistent goals continue until the objective is complete or the user changes their state; and
  • autonomous mode adds bounded continuations and optional quality gates.

See Long-Running and Background Agents for these lifecycle features.

Host Bridge #

Python code uses typed host requests for capabilities whose authoritative state belongs outside the kernel. The rlm callable itself sends messaging and observation requests this way, and the goal, rlm_heartbeat, and compact skills call rlm.host_request(...); the TypeScript host validates the request and owns the state transition.

This keeps credentials, provider execution, transcript writes, worker routing, and scheduling out of Python while retaining a programmatic model interface.

Trust Model #

The Python kernel runs model-generated Python and project commands with the worker's operating-system permissions. It is a durable control environment, not a security sandbox. Review third-party Python skills and use an external sandbox or restricted environment for untrusted repositories and instructions.

For implementation details, see RLM Runtime Architecture.