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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/rlm/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing_extensions import Self

from rlm.tools.registry import preset_skills


PI_INFERENCE_BASE_URL = "https://api.pinference.ai/api/v1"
KERNEL_ENV_CONFIG_ENV = "RLM_KERNEL_ENV"
Expand Down Expand Up @@ -158,7 +160,7 @@ def from_env(
environ: Mapping[str, str] | None = None,
) -> RuntimeConfig:
env = os.environ if environ is None else environ
raw_skills = env.get("RLM_SKILLS", "")
raw_skills = env.get("RLM_SKILLS")
max_depth = int(env.get("RLM_MAX_DEPTH", "0"))
default_concurrency = max(4, max_depth)
max_concurrent_subagents = _positive_int(
Expand Down Expand Up @@ -194,7 +196,11 @@ def from_env(
),
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()),
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"),
)
21 changes: 13 additions & 8 deletions src/rlm/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,19 @@
# the message is compacted in place of them.
CHECKPOINT_COMPACTION_PROMPT = (
"You are performing a CONTEXT CHECKPOINT COMPACTION. "
"Create a handoff summary for another LLM that will resume the task.\n"
"Create a handoff summary another LLM can ACT on immediately to resume the task.\n"
"\n"
"Include:\n"
"- Current progress and key decisions made\n"
"- Important context, constraints, or user preferences\n"
"- What remains to be done (clear next steps)\n"
"- Any critical data, examples, or references needed to continue\n"
"It MUST contain, as fenced code blocks (not prose):\n"
"- The exact shell/test command(s) to reproduce and verify — copy-pasteable, "
"with the real path and test filter\n"
"- Any edit still to apply, as the concrete "
"`await edit(path=..., old_str=..., new_str=...)` call\n"
"\n"
"Be concise, structured, and focused on helping the next LLM "
"seamlessly continue the work."
"Then:\n"
"- A NUMBERED list of remaining next steps\n"
"- Current progress, key decisions, and constraints\n"
"\n"
"Be concise and concrete: prefer runnable commands over descriptions."
)

