Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
43 changes: 43 additions & 0 deletions src/rlm/skills/bash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""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, fresh
subshell, like a plain bash-agent tool but surfaced as a skill.
"""
Comment thread
cursor[bot] marked this conversation as resolved.

from __future__ import annotations

import asyncio


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

Args:
command: The shell command to run.
timeout: Seconds before the command is killed.

Returns:
stdout and stderr of the command (with the exit code when nonzero).
"""
proc = await asyncio.create_subprocess_exec(
"bash",
"-c",
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
return f"Error: command timed out after {timeout}s"
out = out_b.decode(errors="replace")
err = err_b.decode(errors="replace")
result = out + (("\n" + err) if err else "")
if proc.returncode != 0:
result += f"\n[exit code {proc.returncode}]"
result = result.strip() or "(no output)"
if len(result) > 30_000:
result = result[:30_000] + f"\n... [truncated {len(result) - 30_000} chars]"
return result
21 changes: 21 additions & 0 deletions tests/test_builtin_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
from rlm.engine import RLMEngine
from rlm.skills import available_builtin_skills, enable_builtin_skills
from rlm.skills.bash import run as bash
from rlm.skills.edit import run as edit
from rlm.skills.search import format_results
from rlm.skills.search import run as run_search
Expand Down Expand Up @@ -155,3 +156,23 @@ async def post(self, url, **kwargs):
assert secret not in source
meta = json.loads((session.dir / "meta.json").read_text())
assert meta["programmatic_tool_call_stats"]["by_tool_python"] == {"search": 1}


async def test_bash_returns_output():
assert await bash("echo hello") == "hello"


async def test_bash_runs_real_bash_not_sh():
# process substitution is a bashism that /bin/sh (dash) rejects
out = await bash("cat <(echo bashism-works)")
assert "bashism-works" in out


async def test_bash_nonzero_exit_reported():
out = await bash("exit 3")
assert "[exit code 3]" in out


async def test_bash_combines_stderr():
out = await bash("echo out && echo err >&2")
assert "out" in out and "err" in out
Loading