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..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, 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 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 1be4321d5..b21e0b2c0 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -34,6 +34,230 @@ try: except Exception: pass +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 = 32 * 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 + self.render_truncated = False + + 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: + 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: + """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 = [] + if self.stdout: + parts.append(self.stdout) + if self.stdout_truncated: + 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; 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}" + return text + + def __repr__(self) -> str: + # 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: + 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: + # 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) + 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 _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 + + +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 group and finish or cancel every pipe-drain task.""" + _prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM) + 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: + 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. + """ + 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") + 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 = {"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: + await _prime_agent_asyncio.wait_for(_prime_agent_asyncio.shield(collector), timeout=timeout) + except _prime_agent_asyncio.TimeoutError: + 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) + + stdout_text = stdout_capture.text() + stderr_text = stderr_capture.text() + return _PrimeAgentBashResult( + stdout=stdout_text, + stderr=stderr_text, + returncode=proc.returncode if proc.returncode is not None else -1, + 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, + ) + + try: import rlm as _prime_agent_rlm_module rlm = _prime_agent_rlm_module.rlm diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index 8aba99b46..5c5588b09 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -23,6 +23,19 @@ 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("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"); + }); + it("guards Python skill imports so a broken skill does not abort bootstrap", () => { const code = buildRlmBootstrapCode([ { @@ -83,6 +96,137 @@ 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 { + 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)", + '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("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.encode("utf-8")), len(repr(r).encode("utf-8")), 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("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("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("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 bbb128ef4..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, 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 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.", "", @@ -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", () => {