# Appended to the checkpoint prompt when the IPython REPL is active.
Expand Down Expand Up @@ -338,6 +341,8 @@ async def _start(self, prompt: str) -> None:
depth=self.depth,
max_depth=self.max_depth,
broker_endpoint=broker_endpoint,
exec_timeout=self.exec_timeout,
allow_git=self.allow_git,
)
try:
self._repl.start()
Expand Down
78 changes: 43 additions & 35 deletions src/rlm/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,41 +34,19 @@
"`--glob`, `--alternate-refs`, `--reflog`, `--walk-reflogs`, or `-g` will "
"be refused."
)
PROJECT_ENV_PROMPT = (
"The ipython kernel is an isolated venv without the project's packages — "
"never import project modules there. Everything that executes project code "
"(tests, repros, imports) goes through bash with the project's interpreter."
)
IPYTHON_CONTROL_PROMPT = (
"IPython is the agent's long-lived notebook: a persistent control "
"environment for reasoning, context management, state, tool orchestration, "
"and recursive subcalls. Use it to keep intermediate variables, inspect "
"and transform outputs, write small helper functions, and preserve useful "
"state across turns or compaction.\n\n"
"Do not assume IPython is the native runtime of the external thing being "
"investigated. A repository, package, service, dataset, paper, website, "
"benchmark, or API may have its own environment and normal interface. "
"Evaluate external systems through their own interface, then use IPython "
"to coordinate the process and analyze what comes back.\n\n"
"When running shell commands from IPython, use `%%bash` cells. If you use "
"`%%bash`, it must be the first line of the code cell: no comments, "
"spaces, blank lines, imports, or Python statements before it. Avoid "
"`!cmd` shell escapes for project commands so shell behavior is explicit "
"and multi-line commands share one shell context.\n\n"
"Important: do not install dependencies into the IPython kernel just to "
"make an external project import or run there. If a project import, test, "
"script, CLI, or dependency check is needed, run it through that project's "
"own environment and normal command interface. For example, in a Python "
"repo use its documented commands, `uv run ...`, `.venv/bin/python ...`, "
"or the active project interpreter from the repo root. Treat failures from "
"that native environment as the relevant result."
"\n\n"
"Use Python for reading, searching, and editing files — it gives you "
"reusable variables you can slice, filter, and act on without re-reading. "
"Always assign read/search results to named variables so you can revisit "
"them later."
"Run shell commands with `%%bash` as the very first line of a code cell "
"(no comments, imports, or statements before it). " + PROJECT_ENV_PROMPT
)
EDIT_SKILL_PROMPT = (
"For targeted existing-file edits, prefer the pre-imported async `edit` "
"skill from IPython: `old = '''...'''; new = '''...'''; await "
'edit(path="pkg/file.py", old_str=old, new_str=new)`. Use exact '
"old/new strings; if the text contains triple double quotes, use triple "
"single-quoted variables or build `old`/`new` from inspected file slices."
"Inside ipython you can also edit files with the pre-imported async `edit` "
'skill: `await edit(path="pkg/file.py", old_str=..., new_str=...)` — handy '
"for multiline or quote-heavy replacements built from Python strings."
)
SEARCH_SKILL_PROMPT = (
"For web search, use the pre-imported async `search` skill from IPython: "
Expand All @@ -95,9 +73,24 @@ def build_system_prompt(
per-tool schemas, so redundant tool guidance here just inflates
every request.
"""
has_bash = _has_tool(active_tools, "bash")
has_edit = _has_tool(active_tools, "edit")
has_ipython = _has_tool(active_tools, "ipython")
role = "You are a coding agent."
if has_bash:
role += " You have access to a bash tool for running shell commands."
if has_edit:
role += (
" You also have an edit tool for single-occurrence string "
"replacement in a file."
)
if has_ipython:
role += (
" You also have an ipython tool: a persistent Python REPL "
"(variables, imports, and function definitions persist across calls)."
)
parts: list[str] = [
"You are a general purpose agent that uses code to solve tasks.",
"You solve tasks by breaking down problems into sub-tasks, writing and executing code, observing results, and iterating one step at a time.",
role,
"When you are done, stop calling tools and state your final answer.",
"",
f"Working directory: {cwd}",
Expand Down Expand Up @@ -127,6 +120,19 @@ def build_system_prompt(
)
else:
skill_lines.append("The listed skills are IPython-only.")
if "bash" in installed_skills and _has_tool(active_tools, "bash"):
skill_lines.append(
"Inside ipython you can also run shell with `await bash(command=...)` — "
"it returns the output as a string, useful when mixing shell and Python "
"in one cell or avoiding shell quoting."
)
elif "bash" in installed_skills:
skill_lines.append(
"Run shell with `out = await bash('''command here''')` — always "
"triple-quote the command so shell quotes and multi-line scripts never "
"need escaping. It returns the output as a string; no need for "
"`subprocess` or `%%bash`. Chain related commands with && in one call."
)
if "edit" in installed_skills:
skill_lines.append(EDIT_SKILL_PROMPT)
if "search" in installed_skills:
Expand All @@ -143,8 +149,10 @@ def build_system_prompt(
]
)

if _has_tool(active_tools, "ipython"):
if has_ipython and not has_bash and "bash" not in (installed_skills or []):
parts.extend(["", IPYTHON_CONTROL_PROMPT])
elif has_ipython:
parts.extend(["", PROJECT_ENV_PROMPT])

if _should_include_git_history_guard(active_tools, allow_git):
parts.extend(["", GIT_HISTORY_GUARD_PROMPT])
Expand Down
30 changes: 5 additions & 25 deletions src/rlm/skills/bash.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,24 @@
"""Built-in ``bash`` skill — run a shell command from the REPL.

Enabled via ``RLM_SKILLS``; pre-imported into the IPython kernel so the agent calls
``await bash(command="...")`` (or ``await bash("...")``). One command per call, a fresh
``bash -c`` subshell, with the same git-history guard as shell tool paths.
``await bash(command="...")`` (or ``await bash("...")``). Identical semantics to the
``bash`` tool — same ``bash -c`` execution, git-history guard, and output contract —
via the shared runner in ``rlm.tools.bash``.
"""

from __future__ import annotations

import asyncio
import os
import subprocess

from rlm.tools.git_block import find_blocked_command, refusal
from rlm.tools.bash import run_bash


def _default_timeout() -> int:
raw = os.environ.get("RLM_EXEC_TIMEOUT", "")
return int(raw) if raw.isdigit() and int(raw) > 0 else 300


def _run_bash(command: str, timeout: int) -> str:
"""Execute via ``bash -c`` (never /bin/sh) and return combined output."""
blocked = find_blocked_command(command)
if blocked:
return refusal(blocked)
try:
proc = subprocess.run(
["bash", "-c", command],
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return f"Error: command timed out after {timeout}s"
out = proc.stdout + (("\n" + proc.stderr) if proc.stderr else "")
if proc.returncode != 0:
out += f"\n[exit code {proc.returncode}]"
return out.strip() or "(no output)"


async def run(command: str, timeout: int | None = None) -> str:
"""Run a shell command and return its combined output.

Expand All @@ -50,4 +30,4 @@ async def run(command: str, timeout: int | None = None) -> str:
Returns:
stdout and stderr of the command (with the exit code when nonzero).
"""
return await asyncio.to_thread(_run_bash, command, timeout or _default_timeout())
return await asyncio.to_thread(run_bash, command, timeout or _default_timeout())
131 changes: 131 additions & 0 deletions src/rlm/tools/bash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""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``.
"""

from __future__ import annotations

import os
import subprocess
from typing import Any

from rlm.tools.base import ToolContext, ToolOutcome
from rlm.tools.git_block import find_blocked_command, refusal

BASH_SCHEMA = {
"type": "function",
"function": {
"name": "bash",
"description": "Run a shell command and return its output (stdout and stderr).",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to run.",
},
},
"required": ["command"],
},
},
}

