Skip to content

feat: add e2b-acp for running ACP agents in a sandbox - #2061

Open
OndrejDrapalik wants to merge 27 commits into
mainfrom
int-155-acp-python
Open

OndrejDrapalik wants to merge 27 commits into
mainfrom
int-155-acp-python

Conversation

@OndrejDrapalik

@OndrejDrapalik OndrejDrapalik commented Oct 29, 2026 •

Copy link
Copy Markdown

Moved from #2026 so CI runs with repo secrets: GitHub does not pass them to PRs from forks. Same branch and commits by @max-sudolabs, history kept. Review threads are on #2026.

Summary

Adds e2b-acp (packages/acp-python) for running Agent Client Protocol agents (goose, OpenCode, Claude Code through its ACP adapter) inside an E2B sandbox and driving them with the official Python ACP client, agent-client-protocol.

AsyncACPAgent.start(sandbox, command, client, *, ...) starts the agent with stdin open, speaks newline-delimited JSON-RPC over its stdin/stdout through an acp Transport (send / receive / close), and returns an AsyncACPAgent with the ACP connection (from acp.connect_to_agent) plus pid, wait(), stop() and kill(). The client filesystem and terminal capabilities are turned off in initialize, and agent requests for fs/* / terminal/* are rejected in the transport, so the agent edits files and runs commands with its own tools inside the sandbox. Network access and MCP servers passed to the agent are not restricted. The ACP Python SDK is async-only, so the package takes an AsyncSandbox. client is an ACP client or a function that creates one from the connection, like in acp.connect_to_agent.

The agent is started with commands.run(..., background=True, retain_output=False) from #2044 (SDK-407), so a long-running agent does not grow the handle's buffers. No core changes. SDK-408 tracks dropping retained output for background commands in the next major.

Split out of #1992 together with the JS package (#2060). The two PRs are independent and can merge in either order.

Usage

import acp
from e2b import AsyncSandbox
from e2b_acp import AsyncACPAgent

sandbox = await AsyncSandbox.create("goose")
agent = await AsyncACPAgent.start(sandbox, "goose acp --with-builtin developer", client, envs=envs)
try:
    await agent.connection.initialize(protocol_version=acp.PROTOCOL_VERSION)
    session = await agent.connection.new_session(cwd="/home/user", mcp_servers=[])
    await agent.connection.prompt(session_id=session.session_id, prompt=[acp.text_block("Write fizzbuzz.py and run it")])
finally:
    await agent.stop()
    await sandbox.kill()

Review guide

The diff is +2.8k lines, but uv.lock (1.3k, generated), tests and CI make up most of it.

Worth reading (~589 lines):

File Lines What it holds
packages/acp-python/e2b_acp/transport.py 271 Process start, line framing, read queue, write lock, stop and kill
packages/acp-python/e2b_acp/utils.py 167 Incremental line splitter with size limit, initialize rewrite, fs/* / terminal/* rejection
packages/acp-python/e2b_acp/__init__.py 140 Public AsyncACPAgent and AsyncACPAgent.start

Skim: .github/workflows/acp_python_tests.yml and the ACP Python entries in sdk_tests.yml / release.yml (mirror code-interpreter), pyproject.toml, package.json, README, .changeset/acp-python-package.md.

For automated reviewers

Behaviour the code is meant to guarantee, and the test that fails if it breaks. Offline tests use a fake command handle and need no API key; live tests start a scripted Python agent in a sandbox.

Guarantee Code Test
Agent cannot use the client's filesystem or terminal: initialize forces them off (also inside a batch), agent fs/* / terminal/* requests get -32601, notifications are dropped restrict_client_capabilities, filter_agent_message test_utils.py; end to end in test_transport.py
Client writes, automatic replies to the agent and EOF never interleave on stdin _write_lock offline: writes and replies never overlap
stop(timeout=…): EOF and exit are bounded by timeout from the call; then kill unless an exit status is known; raises if the kill fails ACPTransport.close offline: close … tests
kill() releases output waiting on a full read queue, so the handle sees the exit and wait() returns ACPTransport.kill offline: kill lets wait see the exit
After stop(), messages already queued stay readable and later ones are dropped _closing checks in _handle_stdout and _deliver offline: keeps buffered messages / releases a blocked reader
Read queue is bounded at 64 messages; a cancelled receive() never loses a message deque + _readable / _space events offline: full queue, cancelled receive
One message is capped at 32 MiB by bytes, and the limit is validated; a long message is joined once, not rescanned per chunk LineSplitter utils tests (incl. 8 MiB in 1 KiB chunks); live: over the limit
On a failed exit the ACP SDK fails pending requests with Connection closed, so callers use wait() and on_stderr _watch_exit live: agent failure
A failed output stream (for example a raising on_stderr) kills the agent and keeps the original error _watch_exit offline: a failed output stream kills the agent
A raising client factory does not leak the started process; importing e2b_acp emits no DeprecationWarning AsyncACPAgent.start offline: start kills the agent when the client factory raises / import does not warn

Out of scope: restricting network or MCP, batch semantics beyond the boundary check, a sync API, reconnecting to a running agent.

# offline, no key
cd packages/acp-python && uv run pytest tests/test_utils.py tests/test_transport_offline.py
# full, needs E2B_API_KEY
cd packages/acp-python && uv run pytest

Testing

  • e2b-acp: 37 tests (unit, offline, live), ruff and ty on this branch rebased on current main (includes feat(sdk): add retainOutput option to command handles #2044).
  • Offline guards are checked by breaking each guard and confirming a test fails.
  • After moving to AsyncACPAgent.start: goose with gpt-5.6-luna ran the README example end to end (file written, exit 0, no DeprecationWarning under -W error).
  • Real agents through the package before that change: goose 1.54.0, OpenCode 1.19.0 and Claude Code (claude-agent-acp 0.90.0) wrote and ran a file in the sandbox. The client received no fs/* or terminal/* calls, and the agent process was gone after it was stopped.

Release notes for maintainers

  • pnpm run version sets e2b-acp's e2b requirement to the core version being released, because the package needs retain_output from feat(sdk): add retainOutput option to command handles #2044, which is not released yet.
  • PyPI e2b-acp needs trusted publishing configured before the first release.
  • Not wired yet: pkg_artifacts.yml preview builds and staging jobs in sdk_tests.yml. Happy to add either if you want them.

Co-authored-by: Maksim Obukhov maksimobukhov@sudolabs.io
Co-authored-by: Mish Ushakov 10400064+mishushakov@users.noreply.github.com
Co-authored-by: Ondrej Drapalik 43215019+OndrejDrapalik@users.noreply.github.com

max-sudolabs and others added 25 commits October 28, 2026 17:07
Python counterpart of @e2b/acp: newline-delimited JSON framing with a
validated per-message size limit, the initialize capability rewrite
(also inside a batch) that turns off client fs and terminal, and
method-not-found replies for agent fs/* and terminal/* requests.
connect_acp_agent(sandbox, command, client) starts the agent in an
AsyncSandbox and returns an AcpAgent with the agent-client-protocol
connection, close(), wait() and kill(). Async only: the ACP Python SDK
is asynchronous.

Messages reach receive() through a deque, so a cancelled receive never
loses one. Writes, replies and EOF share one lock. close() bounds the
whole shutdown from the call, kills an agent whose output stream failed
without a known exit, keeps buffered messages and drops later ones,
also when the reader waited for room in the queue.
Run the e2b-acp suite when the package or the Python SDK changes, and
include it in the release preflight, tests and publish.
kill() now releases agent output that waits for room in the read
queue, so the command handle can see the exit and wait() returns.
The line splitter kept the unfinished line as one string and split it
again on every chunk, which made a long message quadratic to read. It
now keeps the chunks and their byte count and joins them once.
…op()

connect_acp_agent() takes its optional settings as keyword arguments
only. stop() ends the agent process that connect_acp_agent() started,
and its default timeout is the exported DEFAULT_STOP_TIMEOUT.
A failed output stream, for example after on_stderr raises, left the
agent running with no working connection. The transport now kills it;
receive() and wait() still raise the original error.
…e size error at max_message_bytes

Also document the defaults of stop() and max_message_bytes, export
DEFAULT_MAX_MESSAGE_BYTES and StderrHandler, and describe the Python SDK
streaming hook as internal in the changeset.
…ption

#2044 added retain_output to commands.run and commands.connect, so the
private Commands._start hook this branch carried is gone. The transport
starts the agent with run(background=True, retain_output=False) and the
changeset no longer bumps the Python SDK.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace connect_acp_agent() with the AcpAgent.start() classmethod and import
the ClientSideConnection type from acp.core, so importing e2b_acp no longer
raises a DeprecationWarning.
The client can be a function that creates it from the agent connection, as
acp.connect_to_agent and acp.spawn_agent_process allow. The agent is killed
if the factory raises.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@linear-code

linear-code Bot commented Oct 29, 2026

Copy link
Copy Markdown

INT-155

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T14:20:44.772285Z 8c1bde4 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@changeset-bot

changeset-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 265e519

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@e2b/acp-python Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from a12914d. Download artifacts from this workflow run.

JS SDK (e2b@2.62.1-int-155-acp-python.0, with @e2b/dockerfile-utils@0.1.2-int-155-acp-python.0):

npm install ./e2b-dockerfile-utils-0.1.2-int-155-acp-python.0.tgz ./e2b-2.62.1-int-155-acp-python.0.tgz

CLI (@e2b/cli@2.23.1-int-155-acp-python.0):

npm install ./e2b-cli-2.23.1-int-155-acp-python.0.tgz

Code Interpreter JS SDK (@e2b/code-interpreter@2.9.1-int-155-acp-python.0):

npm install ./e2b-code-interpreter-2.9.1-int-155-acp-python.0.tgz

Desktop JS SDK (@e2b/desktop@2.5.1-int-155-acp-python.0):

npm install ./e2b-desktop-2.5.1-int-155-acp-python.0.tgz

Python SDK (e2b==2.62.0+int.155.acp.python, with e2b-dockerfile-utils==0.1.1+int.155.acp.python):

pip install ./e2b_dockerfile_utils-0.1.1+int.155.acp.python-py3-none-any.whl ./e2b-2.62.0+int.155.acp.python-py3-none-any.whl

Code Interpreter Python SDK (e2b-code-interpreter==2.11.6+int.155.acp.python):

pip install ./e2b_code_interpreter-2.11.6+int.155.acp.python-py3-none-any.whl

Desktop Python SDK (e2b-desktop==2.7.2+int.155.acp.python):

pip install ./e2b_desktop-2.7.2+int.155.acp.python-py3-none-any.whl

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

AcpAgent is async-only, so it takes the Async prefix (TASTE T-2), and ACP
keeps its casing like the JS names (T-12). wait() now documents that a
non-zero exit is returned, not raised.
Mirrors ACPAgentStopOpts in @e2b/acp, and marks the AsyncACPAgent
constructor as internal.
@max-sudolabs

Copy link
Copy Markdown

@OndrejDrapalik two things I can't do from my side (no push or rerun rights on e2b-dev/E2B):

  1. One more commit for this PR: 265e519 on max-sudolabs:int-155-acp-python, a fast-forward from the current head. It adds request_timeout to AsyncACPAgent.stop() and passes it to close_stdin, mirroring ACPAgentStopOpts in feat: add @e2b/acp for running ACP agents in a sandbox #2060 (T-1), and marks the constructor as internal (Devin's T-4/T-26 note). It has an offline test that fails without the forwarding. Ruff, ty, uv lock --check and the offline and live tests pass. To take it: git fetch https://github.com/max-sudolabs/E2B int-155-acp-python && git push origin FETCH_HEAD:int-155-acp-python.
  2. Re-run the failed core SDK jobs on feat: add @e2b/acp for running ACP agents in a sandbox #2060 and feat: add e2b-acp for running ACP agents in a sandbox #2061. None of them touch ACP code. Staging build template timed out at 180s (JS and Python), Desktop bun hit SandboxUnreachableError, and auto-resume wakes paused sandbox saw running instead of paused. The ACP jobs, Lint and Typecheck are green.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants