From cfcb7e65b12dc0990f0a8163bdb7543005e40f0e Mon Sep 17 00:00:00 2001 From: hallerite Date: Fri, 28 Aug 2026 17:54:12 +0000 Subject: [PATCH 1/2] feat!: remove standalone environment configuration (ACP contract only) rlm is consumed exclusively through the versioned ai.prime.rlm/runtime-v1 contract; recursive children inherit configuration in-memory. Remove the env-var config path: - config.py: drop RuntimeConfig/ProviderConfig/InvocationContext.from_env and the env parsing helpers; configuration objects are constructed explicitly. - engine.py: RLMEngine requires an explicit runtime_config; MCP servers come only from the caller (no RLM_MCP_CONFIG fallback). - mcp.py: drop load_mcp_servers/dump_mcp_servers (env serialization path). - client.py: make_client requires an explicit ProviderConfig; drop resolve_provider. - api.py: rlm.run() is broker-only (inside a session); standalone execution raises. - cli.py: `rlm --acp` is the only mode; standalone prompt/interactive modes removed. - README: standalone-configuration section replaced with the contract description; the process env now configures only process infrastructure (RLM_HOME). Engine-to-kernel plumbing vars (RLM_SESSION_DIR, RLM_ALLOW_GIT, RLM_KERNEL_ENV delivery) and registry dev presets (RLM_TOOLING, RLM_BUILTIN_TOOLS) are internal and unchanged. --- README.md | 89 ++++++++------------------ src/rlm/api.py | 14 +++-- src/rlm/cli.py | 56 +++-------------- src/rlm/client.py | 27 +------- src/rlm/config.py | 147 ++----------------------------------------- src/rlm/engine.py | 14 +++-- src/rlm/mcp.py | 22 ------- tests/conftest.py | 18 ++++++ tests/test_acp.py | 48 ++++++++++---- tests/test_config.py | 73 +++++++-------------- tests/test_mcp.py | 48 +------------- tests/test_skills.py | 45 +++++++++---- tests/test_tools.py | 13 +++- 13 files changed, 180 insertions(+), 434 deletions(-) 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) From 93b4318a0704f0193e75003ed91879229d384996 Mon Sep 17 00:00:00 2001 From: hallerite Date: Tue, 1 Sep 2026 15:09:35 +0000 Subject: [PATCH 2/2] feat!: move builtin tool selection into the runtime contract RLM_TOOLING / RLM_BUILTIN_TOOLS were the last env vars configuring model-visible behavior. The contract gains an optional builtin_tools list (None = the ipython-only default); children inherit it via model_copy like the rest of the config. The tooling presets collapse with them: their skills-half was already dead post-contract-only-config (skills are explicit contract input), which left dual/tools indistinguishable. Co-Authored-By: Claude Fable 5 --- README.md | 7 ++-- src/rlm/acp.py | 6 +++ src/rlm/config.py | 3 ++ src/rlm/engine.py | 12 +++--- src/rlm/tools/bash.py | 3 +- src/rlm/tools/fetch.py | 5 ++- src/rlm/tools/registry.py | 86 ++++++++++++++------------------------- tests/test_acp.py | 4 +- tests/test_tools.py | 37 ++++++----------- 9 files changed, 72 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 0e741c9..08d9609 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ A minimal CLI coding agent with a persistent IPython execution environment and optional recursive sub-agents. For a full-fledged coding agent built on the same RLM principles, see [prime-agent](https://github.com/PrimeIntellect-ai/prime-agent). -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. +By default 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`. File edits, shell work, and orchestration all go through it. The runtime contract's `builtin_tools` list can select a different tool set (`bash`, `edit`, `fetch`, `ipython`) for native tool-calling runs. -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`). +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 the contract's `builtin_tools`). 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)). @@ -48,6 +48,7 @@ RLM's ACP surface is a versioned training contract. `initialize` advertises the it, then provide one complete `ai.prime.rlm/runtime-v1` object in `session/new._meta`. The runtime object contains the ACP session ID, model, provider, execution policy, prompt configuration, enabled built-in skills, +optional builtin tool selection, explicit kernel environment, and optional search credential. Nullable and disabled values are sent explicitly as `null` or empty collections. Missing, partial, unknown, or unsupported contracts are rejected; ACP sessions never @@ -81,7 +82,7 @@ no standalone entry point: outside a session the call raises. ## Configuration All runtime configuration enters through the `ai.prime.rlm/runtime-v1` contract object -(model, provider credentials, execution policy, prompt configuration, skills, kernel +(model, provider credentials, execution policy, prompt configuration, skills, builtin tools, kernel environment, search credential). Recursive children inherit the parent's configuration in-memory (`model_copy`); nothing is re-read from the process environment. diff --git a/src/rlm/acp.py b/src/rlm/acp.py index 3d264df..f060d6e 100644 --- a/src/rlm/acp.py +++ b/src/rlm/acp.py @@ -71,6 +71,7 @@ class _RuntimeMetadata(_ContractModel): system_prompt_path: str | None append_to_system_prompt: str | None skills: list[Annotated[str, Field(min_length=1)]] + builtin_tools: list[Annotated[str, Field(min_length=1)]] | None = None kernel_env: dict[str, str] search_api_key: str | None @@ -201,6 +202,11 @@ def _runtime_config(meta_kwargs: Any) -> tuple[RuntimeConfig, str]: system_prompt_path=payload.system_prompt_path, append_to_system_prompt=payload.append_to_system_prompt, skills=tuple(payload.skills), + builtin_tools=( + tuple(payload.builtin_tools) + if payload.builtin_tools is not None + else None + ), kernel_env=tuple(payload.kernel_env.items()), search_api_key=payload.search_api_key, ), diff --git a/src/rlm/config.py b/src/rlm/config.py index abd0bf6..97d0d25 100644 --- a/src/rlm/config.py +++ b/src/rlm/config.py @@ -77,5 +77,8 @@ class RuntimeConfig(_ConfigModel): system_prompt_path: str | None = None append_to_system_prompt: str | None = None skills: tuple[str, ...] = () + builtin_tools: tuple[str, ...] | None = None + """Builtin tool set for every engine in the tree; None = the registry default + (`ipython` alone). Validated against the registry when the engine starts.""" kernel_env: tuple[tuple[str, str], ...] = Field(default=(), repr=False) search_api_key: str | None = Field(default=None, repr=False) diff --git a/src/rlm/engine.py b/src/rlm/engine.py index dd33445..04d6d16 100644 --- a/src/rlm/engine.py +++ b/src/rlm/engine.py @@ -193,12 +193,12 @@ def __init__( self.depth = config.invocation.depth self.allow_git = config.policy.allow_git - # Task MCP tool servers to expose as IPython skills; kwarg wins, otherwise - # parse RLM_MCP_CONFIG (a standard mcpServers config). + # Task MCP tool servers to expose as IPython skills. self.mcp_servers = validate_mcp_servers(mcp_servers or {}) - # Built-in skills (rlm.skills) to enable for this run, from RLM_SKILLS (comma-separated). + # Built-in skills and tool set for this run, from the runtime contract. self.skills = list(config.skills) + self.builtin_tools = config.builtin_tools self.kernel_env = dict(config.kernel_env) self.max_tokens = config.policy.max_tokens @@ -407,7 +407,9 @@ async def _start(self, prompt: str) -> None: try: self._repl.start() - self._active_tools = get_active_builtin_tools(self.exec_timeout) + self._active_tools = get_active_builtin_tools( + self.exec_timeout, self.builtin_tools + ) self._active_tool_schemas = [tool.schema() for tool in self._active_tools] system_prompt = self._load_system_prompt(self._active_tools) @@ -530,7 +532,7 @@ async def _run_loop(self) -> RLMResult: tool_name = tc.function.name tool_args = parsed_args[0] t0 = time.time() - tool = get_builtin_tool(tool_name) + tool = get_builtin_tool(tool_name, self.builtin_tools) if tool is None: tool_result = ToolOutcome(content=f"Error: unknown tool '{tool_name}'") else: diff --git a/src/rlm/tools/bash.py b/src/rlm/tools/bash.py index fc90beb..945209d 100644 --- a/src/rlm/tools/bash.py +++ b/src/rlm/tools/bash.py @@ -1,7 +1,8 @@ """Native ``bash`` builtin tool. Runs one shell command per call in a fresh subshell, like a plain bash agent. -Enabled by default alongside ipython; override the tool set with ``RLM_BUILTIN_TOOLS``. +Selected via the runtime contract's ``builtin_tools`` (the default tool set is +ipython alone). """ from __future__ import annotations diff --git a/src/rlm/tools/fetch.py b/src/rlm/tools/fetch.py index 6b4c074..ccd0c88 100644 --- a/src/rlm/tools/fetch.py +++ b/src/rlm/tools/fetch.py @@ -2,8 +2,9 @@ Tool twin of the ``fetch`` skill (``rlm.skills.fetch``): same cleaning, truncation, and error semantics, exposed as a native tool call instead of a REPL function. -Opt-in: registered but in no tooling preset, so it runs only when named in -``RLM_BUILTIN_TOOLS`` — a network-capable tool stays off by default. +Opt-in: outside the default tool set, so it runs only when the runtime +contract's ``builtin_tools`` names it — a network-capable tool stays off by +default. """ from __future__ import annotations diff --git a/src/rlm/tools/registry.py b/src/rlm/tools/registry.py index 1f1a47e..e11f251 100644 --- a/src/rlm/tools/registry.py +++ b/src/rlm/tools/registry.py @@ -1,14 +1,15 @@ """Builtin tool registry. -rlm's default is the ``skills`` preset: the persistent IPython REPL as the sole -tool, with shell and edits as pre-imported REPL skills (``await bash(...)``, -``await edit(...)``). ``RLM_TOOLING`` selects ``tools`` or ``dual`` presets; -``RLM_BUILTIN_TOOLS`` (comma-separated) overrides the tool set directly. +rlm's default tool set is the persistent IPython REPL as the sole tool, with +shell and edits available as pre-imported REPL skills (``await bash(...)``, +``await edit(...)``). The runtime contract's ``builtin_tools`` list overrides +the tool set directly (e.g. ``["bash", "edit", "ipython"]`` for a native tool +agent). """ from __future__ import annotations -import os +from collections.abc import Sequence from rlm.tools.base import BuiltinTool from rlm.tools.bash import BashTool, EditTool @@ -16,82 +17,57 @@ from rlm.tools.ipython import IpythonTool # All registered tools by name. Tests (and extensions) may add entries; names -# listed in RLM_BUILTIN_TOOLS or the active preset become active. +# listed in the contract's `builtin_tools` or the default set become active. _TOOLS_BY_NAME: dict[str, BuiltinTool] = { "bash": BashTool(), "edit": EditTool(), "fetch": FetchTool(), "ipython": IpythonTool(), } -# NOT an activation list — activation comes from the RLM_TOOLING preset or -# RLM_BUILTIN_TOOLS. _STOCK only marks the shipped tools so they are excluded from -# the extras rule below, which auto-activates entries registered at runtime (test -# fixtures/extensions). A stock tool outside every preset (`fetch` — network-capable) -# therefore runs only when RLM_BUILTIN_TOOLS names it. +# NOT an activation list — activation comes from the contract's `builtin_tools` +# (None = DEFAULT_TOOLS). _STOCK only marks the shipped tools so they are excluded +# from the extras rule below, which auto-activates entries registered at runtime +# (test fixtures/extensions). A stock tool outside the default set (`fetch` — +# network-capable) therefore runs only when `builtin_tools` names it. _STOCK = ("bash", "edit", "fetch", "ipython") +DEFAULT_TOOLS = ("ipython",) -_TOOLING_PRESETS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { - # preset -> (builtin tools, builtin skills) - "dual": (("bash", "edit", "ipython"), ("bash", "edit")), - "tools": (("bash", "edit", "ipython"), ()), - "skills": (("ipython",), ("bash", "edit")), -} - - -def tooling_preset() -> str: - """The RLM_TOOLING preset name: skills (default), tools, or dual.""" - preset = os.environ.get("RLM_TOOLING", "skills").strip() or "skills" - if preset not in _TOOLING_PRESETS: - raise ValueError( - f"RLM_TOOLING must be one of {sorted(_TOOLING_PRESETS)}, got {preset!r}" - ) - return preset - - -def preset_tools() -> tuple[str, ...]: - return _TOOLING_PRESETS[tooling_preset()][0] - - -def preset_skills() -> tuple[str, ...]: - return _TOOLING_PRESETS[tooling_preset()][1] - - -def _selected() -> tuple[BuiltinTool, ...]: - spec = os.environ.get("RLM_BUILTIN_TOOLS", "").strip() - if spec: - names = [n.strip() for n in spec.split(",") if n.strip()] +def _selected(names: Sequence[str] | None) -> tuple[BuiltinTool, ...]: + if names is not None: unknown = [n for n in names if n not in _TOOLS_BY_NAME] if unknown: raise ValueError( - f"RLM_BUILTIN_TOOLS: unknown tool(s) {unknown}; " + f"builtin_tools: unknown tool(s) {unknown}; " f"available: {sorted(_TOOLS_BY_NAME)}" ) return tuple(_TOOLS_BY_NAME[n] for n in names) - # default: the RLM_TOOLING preset's tools plus any extra registered tools - # (fixtures/extensions). - preset = preset_tools() + # default: ipython plus any extra registered tools (fixtures/extensions). extras = [n for n in _TOOLS_BY_NAME if n not in _STOCK] - return tuple(_TOOLS_BY_NAME[n] for n in [*preset, *extras]) + return tuple(_TOOLS_BY_NAME[n] for n in [*DEFAULT_TOOLS, *extras]) -def get_active_builtin_tools(exec_timeout: int = 300) -> list[BuiltinTool]: - """Return the active tools (per RLM_TOOLING/RLM_BUILTIN_TOOLS), with an - engine-specific IPython schema.""" +def get_active_builtin_tools( + exec_timeout: int = 300, names: Sequence[str] | None = None +) -> list[BuiltinTool]: + """Return the active tools (the contract's `builtin_tools`, else the + default set), with an engine-specific IPython schema.""" return [ IpythonTool(exec_timeout) if tool.name == "ipython" else tool - for tool in _selected() + for tool in _selected(names) ] -def get_active_tools() -> list[dict]: +def get_active_tools(names: Sequence[str] | None = None) -> list[dict]: """Return OpenAI tool schemas for the active builtins.""" - return [tool.schema() for tool in _selected()] + return [tool.schema() for tool in _selected(names)] -def get_builtin_tool(name: str) -> BuiltinTool | None: - """Look up a builtin tool handler by name (None if unknown).""" - for tool in _selected(): +def get_builtin_tool( + name: str, names: Sequence[str] | None = None +) -> BuiltinTool | None: + """Look up an active builtin tool handler by name (None if unknown or inactive).""" + for tool in _selected(names): if tool.name == name: return tool return None diff --git a/tests/test_acp.py b/tests/test_acp.py index 4f284c3..0979220 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -540,7 +540,9 @@ def finish_interrupt(self): def shutdown(self): self.stopped = True - monkeypatch.setattr("rlm.engine.get_builtin_tool", lambda name: FailingTool()) + monkeypatch.setattr( + "rlm.engine.get_builtin_tool", lambda name, names=None: FailingTool() + ) client = DummyClient( [ DummyMessage(tool_calls=[DummyToolCall("failing", {})]), diff --git a/tests/test_tools.py b/tests/test_tools.py index a0b9457..026d107 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -100,28 +100,20 @@ def test_ipython_kernel_does_not_inherit_parent_stdio(session, capfd): assert result.strip() == "4 14" -def test_tooling_presets(monkeypatch): - from rlm.tools.registry import preset_skills, preset_tools - - monkeypatch.delenv("RLM_TOOLING", raising=False) - assert preset_tools() == ("ipython",) - assert preset_skills() == ("bash", "edit") - - monkeypatch.setenv("RLM_TOOLING", "dual") - assert preset_tools() == ("bash", "edit", "ipython") - assert preset_skills() == ("bash", "edit") +def test_builtin_tools_selection(): + import pytest - monkeypatch.setenv("RLM_TOOLING", "tools") - assert preset_skills() == () + from rlm.tools.registry import get_active_builtin_tools - monkeypatch.setenv("RLM_TOOLING", "skills") - assert preset_tools() == ("ipython",) + active = [tool.name for tool in get_active_builtin_tools(names=None)] + assert "ipython" in active + assert "bash" not in active # stock tools activate only when named - monkeypatch.setenv("RLM_TOOLING", "bogus") - import pytest + names = ["bash", "edit", "ipython"] + assert [t.name for t in get_active_builtin_tools(names=names)] == names - with pytest.raises(ValueError): - preset_tools() + with pytest.raises(ValueError, match="unknown tool"): + get_active_builtin_tools(names=["bogus"]) def test_real_kernel_and_subprocess_receive_only_explicit_environment( @@ -239,16 +231,13 @@ def test_truncate_tool_output_caps_and_reports(): assert "Total output lines: 10001" in out -def test_fetch_tool_is_opt_in(monkeypatch): +def test_fetch_tool_is_opt_in(): """fetch is registered but joins the active set only when named explicitly.""" from rlm.tools.registry import get_active_builtin_tools - monkeypatch.delenv("RLM_BUILTIN_TOOLS", raising=False) - monkeypatch.delenv("RLM_TOOLING", raising=False) assert "fetch" not in [tool.name for tool in get_active_builtin_tools()] - - monkeypatch.setenv("RLM_BUILTIN_TOOLS", "ipython,fetch") - assert [tool.name for tool in get_active_builtin_tools()] == ["ipython", "fetch"] + active = [t.name for t in get_active_builtin_tools(names=["ipython", "fetch"])] + assert active == ["ipython", "fetch"] def test_fetch_tool_validates_args():