EDIT_SCHEMA = {
"type": "function",
"function": {
"name": "edit",
"description": (
"Replace a string in a file. old_str must occur exactly once in the file; "
"the file is rewritten with old_str replaced by new_str."
),
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path of the file to edit."},
"old_str": {
"type": "string",
"description": "Exact string to replace (must be unique).",
},
"new_str": {"type": "string", "description": "Replacement string."},
},
"required": ["path", "old_str", "new_str"],
},
},
}


def run_bash(
command: str,
timeout: int,
cwd: str | None = None,
allow_git: bool | None = None,
) -> str:
"""Guarded ``bash -c`` execution shared by the bash tool and the bash skill."""
if not isinstance(command, str) or not command.strip():
return "Error: empty command"
blocked = find_blocked_command(command, allow_git=allow_git)
if blocked:
return refusal(blocked)
try:
proc = subprocess.run(
["bash", "-c", command],
capture_output=True,
text=True,
timeout=timeout,
cwd=cwd or None,
)
except subprocess.TimeoutExpired:
return f"Error: command timed out after {timeout}s"
out = proc.stdout + (("\n" + proc.stderr) if proc.stderr else "")
if proc.returncode != 0:
out += f"\n[exit code {proc.returncode}]"
return out.strip() or "(no output)"


class BashTool:
"""One shell command per call, fresh subshell, cwd-anchored."""

name = "bash"

def schema(self) -> dict[str, Any]:
return BASH_SCHEMA

def execute(self, args: dict[str, Any], context: ToolContext) -> ToolOutcome:
return ToolOutcome(
content=run_bash(
args.get("command", ""),
context.exec_timeout,
cwd=context.cwd,
allow_git=context.allow_git,
)
)
Comment thread
cursor[bot] marked this conversation as resolved.


class EditTool:
"""Single-occurrence string replacement, mirroring the edit skill semantics."""

name = "edit"

def schema(self) -> dict[str, Any]:
return EDIT_SCHEMA

def execute(self, args: dict[str, Any], context: ToolContext) -> ToolOutcome:
path, old, new = args.get("path"), args.get("old_str"), args.get("new_str")
if not path or old is None or new is None:
return ToolOutcome(content="Error: path, old_str and new_str are required")
if context.cwd and not os.path.isabs(path):
path = os.path.join(context.cwd, path)
try:
text = open(path, encoding="utf-8").read()
except OSError as e:
return ToolOutcome(content=f"Error: cannot read {path}: {e}")
count = text.count(old)
if count == 0:
return ToolOutcome(content=f"Error: old_str not found in {path}")
if count > 1:
return ToolOutcome(
content=f"Error: old_str occurs {count} times in {path}; must be unique"
)
open(path, "w", encoding="utf-8").write(text.replace(old, new, 1))
return ToolOutcome(content=f"Edited {path} (1 replacement).")
Comment thread
cursor[bot] marked this conversation as resolved.
8 changes: 8 additions & 0 deletions src/rlm/tools/ipython.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,17 @@ def __init__(
depth: int | None = None,
max_depth: int | None = None,
broker_endpoint: BrokerEndpoint | None = None,
exec_timeout: int | None = None,
allow_git: bool | None = None,
):
self.cwd = cwd
self.session = session
self.kernel_env = dict(kernel_env or {})
self.depth = depth
self.max_depth = max_depth
self.broker_endpoint = broker_endpoint
self.exec_timeout = exec_timeout
self.allow_git = allow_git
self._km = None
self._kc = None
self._ipc_dir = None
Expand Down Expand Up @@ -256,6 +260,10 @@ def _inject_startup(self):
os.environ['RLM_SESSION_DIR'] = {session_dir!r} or ''
os.environ['RLM_DEPTH'] = str({depth!r} + 1)
os.environ['NO_COLOR'] = '1'
if {self.exec_timeout!r} is not None:
os.environ['RLM_EXEC_TIMEOUT'] = str({self.exec_timeout!r})
if {self.allow_git!r} is not None:
os.environ['RLM_ALLOW_GIT'] = '1' if {self.allow_git!r} else '0'

import nest_asyncio
nest_asyncio.apply()
Expand Down
Loading
Loading