From 45c57e472a0e84989dd69a512d58850ec6f89ba6 Mon Sep 17 00:00:00 2001 From: Sami Jaghouar Date: Mon, 10 Aug 2026 18:49:40 -0700 Subject: [PATCH 01/12] feat(coding-agent): add async bash() to IPython kernel Add an async bash() function to the RLM bootstrap code in ipython.ts that uses asyncio.create_subprocess_exec to run shell commands without blocking the kernel event loop. Unlike %%bash cells (which block the kernel until the command finishes), await bash('...') keeps the kernel responsive to interrupts and other messages while the process runs. Supports optional timeout (raises TimeoutError) and cwd parameters. Returns a _PrimeAgentBashResult with stdout, stderr, and returncode. Update the RLM system prompt to prefer await bash('...') over %%bash cells. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/prompts/rlm.ts | 2 +- .../coding-agent/src/core/tools/ipython.ts | 71 +++++++++++++++++++ .../coding-agent/test/system-prompt.test.ts | 3 +- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bfd4abb60..cc556d23f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Added an async `bash()` function to the IPython kernel so `await bash("...")` runs shell commands without blocking the kernel event loop. - Fixed URLs not opening on click in fullscreen mode on terminals such as Ghostty; clicking a link in the transcript, dock, or overlays now opens it in the browser. ## [0.7.2] - 2026-08-11 diff --git a/packages/coding-agent/src/core/prompts/rlm.ts b/packages/coding-agent/src/core/prompts/rlm.ts index 7e023d527..a463a02ac 100644 --- a/packages/coding-agent/src/core/prompts/rlm.ts +++ b/packages/coding-agent/src/core/prompts/rlm.ts @@ -16,7 +16,7 @@ const IPYTHON_CONTROL_PROMPT = [ "", "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.", "", - "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.", + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it runs the command via `asyncio.create_subprocess_exec` so the kernel event loop stays responsive to interrupts while the process runs. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 1be4321d5..053e6289a 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -34,6 +34,77 @@ try: except Exception: pass +import asyncio as _prime_agent_asyncio +import subprocess as _prime_agent_subprocess +import shutil as _prime_agent_shutil +import os as _prime_agent_bash_os + + +class _PrimeAgentBashResult: + """Result of an async bash command.""" + def __init__(self, stdout: str, stderr: str, returncode: int): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + def __str__(self) -> str: + parts = [] + if self.stdout: + parts.append(self.stdout) + if self.stderr: + parts.append(self.stderr) + text = chr(10).join(parts) + if self.returncode != 0: + text += chr(10) + chr(10) + f"Command exited with code {self.returncode}" + return text + + def __repr__(self) -> str: + return f"BashResult(returncode={self.returncode}, stdout={len(self.stdout)} chars)" + + +async def bash(command: str, *, timeout: float | None = None, cwd: str | None = None) -> _PrimeAgentBashResult: + """Run a shell command asynchronously without blocking the IPython kernel event loop. + + Unlike %%bash cells (which block the kernel), 'await bash("...")' uses + asyncio.create_subprocess_exec so the kernel stays responsive to interrupts + and other messages while the command runs. + + Args: + command: Shell command string. + timeout: Optional timeout in seconds. Raises TimeoutError if exceeded. + cwd: Working directory (defaults to kernel cwd). + + Returns: + _PrimeAgentBashResult with stdout, stderr, and returncode. + """ + shell = _prime_agent_shutil.which("bash") or "/bin/bash" + env = dict(_prime_agent_bash_os.environ) + work_dir = cwd or _prime_agent_bash_os.getcwd() + + proc = await _prime_agent_asyncio.create_subprocess_exec( + shell, "-c", command, + stdout=_prime_agent_subprocess.PIPE, + stderr=_prime_agent_subprocess.PIPE, + cwd=work_dir, + env=env, + ) + + try: + stdout_bytes, stderr_bytes = await _prime_agent_asyncio.wait_for( + proc.communicate(), timeout=timeout + ) + except _prime_agent_asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise TimeoutError(f"bash command timed out after {timeout}s: {command}") + + return _PrimeAgentBashResult( + stdout=stdout_bytes.decode("utf-8", errors="replace"), + stderr=stderr_bytes.decode("utf-8", errors="replace"), + returncode=proc.returncode if proc.returncode is not None else -1, + ) + + try: import rlm as _prime_agent_rlm_module rlm = _prime_agent_rlm_module.rlm diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index bbb128ef4..a178f215d 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -66,7 +66,7 @@ describe("buildRlmPrompt", () => { "", "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.", "", - "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.", + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it runs the command via `asyncio.create_subprocess_exec` so the kernel event loop stays responsive to interrupts while the process runs. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", @@ -98,6 +98,7 @@ describe("buildRlmPrompt", () => { expect(prompt).toContain("A callable `rlm` is already in your global namespace"); expect(prompt).toContain("IPython is the agent's long-lived notebook"); expect(prompt).toContain("Each `%%bash` cell runs in a throw-away subshell"); + expect(prompt).toContain("prefer `await bash"); }); test("discovers requested models through a bounded authenticated host search", () => { From ff15d7b0612627f5f5dbd10fc4e935aab05ac236 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:16:07 -0700 Subject: [PATCH 02/12] fix(coding-agent): bound async bash subprocesses --- packages/coding-agent/src/core/prompts/rlm.ts | 2 +- .../coding-agent/src/core/tools/ipython.ts | 202 ++++++++++++++++-- .../test/ipython-bootstrap.test.ts | 72 +++++++ .../coding-agent/test/system-prompt.test.ts | 2 +- 4 files changed, 253 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/core/prompts/rlm.ts b/packages/coding-agent/src/core/prompts/rlm.ts index a463a02ac..69f73d04a 100644 --- a/packages/coding-agent/src/core/prompts/rlm.ts +++ b/packages/coding-agent/src/core/prompts/rlm.ts @@ -16,7 +16,7 @@ const IPYTHON_CONTROL_PROMPT = [ "", "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.", "", - 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it runs the command via `asyncio.create_subprocess_exec` so the kernel event loop stays responsive to interrupts while the process runs. `%%bash` cells block the kernel until the command finishes. If you do 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.', + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up the command process tree on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 053e6289a..edf381819 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -37,15 +37,59 @@ except Exception: import asyncio as _prime_agent_asyncio import subprocess as _prime_agent_subprocess import shutil as _prime_agent_shutil +import signal as _prime_agent_signal import os as _prime_agent_bash_os +_PRIME_AGENT_BASH_CAPTURE_LIMIT = 1024 * 1024 +_PRIME_AGENT_BASH_READ_CHUNK = 64 * 1024 +_PRIME_AGENT_BASH_STOP_GRACE = 0.25 + + +class _PrimeAgentBoundedBytes: + """Tail buffer with a fixed memory ceiling and total-byte accounting.""" + def __init__(self, limit: int): + self.limit = limit + self.data = bytearray() + self.total = 0 + + def append(self, chunk: bytes) -> None: + self.total += len(chunk) + if len(chunk) >= self.limit: + self.data[:] = chunk[-self.limit:] + return + overflow = len(self.data) + len(chunk) - self.limit + if overflow > 0: + del self.data[:overflow] + self.data.extend(chunk) + + @property + def truncated(self) -> bool: + return self.total > len(self.data) + + def text(self) -> str: + return bytes(self.data).decode("utf-8", errors="replace") + class _PrimeAgentBashResult: - """Result of an async bash command.""" - def __init__(self, stdout: str, stderr: str, returncode: int): + """Result of an async bash command with bounded captured output.""" + def __init__( + self, + stdout: str, + stderr: str, + returncode: int, + *, + stdout_truncated: bool = False, + stderr_truncated: bool = False, + stdout_total_bytes: int = 0, + stderr_total_bytes: int = 0, + ): self.stdout = stdout self.stderr = stderr self.returncode = returncode + self.stdout_truncated = stdout_truncated + self.stderr_truncated = stderr_truncated + self.stdout_total_bytes = stdout_total_bytes + self.stderr_total_bytes = stderr_total_bytes def __str__(self) -> str: parts = [] @@ -59,49 +103,161 @@ class _PrimeAgentBashResult: return text def __repr__(self) -> str: - return f"BashResult(returncode={self.returncode}, stdout={len(self.stdout)} chars)" + truncated = self.stdout_truncated or self.stderr_truncated + return ( + f"BashResult(returncode={self.returncode}, stdout={len(self.stdout)} chars, " + f"stderr={len(self.stderr)} chars, truncated={truncated})" + ) + + +async def _prime_agent_bash_drain(stream, capture: _PrimeAgentBoundedBytes) -> None: + while True: + chunk = await stream.read(_PRIME_AGENT_BASH_READ_CHUNK) + if not chunk: + return + capture.append(chunk) + + +async def _prime_agent_bash_collect(proc, stdout_task, stderr_task) -> None: + await proc.wait() + await _prime_agent_asyncio.gather(stdout_task, stderr_task) + + +def _prime_agent_bash_signal_group(proc, sig) -> None: + # The shell may have exited while descendants still hold its pipes open, so + # always signal the session by the original leader PID. + try: + _prime_agent_bash_os.killpg(proc.pid, sig) + except (ProcessLookupError, PermissionError): + pass -async def bash(command: str, *, timeout: float | None = None, cwd: str | None = None) -> _PrimeAgentBashResult: - """Run a shell command asynchronously without blocking the IPython kernel event loop. +async def _prime_agent_bash_taskkill(proc) -> None: + # taskkill /T can still find descendants by the original shell PID after + # the shell itself exits, so do not short-circuit on proc.returncode. + taskkill = _prime_agent_shutil.which("taskkill") + if taskkill: + killer = await _prime_agent_asyncio.create_subprocess_exec( + taskkill, "/PID", str(proc.pid), "/T", "/F", + stdin=_prime_agent_subprocess.DEVNULL, + stdout=_prime_agent_subprocess.DEVNULL, + stderr=_prime_agent_subprocess.DEVNULL, + ) + try: + await _prime_agent_asyncio.wait_for(_prime_agent_asyncio.shield(killer.wait()), timeout=2) + except _prime_agent_asyncio.TimeoutError: + killer.kill() + await killer.wait() + except BaseException: + if killer.returncode is None: + killer.kill() + await _prime_agent_asyncio.shield(killer.wait()) + raise + if proc.returncode is None: + proc.kill() - Unlike %%bash cells (which block the kernel), 'await bash("...")' uses - asyncio.create_subprocess_exec so the kernel stays responsive to interrupts - and other messages while the command runs. - Args: - command: Shell command string. - timeout: Optional timeout in seconds. Raises TimeoutError if exceeded. - cwd: Working directory (defaults to kernel cwd). +async def _prime_agent_bash_stop(proc, collector, stdout_task, stderr_task) -> None: + """Stop the command tree and finish or cancel every pipe-drain task.""" + if _prime_agent_bash_os.name == "nt": + await _prime_agent_bash_taskkill(proc) + else: + _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM) + try: + await _prime_agent_asyncio.wait_for( + _prime_agent_asyncio.shield(collector), timeout=_PRIME_AGENT_BASH_STOP_GRACE + ) + return + except _prime_agent_asyncio.TimeoutError: + _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGKILL) - Returns: - _PrimeAgentBashResult with stdout, stderr, and returncode. + try: + await _prime_agent_asyncio.wait_for(_prime_agent_asyncio.shield(collector), timeout=2) + return + except _prime_agent_asyncio.TimeoutError: + if proc.returncode is None: + proc.kill() + finally: + if not collector.done(): + collector.cancel() + for task in (stdout_task, stderr_task): + if not task.done(): + task.cancel() + await _prime_agent_asyncio.gather(collector, stdout_task, stderr_task, return_exceptions=True) + + +async def bash( + command: str, + *, + timeout: float | None = None, + cwd: str | None = None, + max_output_bytes: int = _PRIME_AGENT_BASH_CAPTURE_LIMIT, +) -> _PrimeAgentBashResult: + """Run a shell command without blocking the IPython kernel event loop. + + At most max_output_bytes from the tail of each output stream is retained; + both pipes are continuously drained so large output cannot deadlock or grow + the persistent kernel without bound. Truncation and total byte counts are + reported on the result. """ - shell = _prime_agent_shutil.which("bash") or "/bin/bash" + if not isinstance(max_output_bytes, int) or max_output_bytes <= 0: + raise ValueError("max_output_bytes must be a positive integer") + shell = _prime_agent_shutil.which("bash") + if shell is None: + if _prime_agent_bash_os.name == "nt": + raise RuntimeError("bash() requires bash.exe on Windows") + shell = "/bin/bash" env = dict(_prime_agent_bash_os.environ) work_dir = cwd or _prime_agent_bash_os.getcwd() + spawn_options = {} + if _prime_agent_bash_os.name == "nt": + spawn_options["creationflags"] = _prime_agent_subprocess.CREATE_NEW_PROCESS_GROUP + else: + spawn_options["start_new_session"] = True proc = await _prime_agent_asyncio.create_subprocess_exec( shell, "-c", command, + stdin=_prime_agent_subprocess.DEVNULL, stdout=_prime_agent_subprocess.PIPE, stderr=_prime_agent_subprocess.PIPE, cwd=work_dir, env=env, + limit=_PRIME_AGENT_BASH_READ_CHUNK, + **spawn_options, ) + stdout_capture = _PrimeAgentBoundedBytes(max_output_bytes) + stderr_capture = _PrimeAgentBoundedBytes(max_output_bytes) + stdout_task = _prime_agent_asyncio.create_task(_prime_agent_bash_drain(proc.stdout, stdout_capture)) + stderr_task = _prime_agent_asyncio.create_task(_prime_agent_bash_drain(proc.stderr, stderr_capture)) + collector = _prime_agent_asyncio.create_task(_prime_agent_bash_collect(proc, stdout_task, stderr_task)) try: - stdout_bytes, stderr_bytes = await _prime_agent_asyncio.wait_for( - proc.communicate(), timeout=timeout - ) + await _prime_agent_asyncio.wait_for(_prime_agent_asyncio.shield(collector), timeout=timeout) except _prime_agent_asyncio.TimeoutError: - proc.kill() - await proc.wait() - raise TimeoutError(f"bash command timed out after {timeout}s: {command}") + cleanup = _prime_agent_asyncio.create_task( + _prime_agent_bash_stop(proc, collector, stdout_task, stderr_task) + ) + await _prime_agent_asyncio.shield(cleanup) + raise TimeoutError(f"bash command timed out after {timeout}s") from None + except BaseException: + cleanup = _prime_agent_asyncio.create_task( + _prime_agent_bash_stop(proc, collector, stdout_task, stderr_task) + ) + await _prime_agent_asyncio.shield(cleanup) + raise + finally: + if not collector.done() and proc.returncode is not None: + collector.cancel() + await _prime_agent_asyncio.gather(collector, return_exceptions=True) return _PrimeAgentBashResult( - stdout=stdout_bytes.decode("utf-8", errors="replace"), - stderr=stderr_bytes.decode("utf-8", errors="replace"), + stdout=stdout_capture.text(), + stderr=stderr_capture.text(), returncode=proc.returncode if proc.returncode is not None else -1, + stdout_truncated=stdout_capture.truncated, + stderr_truncated=stderr_capture.truncated, + stdout_total_bytes=stdout_capture.total, + stderr_total_bytes=stderr_capture.total, ) diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index 8aba99b46..dab0399ff 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -23,6 +23,20 @@ describe("IPython RLM bootstrap", () => { expect(buildRlmBootstrapCode()).toContain('_prime_agent_os.environ["NO_COLOR"] = "1"'); }); + it("bounds async bash output and cleans process groups on exceptional exits", () => { + const code = buildRlmBootstrapCode(); + expect(code).not.toContain("proc.communicate()"); + expect(code).toContain("_PrimeAgentBoundedBytes"); + expect(code).toContain("start_new_session"); + expect(code).toContain("killpg"); + expect(code).toContain("except BaseException"); + expect(code).toContain('taskkill, "/PID", str(proc.pid), "/T", "/F"'); + expect(code).toContain("await killer.wait()"); + expect(code).not.toContain( + "async def _prime_agent_bash_taskkill(proc) -> None:\n if proc.returncode is not None", + ); + }); + it("guards Python skill imports so a broken skill does not abort bootstrap", () => { const code = buildRlmBootstrapCode([ { @@ -83,6 +97,64 @@ describeIfKernel("IPython RLM bootstrap (real kernel)", () => { } }, 60_000); + it("runs async bash with bounded stdout and stderr capture", async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const code = [ + "import sys", + 'r = await bash("yes A | head -c 200000; yes B | head -c 200000 >&2", max_output_bytes=4096)', + "print(r.returncode, len(r.stdout), len(r.stderr), r.stdout_truncated, r.stderr_truncated)", + "print(r.stdout_total_bytes, r.stderr_total_bytes)", + ].join("\n"); + const result = await manager.execute(code); + expect(result.status).toBe("ok"); + expect(result.stdout).toContain("0 4096 4096 True True"); + expect(result.stdout).toContain("200000 200000"); + } finally { + await manager.dispose(); + } + }, 60_000); + + it.skipIf(process.platform === "win32")( + "kills descendants on timeout and cancellation", + async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const timeoutMarker = join(dir, "timeout-marker"); + const cancelMarker = join(dir, "cancel-marker"); + const timeout = await manager.execute( + `try: + await bash("(sleep 1; touch '${timeoutMarker}') & wait", timeout=0.1) +except TimeoutError: + print("timed-out")`, + ); + expect(timeout.status).toBe("ok"); + expect(timeout.stdout).toContain("timed-out"); + const cancelled = await manager.execute( + `task = asyncio.create_task(bash("(sleep 1; touch '${cancelMarker}') & wait")) +await asyncio.sleep(0.1) +task.cancel() +try: + await task +except asyncio.CancelledError: + print("cancelled")`, + ); + expect(cancelled.status).toBe("ok"); + expect(cancelled.stdout).toContain("cancelled"); + await new Promise((resolve) => setTimeout(resolve, 1300)); + expect(existsSync(timeoutMarker)).toBe(false); + expect(existsSync(cancelMarker)).toBe(false); + } finally { + await manager.dispose(); + } + }, + 60_000, + ); + it("emits canonical paths for edits after the kernel changes directories", async () => { const firstDir = join(dir, "first"); const secondDir = join(dir, "second"); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index a178f215d..641f80584 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -66,7 +66,7 @@ describe("buildRlmPrompt", () => { "", "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.", "", - 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it runs the command via `asyncio.create_subprocess_exec` so the kernel event loop stays responsive to interrupts while the process runs. `%%bash` cells block the kernel until the command finishes. If you do 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.', + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up the command process tree on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", From 91eae25304ff90fc0d5b6062782ee367aa195467 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:18:03 -0700 Subject: [PATCH 03/12] fix(coding-agent): fail closed for bash on Windows --- .../coding-agent/src/core/tools/ipython.ts | 56 ++++--------------- .../test/ipython-bootstrap.test.ts | 9 +-- 2 files changed, 15 insertions(+), 50 deletions(-) diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index edf381819..1d495a55f 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -132,44 +132,18 @@ def _prime_agent_bash_signal_group(proc, sig) -> None: pass -async def _prime_agent_bash_taskkill(proc) -> None: - # taskkill /T can still find descendants by the original shell PID after - # the shell itself exits, so do not short-circuit on proc.returncode. - taskkill = _prime_agent_shutil.which("taskkill") - if taskkill: - killer = await _prime_agent_asyncio.create_subprocess_exec( - taskkill, "/PID", str(proc.pid), "/T", "/F", - stdin=_prime_agent_subprocess.DEVNULL, - stdout=_prime_agent_subprocess.DEVNULL, - stderr=_prime_agent_subprocess.DEVNULL, - ) - try: - await _prime_agent_asyncio.wait_for(_prime_agent_asyncio.shield(killer.wait()), timeout=2) - except _prime_agent_asyncio.TimeoutError: - killer.kill() - await killer.wait() - except BaseException: - if killer.returncode is None: - killer.kill() - await _prime_agent_asyncio.shield(killer.wait()) - raise - if proc.returncode is None: - proc.kill() async def _prime_agent_bash_stop(proc, collector, stdout_task, stderr_task) -> None: """Stop the command tree and finish or cancel every pipe-drain task.""" - if _prime_agent_bash_os.name == "nt": - await _prime_agent_bash_taskkill(proc) - else: - _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM) - try: - await _prime_agent_asyncio.wait_for( - _prime_agent_asyncio.shield(collector), timeout=_PRIME_AGENT_BASH_STOP_GRACE - ) - return - except _prime_agent_asyncio.TimeoutError: - _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGKILL) + _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM) + try: + await _prime_agent_asyncio.wait_for( + _prime_agent_asyncio.shield(collector), timeout=_PRIME_AGENT_BASH_STOP_GRACE + ) + return + except _prime_agent_asyncio.TimeoutError: + _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGKILL) try: await _prime_agent_asyncio.wait_for(_prime_agent_asyncio.shield(collector), timeout=2) @@ -202,18 +176,12 @@ async def bash( """ if not isinstance(max_output_bytes, int) or max_output_bytes <= 0: raise ValueError("max_output_bytes must be a positive integer") - shell = _prime_agent_shutil.which("bash") - if shell is None: - if _prime_agent_bash_os.name == "nt": - raise RuntimeError("bash() requires bash.exe on Windows") - shell = "/bin/bash" + if _prime_agent_bash_os.name == "nt": + raise RuntimeError("bash() is unavailable on Windows because reliable process-tree cleanup is not supported") + shell = _prime_agent_shutil.which("bash") or "/bin/bash" env = dict(_prime_agent_bash_os.environ) work_dir = cwd or _prime_agent_bash_os.getcwd() - spawn_options = {} - if _prime_agent_bash_os.name == "nt": - spawn_options["creationflags"] = _prime_agent_subprocess.CREATE_NEW_PROCESS_GROUP - else: - spawn_options["start_new_session"] = True + spawn_options = {"start_new_session": True} proc = await _prime_agent_asyncio.create_subprocess_exec( shell, "-c", command, diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index dab0399ff..9cee62da8 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -23,18 +23,15 @@ describe("IPython RLM bootstrap", () => { expect(buildRlmBootstrapCode()).toContain('_prime_agent_os.environ["NO_COLOR"] = "1"'); }); - it("bounds async bash output and cleans process groups on exceptional exits", () => { + it("bounds async bash output and cleans POSIX process groups on exceptional exits", () => { const code = buildRlmBootstrapCode(); expect(code).not.toContain("proc.communicate()"); expect(code).toContain("_PrimeAgentBoundedBytes"); expect(code).toContain("start_new_session"); expect(code).toContain("killpg"); expect(code).toContain("except BaseException"); - expect(code).toContain('taskkill, "/PID", str(proc.pid), "/T", "/F"'); - expect(code).toContain("await killer.wait()"); - expect(code).not.toContain( - "async def _prime_agent_bash_taskkill(proc) -> None:\n if proc.returncode is not None", - ); + expect(code).toContain("bash() is unavailable on Windows"); + expect(code).not.toContain("taskkill"); }); it("guards Python skill imports so a broken skill does not abort bootstrap", () => { From 785b9b75f3008bed2b8a7ff6b1f92e660ad13793 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:27:10 -0700 Subject: [PATCH 04/12] fix(coding-agent): render async bash output --- packages/coding-agent/src/core/tools/ipython.ts | 8 +++----- .../coding-agent/test/ipython-bootstrap.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 1d495a55f..226ee17fe 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -103,11 +103,9 @@ class _PrimeAgentBashResult: return text def __repr__(self) -> str: - truncated = self.stdout_truncated or self.stderr_truncated - return ( - f"BashResult(returncode={self.returncode}, stdout={len(self.stdout)} chars, " - f"stderr={len(self.stderr)} chars, truncated={truncated})" - ) + # IPython renders a cell's final expression with repr(), so expose the + # captured command output instead of hiding it behind a metadata summary. + return str(self) async def _prime_agent_bash_drain(stream, capture: _PrimeAgentBoundedBytes) -> None: diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index 9cee62da8..faf723d9d 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -94,6 +94,19 @@ describeIfKernel("IPython RLM bootstrap (real kernel)", () => { } }, 60_000); + it("renders command output when await bash is the final expression", async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const result = await manager.execute('await bash("printf visible-output")'); + expect(result.status).toBe("ok"); + expect(result.result).toContain("visible-output"); + } finally { + await manager.dispose(); + } + }, 60_000); + it("runs async bash with bounded stdout and stderr capture", async () => { const manager = new KernelManager({ python: python as string, cwd: dir }); try { From 6b696f8ae892b04b18e6eeb6e525877bc5fd9af4 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:35:09 -0700 Subject: [PATCH 05/12] fix(coding-agent): clean completed bash sessions --- .../coding-agent/src/core/tools/ipython.ts | 23 +++++++++++++++--- .../test/ipython-bootstrap.test.ts | 24 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 226ee17fe..ac458d413 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -40,7 +40,7 @@ import shutil as _prime_agent_shutil import signal as _prime_agent_signal import os as _prime_agent_bash_os -_PRIME_AGENT_BASH_CAPTURE_LIMIT = 1024 * 1024 +_PRIME_AGENT_BASH_CAPTURE_LIMIT = 32 * 1024 _PRIME_AGENT_BASH_READ_CHUNK = 64 * 1024 _PRIME_AGENT_BASH_STOP_GRACE = 0.25 @@ -95,8 +95,12 @@ class _PrimeAgentBashResult: parts = [] if self.stdout: parts.append(self.stdout) + if self.stdout_truncated: + parts.append(f"[stdout truncated; showing final {len(self.stdout.encode('utf-8'))} of {self.stdout_total_bytes} bytes]") if self.stderr: parts.append(self.stderr) + if self.stderr_truncated: + parts.append(f"[stderr truncated; showing final {len(self.stderr.encode('utf-8'))} of {self.stderr_total_bytes} bytes]") text = chr(10).join(parts) if self.returncode != 0: text += chr(10) + chr(10) + f"Command exited with code {self.returncode}" @@ -117,8 +121,21 @@ async def _prime_agent_bash_drain(stream, capture: _PrimeAgentBoundedBytes) -> N async def _prime_agent_bash_collect(proc, stdout_task, stderr_task) -> None: - await proc.wait() - await _prime_agent_asyncio.gather(stdout_task, stderr_task) + # asyncio's process wait can remain pending while descendants hold inherited + # pipes, even after the shell's returncode is set. Observe that state directly. + while proc.returncode is None: + await _prime_agent_asyncio.sleep(0.01) + # A successful shell may leave background descendants in its session. Stop + # them before waiting for inherited pipe descriptors to close. + _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM) + drains = _prime_agent_asyncio.gather(stdout_task, stderr_task) + try: + await _prime_agent_asyncio.wait_for( + _prime_agent_asyncio.shield(drains), timeout=_PRIME_AGENT_BASH_STOP_GRACE + ) + except _prime_agent_asyncio.TimeoutError: + _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGKILL) + await drains def _prime_agent_bash_signal_group(proc, sig) -> None: diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index faf723d9d..754c8bc5e 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -117,16 +117,40 @@ describeIfKernel("IPython RLM bootstrap (real kernel)", () => { 'r = await bash("yes A | head -c 200000; yes B | head -c 200000 >&2", max_output_bytes=4096)', "print(r.returncode, len(r.stdout), len(r.stderr), r.stdout_truncated, r.stderr_truncated)", "print(r.stdout_total_bytes, r.stderr_total_bytes)", + 'print(str(r).count("truncated"))', ].join("\n"); const result = await manager.execute(code); expect(result.status).toBe("ok"); expect(result.stdout).toContain("0 4096 4096 True True"); expect(result.stdout).toContain("200000 200000"); + expect(result.stdout).toContain("2"); } finally { await manager.dispose(); } }, 60_000); + it.skipIf(process.platform === "win32")( + "cleans background descendants after the shell exits", + async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + const marker = join(dir, "background-marker"); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const result = await manager.execute( + `r = await bash("(sleep 1; touch '${marker}') &")\nprint(r.returncode)`, + ); + expect(result.status).toBe("ok"); + expect(result.stdout).toContain("0"); + await new Promise((resolve) => setTimeout(resolve, 1300)); + expect(existsSync(marker)).toBe(false); + } finally { + await manager.dispose(); + } + }, + 60_000, + ); + it.skipIf(process.platform === "win32")( "kills descendants on timeout and cancellation", async () => { From 9a0508005bcef227735b4333879a8b5422e8bdc7 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:37:57 -0700 Subject: [PATCH 06/12] fix(coding-agent): validate bash output bounds --- packages/coding-agent/src/core/tools/ipython.ts | 2 +- packages/coding-agent/test/ipython-bootstrap.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index ac458d413..6cafde693 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -189,7 +189,7 @@ async def bash( the persistent kernel without bound. Truncation and total byte counts are reported on the result. """ - if not isinstance(max_output_bytes, int) or max_output_bytes <= 0: + if isinstance(max_output_bytes, bool) or not isinstance(max_output_bytes, int) or max_output_bytes <= 0: raise ValueError("max_output_bytes must be a positive integer") if _prime_agent_bash_os.name == "nt": raise RuntimeError("bash() is unavailable on Windows because reliable process-tree cleanup is not supported") diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index 754c8bc5e..9d791f949 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -31,6 +31,7 @@ describe("IPython RLM bootstrap", () => { expect(code).toContain("killpg"); expect(code).toContain("except BaseException"); expect(code).toContain("bash() is unavailable on Windows"); + expect(code).toContain("isinstance(max_output_bytes, bool)"); expect(code).not.toContain("taskkill"); }); From 66902ff54c41a14cc411a36d33cd7d4d6e39670f Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:40:55 -0700 Subject: [PATCH 07/12] fix(coding-agent): bound rendered bash output --- packages/coding-agent/src/core/prompts/rlm.ts | 2 +- packages/coding-agent/src/core/tools/ipython.ts | 8 +++++--- .../coding-agent/test/ipython-bootstrap.test.ts | 16 ++++++++++++++++ packages/coding-agent/test/system-prompt.test.ts | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/core/prompts/rlm.ts b/packages/coding-agent/src/core/prompts/rlm.ts index 69f73d04a..76508e0d9 100644 --- a/packages/coding-agent/src/core/prompts/rlm.ts +++ b/packages/coding-agent/src/core/prompts/rlm.ts @@ -16,7 +16,7 @@ const IPYTHON_CONTROL_PROMPT = [ "", "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.", "", - 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up the command process tree on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 6cafde693..880153d4c 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -67,7 +67,9 @@ class _PrimeAgentBoundedBytes: return self.total > len(self.data) def text(self) -> str: - return bytes(self.data).decode("utf-8", errors="replace") + # latin-1 is byte-preserving: retained bytes cannot expand into multiple + # Unicode replacement bytes when IPython serializes the execute result. + return bytes(self.data).decode("latin-1") class _PrimeAgentBashResult: @@ -96,11 +98,11 @@ class _PrimeAgentBashResult: if self.stdout: parts.append(self.stdout) if self.stdout_truncated: - parts.append(f"[stdout truncated; showing final {len(self.stdout.encode('utf-8'))} of {self.stdout_total_bytes} bytes]") + parts.append(f"[stdout truncated; showing final {len(self.stdout.encode('latin-1'))} of {self.stdout_total_bytes} bytes]") if self.stderr: parts.append(self.stderr) if self.stderr_truncated: - parts.append(f"[stderr truncated; showing final {len(self.stderr.encode('utf-8'))} of {self.stderr_total_bytes} bytes]") + parts.append(f"[stderr truncated; showing final {len(self.stderr.encode('latin-1'))} of {self.stderr_total_bytes} bytes]") text = chr(10).join(parts) if self.returncode != 0: text += chr(10) + chr(10) + f"Command exited with code {self.returncode}" diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index 9d791f949..c2b4d0371 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -130,6 +130,22 @@ describeIfKernel("IPython RLM bootstrap (real kernel)", () => { } }, 60_000); + it("keeps malformed byte output within the rendered capture bound", async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const result = await manager.execute( + 'r = await bash("yes ÿ | head -c 100000", max_output_bytes=4096)\nprint(len(r.stdout), len(repr(r)), r.stdout_truncated)', + ); + expect(result.status).toBe("ok"); + expect(result.stdout).toContain("4096"); + expect(result.stdout).toContain("True"); + } finally { + await manager.dispose(); + } + }, 60_000); + it.skipIf(process.platform === "win32")( "cleans background descendants after the shell exits", async () => { diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 641f80584..6001ab72b 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -66,7 +66,7 @@ describe("buildRlmPrompt", () => { "", "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.", "", - 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up the command process tree on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", From af69851f583b39ff33daca0b570a9a43cf7c7a1f Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:45:01 -0700 Subject: [PATCH 08/12] fix(coding-agent): escalate bash group cleanup --- .../coding-agent/src/core/tools/ipython.ts | 34 +++++++++++-------- .../test/ipython-bootstrap.test.ts | 21 ++++++++++++ 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 880153d4c..39f78479e 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -130,14 +130,12 @@ async def _prime_agent_bash_collect(proc, stdout_task, stderr_task) -> None: # A successful shell may leave background descendants in its session. Stop # them before waiting for inherited pipe descriptors to close. _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM) - drains = _prime_agent_asyncio.gather(stdout_task, stderr_task) - try: - await _prime_agent_asyncio.wait_for( - _prime_agent_asyncio.shield(drains), timeout=_PRIME_AGENT_BASH_STOP_GRACE - ) - except _prime_agent_asyncio.TimeoutError: + deadline = _prime_agent_asyncio.get_running_loop().time() + _PRIME_AGENT_BASH_STOP_GRACE + while _prime_agent_bash_group_exists(proc) and _prime_agent_asyncio.get_running_loop().time() < deadline: + await _prime_agent_asyncio.sleep(0.01) + if _prime_agent_bash_group_exists(proc): _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGKILL) - await drains + await _prime_agent_asyncio.gather(stdout_task, stderr_task) def _prime_agent_bash_signal_group(proc, sig) -> None: @@ -149,17 +147,25 @@ def _prime_agent_bash_signal_group(proc, sig) -> None: pass +def _prime_agent_bash_group_exists(proc) -> bool: + try: + _prime_agent_bash_os.killpg(proc.pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + async def _prime_agent_bash_stop(proc, collector, stdout_task, stderr_task) -> None: - """Stop the command tree and finish or cancel every pipe-drain task.""" + """Stop the command group and finish or cancel every pipe-drain task.""" _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM) - try: - await _prime_agent_asyncio.wait_for( - _prime_agent_asyncio.shield(collector), timeout=_PRIME_AGENT_BASH_STOP_GRACE - ) - return - except _prime_agent_asyncio.TimeoutError: + deadline = _prime_agent_asyncio.get_running_loop().time() + _PRIME_AGENT_BASH_STOP_GRACE + while _prime_agent_bash_group_exists(proc) and _prime_agent_asyncio.get_running_loop().time() < deadline: + await _prime_agent_asyncio.sleep(0.01) + if _prime_agent_bash_group_exists(proc): _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGKILL) try: diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index c2b4d0371..bf23c686a 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -168,6 +168,27 @@ describeIfKernel("IPython RLM bootstrap (real kernel)", () => { 60_000, ); + it.skipIf(process.platform === "win32")( + "escalates when a descendant ignores SIGTERM and closes pipes", + async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + const marker = join(dir, "term-resistant-marker"); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const result = await manager.execute( + `await bash("(trap '' TERM; exec 1>&- 2>&-; sleep 1; touch '${marker}') &")`, + ); + expect(result.status).toBe("ok"); + await new Promise((resolve) => setTimeout(resolve, 1300)); + expect(existsSync(marker)).toBe(false); + } finally { + await manager.dispose(); + } + }, + 60_000, + ); + it.skipIf(process.platform === "win32")( "kills descendants on timeout and cancellation", async () => { From a15a9e070928c963752bc85ce3d0bf0e303709a2 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:49:00 -0700 Subject: [PATCH 09/12] fix(coding-agent): preserve bounded UTF-8 output --- .../coding-agent/src/core/tools/ipython.ts | 26 ++++++++++++------- .../test/ipython-bootstrap.test.ts | 15 ++++++++++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 39f78479e..dc431a42a 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -51,6 +51,7 @@ class _PrimeAgentBoundedBytes: self.limit = limit self.data = bytearray() self.total = 0 + self.render_truncated = False def append(self, chunk: bytes) -> None: self.total += len(chunk) @@ -67,9 +68,14 @@ class _PrimeAgentBoundedBytes: return self.total > len(self.data) def text(self) -> str: - # latin-1 is byte-preserving: retained bytes cannot expand into multiple - # Unicode replacement bytes when IPython serializes the execute result. - return bytes(self.data).decode("latin-1") + text = bytes(self.data).decode("utf-8", errors="replace") + rendered = text.encode("utf-8") + if len(rendered) <= self.limit: + return text + # Replacement characters can expand malformed bytes. Bound the actual + # UTF-8 payload IPython will serialize, trimming only from the head. + self.render_truncated = True + return rendered[-self.limit:].decode("utf-8", errors="ignore") class _PrimeAgentBashResult: @@ -98,11 +104,11 @@ class _PrimeAgentBashResult: if self.stdout: parts.append(self.stdout) if self.stdout_truncated: - parts.append(f"[stdout truncated; showing final {len(self.stdout.encode('latin-1'))} of {self.stdout_total_bytes} bytes]") + parts.append(f"[stdout truncated; showing final {len(self.stdout.encode('utf-8'))} of {self.stdout_total_bytes} bytes]") if self.stderr: parts.append(self.stderr) if self.stderr_truncated: - parts.append(f"[stderr truncated; showing final {len(self.stderr.encode('latin-1'))} of {self.stderr_total_bytes} bytes]") + parts.append(f"[stderr truncated; showing final {len(self.stderr.encode('utf-8'))} of {self.stderr_total_bytes} bytes]") text = chr(10).join(parts) if self.returncode != 0: text += chr(10) + chr(10) + f"Command exited with code {self.returncode}" @@ -241,12 +247,14 @@ async def bash( collector.cancel() await _prime_agent_asyncio.gather(collector, return_exceptions=True) + stdout_text = stdout_capture.text() + stderr_text = stderr_capture.text() return _PrimeAgentBashResult( - stdout=stdout_capture.text(), - stderr=stderr_capture.text(), + stdout=stdout_text, + stderr=stderr_text, returncode=proc.returncode if proc.returncode is not None else -1, - stdout_truncated=stdout_capture.truncated, - stderr_truncated=stderr_capture.truncated, + stdout_truncated=stdout_capture.truncated or stdout_capture.render_truncated, + stderr_truncated=stderr_capture.truncated or stderr_capture.render_truncated, stdout_total_bytes=stdout_capture.total, stderr_total_bytes=stderr_capture.total, ) diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index bf23c686a..f1f6a0d8e 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -130,13 +130,26 @@ describeIfKernel("IPython RLM bootstrap (real kernel)", () => { } }, 60_000); + it("preserves valid UTF-8 command output", async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const result = await manager.execute(`r = await bash("printf héllo")\nprint(repr(r.stdout))`); + expect(result.status).toBe("ok"); + expect(result.stdout).toContain("héllo"); + } finally { + await manager.dispose(); + } + }, 60_000); + it("keeps malformed byte output within the rendered capture bound", async () => { const manager = new KernelManager({ python: python as string, cwd: dir }); try { await manager.start(); expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); const result = await manager.execute( - 'r = await bash("yes ÿ | head -c 100000", max_output_bytes=4096)\nprint(len(r.stdout), len(repr(r)), r.stdout_truncated)', + 'r = await bash("yes ÿ | head -c 100000", max_output_bytes=4096)\nprint(len(r.stdout.encode("utf-8")), len(repr(r).encode("utf-8")), r.stdout_truncated)', ); expect(result.status).toBe("ok"); expect(result.stdout).toContain("4096"); From dd88b4629d1ea84bc0322d15a9c7222721c52e34 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 13:54:22 -0700 Subject: [PATCH 10/12] docs(coding-agent): qualify async bash platforms --- packages/coding-agent/src/core/prompts/rlm.ts | 2 +- packages/coding-agent/test/system-prompt.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/prompts/rlm.ts b/packages/coding-agent/src/core/prompts/rlm.ts index 76508e0d9..b8cc8b13c 100644 --- a/packages/coding-agent/src/core/prompts/rlm.ts +++ b/packages/coding-agent/src/core/prompts/rlm.ts @@ -16,7 +16,7 @@ const IPYTHON_CONTROL_PROMPT = [ "", "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.", "", - 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells on POSIX platforms: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. On Windows, use `%%bash` because the async `bash()` helper is unavailable. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 6001ab72b..b9ca63f09 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -66,7 +66,7 @@ describe("buildRlmPrompt", () => { "", "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.", "", - 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells on POSIX platforms: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. On Windows, use `%%bash` because the async `bash()` helper is unavailable. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", From bdbd8ec31ffec594403b0620d66d4f983ebb7bf7 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 14:14:08 -0700 Subject: [PATCH 11/12] fix(coding-agent): clarify bash truncation units --- packages/coding-agent/src/core/tools/ipython.ts | 4 ++-- packages/coding-agent/test/ipython-bootstrap.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index dc431a42a..23b49e799 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -104,11 +104,11 @@ class _PrimeAgentBashResult: if self.stdout: parts.append(self.stdout) if self.stdout_truncated: - parts.append(f"[stdout truncated; showing final {len(self.stdout.encode('utf-8'))} of {self.stdout_total_bytes} bytes]") + parts.append(f"[stdout truncated; captured {self.stdout_total_bytes} raw bytes; showing a bounded decoded tail]") if self.stderr: parts.append(self.stderr) if self.stderr_truncated: - parts.append(f"[stderr truncated; showing final {len(self.stderr.encode('utf-8'))} of {self.stderr_total_bytes} bytes]") + parts.append(f"[stderr truncated; captured {self.stderr_total_bytes} raw bytes; showing a bounded decoded tail]") text = chr(10).join(parts) if self.returncode != 0: text += chr(10) + chr(10) + f"Command exited with code {self.returncode}" diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index f1f6a0d8e..a28bae9ce 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -32,6 +32,8 @@ describe("IPython RLM bootstrap", () => { expect(code).toContain("except BaseException"); expect(code).toContain("bash() is unavailable on Windows"); expect(code).toContain("isinstance(max_output_bytes, bool)"); + expect(code).toContain("captured {self.stdout_total_bytes} raw bytes; showing a bounded decoded tail"); + expect(code).not.toContain("of {self.stdout_total_bytes} bytes"); expect(code).not.toContain("taskkill"); }); From 02543c75e57fc1cef3fe3595267a1014b5bc30d7 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 14:17:02 -0700 Subject: [PATCH 12/12] refactor(coding-agent): keep async bash POSIX-only --- packages/coding-agent/src/core/prompts/rlm.ts | 2 +- .../coding-agent/src/core/tools/ipython.ts | 2 - .../test/ipython-bootstrap.test.ts | 127 ++++++++---------- .../coding-agent/test/system-prompt.test.ts | 2 +- 4 files changed, 58 insertions(+), 75 deletions(-) diff --git a/packages/coding-agent/src/core/prompts/rlm.ts b/packages/coding-agent/src/core/prompts/rlm.ts index b8cc8b13c..76508e0d9 100644 --- a/packages/coding-agent/src/core/prompts/rlm.ts +++ b/packages/coding-agent/src/core/prompts/rlm.ts @@ -16,7 +16,7 @@ const IPYTHON_CONTROL_PROMPT = [ "", "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.", "", - 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells on POSIX platforms: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. On Windows, use `%%bash` because the async `bash()` helper is unavailable. `%%bash` cells block the kernel until the command finishes. If you do 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.', + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "", diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 23b49e799..b21e0b2c0 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -205,8 +205,6 @@ async def bash( """ if isinstance(max_output_bytes, bool) or not isinstance(max_output_bytes, int) or max_output_bytes <= 0: raise ValueError("max_output_bytes must be a positive integer") - if _prime_agent_bash_os.name == "nt": - raise RuntimeError("bash() is unavailable on Windows because reliable process-tree cleanup is not supported") shell = _prime_agent_shutil.which("bash") or "/bin/bash" env = dict(_prime_agent_bash_os.environ) work_dir = cwd or _prime_agent_bash_os.getcwd() diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index a28bae9ce..5c5588b09 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -23,14 +23,13 @@ describe("IPython RLM bootstrap", () => { expect(buildRlmBootstrapCode()).toContain('_prime_agent_os.environ["NO_COLOR"] = "1"'); }); - it("bounds async bash output and cleans POSIX process groups on exceptional exits", () => { + it("bounds async bash output and cleans process groups on exceptional exits", () => { const code = buildRlmBootstrapCode(); expect(code).not.toContain("proc.communicate()"); expect(code).toContain("_PrimeAgentBoundedBytes"); expect(code).toContain("start_new_session"); expect(code).toContain("killpg"); expect(code).toContain("except BaseException"); - expect(code).toContain("bash() is unavailable on Windows"); expect(code).toContain("isinstance(max_output_bytes, bool)"); expect(code).toContain("captured {self.stdout_total_bytes} raw bytes; showing a bounded decoded tail"); expect(code).not.toContain("of {self.stdout_total_bytes} bytes"); @@ -161,86 +160,72 @@ describeIfKernel("IPython RLM bootstrap (real kernel)", () => { } }, 60_000); - it.skipIf(process.platform === "win32")( - "cleans background descendants after the shell exits", - async () => { - const manager = new KernelManager({ python: python as string, cwd: dir }); - const marker = join(dir, "background-marker"); - try { - await manager.start(); - expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); - const result = await manager.execute( - `r = await bash("(sleep 1; touch '${marker}') &")\nprint(r.returncode)`, - ); - expect(result.status).toBe("ok"); - expect(result.stdout).toContain("0"); - await new Promise((resolve) => setTimeout(resolve, 1300)); - expect(existsSync(marker)).toBe(false); - } finally { - await manager.dispose(); - } - }, - 60_000, - ); + it("cleans background descendants after the shell exits", async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + const marker = join(dir, "background-marker"); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const result = await manager.execute(`r = await bash("(sleep 1; touch '${marker}') &")\nprint(r.returncode)`); + expect(result.status).toBe("ok"); + expect(result.stdout).toContain("0"); + await new Promise((resolve) => setTimeout(resolve, 1300)); + expect(existsSync(marker)).toBe(false); + } finally { + await manager.dispose(); + } + }, 60_000); - it.skipIf(process.platform === "win32")( - "escalates when a descendant ignores SIGTERM and closes pipes", - async () => { - const manager = new KernelManager({ python: python as string, cwd: dir }); - const marker = join(dir, "term-resistant-marker"); - try { - await manager.start(); - expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); - const result = await manager.execute( - `await bash("(trap '' TERM; exec 1>&- 2>&-; sleep 1; touch '${marker}') &")`, - ); - expect(result.status).toBe("ok"); - await new Promise((resolve) => setTimeout(resolve, 1300)); - expect(existsSync(marker)).toBe(false); - } finally { - await manager.dispose(); - } - }, - 60_000, - ); + it("escalates when a descendant ignores SIGTERM and closes pipes", async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + const marker = join(dir, "term-resistant-marker"); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const result = await manager.execute( + `await bash("(trap '' TERM; exec 1>&- 2>&-; sleep 1; touch '${marker}') &")`, + ); + expect(result.status).toBe("ok"); + await new Promise((resolve) => setTimeout(resolve, 1300)); + expect(existsSync(marker)).toBe(false); + } finally { + await manager.dispose(); + } + }, 60_000); - it.skipIf(process.platform === "win32")( - "kills descendants on timeout and cancellation", - async () => { - const manager = new KernelManager({ python: python as string, cwd: dir }); - try { - await manager.start(); - expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); - const timeoutMarker = join(dir, "timeout-marker"); - const cancelMarker = join(dir, "cancel-marker"); - const timeout = await manager.execute( - `try: + it("kills descendants on timeout and cancellation", async () => { + const manager = new KernelManager({ python: python as string, cwd: dir }); + try { + await manager.start(); + expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok"); + const timeoutMarker = join(dir, "timeout-marker"); + const cancelMarker = join(dir, "cancel-marker"); + const timeout = await manager.execute( + `try: await bash("(sleep 1; touch '${timeoutMarker}') & wait", timeout=0.1) except TimeoutError: print("timed-out")`, - ); - expect(timeout.status).toBe("ok"); - expect(timeout.stdout).toContain("timed-out"); - const cancelled = await manager.execute( - `task = asyncio.create_task(bash("(sleep 1; touch '${cancelMarker}') & wait")) + ); + expect(timeout.status).toBe("ok"); + expect(timeout.stdout).toContain("timed-out"); + const cancelled = await manager.execute( + `task = asyncio.create_task(bash("(sleep 1; touch '${cancelMarker}') & wait")) await asyncio.sleep(0.1) task.cancel() try: await task except asyncio.CancelledError: print("cancelled")`, - ); - expect(cancelled.status).toBe("ok"); - expect(cancelled.stdout).toContain("cancelled"); - await new Promise((resolve) => setTimeout(resolve, 1300)); - expect(existsSync(timeoutMarker)).toBe(false); - expect(existsSync(cancelMarker)).toBe(false); - } finally { - await manager.dispose(); - } - }, - 60_000, - ); + ); + expect(cancelled.status).toBe("ok"); + expect(cancelled.stdout).toContain("cancelled"); + await new Promise((resolve) => setTimeout(resolve, 1300)); + expect(existsSync(timeoutMarker)).toBe(false); + expect(existsSync(cancelMarker)).toBe(false); + } finally { + await manager.dispose(); + } + }, 60_000); it("emits canonical paths for edits after the kernel changes directories", async () => { const firstDir = join(dir, "first"); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index b9ca63f09..6001ab72b 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -66,7 +66,7 @@ describe("buildRlmPrompt", () => { "", "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.", "", - 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells on POSIX platforms: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. On Windows, use `%%bash` because the async `bash()` helper is unavailable. `%%bash` cells block the kernel until the command finishes. If you do 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.', + 'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do 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.', "", "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.", "",