diff --git a/README.md b/README.md index 8ec4190..0e741c9 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ A minimal CLI coding agent with a persistent IPython execution environment and o The model gets a single built-in tool, `ipython`: a persistent IPython kernel for Python, shell commands via `!command`, and multi-line shell scripts via `%%bash`. The tool set is not configurable. File edits, shell work, and orchestration all go through it. -For convenience, rlm ships built-in *skills* that can be enabled per run via `RLM_SKILLS` (comma-separated, off by default): `edit` (single-occurrence string replacement), `search` (web search via Serper, needs `SERPER_API_KEY`), and `fetch` (retrieve a URL as cleaned text). Enabled skills are pre-imported into the IPython kernel like any other skill (see [Skills](#skills)), so the agent calls `await edit(path=..., old_str=..., new_str=...)`, `await search(query=...)`, or `await fetch(url=...)`. `fetch` also exists as a native builtin tool with the same semantics, for tool-calling runs (opt-in via `RLM_BUILTIN_TOOLS`). +For convenience, rlm ships built-in *skills* that can be enabled per session via the runtime contract's `skills` list (off by default): `edit` (single-occurrence string replacement), `search` (web search via Serper, needs `SERPER_API_KEY`), and `fetch` (retrieve a URL as cleaned text). Enabled skills are pre-imported into the IPython kernel like any other skill (see [Skills](#skills)), so the agent calls `await edit(path=..., old_str=..., new_str=...)`, `await search(query=...)`, or `await fetch(url=...)`. `fetch` also exists as a native builtin tool with the same semantics, for tool-calling runs (opt-in via `RLM_BUILTIN_TOOLS`). -Context is reclaimed automatically: when a turn's prompt token count crosses `RLM_SUMMARIZE_AT_TOKENS`, the engine compacts the conversation into a summary and continues on a fresh branch. The IPython kernel keeps running across the compaction, so REPL state survives (see [Compaction](#compaction)). +Context is reclaimed automatically: when a turn's prompt token count crosses the policy's `summarize_at_tokens`, the engine compacts the conversation into a summary and continues on a fresh branch. The IPython kernel keeps running across the compaction, so REPL state survives (see [Compaction](#compaction)). Inside the IPython session, a callable `rlm` is pre-injected into the namespace. When recursion is allowed, the model can call `await rlm(...)` to spawn sub-agents. Skills supplied by the host environment (see [Skills](#skills)) are importable directly by name, e.g. `import websearch`. @@ -21,30 +21,19 @@ source .venv/bin/activate ## CLI -```bash -rlm "fix the auth bug in login.py" - -# Override model -RLM_MODEL=openai/gpt-5-mini rlm "refactor the parser" +rlm runs exclusively as an [Agent Client Protocol](https://agentclientprotocol.com/) agent: -# Append extra instructions to the generated system prompt -RLM_APPEND_TO_SYSTEM_PROMPT="Always run tests before finishing." rlm "solve the task" - -# Replace the generated system prompt from a file -RLM_SYSTEM_PROMPT_PATH=/tmp/system.txt rlm "solve the task" +```bash +rlm --acp ``` -Skill CLIs provided by the host environment are on `$PATH` and invoked the same way (e.g. `websearch --queries "latest jupyter_client release"` when the `websearch` skill is installed). +There is no standalone prompt mode: every session is created by an ACP client that +supplies the full runtime configuration (see below). Skill CLIs provided by the host +environment are on `$PATH` inside the kernel (e.g. `websearch --queries "..."` when +the `websearch` skill is installed). ## Agent Client Protocol -RLM can run as an [Agent Client Protocol](https://agentclientprotocol.com/) -agent over stdio: - -```bash -rlm --acp -``` - Each ACP session owns one persistent RLM engine. Repeated `session/prompt` requests retain both the model conversation and the live IPython kernel. The agent accepts text prompts and stdio or streamable HTTP MCP servers, including @@ -54,8 +43,7 @@ not advertise `session/load`: an arbitrary live Python kernel cannot be reconstructed after the ACP process exits, so clients must keep the process alive for the lifetime of a session. -RLM's ACP surface is a versioned training contract, not a compatibility layer -over the standalone CLI. `initialize` advertises the exact +RLM's ACP surface is a versioned training contract. `initialize` advertises the exact `ai.prime.rlm/contract-v1` marker in its response `_meta`; clients must require it, then provide one complete `ai.prime.rlm/runtime-v1` object in `session/new._meta`. The runtime object contains the ACP session ID, model, @@ -84,46 +72,24 @@ order even when a consumer's physical token-prefix graph splits. ACP consumers can resolve the request IDs onto their own message nodes while harnesses that do not understand the extension ignore it. -## Python SDK +## Python API (inside a session) -```python -import asyncio -import rlm +Inside a running session's IPython kernel, `rlm.run("sub-task")` (or the pre-injected +`rlm(...)` callable) spawns a recursive sub-agent through the session's broker. There is +no standalone entry point: outside a session the call raises. -result = asyncio.run(rlm.run("fix the bug")) -``` +## Configuration -## Standalone configuration +All runtime configuration enters through the `ai.prime.rlm/runtime-v1` contract object +(model, provider credentials, execution policy, prompt configuration, skills, kernel +environment, search credential). Recursive children inherit the parent's configuration +in-memory (`model_copy`); nothing is re-read from the process environment. -The CLI and Python API resolve standalone configuration from environment -variables. ACP sessions ignore these runtime fields and require the explicit -versioned contract described above. +The process environment configures only process infrastructure: | Variable | Default | Description | | ---------- | --------- | ------------- | | `RLM_HOME` | `~/.rlm` | Root directory for sessions and data | -| `RLM_MODEL` | `openai/gpt-5-mini` | Model name (PI Inference slug). Override with `--model` or `RLM_MODEL` for OpenAI/Anthropic direct (e.g. `gpt-4o`, `claude-sonnet-4-5`) | -| `RLM_API_KEY` / `RLM_BASE_URL` | — / SDK default (`https://api.openai.com/v1`) | Explicit override (highest priority). Independent: setting `RLM_API_KEY` alone targets the SDK default endpoint; set `RLM_BASE_URL` too for a custom endpoint. For PI, use `PRIME_API_KEY` (below) which owns the full pair. | -| `SERPER_API_KEY` | — | API key for the built-in `search` skill (Serper backend). Resolved by the supervisor and not copied into the kernel. | -| `PRIME_API_KEY` | — | PI Inference pair: targets `https://api.pinference.ai/api/v1` and forwards `PRIME_TEAM_ID` as `X-Prime-Team-ID` when set. | -| `OPENAI_API_KEY` / `OPENAI_BASE_URL` | resolved at startup | OpenAI pair (covers OpenAI direct and verifiers' rollout tunnel). Provider precedence: explicit → PI → OpenAI. Keys are scoped to their own base URL so an `OPENAI_API_KEY` lying around can't leak to PI Inference. | -| `RLM_SKILLS` | — | Comma-separated built-in skills to enable (`edit`, `search`, `fetch`); pre-imported into the kernel. Unknown names raise. See [Skills](#skills). | -| `RLM_MCP_CONFIG` | — | Standard `mcpServers` config (streamable HTTP or stdio); each server's tools become pre-imported IPython skills (`_`). See [MCP tools as skills](#mcp-tools-as-skills). | -| `RLM_KERNEL_ENV` | `{}` | JSON object of task variables explicitly passed to IPython and its subprocesses. Supervisor, provider, MCP, and broker configuration names are reserved. | -| `RLM_MAX_DEPTH` | `0` | Max recursion depth (`0` means no sub-agents) | -| `RLM_MAX_CONCURRENT_SUBAGENTS` | `max(4, RLM_MAX_DEPTH)` | Maximum live recursive agents in a session tree. Capacity is reserved per depth to prevent nested-call deadlocks. | -| `RLM_MAX_SUBAGENT_CALLS` | `64` | Maximum accepted recursive calls across the complete session tree. | -| `RLM_EXEC_TIMEOUT` | `300` | Seconds per IPython execution | -| `RLM_MAX_OUTPUT` | `-1` | Max chars returned from a tool call (`-1` disables truncation; `0` is invalid) | -| `RLM_MAX_TOOL_OUTPUT_CHARS` | — | Preserve only a head/tail window of this many characters from raw IPython output before it enters the conversation. | -| `RLM_SUMMARIZE_AT_TOKENS` | — | Auto-compaction threshold: when a turn's prompt tokens reach this value, the conversation is compacted into a summary. Unset disables auto-compaction. | -| `RLM_MAX_TOKENS` | `0` | Optional completion-token budget (`0` disables) | -| `RLM_APPEND_TO_SYSTEM_PROMPT` | — | Extra instructions appended to the generated system prompt | -| `RLM_SYSTEM_PROMPT_PATH` | — | Path to a file whose contents fully replace the generated system prompt | -| `RLM_ALLOW_GIT` | — | Set to `1` to disable the restricted git-history guard. When unset, shell-capable prompts tell agents not to use task-specific online hints or solutions from other git history, and broad-history `git log` options such as `--all` are refused. | -| `RLM_SDK_MAX_RETRIES` | `5` | Per-request retry count passed to the OpenAI SDK (in addition to the call-site retry wrapper that rides out longer outages). | - -`RLM_SYSTEM_PROMPT_PATH` takes precedence over `RLM_APPEND_TO_SYSTEM_PROMPT`. CLI flags override env vars: `rlm --model gpt-5-mini --append-to-system-prompt "..." --system-prompt-path /tmp/system.txt "prompt"`. ## Recursion @@ -148,9 +114,9 @@ Recursive calls are created by a session-local supervisor rather than by the IPy ## Compaction -There is no model-driven compaction tool. Compaction is automatic: set `RLM_SUMMARIZE_AT_TOKENS` and, once a turn's prompt token count reaches that threshold, the engine asks the model for a handoff summary and resumes the task on a fresh branch seeded with that summary. The original task prompt is dropped — the summary carries the goal forward. +There is no model-driven compaction tool. Compaction is automatic: set the policy's `summarize_at_tokens` and, once a turn's prompt token count reaches that threshold, the engine asks the model for a handoff summary and resumes the task on a fresh branch seeded with that summary. The original task prompt is dropped — the summary carries the goal forward. -The IPython kernel keeps running across the compaction, so all variables, imports, and in-memory data are preserved; the model is told to mention important variable names in its summary so the resumed branch knows what's available. With `RLM_SUMMARIZE_AT_TOKENS` unset, no auto-compaction occurs. +The IPython kernel keeps running across the compaction, so all variables, imports, and in-memory data are preserved; the model is told to mention important variable names in its summary so the resumed branch knows what's available. With `summarize_at_tokens` unset, no auto-compaction occurs. ## Session Directory @@ -171,7 +137,7 @@ These artifacts are consumable for debugging, visualization, or training-data ex ## Skills -`rlm` ships a small set of built-in skills enabled per run via `RLM_SKILLS` (`edit`, `search`; see [MCP tools as skills](#mcp-tools-as-skills) for the related MCP path). `edit` runs in the kernel. Credentialed `search` runs in the supervisor through the capability broker, so `SERPER_API_KEY` is unavailable to IPython and its subprocesses; it returns title/URL/snippet for a single query (`await search(query="...")`). Additional skills are supplied by the host environment: before `install.sh` runs, the environment places skill packages under `/task/rlm-skills//`, and `install.sh` installs them alongside `rlm` so they're both importable and on `$PATH`. +`rlm` ships a small set of built-in skills enabled per session via the runtime contract's `skills` list (`edit`, `search`, `fetch`; see [MCP tools as skills](#mcp-tools-as-skills) for the related MCP path). `edit` runs in the kernel. Credentialed `search` runs in the supervisor through the capability broker, so `SERPER_API_KEY` is unavailable to IPython and its subprocesses; it returns title/URL/snippet for a single query (`await search(query="...")`). Additional skills are supplied by the host environment: before `install.sh` runs, the environment places skill packages under `/task/rlm-skills//`, and `install.sh` installs them alongside `rlm` so they're both importable and on `$PATH`. From IPython, import a skill and call its async `run(...)` entrypoint: @@ -232,7 +198,7 @@ For running `rlm` against a specific skill set outside of a sandbox-orchestrated ### MCP tools as skills -A host harness can wire task-specific [MCP](https://modelcontextprotocol.io) tool servers to `rlm` by setting `RLM_MCP_CONFIG` to a standard `mcpServers` config. Streamable HTTP and stdio transports are supported: +A host harness can wire task-specific [MCP](https://modelcontextprotocol.io) tool servers to `rlm` through the ACP session (a standard `mcpServers` config shape). Streamable HTTP and stdio transports are supported: ```json { @@ -247,7 +213,7 @@ A host harness can wire task-specific [MCP](https://modelcontextprotocol.io) too } ``` -Programmatically, pass `mcp_servers={"tools": "http://127.0.0.1:8000/mcp"}` to `RLMEngine` / `rlm.run` instead (it takes precedence over `RLM_MCP_CONFIG`). An HTTP server may instead be `{"url": "...", "headers": {"Authorization": "..."}}` when it needs request headers; stdio servers use the same `command` / `args` / `env` shape shown above. +Programmatically, pass `mcp_servers={"tools": "http://127.0.0.1:8000/mcp"}` to `RLMEngine`. An HTTP server may instead be `{"url": "...", "headers": {"Authorization": "..."}}` when it needs request headers; stdio servers use the same `command` / `args` / `env` shape shown above. At startup `rlm` connects to each server, lists its tools, and generates one skill per tool (named `_`, e.g. `tools_add_event`). These join the installed skills — pre-imported into the IPython namespace as async functions the agent calls programmatically, with a signature built from the tool's input schema: @@ -262,7 +228,7 @@ Each call connects using the configured transport, invokes the tool, and returns The IPython kernel always runs in rlm's own Python (`sys.executable`). `install.sh` puts `rlm` and all discovered skills into the same `uv tool install` environment, so `from rlm import run`, `import edit`, etc. work natively from inside an IPython cell. -The kernel starts from a small platform environment (`PATH`, home/user/shell, locale, temporary-directory, certificate, and virtual-environment variables) plus the explicit `RLM_KERNEL_ENV` mapping. It receives private Jupyter/IPython config directories and does not inherit the rest of the supervisor process environment. This de-ambients credentials; it is not hostile-code containment because the kernel still shares the sandbox user, filesystem, process namespace, and network with the supervisor. +The kernel starts from a small platform environment (`PATH`, home/user/shell, locale, temporary-directory, certificate, and virtual-environment variables) plus the contract's explicit `kernel_env` mapping. It receives private Jupyter/IPython config directories and does not inherit the rest of the supervisor process environment. This de-ambients credentials; it is not hostile-code containment because the kernel still shares the sandbox user, filesystem, process namespace, and network with the supervisor. To exercise packages from the target project's `.venv` (e.g. running its test suite), shell out from an IPython cell: `!./.venv/bin/python3 -m pytest`. The kernel itself stays isolated from whatever project venv the agent is working on — no cross-cell state involving sandbox packages. @@ -290,6 +256,3 @@ uv sync --group dev uv run pytest tests/ ``` -## Interactive Mode - -Running `rlm` with no prompts enters a placeholder interactive mode. The TUI is not implemented yet. diff --git a/src/rlm/api.py b/src/rlm/api.py index 68d3377..1cdebdd 100644 --- a/src/rlm/api.py +++ b/src/rlm/api.py @@ -1,13 +1,15 @@ """Public Python API for running rlm agents.""" from rlm import broker -from rlm.engine import RLMEngine from rlm.types import RLMResult async def run(prompt: str) -> RLMResult: - """Run a single rlm agent.""" - if broker.is_configured(): - return await broker.run(prompt) - engine = RLMEngine() - return await engine.run(prompt) + """Run a recursive sub-agent through the session's broker.""" + if not broker.is_configured(): + raise RuntimeError( + "rlm.run() requires the recursion broker (available inside a " + "running rlm session). Standalone execution was removed: rlm is " + "consumed via the ACP runtime contract." + ) + return await broker.run(prompt) diff --git a/src/rlm/cli.py b/src/rlm/cli.py index 44d08d9..dcc6437 100644 --- a/src/rlm/cli.py +++ b/src/rlm/cli.py @@ -1,12 +1,8 @@ -"""CLI entry point.""" +"""CLI entry point: an Agent Client Protocol agent over stdio.""" from __future__ import annotations import asyncio -import os -import sys - -import rlm def main(): @@ -14,57 +10,19 @@ def main(): parser = argparse.ArgumentParser( prog="rlm", - description="A minimalistic CLI agent for true recursion.", - ) - parser.add_argument( - "prompt", - nargs="?", - help="Task prompt (omit for interactive mode)", - ) - parser.add_argument( - "--model", default=None, help="Model name (overrides RLM_MODEL)" - ) - parser.add_argument( - "--system-prompt-path", - default=None, - help="Path to a file whose contents replace the generated system prompt", - ) - parser.add_argument( - "--append-to-system-prompt", - default=None, - help="Extra instructions appended to the generated system prompt", + description="A minimalistic recursive agent, served over the Agent Client Protocol.", ) parser.add_argument( "--acp", action="store_true", - help="Serve as an Agent Client Protocol agent over stdio", + help="Serve as an Agent Client Protocol agent over stdio (the only mode)", ) args = parser.parse_args() + if not args.acp: + parser.error("rlm runs only as an ACP agent: use `rlm --acp`") + from rlm.acp import serve_acp - # Apply CLI overrides to env - if args.model: - os.environ["RLM_MODEL"] = args.model - if args.system_prompt_path: - os.environ["RLM_SYSTEM_PROMPT_PATH"] = args.system_prompt_path - if args.append_to_system_prompt: - os.environ["RLM_APPEND_TO_SYSTEM_PROMPT"] = args.append_to_system_prompt - - if args.acp: - if args.prompt: - parser.error("a prompt cannot be supplied with --acp") - from rlm.acp import serve_acp - - asyncio.run(serve_acp()) - elif args.prompt: - print(asyncio.run(rlm.run(args.prompt)).answer) - else: - _run_interactive() - - -def _run_interactive(): - print("rlm interactive mode") - print('TUI not yet implemented. Use: rlm "your prompt" for headless mode.') - sys.exit(0) + asyncio.run(serve_acp()) if __name__ == "__main__": diff --git a/src/rlm/client.py b/src/rlm/client.py index 4edd73f..80a8272 100644 --- a/src/rlm/client.py +++ b/src/rlm/client.py @@ -39,31 +39,8 @@ _RETRY_DELAYS: tuple[int, ...] = (15, 30, 60, 90, 120) -def resolve_provider() -> tuple[str | None, str | None, dict[str, str]]: - """Pick the first provider whose key is set: ``(base_url, api_key, headers)``. - - Each provider is a self-contained pair so a key never reaches a base - URL it wasn't issued for: - - 1. **Explicit** — ``RLM_API_KEY`` (pairs with ``RLM_BASE_URL`` if set, - otherwise SDK default = ``api.openai.com``). Set both for a - non-OpenAI custom endpoint. - 2. **PI Inference** — ``PRIME_API_KEY`` at PI's base, with - ``PRIME_TEAM_ID`` forwarded as ``X-Prime-Team-ID``. - 3. **OpenAI** — ``OPENAI_API_KEY`` set: capture ``OPENAI_API_KEY`` and - ``OPENAI_BASE_URL`` into the trusted provider configuration. Covers - OpenAI direct and verifiers' rollout tunnel both. - - Falls back to PI + ``"EMPTY"`` so the SDK can't silently inherit - ``OPENAI_API_KEY`` and ship it to the PI default base. - """ - provider = ProviderConfig.from_env() - return provider.base_url, provider.api_key, provider.headers.copy() - - -def make_client(provider: ProviderConfig | None = None) -> AsyncOpenAI: - """Create an AsyncOpenAI client from explicit or environment configuration.""" - provider = provider or ProviderConfig.from_env() +def make_client(provider: ProviderConfig) -> AsyncOpenAI: + """Create an AsyncOpenAI client from an explicit provider configuration.""" reserved = sorted( name for name in provider.headers diff --git a/src/rlm/config.py b/src/rlm/config.py index 9aa3ca1..abd0bf6 100644 --- a/src/rlm/config.py +++ b/src/rlm/config.py @@ -1,62 +1,16 @@ -"""Validated runtime configuration for RLM engines.""" +"""Validated runtime configuration for RLM engines. -from __future__ import annotations +Configuration enters an rlm process exactly once, through the versioned ACP +runtime contract (``ai.prime.rlm/runtime-v1``); recursive children inherit it +in-memory via ``model_copy``. There is no environment-variable resolution. +""" -import json -import os -from typing import Mapping +from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing_extensions import Self from rlm.semantic import ACP_EXTENSION_HEADER_NAMES -from rlm.tools.registry import preset_skills - - -PI_INFERENCE_BASE_URL = "https://api.pinference.ai/api/v1" -KERNEL_ENV_CONFIG_ENV = "RLM_KERNEL_ENV" - - -def _optional_positive_int(value: str | int | None, name: str) -> int | None: - if value is None or value == "": - return None - if isinstance(value, bool): - raise ValueError(f"{name} must be an int") - try: - parsed = int(value) - except (TypeError, ValueError) as exc: - raise ValueError(f"{name} must be an int (got {value!r})") from exc - return parsed if parsed > 0 else None - - -def _positive_int(value: str | int, name: str) -> int: - parsed = _optional_positive_int(value, name) - if parsed is None: - raise ValueError(f"{name} must be positive") - return parsed - - -def _summarize_at_tokens(value: str | int | None) -> int | None: - """Unset -> the 256k default; "" or "0" -> disabled; else a positive threshold.""" - if value is None: - return 256_000 - if value in ("", "0", 0): - return None - parsed = _optional_positive_int(value, "summarize_at_tokens") - if parsed is None: - raise ValueError(f"summarize_at_tokens must be positive (got {value})") - return parsed - - -def _kernel_env(value: str | None) -> tuple[tuple[str, str], ...]: - if not value: - return () - parsed = json.loads(value) - if not isinstance(parsed, dict) or not all( - isinstance(key, str) and isinstance(item, str) for key, item in parsed.items() - ): - raise ValueError(f"{KERNEL_ENV_CONFIG_ENV} must be a JSON object of strings") - return tuple(parsed.items()) class _ConfigModel(BaseModel): @@ -84,49 +38,12 @@ def _reserve_transport_headers(cls, headers: dict[str, str]) -> dict[str, str]: raise ValueError(f"provider headers contain reserved names: {reserved}") return headers - @classmethod - def from_env(cls, environ: Mapping[str, str] | None = None) -> ProviderConfig: - env = os.environ if environ is None else environ - max_retries = int(env.get("RLM_SDK_MAX_RETRIES", "5")) - if api_key := env.get("RLM_API_KEY"): - return cls( - base_url=env.get("RLM_BASE_URL"), - api_key=api_key, - max_retries=max_retries, - ) - if api_key := env.get("PRIME_API_KEY"): - headers = {} - if team_id := env.get("PRIME_TEAM_ID"): - headers["X-Prime-Team-ID"] = team_id - return cls( - base_url=PI_INFERENCE_BASE_URL, - api_key=api_key, - headers=headers, - max_retries=max_retries, - ) - if env.get("OPENAI_API_KEY"): - return cls( - base_url=env.get("OPENAI_BASE_URL"), - api_key=env["OPENAI_API_KEY"], - max_retries=max_retries, - ) - return cls( - base_url=PI_INFERENCE_BASE_URL, - api_key="EMPTY", - max_retries=max_retries, - ) - class InvocationContext(_ConfigModel): """Trusted identity of one engine within a recursive session tree.""" depth: int = Field(default=0, ge=0) - @classmethod - def from_env(cls, environ: Mapping[str, str] | None = None) -> InvocationContext: - env = os.environ if environ is None else environ - return cls(depth=int(env.get("RLM_DEPTH", "0"))) - def child(self) -> InvocationContext: return InvocationContext(depth=self.depth + 1) @@ -162,55 +79,3 @@ class RuntimeConfig(_ConfigModel): skills: tuple[str, ...] = () kernel_env: tuple[tuple[str, str], ...] = Field(default=(), repr=False) search_api_key: str | None = Field(default=None, repr=False) - - @classmethod - def from_env( - cls, - *, - environ: Mapping[str, str] | None = None, - ) -> RuntimeConfig: - env = os.environ if environ is None else environ - raw_skills = env.get("RLM_SKILLS") - max_depth = int(env.get("RLM_MAX_DEPTH", "1")) - default_concurrency = max(4, max_depth) - max_concurrent_subagents = _positive_int( - env.get("RLM_MAX_CONCURRENT_SUBAGENTS", str(default_concurrency)), - "RLM_MAX_CONCURRENT_SUBAGENTS", - ) - if max_depth > max_concurrent_subagents: - raise ValueError( - "RLM_MAX_CONCURRENT_SUBAGENTS must be at least RLM_MAX_DEPTH" - ) - return cls( - model=env.get("RLM_MODEL", "openai/gpt-5-mini"), - provider=ProviderConfig.from_env(env), - invocation=InvocationContext.from_env(env), - policy=ExecutionPolicy( - max_depth=max_depth, - exec_timeout=int(env.get("RLM_EXEC_TIMEOUT", "300")), - max_tokens=_optional_positive_int( - env.get("RLM_MAX_TOKENS"), "RLM_MAX_TOKENS" - ), - summarize_at_tokens=_summarize_at_tokens( - env.get("RLM_SUMMARIZE_AT_TOKENS") - ), - max_compactions=_optional_positive_int( - env.get("RLM_MAX_COMPACTIONS"), "RLM_MAX_COMPACTIONS" - ), - max_concurrent_subagents=max_concurrent_subagents, - max_subagent_calls=_positive_int( - env.get("RLM_MAX_SUBAGENT_CALLS", "64"), - "RLM_MAX_SUBAGENT_CALLS", - ), - allow_git=env.get("RLM_ALLOW_GIT") == "1", - ), - system_prompt_path=env.get("RLM_SYSTEM_PROMPT_PATH"), - append_to_system_prompt=env.get("RLM_APPEND_TO_SYSTEM_PROMPT"), - skills=( - tuple(s.strip() for s in raw_skills.split(",") if s.strip()) - if raw_skills is not None - else preset_skills() - ), - kernel_env=_kernel_env(env.get(KERNEL_ENV_CONFIG_ENV)), - search_api_key=env.get("SERPER_API_KEY"), - ) diff --git a/src/rlm/engine.py b/src/rlm/engine.py index 816c1c6..dd33445 100644 --- a/src/rlm/engine.py +++ b/src/rlm/engine.py @@ -22,7 +22,7 @@ ) from rlm.config import RuntimeConfig from rlm.semantic import SemanticEdgeTracker -from rlm.mcp import MCPServer, load_mcp_servers, validate_mcp_servers +from rlm.mcp import MCPServer, validate_mcp_servers from rlm.prompt import build_system_prompt from rlm.session import Session from rlm.skills import enable_builtin_skills @@ -174,7 +174,13 @@ def __init__( parent_session_id: str | None = None, spawned_by_request_id: str | None = None, ): - self.runtime_config = runtime_config or RuntimeConfig.from_env() + if runtime_config is None: + raise ValueError( + "RLMEngine requires an explicit runtime_config: standalone " + "environment configuration was removed (rlm is consumed via " + "the ACP runtime contract; children inherit in-memory)." + ) + self.runtime_config = runtime_config config = self.runtime_config self.model = config.model self.cwd = cwd or os.getcwd() @@ -189,9 +195,7 @@ def __init__( # Task MCP tool servers to expose as IPython skills; kwarg wins, otherwise # parse RLM_MCP_CONFIG (a standard mcpServers config). - self.mcp_servers = validate_mcp_servers( - mcp_servers if mcp_servers is not None else load_mcp_servers() - ) + self.mcp_servers = validate_mcp_servers(mcp_servers or {}) # Built-in skills (rlm.skills) to enable for this run, from RLM_SKILLS (comma-separated). self.skills = list(config.skills) diff --git a/src/rlm/mcp.py b/src/rlm/mcp.py index 7518397..d58de92 100644 --- a/src/rlm/mcp.py +++ b/src/rlm/mcp.py @@ -5,7 +5,6 @@ import inspect import json import keyword -import os import re import secrets from collections.abc import AsyncIterator, Iterable @@ -19,7 +18,6 @@ from mcp.client.streamable_http import streamablehttp_client from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator -MCP_CONFIG_ENV = "RLM_MCP_CONFIG" MAX_MCP_TOOLS = 128 MAX_MCP_SKILL_NAME_CHARS = 96 MAX_MCP_DESCRIPTOR_BYTES = 1024 * 1024 @@ -60,12 +58,6 @@ class MCPStdioServer(_MCPConfigModel): _MCP_SERVERS_ADAPTER = TypeAdapter(dict[Annotated[str, Field(min_length=1)], MCPServer]) -class _MCPServersDocument(_MCPConfigModel): - mcp_servers: dict[Annotated[str, Field(min_length=1)], MCPServer] = Field( - alias="mcpServers" - ) - - @dataclass(frozen=True) class MCPToolDescriptor: """Public information exposed to an IPython kernel for one MCP tool.""" @@ -189,14 +181,6 @@ def write_skill_modules( return write_skill_modules(descriptors, dest_dir, reserved_names) -def load_mcp_servers() -> dict[str, MCPServer]: - """Parse ``RLM_MCP_CONFIG`` into validated HTTP or stdio servers.""" - raw = os.environ.get(MCP_CONFIG_ENV) - if not raw: - return {} - return _MCPServersDocument.model_validate_json(raw).mcp_servers - - def _skill_name(server: str, tool: str) -> str: """Return the normalized Python name for a server tool.""" ident = re.sub(r"\W", "_", f"{server}_{tool}") @@ -207,12 +191,6 @@ def validate_mcp_servers(servers: dict[str, Any]) -> dict[str, MCPServer]: return _MCP_SERVERS_ADAPTER.validate_python(servers) -def dump_mcp_servers(servers: dict[str, MCPServer]) -> str: - """Serialize servers as a standard ``mcpServers`` configuration.""" - document = _MCPServersDocument(mcpServers=validate_mcp_servers(servers)) - return document.model_dump_json(by_alias=True, exclude_defaults=True) - - @asynccontextmanager async def _client_session( server: MCPServer, cwd: str | None = None diff --git a/tests/conftest.py b/tests/conftest.py index b38ce16..4f35734 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,12 @@ from fixtures.tools.add import AddTool from fixtures.tools.boom import BoomTool +from rlm.config import ( + ExecutionPolicy, + InvocationContext, + ProviderConfig, + RuntimeConfig, +) from rlm.session import Session from rlm.tools import registry as tool_registry @@ -131,6 +137,18 @@ def show_tool_result(output: str) -> None: print(f"\n── tool result ──\n{output.rstrip()}\n─────────────────") +def make_runtime_config(**overrides) -> RuntimeConfig: + """A minimal explicit RuntimeConfig for engine tests (no environment resolution).""" + defaults = dict( + model="dummy-model", + provider=ProviderConfig(base_url=None, api_key="EMPTY"), + invocation=InvocationContext(), + policy=ExecutionPolicy(), + ) + defaults.update(overrides) + return RuntimeConfig(**defaults) + + # --- Fixtures ------------------------------------------------------------ diff --git a/tests/test_acp.py b/tests/test_acp.py index 3f3f29c..4f284c3 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -14,7 +14,7 @@ from acp.schema import EnvVariable, HttpHeader, HttpMcpServer, McpServerStdio import pytest -from conftest import DummyClient, DummyMessage, DummyToolCall +from conftest import DummyClient, DummyMessage, DummyToolCall, make_runtime_config from rlm.acp import ( ACP_SEMANTIC_EDGES_METADATA_KEY, CONTRACT_METADATA_KEY, @@ -152,7 +152,9 @@ async def test_engine_prompt_preserves_conversation(session): client = DummyClient( [DummyMessage(content="first"), DummyMessage(content="second")] ) - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore[arg-type] try: first = await engine.prompt("one") @@ -238,7 +240,9 @@ async def test_engine_prompt_preserves_ipython_kernel(session): DummyMessage(content="done"), ] ) - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore[arg-type] try: await engine.prompt("remember a value") @@ -266,7 +270,9 @@ async def block_first_prompt(**kwargs): return await create(**kwargs) client.create = block_first_prompt - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore[arg-type] pending = asyncio.create_task(engine.prompt("cancel me")) await prompt_started.wait() @@ -356,7 +362,9 @@ async def flaky_first_call(**kwargs): async def test_latest_cancelled_prompt_does_not_finalize_prior_result(session): client = DummyClient([DummyMessage(content="first")]) - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore[arg-type] await engine.prompt("one") prompt_started = asyncio.Event() @@ -378,11 +386,16 @@ async def block_prompt(**kwargs): assert "answer_preview" not in meta -async def test_depth_limit_is_a_completed_result(monkeypatch, session): - monkeypatch.setenv("RLM_DEPTH", "1") - monkeypatch.setenv("RLM_MAX_DEPTH", "0") +async def test_depth_limit_is_a_completed_result(session): client = DummyClient([]) - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, + session=session, + runtime_config=make_runtime_config( + invocation=InvocationContext(depth=1), + policy=ExecutionPolicy(max_depth=0), + ), + ) # type: ignore[arg-type] result = await engine.run("too deep") @@ -394,7 +407,9 @@ async def test_depth_limit_is_a_completed_result(monkeypatch, session): async def test_compaction_counts_seed_prompt(session): client = DummyClient([DummyMessage(content="summary")]) - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore[arg-type] messages = [ {"role": "system", "content": "system"}, {"role": "user", "content": "original prompt"}, @@ -417,7 +432,9 @@ async def test_engine_failed_prompt_can_be_retried(session): DummyMessage(content="continued"), ] ) - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore[arg-type] with pytest.raises(RuntimeError, match="boom"): await engine.prompt("fail") @@ -530,7 +547,9 @@ def shutdown(self): DummyMessage(content="continued"), ] ) - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore[arg-type] repl = FakeREPL() engine._started = True engine._messages = [{"role": "system", "content": "system"}] @@ -585,7 +604,9 @@ async def test_engine_cancelled_tool_recovers_kernel(session, tmp_path): DummyMessage(content="continued"), ] ) - engine = RLMEngine(client=client, session=session) # type: ignore[arg-type] + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore[arg-type] pending = asyncio.create_task(engine.prompt("cancel the tool")) for _ in range(100): @@ -664,6 +685,7 @@ async def test_engine_failed_start_publishes_no_semantic_edge(monkeypatch, sessi engine = RLMEngine( client=DummyClient([]), # type: ignore[arg-type] session=session, + runtime_config=make_runtime_config(), ) async def fail_start(prompt: str) -> None: diff --git a/tests/test_config.py b/tests/test_config.py index 2bdce53..beddc2f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,6 @@ from rlm.client import make_client from rlm.config import ( - KERNEL_ENV_CONFIG_ENV, ExecutionPolicy, InvocationContext, ProviderConfig, @@ -10,53 +9,33 @@ ) -def test_runtime_config_resolves_and_redacts_environment(): - config = RuntimeConfig.from_env( - environ={ - "RLM_MODEL": "override", - "RLM_API_KEY": "secret", - "RLM_BASE_URL": "http://interceptor", - "RLM_DEPTH": "2", - "RLM_MAX_DEPTH": "4", - "RLM_EXEC_TIMEOUT": "30", - "RLM_MAX_TOKENS": "500", - "RLM_SUMMARIZE_AT_TOKENS": "2048", - "RLM_MAX_COMPACTIONS": "3", - "RLM_ALLOW_GIT": "1", - "RLM_SKILLS": "search, edit", - KERNEL_ENV_CONFIG_ENV: '{"TASK_TOKEN": "task-secret"}', - "SERPER_API_KEY": "search-secret", - }, +def test_runtime_config_redacts_secrets(): + config = RuntimeConfig( + model="override", + provider=ProviderConfig(base_url="http://interceptor", api_key="test-key"), + invocation=InvocationContext(depth=2), + policy=ExecutionPolicy( + max_depth=4, + exec_timeout=30, + max_tokens=500, + summarize_at_tokens=2048, + max_compactions=3, + allow_git=True, + ), + skills=("search", "edit"), + kernel_env=(("TASK_TOKEN", "task-secret"),), + search_api_key="search-secret", ) - assert config.model == "override" - assert config.invocation == InvocationContext(depth=2) - assert config.policy == ExecutionPolicy( - max_depth=4, - exec_timeout=30, - max_tokens=500, - summarize_at_tokens=2048, - max_compactions=3, - max_concurrent_subagents=4, - max_subagent_calls=64, - allow_git=True, - ) - assert config.skills == ("search", "edit") - assert config.kernel_env == (("TASK_TOKEN", "task-secret"),) - assert config.search_api_key == "search-secret" + assert config.invocation.child() == InvocationContext(depth=3) assert "task-secret" not in repr(config) assert "search-secret" not in repr(config) assert "test-key" not in repr(config.provider) -def test_runtime_config_rejects_unsafe_recursive_and_environment_values(): - with pytest.raises(ValueError, match="at least RLM_MAX_DEPTH"): - RuntimeConfig.from_env( - environ={ - "RLM_MAX_DEPTH": "3", - "RLM_MAX_CONCURRENT_SUBAGENTS": "2", - } - ) +def test_policy_rejects_unsafe_recursive_values_and_reserved_headers(): + with pytest.raises(ValueError, match="at least max_depth"): + ExecutionPolicy(max_depth=3, max_concurrent_subagents=2) provider = ProviderConfig( base_url="http://interceptor", api_key="secret", @@ -67,12 +46,6 @@ def test_runtime_config_rejects_unsafe_recursive_and_environment_values(): def test_default_policy_enables_compaction_and_recursion(): - config = RuntimeConfig.from_env(environ={}) - assert config.policy.summarize_at_tokens == 256_000 - assert config.policy.max_depth == 1 - - -def test_summarize_at_tokens_disabled_by_zero_or_empty(): - for raw in ("", "0"): - config = RuntimeConfig.from_env(environ={"RLM_SUMMARIZE_AT_TOKENS": raw}) - assert config.policy.summarize_at_tokens is None + policy = ExecutionPolicy() + assert policy.summarize_at_tokens == 256_000 + assert policy.max_depth == 1 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index ab3c75a..6b863a0 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -66,50 +66,6 @@ def _process_exists(pid: int) -> bool: return True -def test_load_mcp_servers(monkeypatch): - monkeypatch.delenv(mcp.MCP_CONFIG_ENV, raising=False) - assert mcp.load_mcp_servers() == {} - - monkeypatch.setenv( - mcp.MCP_CONFIG_ENV, - '{"mcpServers": {"tools": {"url": "http://h/mcp"}, "web": {"url": "http://h/web", "headers": {"Authorization": "secret"}}, "local": {"command": "/bin/server", "args": ["--stdio"], "env": {"TOKEN": "secret"}}}}', - ) - servers = mcp.load_mcp_servers() - assert servers == { - "tools": mcp.MCPHTTPServer(url="http://h/mcp"), - "web": mcp.MCPHTTPServer( - url="http://h/web", - headers={"Authorization": "secret"}, - ), - "local": mcp.MCPStdioServer( - command="/bin/server", - args=["--stdio"], - env={"TOKEN": "secret"}, - ), - } - assert json.loads(mcp.dump_mcp_servers(servers)) == { - "mcpServers": { - "tools": {"url": "http://h/mcp"}, - "web": { - "url": "http://h/web", - "headers": {"Authorization": "secret"}, - }, - "local": { - "command": "/bin/server", - "args": ["--stdio"], - "env": {"TOKEN": "secret"}, - }, - } - } - - monkeypatch.setenv( - mcp.MCP_CONFIG_ENV, - '{"mcpServers":{"bad":{"url":123,"command":"also-bad"}}}', - ) - with pytest.raises(ValueError): - mcp.load_mcp_servers() - - def test_build_signature(): params = mcp.build_signature(SCHEMA).parameters assert list(params) == ["day", "count"] @@ -120,12 +76,11 @@ def test_build_signature(): assert params["count"].annotation is int -async def test_real_kernel_uses_mcp_without_transport_secrets(monkeypatch, tmp_path): +async def test_real_kernel_uses_mcp_without_transport_secrets(tmp_path): server_cwd = tmp_path / "server" server_cwd.mkdir() session = Session(tmp_path / "session") servers = {"local": _stdio_server("stdio-secret")} - monkeypatch.setenv(mcp.MCP_CONFIG_ENV, mcp.dump_mcp_servers(servers)) client = DummyClient( [ DummyMessage( @@ -151,6 +106,7 @@ async def test_real_kernel_uses_mcp_without_transport_secrets(monkeypatch, tmp_p client=client, # type: ignore[arg-type] session=session, runtime_config=_config(), + mcp_servers=servers, cwd=str(server_cwd), ) diff --git a/tests/test_skills.py b/tests/test_skills.py index 851584e..6d08b64 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -23,6 +23,7 @@ DummyClient, DummyMessage, DummyToolCall, + make_runtime_config, show_tool_result, tool_result, ) @@ -42,7 +43,9 @@ async def test_python_skill_valid(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore result = await engine.run(prompt) @@ -75,7 +78,9 @@ async def test_bash_skill_valid(session, monkeypatch, tmp_path): ), ) monkeypatch.setattr(sys, "argv", [str(launcher_dir / "rlm")]) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore result = await engine.run(prompt) @@ -102,7 +107,9 @@ async def test_python_skill_invalid_args(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore result = await engine.run(prompt) @@ -122,7 +129,9 @@ async def test_bash_skill_invalid_args(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore result = await engine.run(prompt) @@ -146,7 +155,9 @@ async def test_python_skill_raises(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore result = await engine.run(prompt) @@ -166,7 +177,9 @@ async def test_bash_skill_raises(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore result = await engine.run(prompt) @@ -187,7 +200,9 @@ async def test_python_skill_halt_on_raise(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore await engine.run("try boom then say") @@ -206,7 +221,9 @@ async def test_bash_skill_halt_on_raise(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore await engine.run("try boom then say") @@ -225,7 +242,9 @@ async def test_valid_python_skill_metrics(session): DummyMessage(content="ok"), ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore await engine.run("say hi") @@ -240,7 +259,9 @@ async def test_valid_bash_skill_metrics(session): DummyMessage(content="ok"), ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore await engine.run("say hi") @@ -270,7 +291,9 @@ async def test_skill_introspection(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore await engine.run("introspect say") diff --git a/tests/test_tools.py b/tests/test_tools.py index f21def3..a0b9457 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -12,6 +12,7 @@ DummyClient, DummyMessage, DummyToolCall, + make_runtime_config, show_tool_result, tool_result, ) @@ -29,7 +30,9 @@ async def test_valid_tool(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore result = await engine.run(prompt) @@ -53,7 +56,9 @@ async def test_multiple_tool_calls(session): ] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore await engine.run(prompt) @@ -67,7 +72,9 @@ async def test_tool_raises(session): messages = [DummyMessage(tool_calls=[DummyToolCall("boom", {})])] client = DummyClient(messages) - engine = RLMEngine(client=client, session=session) # type: ignore + engine = RLMEngine( + client=client, session=session, runtime_config=make_runtime_config() + ) # type: ignore with pytest.raises(RuntimeError, match="boom"): await engine.run(prompt)