make rlm spawns fail-closed: schema-bound results by default, guarded context, explicit unguarded opt-out
75 unmodified lines7677787980818283842 unmodified lines878889909192939495100 unmodified lines196197198199198200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236267 unmodified linesExpand all75 unmodified lines rlm_module.rlm( "check the API", name="api-reviewer", model="deepseek/deepseek-v4-flash", result={"schema": {"type": "object"}, "required": False}, ) )
host_request.assert_awaited_once_with(2 unmodified lines "prompt": "check the API", "kwargs": { "name": "api-reviewer", "model": "deepseek/deepseek-v4-flash", "result": {"schema": {"type": "object"}, "required": False}, }, }, ) self.assertEqual(result.rlm_child_id, "sub-a1b2c3d4")100 unmodified lines "model": "test/model", } ) with patch.object(rlm_module, "host_request", host_request): child = asyncio.run(rlm_module.rlm("check")) child = asyncio.run( rlm_module.rlm("check", result={"schema": {"type": "object"}, "required": False}) ) self.assertIsInstance(child, rlm_module.RLMSubagent) self.assertEqual(child.id, "sub-a1b2c3d4") self.assertEqual(child.session_name, "worker") self.assertTrue(child.running) for method in ("refresh", "send", "observe", "info", "result", "delete"): self.assertTrue(callable(getattr(child, method)), method)
def test_requires_result_contract_unless_unguarded(self) -> None: host_request = AsyncMock( return_value={ "rlm_child_id": "sub-optout", "name": "optout", "session_dir": "/tmp/parent/sub-optout", "model": "test/model", } ) with patch.object(rlm_module, "host_request", host_request): with self.assertRaisesRegex(ValueError, "result contract"): asyncio.run(rlm_module.rlm("no schema")) with self.assertRaisesRegex(TypeError, "unguarded must be a bool"): asyncio.run(rlm_module.rlm("no schema", unguarded="yes")) # Explicit opt-out skips the requirement and passes through to the host. asyncio.run(rlm_module.rlm("no schema, opted out", unguarded=True)) self.assertEqual(host_request.await_count, 1) self.assertEqual( host_request.await_args.args[1], {"prompt": "no schema, opted out", "kwargs": {"unguarded": True}}, )
def test_subagent_send_and_observe_route_through_host(self) -> None: child = rlm_module.RLMSubagent( rlm_child_id="sub-a1b2c3d4", name="worker",267 unmodified linesExpand all7 unmodified lines89101112131213141594 unmodified lines7 unmodified lines}));
vi.mock("openai", () => { class FakeOpenAI { constructor(_options: unknown) {}
chat = { completions: { create: (params: unknown) => { mockState.lastParams = params;94 unmodified lines12- RLM spawns are now fail-closed at the agent-facing boundary: `rlm.run` requires a `result` contract so a child's communication with its parent is schema-bound, unless the caller passes the explicit opt-out `unguarded=True`.- When a `result` contract is declared without a `context`, the host applies a guarded default context: terminal and direct-message payloads are withheld behind fixed notices and observation previews are stripped, so `rlm.submit_result` is the child's only channel into the parent context. Explicit `context` entries override the corresponding default.50 unmodified lines5152535455555657585859606162636465665 unmodified lines727374757276777875798081828342 unmodified lines12612712812912613013113213313413513613713813968 unmodified lines50 unmodified linesEach `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. Spawn a child with a direct call: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:
```pythonhandle = await rlm("Review the authentication flow for security issues", name="auth-reviewer")handle = await rlm( "Review the authentication flow for security issues", name="auth-reviewer", 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.5 unmodified linestest_review = await rlm("Review the test coverage", name="test-reviewer")integration_audit = await rlm("Run the slow integration audit", name="integration-audit")```
Without a result contract, results arrive through explicit replies or files, never as an `rlm()` return value. Children reply when an answer is needed, and the parent can follow up with a retained child, all through the same `rlm` surface: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`:
```pythonawait rlm.send_parent(message) # from inside a childawait rlm.submit_result({"status": "approved"}) # from inside a guarded childawait api_review.send("Check the newly added regression test.")```
#### Composable result and context contracts42 unmodified lines```
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. Omitting `result` and `context` preserves ordinary Prime behavior. 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.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.
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
68 unmodified lines4694 unmodified linesExpand all469546964697469846994699470047014702470378 unmodified lines4782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821159 unmodified linesExpand all4694 unmodified linesExpand allimport rlm
async def _delayed_rlm(): await asyncio.sleep(0.05) return await rlm.run("detached child after idle") return await rlm.run("detached child after idle", unguarded=True)
_task = asyncio.create_task(_delayed_rlm())print("scheduled")`);78 unmodified lines }); return session; }
it("requires a result contract at the rlm.run boundary unless unguarded", async () => { const runHandler = createRlmRunHostHandler(async ({ prompt, kwargs }) => ({ rlm_child_id: "sub-boundary", name: "boundary", session_dir: "/tmp/sub-boundary", model: "test/model", prompt, kwargs, })); await expect(runHandler({ prompt: "no schema", kwargs: {} })).rejects.toThrow( /rlm\.run requires a result contract/, ); await expect( runHandler({ prompt: "no schema but opted out", kwargs: { unguarded: true }, }), ).resolves.toMatchObject({ prompt: "no schema but opted out" }); await expect( runHandler({ prompt: "schema bound", kwargs: { result: { schema: { type: "object" } } }, }), ).resolves.toMatchObject({ prompt: "schema bound" }); await expect( runHandler({ prompt: "bad flag", kwargs: { unguarded: "yes" }, }), ).rejects.toThrow(/rlm\.run unguarded must be a boolean/); });
it("does not create a /tmp dir or set RLM_SESSION_DIR for a non-persisted session", () => { const root = createSession(SessionManager.inMemory(tempDir)); const inspectable = root as unknown as InspectableRlmDirSession;
159 unmodified linesExpand all338 unmodified linesExpand all339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392338 unmodified linesExpand all expect(instructions).toContain("await rlm.submit_result(value)"); expect(instructions).toContain('"finding_ids"'); expect(instructions).not.toContain("artifact-only"); });
describe("guarded-by-default context", () => { it("withholds terminal, direct messages, and observation when a result is declared without context", () => { const contract = normalizeRlmChildContract( { schema: { type: "object", properties: {} }, required: false }, undefined, { guardedByDefault: true }, )!; expect(contract.context).toEqual({ terminal: { payload: false, notify: true }, direct_messages: { payload: false, notify: true }, observe: false, }); });
it("keeps explicit context entries and fills the rest with guarded defaults", () => { const contract = normalizeRlmChildContract( { schema: { type: "object", properties: {} }, required: false }, { direct_messages: { payload: true } }, { guardedByDefault: true }, )!; expect(contract.context).toEqual({ terminal: { payload: false, notify: true }, direct_messages: { payload: true, notify: true }, observe: false, }); });
it("does not add guarded defaults when the caller opted out", () => { expect(normalizeRlmChildContract(undefined, undefined, { guardedByDefault: true })).toBeUndefined(); expect( normalizeRlmChildContract({ schema: { type: "object", properties: {} }, required: false }, undefined, { guardedByDefault: false, })?.context, ).toBeUndefined(); });
it("renders guarded doctrine for a child whose payloads are withheld", () => { const doctrine = buildChildAgentDoctrine({ depth: 1, activeTools: ["ipython"], resultContract: true, resultRequired: true, contextGuarded: true, }); expect(doctrine).toContain("your only communication channel"); expect(doctrine).not.toContain("only for coordination or clarification"); }); });});337 unmodified linesExpand all33833934034134234234334434534671 unmodified lines418419420421422422423424424425426427428429262 unmodified linesExpand all337 unmodified linesExpand all expect(prompt).toContain("When to call `await refine.run()`"); expect(prompt).toContain("Call contract: read each installed Python skill's SKILL.md"); expect(prompt).toContain("Continual harness skill entries are Python REPL skills"); expect(prompt).toContain("Spawn a continual harness subagent spec by composing a concise task prompt"); expect(prompt).toContain("handle = await rlm('sub-task')"); expect(prompt).toContain("handle = await rlm('sub-task', result=...)"); expect(prompt).toContain("admission returns immediately"); expect(prompt).toContain("never the child's answer"); expect(prompt).toContain("await rlm.send_parent(message)"); expect(prompt).toContain("await rlm.list_subagents()");71 unmodified lines
expect(prompt).toContain("You are a general purpose agent that uses code to solve tasks."); expect(prompt).toContain("Working directory: /repo"); expect(prompt).toContain("Conversation log: /repo/.pi/sessions/session.jsonl"); expect(prompt).toContain("await rlm('sub-task')"); expect(prompt).toContain("await rlm('sub-task'"); expect(prompt).toContain("returns at admission, not completion"); expect(prompt).toContain("Without a result contract, answers arrive through messaging or files"); expect(prompt).toContain("only schema-less escape is the loud opt-out"); expect(prompt).toContain("Guarded is the default"); expect(prompt).toContain("recover direct child handles"); expect(prompt).toContain("kernel restart or compaction"); expect(prompt).toContain("rlm.list_subagents"); expect(prompt).toContain("await child.delete()");262 unmodified linesExpand all