Repository navigation
feat: add e2b-acp for running ACP agents in a sandbox #2026
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
Open
max-sudolabs
wants to merge
19
commits into
e2b-dev:main
Choose a base branch
from
max-sudolabs:int-155-acp-python
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,748
−0
Open
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
aed70da
feat(acp-python): add e2b-acp package with ACP framing helpers
max-sudolabs 48b0157
feat(acp-python): add connect_acp_agent
max-sudolabs 7704b93
test(acp-python): cover connect_acp_agent against a scripted agent
max-sudolabs 358e386
ci: test and release the e2b-acp package
max-sudolabs 0530696
chore: add changeset for e2b-acp
max-sudolabs 6ba181b
chore: add acp-python to the pnpm lockfile
max-sudolabs 8b2c413
fix(acp-python): raise the e2b floor to the released core version
max-sudolabs dd7ba13
fix(acp-python): raise from close() when the agent could not be killed
max-sudolabs 564d44f
docs(acp-python): describe how requests fail when the agent exits
max-sudolabs 660e76a
fix(acp-python): drop the unused xdist flag from make test
max-sudolabs 52ecfa8
test(acp-python): drop live tests the offline tests already cover
max-sudolabs 785d3c7
fix(acp-python): let kill() stop output that waits for the client
max-sudolabs 2e49ccd
fix(acp-python): split agent output without rescanning a pending line
max-sudolabs 114176d
refactor(acp-python): make options keyword-only, rename close() to st…
max-sudolabs 4517842
chore(acp-python): refresh uv.lock for the current Python SDK
max-sudolabs 3135ded
fix(acp-python): kill the agent when its output stream fails
max-sudolabs e3050c9
refactor(acp-python): rename AcpAgentExit to AcpAgentResult, point th…
max-sudolabs e0073d2
refactor(acp-python): start the agent with the public retain_output o…
mishushakov c5734e6
chore(acp-python): refresh uv.lock for the current Python SDK
mishushakov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@e2b/acp-python": minor | ||
| --- | ||
|
|
||
| Add `e2b-acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `connect_acp_agent(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| name: Test ACP Python SDK | ||
|
|
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| E2B_DOMAIN: | ||
| required: false | ||
| type: string | ||
| default: '' | ||
| secrets: | ||
| E2B_API_KEY: | ||
| required: true | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| defaults: | ||
| run: | ||
| working-directory: ./packages/acp-python | ||
| shell: bash | ||
| name: ACP Python SDK - Build and test | ||
| runs-on: ubuntu-22.04 | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | ||
|
|
||
| - name: Parse .tool-versions | ||
| uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1 | ||
| with: | ||
| filename: '.tool-versions' | ||
| uppercase: 'true' | ||
| prefix: 'tool_version_' | ||
|
|
||
| - name: Install uv | ||
| uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 | ||
| with: | ||
| version: '${{ env.TOOL_VERSION_UV }}' | ||
| python-version: '${{ env.TOOL_VERSION_PYTHON }}' | ||
| enable-cache: true | ||
|
|
||
| - name: Install dependencies | ||
| run: uv sync --locked --package e2b-acp | ||
|
|
||
| - name: Test build | ||
| run: uv build | ||
|
|
||
| - name: Run tests | ||
| run: uv run pytest --verbose | ||
| env: | ||
| E2B_API_KEY: ${{ secrets.E2B_API_KEY }} | ||
| E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| MIT License | ||
|
|
||
| Copyright (c) 2025 FOUNDRYLABS, INC. | ||
|
|
||
| Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: | ||
|
|
||
| The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. | ||
|
|
||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| typecheck: | ||
| ty check e2b_acp | ||
|
|
||
| lint: | ||
| ruff check . | ||
| ruff format --check . | ||
|
|
||
| format: | ||
| ruff format . | ||
|
|
||
| .PHONY: test | ||
| test: | ||
| uv run pytest --verbose |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| # E2B ACP | ||
|
|
||
| Run [Agent Client Protocol](https://agentclientprotocol.com) (ACP) agents such as goose, OpenCode or Claude Code inside an [E2B](https://e2b.dev) sandbox, and drive them from your code with the official ACP client. | ||
|
|
||
| Source: [packages/acp-python](https://github.com/e2b-dev/E2B/tree/main/packages/acp-python) | ||
|
|
||
| ## Install | ||
|
|
||
| ``` | ||
| pip install e2b e2b-acp agent-client-protocol | ||
| ``` | ||
|
|
||
| Set `E2B_API_KEY` to your [E2B API key](https://e2b.dev/dashboard?tab=keys), and the provider key the agent needs. | ||
|
|
||
| ## Usage | ||
|
|
||
| Create a sandbox from a template with the agent installed, then start the agent and connect your ACP client to it. The ACP Python SDK is asynchronous, so use `AsyncSandbox`. | ||
|
|
||
| ```python | ||
| import asyncio | ||
| import os | ||
|
|
||
| import acp | ||
| from acp import schema | ||
| from e2b import AsyncSandbox | ||
| from e2b_acp import connect_acp_agent | ||
|
|
||
|
|
||
| class Client: | ||
| async def request_permission(self, session_id, tool_call, options, **kwargs): | ||
| allow = next((o for o in options if o.kind == "allow_once"), options[0]) | ||
| return schema.RequestPermissionResponse( | ||
| outcome=schema.AllowedOutcome(outcome="selected", option_id=allow.option_id) | ||
| ) | ||
|
|
||
| async def session_update(self, session_id, update, **kwargs): | ||
| if isinstance(update, schema.AgentMessageChunk) and update.content.type == "text": | ||
| print(update.content.text, end="") | ||
|
|
||
|
|
||
| async def main(): | ||
| sandbox = await AsyncSandbox.create("goose", timeout=600) | ||
| try: | ||
| agent = await connect_acp_agent( | ||
| sandbox, | ||
| "goose acp --with-builtin developer", | ||
| Client(), | ||
| envs={ | ||
| "GOOSE_PROVIDER": "openai", | ||
| "GOOSE_MODEL": "gpt-5.6-luna", | ||
| "OPENAI_API_KEY": os.environ["OPENAI_API_KEY"], | ||
| }, | ||
| ) | ||
| 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("Create fizzbuzz.py for 1 to 15 and run it.")], | ||
| ) | ||
| await agent.stop() | ||
| finally: | ||
| await sandbox.kill() | ||
|
|
||
|
|
||
| asyncio.run(main()) | ||
| ``` | ||
|
|
||
| ## Where the agent's tools run | ||
|
|
||
| The agent talks ACP over its stdin and stdout. `connect_acp_agent` turns off the client filesystem and terminal capabilities in `initialize` and answers agent requests for `fs/*` or `terminal/*` with an error, so the agent edits files and runs commands with its own tools inside the sandbox. It does not restrict the agent's network access or the MCP servers you pass to it. | ||
|
|
||
| ## Lifecycle | ||
|
|
||
| - `agent.stop()` sends EOF to the agent and kills it if it has not exited within `timeout` (5 seconds by default) of the call. Messages the agent sends after that are not delivered. | ||
| - `agent.wait()` returns the exit code once the agent exits. | ||
| - If the agent exits, pending requests on the connection fail with a connection error. Use `agent.wait()` for the exit code and pass `on_stderr` to receive the agent's logs. | ||
| - The agent runs as long as the sandbox does, so set the sandbox timeout for the longest task you expect. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| """ | ||
| Run [Agent Client Protocol](https://agentclientprotocol.com) agents in E2B sandboxes. | ||
| """ | ||
|
|
||
| from typing import Dict, Optional | ||
|
|
||
| import acp | ||
| from e2b import AsyncSandbox | ||
| from e2b.connection_config import Username | ||
|
|
||
| from .transport import DEFAULT_STOP_TIMEOUT, AcpAgentResult, AcpTransport, StderrHandler | ||
| from .utils import DEFAULT_MAX_MESSAGE_BYTES | ||
|
|
||
|
|
||
| class AcpAgent: | ||
| """ | ||
| ACP agent running in a sandbox, connected to your client. | ||
| """ | ||
|
|
||
| def __init__(self, transport: AcpTransport, connection: acp.ClientSideConnection): | ||
| self._transport = transport | ||
| self._connection = connection | ||
|
|
||
| @property | ||
| def connection(self) -> acp.ClientSideConnection: | ||
| """ | ||
| Connection to the agent. Use it to initialize, create sessions and send prompts. | ||
| """ | ||
| return self._connection | ||
|
|
||
| @property | ||
| def pid(self) -> int: | ||
| """ | ||
| Process ID of the agent in the sandbox. | ||
| """ | ||
| return self._transport.pid | ||
|
|
||
| async def wait(self) -> AcpAgentResult: | ||
| """ | ||
| Wait for the agent process to exit. | ||
|
|
||
| It raises if the agent's output stream failed before its exit status was known. | ||
|
|
||
| :return: Exit status of the agent | ||
| """ | ||
| return await self._transport.wait() | ||
|
|
||
| async def stop(self, *, timeout: float = DEFAULT_STOP_TIMEOUT) -> None: | ||
| """ | ||
| Stop the agent: send EOF to its stdin and kill it if it has not | ||
| exited within `timeout` of this call. Messages the agent sends after | ||
| this call are not delivered. | ||
|
|
||
| :param timeout: Time for the agent to exit in **seconds**; defaults to ``DEFAULT_STOP_TIMEOUT`` (5 seconds) | ||
|
|
||
| :raises SandboxException: If the agent could not be killed | ||
| """ | ||
| await self._transport.close(timeout) | ||
|
|
||
| async def kill(self) -> bool: | ||
| """ | ||
| Kill the agent process with `SIGKILL`. | ||
|
|
||
| :return: `True` if the agent was killed, `False` if it was not found | ||
| """ | ||
| return await self._transport.kill() | ||
|
|
||
|
|
||
| async def connect_acp_agent( | ||
| sandbox: AsyncSandbox, | ||
| command: str, | ||
| client: acp.Client, | ||
| *, | ||
| cwd: Optional[str] = None, | ||
| envs: Optional[Dict[str, str]] = None, | ||
| user: Optional[Username] = None, | ||
| on_stderr: Optional[StderrHandler] = None, | ||
| max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES, | ||
| request_timeout: Optional[float] = None, | ||
| ) -> AcpAgent: | ||
| """ | ||
| Start an ACP agent in the sandbox and connect your ACP client to it over | ||
| the agent's stdin/stdout. | ||
|
|
||
| The client filesystem and terminal capabilities are turned off in | ||
| `initialize`, and agent requests for `fs/*` or `terminal/*` are answered | ||
| with an error without reaching your client, so the agent edits files and | ||
| runs commands with its own tools in the sandbox. Network access and MCP | ||
| servers you pass to the agent are not restricted. | ||
|
|
||
| ```python | ||
| import acp | ||
| from e2b import AsyncSandbox | ||
| from e2b_acp import connect_acp_agent | ||
|
|
||
| sandbox = await AsyncSandbox.create("goose") | ||
| agent = await connect_acp_agent(sandbox, "goose acp", client) | ||
| await agent.connection.initialize(protocol_version=acp.PROTOCOL_VERSION) | ||
| # ... | ||
| await agent.stop() | ||
| ``` | ||
|
|
||
| :param sandbox: Sandbox with the agent installed | ||
| :param command: Command that starts the agent in ACP mode, for example `goose acp` | ||
| :param client: Your ACP client. It answers permission requests and receives the agent's session updates | ||
| :param cwd: Working directory of the agent | ||
| :param envs: Environment variables for the agent | ||
| :param user: User to run the agent as | ||
| :param on_stderr: Callback for the agent's stderr output (logs). The agent's stdout is reserved for the protocol | ||
| :param max_message_bytes: Maximum size of a single message from the agent in **bytes**; defaults to ``DEFAULT_MAX_MESSAGE_BYTES`` (32 MiB) | ||
| :param request_timeout: Timeout for starting the agent in **seconds** | ||
|
|
||
| :return: `AcpAgent` with the ACP connection | ||
|
|
||
| :raises InvalidArgumentException: If `max_message_bytes` is not a positive integer | ||
| :raises SandboxException: If the agent could not be started | ||
| """ | ||
| transport = AcpTransport(sandbox, max_message_bytes, on_stderr) | ||
| await transport.start(command, envs, user, cwd, request_timeout) | ||
| return AcpAgent(transport, acp.connect_to_agent(client, transport)) | ||
|
|
||
|
|
||
| __all__ = [ | ||
| "DEFAULT_MAX_MESSAGE_BYTES", | ||
| "DEFAULT_STOP_TIMEOUT", | ||
| "AcpAgent", | ||
| "AcpAgentResult", | ||
| "StderrHandler", | ||
| "connect_acp_agent", | ||
| ] | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
T-26 — anything open-ended (a running command, a PTY, a watch) returns a dedicated
*Handleclass whose constructor is internal.AcpAgentis exactly that shape (pid,wait(),stop(),kill()over a running process, obtained only fromconnect_acp_agent), so name it likeCommandHandle/WatchHandleand keep__init__out of the documented surface. Rename the references in__all__, the return annotation and the docstrings too.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leaving the name for now, together with the related T-4 comment on #2025 (
AcpAgent.startinstead ofconnectAcpAgent).AcpAgentalso carries the ACPconnection, not only the process.@mishushakov, if you want
AcpAgentHandleand astartfactory, I'll rename both packages and the templates, cookbook and docs at once.