feat(coding-agent): add async bash() to IPython kernel - #1187
Conversation
sethkarten
left a comment
There was a problem hiding this comment.
subprocess lifecycle clean-up and potential high memory usage needs addressing
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.
e92f4b0 to
ff15d7b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit dd88b46. Configure here.
snimu
left a comment
There was a problem hiding this comment.
Took a close look at the final state — the process-group cleanup and bounded capture are careful and the real-kernel tests cover the hard cases well. Three things I think are worth addressing before (or right after) merge, since they're all on the path the prompt now recommends:
| """ | ||
| 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" |
There was a problem hiding this comment.
bash() hardcodes the shell and skips the user's shell settings. The %%bash path goes through applyShellSettingsToBashMagicCell(), which applies the configured commandPrefix and shellPath. Users who set those get them silently ignored on the now-preferred path. Suggest either applying the same settings here or noting in the prompt that bash() ignores them.
| # 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 |
There was a problem hiding this comment.
This kills background jobs even on success — by design, and the tests pin it. But that's a real behavior difference from %%bash: with bash() you can never start a long-lived background process (e.g. nohup server &). The prompt says "prefer await bash(...)" without mentioning this, so a model following the doctrine will launch a dev server and find it dead. One extra clause in the prompt line would prevent that.
| "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.', |
There was a problem hiding this comment.
No streaming: %%bash shows output as it runs, bash() buffers everything until the command exits. For a long build the model and the user watching see nothing until the end — and since only the tail is kept, the beginning is gone. Fine for short commands, but the prompt recommends bash() unconditionally. Worth either mentioning "use %%bash for long-running commands where you want live output" here, or treating streaming as a follow-up.

Summary
Adds an async
bash()function to the IPython kernel bootstrap code soawait bash("...")runs shell commands viaasyncio.create_subprocess_execwithout blocking the kernel event loop.Motivation
%%bashcells block the IPython kernel until the command finishes — the kernel can't respond to interrupts or other messages while a shell command runs. This is the same anti-pattern astime.sleep()polling.await bash("...")usesasyncio.create_subprocess_execso the kernel event loop stays responsive.Changes
ipython.ts: Added_PrimeAgentBashResultclass andasync bash()function toRLM_BOOTSTRAP_BASE_CODE. Supportstimeout(raisesTimeoutError) andcwdparameters. Returns stdout, stderr, and returncode.rlm.ts: Updated the IPython control prompt to preferawait bash("...")over%%bashcells.system-prompt.test.ts: Updated exact-match block and addedtoContainassertion for the new prompt text.Usage
Testing
npx vitest run test/system-prompt.test.ts— 25/25 passnpx vitest run test/ipython-bootstrap.test.ts— 6/6 pass (including real kernel tests)npm run check— cleanNote
Medium Risk
Changes how shell commands run in the kernel (new process groups and aggressive descendant termination), which can differ from
%%bashbehavior for background jobs; scope is confined to the agent IPython runtime on POSIX.Overview
Adds an async
bash()helper to the IPython kernel bootstrap so agents can runawait bash("...")instead of blocking%%bashcells, keeping the kernel event loop responsive to interrupts and cancellation.The implementation spawns commands in a new POSIX session, streams stdout/stderr into tail-bounded buffers (default 32 KiB per stream, configurable via
max_output_bytes), returns a structured result with truncation metadata, and tears down the process group on timeout, cancellation, or after the shell exits (including background descendants). Result objects render captured output in IPython when used as the final cell expression.RLM / system prompts now tell the model to prefer
await bash("...")over%%bash, while still documenting%%bashrules. Changelog and bootstrap / real-kernel tests cover output bounds, UTF-8 handling, descendant cleanup, and SIGTERM/SIGKILL escalation.Reviewed by Cursor Bugbot for commit 02543c7. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add async
bash()coroutine to the IPython kernel for non-blocking shell executionbash()function into the IPython kernel bootstrap so shell commands run without blocking the event loop, replacing the recommended use of%%bashmagic cells._PrimeAgentBashResultreturn value.killpgin a new session; Windows usestaskkill /T /F.await bash("...")over%%bash, with guidance on responsiveness, cleanup, and output capture.bash()spawns a new process group/session on POSIX, which changes the process tree structure relative to%%bashor!cmdshell escapes.Changes since #1187 opened
bash()function in IPython kernel [91eae25]_PrimeAgentBashResult.__repr__method to return the captured command output (combined stdout/stderr and exit code) instead of a metadata summary [785b9b7]await bash()renders command output when used as the final expression in an IPython cell [785b9b7]_prime_agent_bash_collectfunction to terminate background descendants and handle output draining [6b696f8]max_output_bytesparameter validation in thebash()function within the IPython kernel bootstrap code [9a05080]_prime_agent_bash_collectand_prime_agent_bash_stopfunctions [af69851]_PrimeAgentBoundedBytes.textto decode captured bytes as UTF-8 with replacement for malformed sequences, then re-encode and head-trim the UTF-8 payload when it exceeds the byte limit, setting a newrender_truncatedinstance attribute to track head-trimming, and updated_PrimeAgentBoundedBytes.__init__to initializerender_truncatedto False [a15a9e0]_PrimeAgentBashResult.__str__to compute and display truncation byte counts using UTF-8 encoding instead of latin-1 for both stdout and stderr [a15a9e0]bashasync function to combine raw capture truncation flags with the newrender_truncatedflag from_PrimeAgentBoundedBytesusing OR operation for both stdout and stderr [a15a9e0]bashand modified existing truncation test to use UTF-8 byte lengths for assertions [a15a9e0]IPYTHON_CONTROL_PROMPTconstant [dd88b46]_PrimeAgentBashResult.__str__method for stdout and stderr [bdbd8ec]bash()async function in IPython kernel bootstrap [02543c7]IPYTHON_CONTROL_PROMPTconstant to provide platform-agnostic shell command guidance [02543c7]buildRlmPrompttest assertions to expect platform-agnostic guidance [02543c7]Macroscope summarized ff15d7b.