Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ The agent can filter large outputs in Python, reuse earlier work, and delegate
independent subtasks to child agents with their own sessions and Python state.
MCP, skills, permissions, and resumable sessions round out the workflow.

Children can independently select a configured model and reasoning level:

```python
models = await rlm.find_models(limit=100)
chosen = next((m for m in models if "high" in m.thinking_levels), None)
if chosen is not None:
reviewer = await rlm.spawn(
"Review the current diff.", name="reviewer",
model=chosen.selector, thinking="high",
)
```

Omitted options inherit the immediate parent. Invalid selections fail without
fallback. Discovery reflects configuration, not live health or credits. See the
[child model API](docs/rlm-child-models.md).

## Quick start

**Desktop:** Download the package for your OS and architecture from
Expand Down
12 changes: 10 additions & 2 deletions apps/tui/src/generated/protocol/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,7 +1002,15 @@ export type SearchFailedPayload = { searchId: ReferenceSearchId, query: string,

export type AgentToolPolicy = "inherit" | "deny_all";

export type SpawnAgentParams = { session_id: SessionId, message: string, fork_turns?: string | null, max_turns?: number | null, tool_policy: AgentToolPolicy, ephemeral: boolean,
export type SpawnAgentParams = { session_id: SessionId, message: string,
/**
* Exact connection/model selector. Omission inherits the immediate parent.
*/
model?: string | null,
/**
* Reasoning selection validated against the child model, never clamped.
*/
thinking?: string | null, fork_turns?: string | null, max_turns?: number | null, tool_policy: AgentToolPolicy, ephemeral: boolean,
/**
* Caller-requested child name. RLM `spawn(name=…)` addressing (messaging,
* observation, delete) keys off this name, so it must be honored when
Expand All @@ -1017,7 +1025,7 @@ export type SpawnAgentResult = { task_id: TaskId, child_session_id: SessionId, a
*/
session_dir?: string | null,
/**
* Model binding the child runs with (inherited from the parent).
* Canonical model selector bound to the child session.
*/
model?: string | null, };

Expand Down
16 changes: 15 additions & 1 deletion apps/tui/src/generated/protocol/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -173689,6 +173689,13 @@
"message": {
"type": "string"
},
"model": {
"description": "Exact connection/model selector. Omission inherits the immediate parent.",
"type": [
"string",
"null"
]
},
"nickname": {
"description": "Caller-requested child name. RLM `spawn(name=…)` addressing (messaging, observation, delete) keys off this name, so it must be honored when present; `None` keeps the generated adjective-noun nickname.",
"type": [
Expand All @@ -173699,6 +173706,13 @@
"session_id": {
"$ref": "#/definitions/SessionId"
},
"thinking": {
"description": "Reasoning selection validated against the child model, never clamped.",
"type": [
"string",
"null"
]
},
"tool_policy": {
"default": "inherit",
"allOf": [
Expand Down Expand Up @@ -173743,7 +173757,7 @@
"$ref": "#/definitions/SessionId"
},
"model": {
"description": "Model binding the child runs with (inherited from the parent).",
"description": "Canonical model selector bound to the child session.",
"type": [
"string",
"null"
Expand Down
2 changes: 2 additions & 0 deletions crates/core/prompts/rlm/repl_control.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Terminology: continual harness names the persisted prompt, memory, skill, and su

RLM-native call contract: installed Python skills are pre-imported modules. Read the matching SKILL.md and call its documented function, such as `await <skill_import>.<function>(...)`; when a CLI exists, use `<skill_import> ...` from shell. Continual harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Spawn a reusable delegation spec with `await rlm.spawn('sub-task', name='worker')` (host wire action `rlm.run`); admission returns a child handle immediately. Final assistant output is included in the child completion record; use an available messaging capability for timely updates and files for substantial artifacts. Results are never an `rlm.spawn()` return value. Do not invent non-native wrappers such as `call_skill(...)` or `run_subagent(...)`. Do not call `rlm.create_session` — daemon-backed top-level sessions are out of scope and denied by the host.

Child model selection: `await rlm.find_models(query='', limit=8)` lists enabled, configured, routable models with `provider`, `id`, `name`, `selector`, and `thinking_levels`; it does not probe live health or credits. Use an exact returned selector with `await rlm.spawn('sub-task', name='worker', model=chosen.selector, thinking='high')`, choosing a supported thinking level. Model IDs may contain slashes. Omitted model/thinking (or `None`) inherit the immediate parent's values. Invalid selectors or unsupported explicit or inherited thinking fail admission without silent fallback; when changing models, check its supported levels. `limit` is an integer from 1 to 100. Catalog descriptors contain no credentials.

RLM host APIs are called from Python through `ipython`, not as separate model tools:
- `await rlm.update_plan([{"step": "Inspect", "status": "in_progress"}])` publishes a session plan during execution. Plan Mode should keep its draft in Python and present the final `<proposed_plan>` instead.
- `await rlm.request_user_input([{"id": "choice", "header": "Choice", "question": "Which option?", "options": [{"label": "A", "description": "..."}, {"label": "B", "description": "..."}]}])` uses the session's question UI.
Expand Down
4 changes: 4 additions & 0 deletions crates/core/src/tools/handlers/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ impl ToolHandler for AgentToolHandler {
let input: SpawnAgentInput = parse_input(input)?;
let result = coordinator
.spawn_agent(SpawnAgentParams {
model: None,
thinking: None,
session_id,
message: input.message,
fork_turns: input.fork_turns,
Expand Down Expand Up @@ -707,6 +709,8 @@ mod tests {
assert_eq!(
coordinator.spawned.lock().await.as_slice(),
&[SpawnAgentParams {
model: None,
thinking: None,
session_id,
message: "review this".to_string(),
fork_turns: Some("all".to_string()),
Expand Down
23 changes: 17 additions & 6 deletions crates/kernel/rlm-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,18 +179,29 @@ import rlm
result = await rlm.bash("cargo test -p devo-kernel")
print(result.exit_code, result.output)

reviewer = await rlm.spawn(
"Review the current diff for concurrency bugs.",
name="reviewer",
model="openai-codex/gpt-5.4-mini",
)
print(reviewer.model)
models = await rlm.find_models(limit=100)
chosen = next((m for m in models if "high" in m.thinking_levels), None)
if chosen is not None:
reviewer = await rlm.spawn(
"Review the current diff for concurrency bugs.",
name="reviewer", model=chosen.selector, thinking="high",
)
print(reviewer.model)
```

`rlm.spawn` returns when the child is admitted, not when it finishes. The child
runs as a separate session; results arrive through `agent_message` replies or
files, usually on a later turn.

Omitted `model` and `thinking` (or `None`) inherit the immediate parent's values.
An unsupported inherited thinking level fails just like an explicit unsupported
level; there is no silent fallback. `find_models` returns enabled, configured,
routable models and supported thinking levels without credentials or network
probes. It does not guarantee provider health or credits. Selectors are exact
`provider/model` strings, and model IDs can contain slashes. `limit` must be an
integer from 1 to 100, not a boolean. See the
[child model API](../../../docs/rlm-child-models.md) for details.

`rlm.read` and `rlm.write` try the direct filesystem call first. On `OSError`
they ask the host to perform the operation through its approval pipeline. A
host denial surfaces as `PermissionError`. These helpers are an entry point, not
Expand Down
45 changes: 39 additions & 6 deletions crates/kernel/rlm-runtime/src/rlm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import sys
import types
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -39,6 +39,7 @@ class RLMModel:
id: str
name: str
selector: str
thinking_levels: list[str] = field(default_factory=list)


@dataclass(frozen=True)
Expand Down Expand Up @@ -268,12 +269,27 @@ async def spawn(
"""Spawn a recursive Devo child and return once its task is admitted.

``name`` is required and must be unique among siblings.
``model`` selects a child with an exact ``provider/model`` selector.
``model`` selects a child with an exact ``provider/model`` selector from
``find_models`` (model IDs may contain slashes); there is no fallback.
``thinking`` sets the child reasoning level (e.g. 'off', 'low', 'medium', 'high');
defaults to the parent level; levels invalid for the resolved model fail the spawn.
Omitted or None options inherit the immediate parent's model and thinking.
Levels invalid for the resolved model, including inherited levels, fail the spawn.
"""
if not isinstance(prompt, str):
raise TypeError(f"prompt must be str, got {type(prompt).__name__}")
if not prompt.strip():
raise ValueError("prompt must be a non-empty string")
for label, value in (("name", name), ("model", model), ("thinking", thinking)):
if value is None and label != "name":
continue
if not isinstance(value, str):
raise TypeError(f"{label} must be str, got {type(value).__name__}")
if not value.strip():
raise ValueError(f"{label} must be a non-empty string")
if model is not None:
provider, separator, model_id = model.partition("/")
if not separator or not provider.strip() or not model_id.strip():
raise ValueError("model must be an exact provider/model selector")
kwargs: dict[str, Any] = {"name": name}
if model is not None:
kwargs["model"] = model
Expand All @@ -293,7 +309,15 @@ def _model_from_payload(payload: Any) -> RLMModel:
selector = payload.get("selector")
if not all(isinstance(value, str) and value for value in (provider, model_id, name, selector)):
raise RuntimeError("rlm.find_models returned an invalid model entry")
return RLMModel(provider=provider, id=model_id, name=name, selector=selector)
thinking_levels = payload.get("thinking_levels", [])
if not isinstance(thinking_levels, list) or not all(
isinstance(level, str) and level.strip() for level in thinking_levels
):
raise RuntimeError("rlm.find_models returned invalid thinking_levels")
return RLMModel(
provider=provider, id=model_id, name=name, selector=selector,
thinking_levels=thinking_levels,
)


async def create_session(
Expand Down Expand Up @@ -324,12 +348,21 @@ async def create_session(


async def find_models(query: str = "", limit: int = 8) -> list[RLMModel]:
"""Search a bounded list of models backed by active user credentials."""
"""Search enabled, configured, routable models without network probes.

Availability describes local configuration, not live health or credit. Each
descriptor includes supported thinking levels; older hosts may omit them.
``limit`` must be an integer from 1 to 100 (not bool).
"""
if not isinstance(query, str):
raise TypeError(f"query must be str, got {type(query).__name__}")
if not isinstance(limit, int):
if isinstance(limit, bool) or not isinstance(limit, int):
raise TypeError(f"limit must be int, got {type(limit).__name__}")
if not 1 <= limit <= 100:
raise ValueError("limit must be between 1 and 100")
payload = await host_request("rlm.find_models", {"query": query, "limit": limit})
if not isinstance(payload, dict):
raise RuntimeError("rlm.find_models returned an invalid models list")
models = payload.get("models")
if not isinstance(models, list):
raise RuntimeError("rlm.find_models returned an invalid models list")
Expand Down
6 changes: 5 additions & 1 deletion crates/kernel/rlm-runtime/src/rlm/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,11 @@ def overview(self, *, max_entries_per_kind: int = 20, global_: bool = False, **k
"harness skill entries are Python REPL skills and must include a Python reference plus arguments. "
"Spawn a subagent spec by composing a concise task prompt and calling "
"handle = await rlm.spawn('sub-task', name='worker'); admission returns immediately with rlm_child_id, name, session_dir, "
"and model, never the child's answer. Results arrive only through explicit agent_message replies or "
"and model. Discover configured models with await rlm.find_models(query='', limit=8); descriptors include selector "
"and thinking_levels, not credentials or live health guarantees. Pass model=chosen.selector and a supported "
"thinking level to spawn; omitted options inherit the immediate parent. Invalid selectors or unsupported "
"explicit/inherited thinking fail without fallback. The handle contains admission metadata "
"and never the child's answer. Results arrive through child completion, explicit agent_message replies or "
"files; children reply with await agent_message.send(message, receiver_role='parent'). Use "
"await rlm.list_subagents() to recover direct children. A recovered child uses child.session_name "
"(the spawn handle uses handle.name); send await agent_message.send(..., "
Expand Down
103 changes: 103 additions & 0 deletions crates/kernel/rlm-runtime/tests/test_child_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import unittest
from importlib import import_module
from pathlib import Path
from unittest.mock import AsyncMock, patch

import rlm

repl = import_module("rlm.repl")


class ChildModelTests(unittest.IsolatedAsyncioTestCase):
async def test_harness_documents_discovery_and_no_fallback(self):
harness = import_module("rlm.harness")
overview = harness.HarnessState(in_memory=True).overview()
for guidance in ("rlm.find_models", "thinking_levels", "model=chosen.selector",
"immediate parent", "without fallback"):
self.assertIn(guidance, overview)

async def test_spawn_wire_options_and_inheritance(self):
response = {
"rlm_child_id": "child", "name": "worker",
"session_dir": "child-session", "model": "gateway/org/model",
}
bridge = AsyncMock(return_value={"status": "ok", "result": response})
with patch.object(repl, "host_request", bridge):
for options in ({}, {"model": None, "thinking": None},
{"model": "gateway/org/model", "thinking": "high"}):
handle = await rlm.rlm.spawn("Review code", name="worker", **options)
self.assertEqual(handle, rlm.RLMSpawnHandle(
**{**response, "session_dir": Path("child-session")}))
bridge.assert_awaited_with({
"type": "rlm.run", "prompt": "Review code",
"kwargs": {"name": "worker", **{
key: value for key, value in options.items() if value is not None}},
})

async def test_invalid_spawn_arguments_do_not_reach_host(self):
cases = [({"prompt": value}, TypeError) for value in (None, 1, True)]
cases += [({"prompt": value}, ValueError) for value in ("", " ")]
for field in ("name", "model", "thinking"):
cases += [({field: value}, TypeError) for value in (1, True, [])]
cases += [({field: value}, ValueError) for value in ("", " ")]
cases += [({"name": None}, TypeError)]
cases += [({"model": value}, ValueError) for value in ("model", "/id", "provider/", " /id")]
bridge = AsyncMock()
with patch.object(repl, "host_request", bridge):
for overrides, error in cases:
with self.subTest(overrides=overrides), self.assertRaises(error):
await rlm.spawn(**{"prompt": "Task", "name": "worker", **overrides})
bridge.assert_not_awaited()

async def test_find_models_wire_and_legacy_descriptors(self):
legacy = {"provider": "gateway", "id": "org/model", "name": "Model",
"selector": "gateway/org/model"}
current = {**legacy, "thinking_levels": ["off", "high"]}
bridge = AsyncMock(return_value={"status": "ok", "result": {"models": [legacy, current]}})
with patch.object(repl, "host_request", bridge):
self.assertEqual(await rlm.rlm.find_models("org", 100), [
rlm.RLMModel(**legacy), rlm.RLMModel(**current)])
bridge.assert_awaited_with({"type": "rlm.find_models", "query": "org", "limit": 100})
await rlm.find_models()
bridge.assert_awaited_with({"type": "rlm.find_models", "query": "", "limit": 8})
await rlm.find_models(limit=1)

async def test_find_models_invalid_arguments_do_not_reach_host(self):
bridge = AsyncMock()
with patch.object(repl, "host_request", bridge):
for query in (None, 1, [], True):
with self.subTest(query=query), self.assertRaises(TypeError):
await rlm.find_models(query)
for limit in (True, False, "8", 1.0, None):
with self.subTest(limit=limit), self.assertRaises(TypeError):
await rlm.find_models(limit=limit)
for limit in (0, -1, 101):
with self.subTest(limit=limit), self.assertRaises(ValueError):
await rlm.find_models(limit=limit)
bridge.assert_not_awaited()

async def test_invalid_catalog_responses(self):
base = {"provider": "p", "id": "m", "name": "M", "selector": "p/m"}
payloads = [None, [], {}, {"models": None}, {"models": [None]}, {"models": [{}]}]
payloads += [{"models": [{**base, "thinking_levels": levels}]}
for levels in (None, "high", [True], [""], [" "])]
for payload in payloads:
with self.subTest(payload=payload), patch.object(repl, "host_request", AsyncMock(
return_value={"status": "ok", "result": payload}
)), self.assertRaises(RuntimeError):
await rlm.find_models()

async def test_host_errors_propagate_without_retry_or_fallback(self):
for operation in ("spawn", "find_models"):
bridge = AsyncMock(return_value={"status": "error", "error": "unsupported inherited thinking"})
with self.subTest(operation=operation), patch.object(repl, "host_request", bridge):
with self.assertRaisesRegex(RuntimeError, "unsupported inherited thinking"):
if operation == "spawn":
await rlm.spawn("Task", name="worker", model="gateway/org/model")
else:
await rlm.find_models()
self.assertEqual(bridge.await_count, 1)


if __name__ == "__main__":
unittest.main()
Loading
Loading