Repository navigation
feat(coding-agent): add async bash() to IPython kernel #1187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
45c57e4
ff15d7b
91eae25
785b9b7
6b696f8
9a05080
66902ff
af69851
a15a9e0
dd88b46
bdbd8ec
02543c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No streaming: |
||
| "", | ||
| "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.", | ||
| "", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,218 @@ 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 | ||
|
|
||
| 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: | ||
| # 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") | ||
|
macroscopeapp[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| 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; 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('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}" | ||
| 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) | ||
|
sethkarten marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This kills background jobs even on success — by design, and the tests pin it. But that's a real behavior difference from |
||
| # 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: | ||
| # 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 _prime_agent_bash_stop(proc, collector, stdout_task, stderr_task) -> None: | ||
| """Stop the command tree 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: | ||
| _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") | ||
| 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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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( | ||
|
macroscopeapp[bot] marked this conversation as resolved.
Resolved
macroscopeapp[bot] marked this conversation as resolved.
Resolved
sethkarten marked this conversation as resolved.
Resolved
|
||
| 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) | ||
|
|
||
| return _PrimeAgentBashResult( | ||
| 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, | ||
| ) | ||
|
|
||
|
|
||
| try: | ||
| import rlm as _prime_agent_rlm_module | ||
| rlm = _prime_agent_rlm_module.rlm | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.