complete guarded RLM results and background completion wakeups
53 unmodified lines545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031241 unmodified linesExpand all53 unmodified lines handle = bash("echo again") awaited = await handle self.assertEqual(handle.poll(), awaited)
async def test_background_completion_notifies_once_and_poll_acknowledges(self): events = [] reported = threading.Event() original = bash_module._completion_notifier self.addCleanup(bash_module._set_completion_notifier, original)
def notify(event): events.append(event) reported.set()
bash_module._set_completion_notifier(notify) handle = bash("exit 7") pid = handle.pid self.assertTrue(await asyncio.to_thread(reported.wait, 5)) self.assertEqual( events, [ { "kind": "completed", "handleId": handle._handle_id, "pid": pid, "exitCode": 7, "duration": mock.ANY, } ], )
self.assertEqual(handle.poll().exit_code, 7) self.assertEqual(events[-1], {"kind": "observed", "handleId": handle._handle_id}) self.assertEqual(len(events), 2)
async def test_awaited_completion_does_not_notify(self): events = [] original = bash_module._completion_notifier self.addCleanup(bash_module._set_completion_notifier, original) bash_module._set_completion_notifier(events.append)
result = await bash("true")
self.assertEqual(result.exit_code, 0) self.assertEqual(events, [])
async def test_rejects_cd_prefixes_with_chdir_guidance(self): for command in ( "cd /tmp && pwd", "cd /tmp; pwd",1241 unmodified linesExpand all125 unmodified linesExpand all126127128129130130131132133134801 unmodified linesExpand all125 unmodified linesExpand all "Call contract: installed Python skills use await <skill_import>(...)", reloaded.overview(), ) overview = reloaded.overview() self.assertIn("handle = await rlm('sub-task')", overview) self.assertIn("handle = await rlm('sub-task', model='provider/model')", overview) self.assertIn("never the child's answer", overview) self.assertIn("receiver_role='parent'", overview) self.assertIn("await rlm.list_subagents()", overview) self.assertIn("receiver_role='child'", overview)801 unmodified linesExpand all75 unmodified lines7677787980808182838483 unmodified lines75 unmodified linesPrime Agent runs in your current working directory and can modify files there. Use git or another checkpointing workflow if you want easy rollback.
## Recursive Subagents
Recursive subagents are a built-in Prime Agent capability. The model spawns independent work from the Python REPL with `await rlm("subtask")`; each call returns at admission with a child handle and never returns the answer. Children send requested results as explicit `agent_message` replies to the parent or write them to files. Child agents use the same TypeScript agent runtime, providers, tools, skills, and session machinery as the parent.Recursive subagents are a built-in Prime Agent capability. The model spawns independent work from the Python REPL with `await rlm("subtask", model="provider/model")`; each call returns at admission with a child handle and never returns the answer. Children send requested results as explicit `agent_message` replies to the parent or write them to files. Child agents use the same TypeScript agent runtime, providers, tools, skills, and session machinery as the parent.
You can prompt the model to use that capability directly:
```text83 unmodified lines24 unmodified lines25262728293031323312 unmodified lines4647484949505152535452 unmodified lines10710810911011011111211311411522 unmodified lines1381391401411411421421431441451461478 unmodified lines1561571581591591601611621631641651661661671681691701716 unmodified lines17817918018118118218318218318418518618718860 unmodified lines24925025125225325425525625725825926026126226326426526626718 unmodified lines24 unmodified lines```pythonhandle = 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)```12 unmodified lines participant C as Child AgentSession participant P as Model provider
M->>H: Python tool call H->>K: execute await rlm("inspect the API") H->>K: execute await rlm("inspect the API", model="provider/model") K->>H: host_request · rlm.run H->>H: check depth and resolve model H->>H: admit child task and update registry H-->>K: RLMSubagent52 unmodified lines
A running cell can await task admission:
```pythonhandle = await rlm("subtask")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.
22 unmodified lines
The kernel bootstrap places the callable `rlm` object in the user namespace, so these are equivalent:
```pythonawait rlm("subtask")await rlm.run("subtask")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.
8 unmodified linesContracted 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. Model search is bounded to active, non-expired credentials. If an exact selection is unavailable or fails auth preflight, spawn fails instead of silently falling back to another model. A child otherwise inherits the parent model.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. Resolve the requested model or inherit the parent model.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.6 unmodified lines
Each direct call admits an independent child and returns its handle immediately:
```pythonapi_review = await rlm("review the API", name="api-reviewer")test_review = await rlm("review the tests", name="test-reviewer")audit = await rlm("slow independent audit", name="audit-reviewer")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.
60 unmodified lines```
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:
```pythonchecks = 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.
18 unmodified lines56 unmodified lines57585960616263646566666768697071717273727374757677787980818216 unmodified lines991001011021031041051061071081091101111121061131141151161176 unmodified lines12412512612712112812913012413113213313413513613713813914013214114214314414571 unmodified lines56 unmodified lines```pythonhandle = 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. The child inherits the parent model, provider configuration, skills, tools, retry policy, and resource loader unless the call requests another configured 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:
```pythonapi_review = await rlm("Review the public API", name="api-reviewer")test_review = await rlm("Review the test coverage", name="test-reviewer")integration_audit = await rlm("Run the slow integration audit", name="integration-audit")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`:
16 unmodified lines }, "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": ["status", "finding_ids"]}, "result": {"fields": result_fields}, },)```
6 unmodified lines "private_notes": "not admitted to parent context",})```
The parent explicitly reads a schema-validated projection:The parent reads the schema-validated projection admitted at spawn. Omitting `fields` returns the admitted set; pass `fields` only to request a narrower projection:
```pythonresult = await rlm.get_result(review, fields=["status"])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, leaving `rlm.submit_result` as the child's only channel into the parent context. 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.**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.71 unmodified lines110 unmodified linesExpand all1111121131141151161161171171181191201211221231241251261271281291306 unmodified lines137138139140132141142143144145242 unmodified linesExpand all110 unmodified linesExpand all# never the child's answer.review = await rlm( "Review authentication and reply to the parent with findings.", name="auth-reviewer", model="provider/model",)tests = await rlm("Find missing regression tests and reply to the parent.", name="test-reviewer")docs = await rlm("Find stale public documentation and reply to the parent.", name="docs-reviewer")tests = await rlm( "Find missing regression tests and reply to the parent.", name="test-reviewer", model="provider/model",)docs = await rlm( "Find stale public documentation and reply to the parent.", name="docs-reviewer", model="provider/model",)
# Children reply from their own sessions with:# await agent_message.send(message, receiver_role="parent")# Their replies arrive here as ordinary agent messages.6 unmodified lines receiver_name=review.name,)```
Children inherit the parent model unless the user requests another model. They run as TypeScript `AgentSession` instances under the same root worker and can use the same provider, tools, skills, session storage, and scheduling system. See [RLM Runtime Architecture](rlm-runtime.md).Every child spawn must select an exact `provider/model`; the parent model is never inherited implicitly. Children run as TypeScript `AgentSession` instances under the same root worker and can use the same provider, tools, skills, session storage, and scheduling system. See [RLM Runtime Architecture](rlm-runtime.md).
## Context Files
Prime Agent loads `AGENTS.md` or `CLAUDE.md` at startup from:242 unmodified linesExpand all134 unmodified linesExpand all13513613713813913914014114214314414510 unmodified lines134 unmodified linesExpand all const acpSession = await handle.agent.request("session/new", { cwd: tempDir, mcpServers: [] });
// No prompt turn: a fire-and-forget subagent must still reach the client, // which is only true if ACP subscribes for the session lifetime. await session.runRlmChild("summarize shard 1"); await session.runRlmChild("summarize shard 1", { model: `${model.provider}/${model.id}`, });
// Drain the async notification queue. await new Promise((resolve) => setTimeout(resolve, 50));
10 unmodified lines