Skip to content

e2b-adk v0.1.0: Google ADK plugin backed by E2B#1

Merged
OndrejDrapalik merged 37 commits into
mainfrom
feature/e2b-adk-plugin
Jul 16, 2026
Merged

e2b-adk v0.1.0: Google ADK plugin backed by E2B#1
OndrejDrapalik merged 37 commits into
mainfrom
feature/e2b-adk-plugin

Conversation

@max-sudolabs

Copy link
Copy Markdown
Collaborator

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.

plugin = E2BPlugin()
agent = Agent(model="gemini-2.5-flash", name="coder", tools=plugin.get_tools(), ...)
app = App(name="demo", root_agent=agent, plugins=[plugin])

What's inside

  • E2BPlugin (a BasePlugin): owns one lazily-created AsyncSandbox per
    plugin instance; get_tools() returns six tools sharing that sandbox; the
    tool callbacks add keep-alive, owned-tool scoping (foreign tools are left
    alone), and secret-safe arg redaction in debug logs; close() kills the
    sandbox when the runner exits (wired into ADK's plugin teardown).
  • Six tools: run_code (stateful kernel — variables persist across calls),
    run_command, write_file, read_file, list_files,
    start_background_command (returns pid and an optional preview URL for an
    exposed port).
  • Result contract — tools never raise out of run_async:
    • ran-but-errored (non-zero exit, exception in the user's code) →
      success: True with the captured output and normalized error;
    • couldn't run (sandbox unavailable, create failure, timeout) →
      success: False with an error message;
    • oversized output is truncated at 10 KB with an explicit
      …[truncated N bytes] marker so one call can't flood the context window.
  • Neutral passthrough: every AsyncSandbox.create() option is forwarded
    verbatim (named params plus **opts for connection-level ApiParams); the
    plugin overrides none of E2B's defaults. lifecycle (pause/auto-resume) is
    the caller's choice. A drift-guard test validates the forwarded option set
    against the installed SDK's real signature.
  • Concurrency: SandboxManager guards create/teardown with an
    asyncio.Lock (double-checked), so a burst of concurrent first calls can
    never create and leak a second billable sandbox.
  • Keep-alive: every owned tool call re-applies timeout via
    set_timeout, so an active session never expires mid-run; an idle gap longer
    than timeout still expires the sandbox under E2B's default lifecycle (see
    the open question below).

Testing

  • 63 unit tests against a mocked AsyncSandbox — CI needs no credentials
    or network.
  • Live smoke suite behind 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_code through the full ADK loop.
  • SDK surface verified against the installed wheels, not docs:
    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_host is sync; set_timeout /
    kill are async behind a decorator that hides them from
    iscoroutinefunction).
  • Mutation-checked safety tests: breaking the lock, the callback scoping,
    the redaction, the non-zero-exit mapping, the truncation boundary, etc. makes
    the matching test fail — the tests demonstrably bite.
  • CI: ruff + mypy --strict + pytest on Python 3.10–3.13, 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 than timeout loses its
sandbox. The manager still holds the cached handle, so every later tool call
returns a success: False result ("The sandbox was not found …") until the
plugin 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, nothing
raises, 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

  • PyPI publish (manual, after review).
  • E2B website docs page (docs/agents/google-adk.mdx) — separate PR to
    e2b-dev/docs, including the google-adk.svg icon which doesn't exist there
    yet.
  • v2 candidates: rich outputs (charts/images from run_code results), partial
    output capture on command timeout via streaming callbacks, readiness checks
    for start_background_command preview URLs.

Notes for reviewers

  • v1 assumes sequential tool calls (documented in the README); concurrent calls
    share one sandbox and its kernel state.
  • Tests intentionally mock the SDK; the live suite exists precisely to validate
    mock fidelity — run it with uv run pytest -m live (creates billable
    sandboxes).

tomasvarga and others added 25 commits July 3, 2026 13:37
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.
…mple prints

Part of the Fable 5 pre-release audit.
@cla-bot

cla-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

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'

@tomasvarga
tomasvarga self-requested a review July 10, 2026 10:05
@cla-bot cla-bot Bot added the cla-signed label Jul 10, 2026
Comment thread LICENSE Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread .github/workflows/ci.yml
Comment thread docs/e2b-site/agents/google-adk.mdx Outdated
Comment thread docs/e2b-site/agents/google-adk.mdx Outdated
Comment thread e2b_adk/tools.py Outdated
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.
@max-sudolabs

Copy link
Copy Markdown
Collaborator Author

@cla-bot check

@cla-bot

cla-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

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'.
@max-sudolabs
max-sudolabs marked this pull request as ready for review July 13, 2026 12:41

@cursor cursor 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.

Stale comment

Comment thread e2b_adk/plugin.py Outdated

@tomasvarga tomasvarga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

all good from my side

Copy link
Copy Markdown
Contributor

@tomasvarga OOO today, will look Wendesday morning.

@cursor cursor 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.

Stale comment

Comment thread e2b_adk/tools/write_file.py
Comment thread e2b_adk/tools/run_command.py
Comment thread e2b_adk/tools/run_code.py
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) gives preview_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) gives https://8000.0-<id>.e2b.app, silently unreachable but still success: True.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@OndrejDrapalik

Copy link
Copy Markdown
Contributor

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 run_command(timeout=1200) on a default 300s sandbox gets killed around t=300, loses its output, then falls into the same dead-handle state. That makes the README line "an active session never expires mid-run" not quite hold.

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.
@max-sudolabs

max-sudolabs commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

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 run_command(timeout=1200) on a default 300s sandbox gets killed around t=300, loses its output, then falls into the same dead-handle state. That makes the README line "an active session never expires mid-run" not quite hold.

Not something to solve in this PR, but flagging it as a distinct case from the one you already noted.

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.

@OndrejDrapalik
OndrejDrapalik merged commit d31486b into main Jul 16, 2026
7 checks passed
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