e2b-adk v0.1.0: Google ADK plugin backed by E2B#1
Conversation
Publishable, importable package skeleton + the two dependency-free core helpers: result contract/truncation (_results.py) and lazy sandbox lifecycle manager (_sandbox.py). MIT-licensed, hatchling build. Tasks: - task(e2b-adk-plugin.1.1.A): Package skeleton, pyproject.toml (hatchling, pinned deps), MIT LICENSE [TECH-2/6/11.6] - task(e2b-adk-plugin.1.2.A): Result contract + output truncation in _results.py [TECH-5] - task(e2b-adk-plugin.1.2.B): Lazy sandbox lifecycle SandboxManager in _sandbox.py [TECH-3.2]
- _results.py: set reserved contract keys (success/error/partial_output) last so caller/**extra fields can never override them - _results.py: reject negative truncate_output limit; clarify that limit bounds retained payload bytes (marker appended beyond it) - _sandbox.py: clear cached sandbox in finally so a failed kill() never leaves a dead sandbox to be reused - add e2b_adk/py.typed (PEP 561) to back the Typing :: Typed classifier - tests: reserved-key overrides, partial-multibyte split, limit=0, negative limit, and shutdown-resets-on-kill-failure
The two execution tools and the plugin that wires them, so an ADK agent can run and verify code and shell commands in an E2B sandbox end-to-end. Core changes: - run_code / run_command (BaseTool): single result contract, output truncation, failures returned (never raised); run_command catches CommandExitException so a non-zero exit stays success:true. - run_code advertises E2B's supported languages, derived from the SDK's RunCodeLanguage rather than a hardcoded list; input normalized to lowercase. - E2BPlugin: lazy, shared SandboxManager (no sandbox at init), get_tools(), async close() teardown, and two-tier tool logging (warn on a handled failure, error on an uncaught exception). - examples/code_generator.py flagship example (import-safe; a live run requires E2B and model credentials).
- SandboxManager.get/shutdown guard creation with an asyncio.Lock so concurrent tool calls create exactly one sandbox, never leaking one. - run_code falls back to python on a null/non-string language instead of raising out of run_async. - run_code returns execution.text (the main-result value) rather than the first result, which may be a display output.
Add 5 tests asserting the asyncio.Lock serializes sandbox creation: concurrent get() bursts create exactly one sandbox, a second get() waits on an in-flight create, shutdown() shares the lock so teardown cannot race a create, and separate managers stay independent.
The four remaining tools that complete the core tool set, so an ADK agent can read and write files, list directories, and start long-running processes in an E2B sandbox. Core changes: - write_file / read_file / list_files (BaseTool): single result contract, output truncation, failures returned (never raised); list_files normalizes E2B's FileType (including a missing type) to a plain "file"/"dir" string. - start_background_command (long-running): returns the pid immediately and, when given a port, a preview URL built from the sandbox host; readiness is reported as unknown since the service may not be listening yet. - E2BPlugin.get_tools() now returns all six tools sharing one sandbox; the new tools are exported from the package root. - Tool-level failure logs demoted to debug so a handled failure is surfaced once (at warning) by the plugin callback rather than twice.
- All tools validate required args and return a failure result instead of raising KeyError out of run_async; write_file accepts empty content. - start_background_command degrades to preview_url=None when host resolution fails, keeping the already-started pid rather than raising. - E2BPlugin redacts content/code/envs values in the debug tool-start log so secrets and large payloads never reach the logs.
- SandboxManager.refresh_timeout() resets the sandbox expiry window (set_timeout) on every tool call, so an active session outlives the E2B default 300s TTL; best-effort and a no-op before creation. - Plugin callbacks act only on this plugin's own tools (identity check on the shared manager), so foreign tools in the same app get no misleading logs or warnings. - Tests: cover the keep-alive (configured vs default timeout, no-op before creation and after shutdown, errors swallowed) and the callback scoping (foreign and manager-less tools are ignored).
Exercises all six tools against a real sandbox (stateful kernel, error normalization, file roundtrip, background command with a served preview URL) and the full ADK agent loop against Gemini. Deselected from the default run via the 'live' marker so unit tests stay mocked and offline.
A non-None return would be treated by ADK as the tool result and skip running the tool entirely.
Verifies against a real sandbox what unit tests could only assume: rejected writes and missing directories map to failure results, a real command timeout returns partial_output instead of raising, oversized output is truncated, bash is accepted as a language, and a nonexistent background binary never raises.
- examples/data_analysis.py: agent saves a dataset and computes answers with pandas in the sandbox instead of guessing - examples/code_generator.py: switch model to gemini-2.5-flash (runs on a free Gemini key; pro needs a paid tier)
Install, credentials, quickstart, the six-tool reference, plugin configuration, and v1 caveats.
- .github/workflows/ci.yml: ruff, mypy, pytest, build + twine check across Python 3.10-3.13; tests are fully mocked (no live E2B) - pyproject.toml: project URLs, twine dev-dep, and relaxed runtime pins (e2b-code-interpreter/google-adk to >=x,<3 so consumers aren't hard-pinned)
Core changes: - E2BPlugin: add secure, allow_internet_access, mcp, network, lifecycle, and volume_mounts named params, plus a **opts catch-all that forwards connection-level options (proxy, request_timeout, headers, ...) and any future create() parameter verbatim; only options the caller sets are passed - document that api_key is read from E2B_API_KEY when omitted, and that lifecycle defaults to E2B's on_timeout=kill unless overridden - start_background_command: add an optional timeout field bounding the background process lifetime (E2B default is 60s) - SandboxManager.get: note the cached handle is returned without a liveness check - tests: SDK-drift guard (every forwarded opt is a named create() param or an ApiParams key) and **opts passthrough coverage
The bounded-output note showed `[truncated N bytes]`; the code emits a leading ellipsis (`…[truncated N bytes]`). Align the doc with the actual marker.
Codex review flagged that a bare 'uv sync' could silently relock a stale lockfile during CI instead of failing. Use 'uv sync --locked' in both jobs.
Reflect the expanded E2BPlugin constructor (secure, allow_internet_access, mcp, network, lifecycle, volume_mounts, and the **opts catch-all) so the config block matches the public API.
…files Part of the Fable 5 pre-release audit.
Part of the Fable 5 pre-release audit.
…mple prints Part of the Fable 5 pre-release audit.
|
We require contributors to sign our Contributor License Agreement, and we don't have @max-sudolabs on file. You can sign our CLA at https://e2b.dev/docs/cla . Once you've signed, post a comment here that says '@cla-bot check' |
Each of the six tools now lives in its own module with a shared _common helpers module; the package __init__ re-exports the public surface so existing imports (e2b_adk.tools.RunCode, etc.) are unchanged.
|
@cla-bot check |
|
The cla-bot has been summoned, and re-checked this pull request! |
The build job needs the dev extra (build, twine) synced before running python -m build; it was dropped in the workflow rewrite, breaking the job with 'No module named build'.
tomasvarga
left a comment
There was a problem hiding this comment.
all good from my side
|
@tomasvarga OOO today, will look Wendesday morning. |
| logger.debug("run_code: execution could not run: %s", exc) | ||
| return failure_result(f"Failed to run code: {exc}") | ||
|
|
||
| stdout = truncate_output(_join(execution.logs.stdout)) |
There was a problem hiding this comment.
Think this one can break the "tools never raise out of run_async" contract from the PR description.
The post-processing at 107-120 runs outside the try/except. truncate_output does text.encode("utf-8") with no errors=, so a lone surrogate in the output raises UnicodeEncodeError, and that escapes run_async. The SDK builds execution.logs.stdout via json.loads on the streamed lines, and json.loads will produce lone-surrogate strings from \udXXX escapes, so sandbox code printing surrogate-escaped bytes is enough to trigger it. Since on_tool_error_callback returns None, ADK re-raises and the whole agent invocation dies.
run_command and read_file dodge this because they decode with errors="replace", but run_code has no equivalent step. Reproduced locally with mocks against the pinned wheels.
There was a problem hiding this comment.
Fixed in e23bc76. Tool output now normalizes surrogate-containing strings before UTF-8 truncation, preventing post-processing from raising. Added regression coverage using a JSON-decoded lone surrogate. The exact malformed value cannot be produced live because Jupyter rejects it upstream, but normal live E2B output passed.
| command = _require_str(args, "command") | ||
| if command is None: | ||
| return failure_result("Missing or invalid required argument: command") | ||
| port: int | None = args.get("port") |
There was a problem hiding this comment.
port is the only tool arg that skips validation, and it flows straight into the preview URL host.
port goes into sandbox.get_host(port), which is just f-string interpolation (f"{port}-{sandbox_id}...") and never raises, so the try/except around it can't catch a bad value. Two ways it bites:
- A model passing
port="evil.example/#"(say via prompt injection) givespreview_url="https://evil.example/#-<sandbox-id>.e2b.app", an attacker-controlled host in a field apps tend to render as a clickable link. - A float
8000.0(some model/serialization paths deliver floats for integer params) giveshttps://8000.0-<id>.e2b.app, silently unreachable but stillsuccess: True.
There was a problem hiding this comment.
Fixed in 602b3b8. start_background_command now accepts only non-boolean integers from 1 through 65535. Invalid values return a failure before sandbox creation or process startup. Tests cover strings, floats, booleans, zero, negatives, and values above 65535.
| "id. Provide a port to also get a preview URL for the exposed " | ||
| "service; the URL is syntactic and readiness is not verified." | ||
| ), | ||
| is_long_running=True, |
There was a problem hiding this comment.
Heads up, I don't think is_long_running=True does what the docstring assumes here.
In ADK 2.3.0 the flag means the real FunctionResponse gets injected later, not "fire and return". It stamps long_running_tool_ids on the model's function-call event, which makes event.is_final_response() return True for that event. But this tool returns its full result (pid, preview_url) right away and never injects anything later. So a consumer using ADK's documented if event.is_final_response() loop treats an empty function-call event as the final answer and blanks the turn. It also trips should_pause_invocation in resumable apps.
Verified against installed google-adk 2.3.0 with a mocked runner.
There was a problem hiding this comment.
Fixed in 10cff0d. Removed is_long_running because the tool returns its complete FunctionResponse immediately, even though the E2B process itself continues in the background. Added an ADK-level regression test and verified the FunctionCall → FunctionResponse → final response sequence with live Gemini.
|
On the "Open question" (dead sandbox handle after idle expiry): there's a second case worth separating from the idle-expiry one. The keep-alive only re-arms at call start with the create-time TTL, so a single call longer than the sandbox timeout expires the sandbox mid-run. For example Not something to solve in this PR, but flagging it as a distinct case from the one you already noted. |
Normalize surrogate-containing strings before truncation so code output cannot raise during post-processing.
Reject non-integer and out-of-range ports before sandbox creation or process startup.
Remove the long-running marker because the tool returns its complete FunctionResponse immediately.
Refresh sandbox TTL on entry, periodically during SDK operations, and on exit so long calls cannot expire mid-run.
Fixed in 881e9ef. Every SDK operation now runs inside a keep-alive scope that refreshes the sandbox TTL on entry, periodically at half-TTL intervals, and again on exit. All sandbox tools use this scope. Verified live with the default 300-second TTL: a 305-second command completed successfully. But we can revert particularly this one if you say so. |
e2b-adk: Google ADK plugin backed by E2B sandboxes
Summary
First release (v0.1.0) of e2b-adk — an open-source Google ADK plugin that
gives ADK agents an isolated E2B sandbox: generate and run code, execute shell
commands, manage files, and drive long-running background processes. Greenfield
PR — the diff is the whole package.
What's inside
E2BPlugin(aBasePlugin): owns one lazily-createdAsyncSandboxperplugin instance;
get_tools()returns six tools sharing that sandbox; thetool callbacks add keep-alive, owned-tool scoping (foreign tools are left
alone), and secret-safe arg redaction in debug logs;
close()kills thesandbox when the runner exits (wired into ADK's plugin teardown).
run_code(stateful kernel — variables persist across calls),run_command,write_file,read_file,list_files,start_background_command(returnspidand an optional preview URL for anexposed port).
run_async:success: Truewith the captured output and normalized error;success: Falsewith anerrormessage;…[truncated N bytes]marker so one call can't flood the context window.AsyncSandbox.create()option is forwardedverbatim (named params plus
**optsfor connection-levelApiParams); theplugin overrides none of E2B's defaults.
lifecycle(pause/auto-resume) isthe caller's choice. A drift-guard test validates the forwarded option set
against the installed SDK's real signature.
SandboxManagerguards create/teardown with anasyncio.Lock(double-checked), so a burst of concurrent first calls cannever create and leak a second billable sandbox.
timeoutviaset_timeout, so an active session never expires mid-run; an idle gap longerthan
timeoutstill expires the sandbox under E2B's default lifecycle (seethe open question below).
Testing
AsyncSandbox— CI needs no credentialsor network.
pytest -m live(excluded by default; billable):all six tools against a real sandbox, real error paths (missing files,
command timeout, non-UTF-8-friendly cases), and a Gemini agent end-to-end
driving
run_codethrough the full ADK loop.e2b-code-interpreter 2.8.1 and google-adk 2.3.0 — the lockfile pins exactly
the declared minimums, so CI tests the floor of the supported range.
Sync/async traps confirmed from source (
get_hostis sync;set_timeout/killare async behind a decorator that hides them fromiscoroutinefunction).the redaction, the non-zero-exit mapping, the truncation boundary, etc. makes
the matching test fail — the tests demonstrably bite.
uv sync --locked, build +twine check.Open question for reviewers
Dead sandbox handle after idle expiry (default lifecycle). Under E2B's
default
on_timeout: kill, a session idle longer thantimeoutloses itssandbox. The manager still holds the cached handle, so every later tool call
returns a
success: Falseresult ("The sandbox was not found …") until theplugin is closed. This is deliberate: auto-recreating on failure would silently
lose sandbox state (files, kernel variables), and the supported recovery is
opting into
lifecycle={"on_timeout": {"action": "pause"}, "auto_resume": True}. The failure mode is verified live: per-call failure results, nothingraises,
close()on a dead sandbox is clean.Question: is that per-call failure result acceptable for v1, or do we want a
clearer "sandbox expired — start a new session or enable pause/auto-resume"
signal (e.g. a dedicated error code in the result)?
Out of scope / follow-ups
docs/agents/google-adk.mdx) — separate PR toe2b-dev/docs, including the
google-adk.svgicon which doesn't exist thereyet.
run_coderesults), partialoutput capture on command timeout via streaming callbacks, readiness checks
for
start_background_commandpreview URLs.Notes for reviewers
share one sandbox and its kernel state.
mock fidelity — run it with
uv run pytest -m live(creates billablesandboxes).