e2b-adk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ E2B_API_KEY=
2
+ GOOGLE_API_KEY=
@@ -0,0 +1,33 @@
1
+ /.agents/
2
+ /.claude/
3
+ /.planning/
4
+
5
+ # Python
6
+ .venv/
7
+ __pycache__/
8
+ *.pyc
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+
16
+ # Docs staged here for separate PRs to other repos (e2b-dev/docs, google/adk-docs)
17
+ /docs/
18
+
19
+ # Features
20
+ /features/
21
+ /CLAUDE.md
22
+ /AGENTS.md
23
+ /CLAUDE_ADDITIONS.md
24
+ /AGENTS_ADDITIONS.md
25
+ /DECISIONS.md
26
+
27
+ # Environment
28
+ .env
29
+ !.env.example
30
+
31
+ # Verification credentials (never commit)
32
+ .env.verification
33
+ /.claude/verification/auth-state.json
e2b_adk-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FoundryLabs, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
e2b_adk-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: e2b-adk
3
+ Version: 0.1.0
4
+ Summary: Google ADK plugin backed by E2B sandboxes.
5
+ Project-URL: Homepage, https://github.com/e2b-dev/e2b-adk-plugin
6
+ Project-URL: Repository, https://github.com/e2b-dev/e2b-adk-plugin
7
+ Project-URL: Issues, https://github.com/e2b-dev/e2b-adk-plugin/issues
8
+ Author-email: e2b <hello@e2b.dev>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: adk,agent,code-interpreter,e2b,google-adk,sandbox
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: e2b-code-interpreter<3,>=2.8.1
21
+ Requires-Dist: e2b<3,>=2.32.0
22
+ Requires-Dist: google-adk<3,>=2.3.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: build; extra == 'dev'
25
+ Requires-Dist: mypy; extra == 'dev'
26
+ Requires-Dist: pytest; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio; extra == 'dev'
28
+ Requires-Dist: ruff; extra == 'dev'
29
+ Requires-Dist: twine; extra == 'dev'
30
+ Provides-Extra: examples
31
+ Requires-Dist: python-dotenv; extra == 'examples'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # e2b-adk
35
+
36
+ A [Google ADK](https://google.github.io/adk-docs/) plugin backed by
37
+ [E2B](https://e2b.dev) sandboxes. Attach it to an ADK agent and the agent can
38
+ generate and run code, execute shell commands, read and write files, and start
39
+ long-running processes — all inside an isolated E2B sandbox, with no
40
+ infrastructure of your own.
41
+
42
+ Under the hood every tool call runs in the
43
+ [E2B Code Interpreter](https://e2b.dev/docs), so the model's code executes in a
44
+ real Python kernel instead of being trusted blind.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install e2b-adk
50
+ ```
51
+
52
+ Or with [uv](https://docs.astral.sh/uv/):
53
+
54
+ ```bash
55
+ uv pip install e2b-adk
56
+ ```
57
+
58
+ ## Credentials
59
+
60
+ You need an E2B API key for the sandbox and a model key for the agent's LLM
61
+ (Gemini is ADK's default):
62
+
63
+ ```bash
64
+ export E2B_API_KEY="..."
65
+ export GOOGLE_API_KEY="..."
66
+ ```
67
+
68
+ Get an E2B key at [e2b.dev/dashboard](https://e2b.dev/dashboard).
69
+
70
+ ## Quickstart
71
+
72
+ Create the plugin, hand its tools to an `Agent`, and register the plugin on the
73
+ `App`. The plugin owns the sandbox lifecycle — you never create or tear one down
74
+ yourself.
75
+
76
+ ```python
77
+ import asyncio
78
+
79
+ from google.adk.agents import Agent
80
+ from google.adk.apps import App
81
+ from google.adk.runners import InMemoryRunner
82
+
83
+ from e2b_adk import E2BPlugin
84
+
85
+
86
+ async def main() -> None:
87
+ plugin = E2BPlugin()
88
+ agent = Agent(
89
+ model="gemini-2.5-flash",
90
+ name="coder",
91
+ instruction="Write and run Python to answer the user. Verify with run_code.",
92
+ tools=plugin.get_tools(),
93
+ )
94
+ app = App(name="demo", root_agent=agent, plugins=[plugin])
95
+
96
+ async with InMemoryRunner(app=app) as runner:
97
+ # run_debug prints the conversation as it runs.
98
+ await runner.run_debug("Compute the 20th Fibonacci number.")
99
+
100
+
101
+ asyncio.run(main())
102
+ ```
103
+
104
+ ## Tools
105
+
106
+ `plugin.get_tools()` returns six tools. Each returns a JSON-serializable dict
107
+ with a `success` flag — tools report failures in the result rather than raising,
108
+ so a bad call never aborts the agent run. `success` means the tool *ran*: code
109
+ that raised or a command that exited non-zero still returns `success: True` with
110
+ the failure captured in `error` / `exit_code`. Only a call that could not run at
111
+ all (sandbox unavailable, timeout) returns `success: False`.
112
+
113
+ | Tool | Does | Key result fields |
114
+ |------|------|-------------------|
115
+ | `run_code` | Run code in a stateful kernel (variables persist across calls) | `stdout`, `stderr`, `text`, `error` |
116
+ | `run_command` | Run a shell command (each call is independent) | `stdout`, `stderr`, `exit_code` |
117
+ | `write_file` | Write a file in the sandbox | `path` |
118
+ | `read_file` | Read a file back | `path`, `content` |
119
+ | `list_files` | List a directory | `entries` (`[{name, type}]`) |
120
+ | `start_background_command` | Start a long-running process; optional preview URL for a port | `pid`, `preview_url` (+ `readiness` when `port` is set) |
121
+
122
+ ### Configuration
123
+
124
+ `E2BPlugin` takes keyword-only options, all optional. Anything you don't set
125
+ falls back to the E2B SDK's own default — the plugin overrides none of them.
126
+
127
+ ```python
128
+ E2BPlugin(
129
+ # Common options
130
+ api_key=None, # defaults to the E2B_API_KEY env var
131
+ template=None, # E2B sandbox template
132
+ metadata=None, # dict[str, str] attached to the sandbox
133
+ envs=None, # dict[str, str] environment variables
134
+ timeout=None, # sandbox timeout in seconds (re-applied on every tool call)
135
+ plugin_name="e2b_plugin",
136
+ # Passed straight through to AsyncSandbox.create() — see the E2B SDK docs
137
+ secure=None,
138
+ allow_internet_access=None,
139
+ mcp=None,
140
+ network=None,
141
+ lifecycle=None, # e.g. pause/auto-resume; defaults to E2B's behavior
142
+ volume_mounts=None,
143
+ # **opts: any other AsyncSandbox.create() / connection param (proxy, request_timeout, …)
144
+ )
145
+ ```
146
+
147
+ ## Examples
148
+
149
+ - [`examples/data_analysis.py`](examples/data_analysis.py) — a data-analysis
150
+ agent that saves a dataset and computes real answers with pandas instead of
151
+ guessing.
152
+ - [`examples/code_generator.py`](examples/code_generator.py) — a code generator
153
+ that executes every snippet before returning it.
154
+
155
+ Run one with the credentials above:
156
+
157
+ ```bash
158
+ uv run --extra examples python examples/data_analysis.py
159
+ ```
160
+
161
+ ## v1 notes
162
+
163
+ - **Sequential tool calls.** One sandbox is shared per plugin; calls are meant
164
+ to run one at a time, not concurrently. Concurrent calls are safe for the
165
+ sandbox lifecycle (creation is locked) but share one filesystem and kernel.
166
+ - **Text-only output.** Tools return text — `stdout`/`stderr`/file contents.
167
+ Rich results (charts, dataframes, images) are not surfaced yet.
168
+ - **Lazy, shared sandbox lifecycle.** The sandbox is created on the first tool
169
+ call and reused for the agent's lifetime, then killed when the runner exits.
170
+ - **Keep-alive while active, expiry when idle.** While a sandbox SDK operation
171
+ is active, a best-effort heartbeat refreshes its `timeout` (E2B's default is
172
+ 300s), including when a foreground call runs longer than that timeout. A
173
+ final refresh starts the full idle window when the operation ends. An idle
174
+ gap longer than `timeout` still expires the sandbox under E2B's default
175
+ lifecycle (`on_timeout: kill`), and later tool calls return failure results —
176
+ pass e.g.
177
+ `lifecycle={"on_timeout": {"action": "pause"}, "auto_resume": True}` to pause
178
+ and auto-resume across idle gaps instead.
179
+ - **Bounded output.** Output larger than 10 KB per field is truncated with a
180
+ `…[truncated N bytes]` marker so a single call can't flood the model's context.
181
+
182
+ ## License
183
+
184
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,151 @@
1
+ # e2b-adk
2
+
3
+ A [Google ADK](https://google.github.io/adk-docs/) plugin backed by
4
+ [E2B](https://e2b.dev) sandboxes. Attach it to an ADK agent and the agent can
5
+ generate and run code, execute shell commands, read and write files, and start
6
+ long-running processes — all inside an isolated E2B sandbox, with no
7
+ infrastructure of your own.
8
+
9
+ Under the hood every tool call runs in the
10
+ [E2B Code Interpreter](https://e2b.dev/docs), so the model's code executes in a
11
+ real Python kernel instead of being trusted blind.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install e2b-adk
17
+ ```
18
+
19
+ Or with [uv](https://docs.astral.sh/uv/):
20
+
21
+ ```bash
22
+ uv pip install e2b-adk
23
+ ```
24
+
25
+ ## Credentials
26
+
27
+ You need an E2B API key for the sandbox and a model key for the agent's LLM
28
+ (Gemini is ADK's default):
29
+
30
+ ```bash
31
+ export E2B_API_KEY="..."
32
+ export GOOGLE_API_KEY="..."
33
+ ```
34
+
35
+ Get an E2B key at [e2b.dev/dashboard](https://e2b.dev/dashboard).
36
+
37
+ ## Quickstart
38
+
39
+ Create the plugin, hand its tools to an `Agent`, and register the plugin on the
40
+ `App`. The plugin owns the sandbox lifecycle — you never create or tear one down
41
+ yourself.
42
+
43
+ ```python
44
+ import asyncio
45
+
46
+ from google.adk.agents import Agent
47
+ from google.adk.apps import App
48
+ from google.adk.runners import InMemoryRunner
49
+
50
+ from e2b_adk import E2BPlugin
51
+
52
+
53
+ async def main() -> None:
54
+ plugin = E2BPlugin()
55
+ agent = Agent(
56
+ model="gemini-2.5-flash",
57
+ name="coder",
58
+ instruction="Write and run Python to answer the user. Verify with run_code.",
59
+ tools=plugin.get_tools(),
60
+ )
61
+ app = App(name="demo", root_agent=agent, plugins=[plugin])
62
+
63
+ async with InMemoryRunner(app=app) as runner:
64
+ # run_debug prints the conversation as it runs.
65
+ await runner.run_debug("Compute the 20th Fibonacci number.")
66
+
67
+
68
+ asyncio.run(main())
69
+ ```
70
+
71
+ ## Tools
72
+
73
+ `plugin.get_tools()` returns six tools. Each returns a JSON-serializable dict
74
+ with a `success` flag — tools report failures in the result rather than raising,
75
+ so a bad call never aborts the agent run. `success` means the tool *ran*: code
76
+ that raised or a command that exited non-zero still returns `success: True` with
77
+ the failure captured in `error` / `exit_code`. Only a call that could not run at
78
+ all (sandbox unavailable, timeout) returns `success: False`.
79
+
80
+ | Tool | Does | Key result fields |
81
+ |------|------|-------------------|
82
+ | `run_code` | Run code in a stateful kernel (variables persist across calls) | `stdout`, `stderr`, `text`, `error` |
83
+ | `run_command` | Run a shell command (each call is independent) | `stdout`, `stderr`, `exit_code` |
84
+ | `write_file` | Write a file in the sandbox | `path` |
85
+ | `read_file` | Read a file back | `path`, `content` |
86
+ | `list_files` | List a directory | `entries` (`[{name, type}]`) |
87
+ | `start_background_command` | Start a long-running process; optional preview URL for a port | `pid`, `preview_url` (+ `readiness` when `port` is set) |
88
+
89
+ ### Configuration
90
+
91
+ `E2BPlugin` takes keyword-only options, all optional. Anything you don't set
92
+ falls back to the E2B SDK's own default — the plugin overrides none of them.
93
+
94
+ ```python
95
+ E2BPlugin(
96
+ # Common options
97
+ api_key=None, # defaults to the E2B_API_KEY env var
98
+ template=None, # E2B sandbox template
99
+ metadata=None, # dict[str, str] attached to the sandbox
100
+ envs=None, # dict[str, str] environment variables
101
+ timeout=None, # sandbox timeout in seconds (re-applied on every tool call)
102
+ plugin_name="e2b_plugin",
103
+ # Passed straight through to AsyncSandbox.create() — see the E2B SDK docs
104
+ secure=None,
105
+ allow_internet_access=None,
106
+ mcp=None,
107
+ network=None,
108
+ lifecycle=None, # e.g. pause/auto-resume; defaults to E2B's behavior
109
+ volume_mounts=None,
110
+ # **opts: any other AsyncSandbox.create() / connection param (proxy, request_timeout, …)
111
+ )
112
+ ```
113
+
114
+ ## Examples
115
+
116
+ - [`examples/data_analysis.py`](examples/data_analysis.py) — a data-analysis
117
+ agent that saves a dataset and computes real answers with pandas instead of
118
+ guessing.
119
+ - [`examples/code_generator.py`](examples/code_generator.py) — a code generator
120
+ that executes every snippet before returning it.
121
+
122
+ Run one with the credentials above:
123
+
124
+ ```bash
125
+ uv run --extra examples python examples/data_analysis.py
126
+ ```
127
+
128
+ ## v1 notes
129
+
130
+ - **Sequential tool calls.** One sandbox is shared per plugin; calls are meant
131
+ to run one at a time, not concurrently. Concurrent calls are safe for the
132
+ sandbox lifecycle (creation is locked) but share one filesystem and kernel.
133
+ - **Text-only output.** Tools return text — `stdout`/`stderr`/file contents.
134
+ Rich results (charts, dataframes, images) are not surfaced yet.
135
+ - **Lazy, shared sandbox lifecycle.** The sandbox is created on the first tool
136
+ call and reused for the agent's lifetime, then killed when the runner exits.
137
+ - **Keep-alive while active, expiry when idle.** While a sandbox SDK operation
138
+ is active, a best-effort heartbeat refreshes its `timeout` (E2B's default is
139
+ 300s), including when a foreground call runs longer than that timeout. A
140
+ final refresh starts the full idle window when the operation ends. An idle
141
+ gap longer than `timeout` still expires the sandbox under E2B's default
142
+ lifecycle (`on_timeout: kill`), and later tool calls return failure results —
143
+ pass e.g.
144
+ `lifecycle={"on_timeout": {"action": "pause"}, "auto_resume": True}` to pause
145
+ and auto-resume across idle gaps instead.
146
+ - **Bounded output.** Output larger than 10 KB per field is truncated with a
147
+ `…[truncated N bytes]` marker so a single call can't flood the model's context.
148
+
149
+ ## License
150
+
151
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,34 @@
1
+ """e2b-adk — Google ADK plugin backed by E2B sandboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version
6
+
7
+ from e2b import ConnectionConfig
8
+
9
+ from .plugin import E2BPlugin
10
+ from .tools import (
11
+ ListFiles,
12
+ ReadFile,
13
+ RunCode,
14
+ RunCommand,
15
+ StartBackgroundCommand,
16
+ WriteFile,
17
+ )
18
+
19
+ # E2B's integration hook is process-wide and must be set before the SDK builds
20
+ # a connection config. Importing the modules above has no runtime side effects;
21
+ # plugin construction can only happen after package initialization completes.
22
+ __version__ = version("e2b-adk")
23
+ ConnectionConfig.set_integration(f"e2b-adk/{__version__}")
24
+
25
+ __all__ = [
26
+ "E2BPlugin",
27
+ "__version__",
28
+ "ListFiles",
29
+ "ReadFile",
30
+ "RunCode",
31
+ "RunCommand",
32
+ "StartBackgroundCommand",
33
+ "WriteFile",
34
+ ]
@@ -0,0 +1,96 @@
1
+ """Result-contract helpers and output truncation shared by all tools.
2
+
3
+ Pure, dependency-free functions (no E2B/ADK imports). Every tool returns a
4
+ JSON-serializable ``dict`` following a single shape:
5
+
6
+ * Success results carry ``success: True`` plus tool-specific fields.
7
+ * Failure results carry ``success: False``, a human-readable ``error``, the
8
+ echoed input relevant to the call (``path`` / ``command``), and any
9
+ ``partial_output`` captured before the failure.
10
+
11
+ Oversized text output is trimmed to a byte bound with an explicit marker so a
12
+ single tool call cannot exhaust the agent's context window.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ #: Default upper bound (in bytes) for a single truncatable text field.
20
+ DEFAULT_MAX_OUTPUT_BYTES = 10_000
21
+
22
+
23
+ def truncate_output(text: str, limit: int = DEFAULT_MAX_OUTPUT_BYTES) -> str:
24
+ """Trim ``text`` to at most ``limit`` *payload* bytes (UTF-8), plus a marker.
25
+
26
+ ``limit`` bounds the retained payload, not the final string: when truncation
27
+ occurs a short ``…[truncated N bytes]`` marker is appended *beyond* the
28
+ ``limit`` bytes, so the returned string may exceed ``limit`` by the marker's
29
+ length. This keeps a single tool call from exhausting the agent's context
30
+ while still reporting the exact omitted-byte count.
31
+
32
+ Text is first normalized to valid Unicode: well-formed surrogate pairs are
33
+ combined and lone surrogates are replaced with ``U+FFFD``. Text whose UTF-8
34
+ encoding is then within ``limit`` is returned without a marker. Otherwise
35
+ the first ``limit`` bytes are kept (decoded back at a valid character
36
+ boundary, dropping any partial trailing char).
37
+ """
38
+ if limit < 0:
39
+ raise ValueError(f"limit must be non-negative, got {limit}")
40
+ try:
41
+ encoded = text.encode("utf-8")
42
+ normalized = text
43
+ except UnicodeEncodeError:
44
+ # E2B builds streamed log text with json.loads(), which can produce
45
+ # Python strings containing UTF-16 surrogate code points. A UTF-16
46
+ # round-trip combines valid pairs and replaces only malformed lone
47
+ # surrogates, yielding text that is safe to encode and serialize.
48
+ normalized = text.encode("utf-16", errors="surrogatepass").decode(
49
+ "utf-16", errors="replace"
50
+ )
51
+ encoded = normalized.encode("utf-8")
52
+ total = len(encoded)
53
+ if total <= limit:
54
+ return normalized
55
+
56
+ # Decode the retained prefix, dropping any partial trailing char.
57
+ kept = encoded[:limit].decode("utf-8", errors="ignore")
58
+ omitted = total - len(kept.encode("utf-8"))
59
+ return f"{kept}…[truncated {omitted:,} bytes]"
60
+
61
+
62
+ def success_result(**fields: Any) -> dict[str, Any]:
63
+ """Build a success result: ``{**fields, "success": True}``.
64
+
65
+ Callers pass the tool-specific fields (e.g. ``stdout``, ``path``,
66
+ ``exit_code``) as keyword arguments. The reserved ``success`` key is set
67
+ last so a caller-supplied ``success`` field can never override the contract.
68
+ """
69
+ return {**fields, "success": True}
70
+
71
+
72
+ def failure_result(
73
+ error: str,
74
+ *,
75
+ path: str | None = None,
76
+ command: str | None = None,
77
+ partial_output: str = "",
78
+ **extra: Any,
79
+ ) -> dict[str, Any]:
80
+ """Build a failure result following the single failure contract.
81
+
82
+ Always includes ``success: False``, the ``error`` message, and
83
+ ``partial_output``. The echoed input (``path`` and/or ``command``) is
84
+ included only when supplied. Extra tool-specific fields may be passed via
85
+ ``**extra``. Reserved contract keys are applied *after* ``**extra`` so an
86
+ ``extra`` field can never override them.
87
+ """
88
+ result: dict[str, Any] = dict(extra)
89
+ result["success"] = False
90
+ result["error"] = error
91
+ result["partial_output"] = partial_output
92
+ if path is not None:
93
+ result["path"] = path
94
+ if command is not None:
95
+ result["command"] = command
96
+ return result
@@ -0,0 +1,127 @@
1
+ """Lazy E2B sandbox lifecycle management.
2
+
3
+ ``SandboxManager`` owns a single ``AsyncSandbox`` that is created lazily on the
4
+ first tool call and reused for the lifetime of the plugin instance, then torn
5
+ down on shutdown. An ``asyncio.Lock`` guards creation and teardown so that
6
+ concurrent tool calls (an agent turn may dispatch several at once) can never
7
+ create two sandboxes and leak one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from collections.abc import AsyncIterator
15
+ from contextlib import asynccontextmanager
16
+ from typing import Any, cast
17
+
18
+ from e2b_code_interpreter import AsyncSandbox
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class SandboxManager:
24
+ """Lazily creates and caches one ``AsyncSandbox``.
25
+
26
+ Any keyword arguments (``api_key``, ``template``, ``metadata``, ``envs``,
27
+ ``timeout``, ...) are stored and forwarded verbatim to
28
+ ``AsyncSandbox.create(...)`` on first use.
29
+ """
30
+
31
+ def __init__(self, **opts: Any) -> None:
32
+ self._opts: dict[str, Any] = opts
33
+ self._sandbox: AsyncSandbox | None = None
34
+ self._lock = asyncio.Lock()
35
+
36
+ async def get(self) -> AsyncSandbox:
37
+ """Return the live sandbox, creating it on the first call and caching it.
38
+
39
+ Subsequent calls return the cached instance without creating a second
40
+ sandbox. Creation is guarded by a lock with a double-check so that two
41
+ concurrent first calls create exactly one sandbox, never two.
42
+
43
+ The cached handle is returned as-is without a liveness check: an
44
+ already-created sandbox that has since expired is not detected or
45
+ re-created here (see :meth:`keep_alive` for the keep-alive scope that
46
+ prevents expiry during active use).
47
+ """
48
+ if self._sandbox is None:
49
+ async with self._lock:
50
+ if self._sandbox is None:
51
+ self._sandbox = await AsyncSandbox.create(**self._opts)
52
+ return self._sandbox
53
+
54
+ async def refresh_timeout(self) -> None:
55
+ """Push the sandbox's expiry window forward (no-op when none exists).
56
+
57
+ An E2B sandbox expires ``timeout`` seconds (SDK default 300) after
58
+ creation or the last ``set_timeout`` call — *not* after the last use.
59
+ This one-shot operation is also used by :meth:`keep_alive`, whose
60
+ recurring heartbeat covers an SDK operation that runs longer than the
61
+ timeout. Best-effort: a keep-alive failure is logged and swallowed.
62
+ """
63
+ sandbox = self._sandbox
64
+ if sandbox is not None:
65
+ await self._refresh_sandbox(sandbox)
66
+
67
+ def _timeout_seconds(self) -> int:
68
+ """Return the caller-configured TTL or E2B's default."""
69
+ timeout = self._opts.get("timeout")
70
+ if isinstance(timeout, int) and timeout > 0:
71
+ return timeout
72
+ return int(AsyncSandbox.default_sandbox_timeout)
73
+
74
+ def _keepalive_interval_seconds(self) -> float:
75
+ """Refresh halfway through the TTL, with a small positive floor."""
76
+ return max(self._timeout_seconds() / 2, 0.1)
77
+
78
+ async def _refresh_sandbox(self, sandbox: AsyncSandbox) -> None:
79
+ """Best-effort refresh for the currently cached sandbox."""
80
+ if self._sandbox is not sandbox:
81
+ return
82
+ try:
83
+ # E2B's class_method_variant decorator exposes set_timeout to mypy
84
+ # as Any | None even though the bound instance method is awaitable.
85
+ await cast(Any, sandbox).set_timeout(self._timeout_seconds())
86
+ except Exception as exc: # noqa: BLE001 — keep-alive is best-effort
87
+ logger.debug("sandbox keep-alive failed: %s", exc)
88
+
89
+ async def _keepalive_loop(self, sandbox: AsyncSandbox) -> None:
90
+ """Refresh ``sandbox`` until its active SDK operation finishes."""
91
+ while self._sandbox is sandbox:
92
+ await asyncio.sleep(self._keepalive_interval_seconds())
93
+ await self._refresh_sandbox(sandbox)
94
+
95
+ @asynccontextmanager
96
+ async def keep_alive(self, sandbox: AsyncSandbox) -> AsyncIterator[None]:
97
+ """Keep ``sandbox`` alive for the full duration of one SDK operation.
98
+
99
+ The first refresh handles cached sandboxes, the heartbeat prevents a
100
+ call longer than the TTL from expiring mid-run, and the final refresh
101
+ starts a full idle window when the operation completes. Every refresh
102
+ is best-effort so lifecycle maintenance never changes a tool result.
103
+ """
104
+ await self._refresh_sandbox(sandbox)
105
+ heartbeat = asyncio.create_task(self._keepalive_loop(sandbox))
106
+ try:
107
+ yield
108
+ finally:
109
+ heartbeat.cancel()
110
+ await asyncio.gather(heartbeat, return_exceptions=True)
111
+ await self._refresh_sandbox(sandbox)
112
+
113
+ async def shutdown(self) -> None:
114
+ """Kill the sandbox if one exists and clear the cache.
115
+
116
+ A no-op when no sandbox was ever created. The cache is cleared even if
117
+ ``kill()`` raises (the error still propagates), so a dead sandbox is
118
+ never handed back — after shutdown, a later ``get()`` lazily creates a
119
+ fresh sandbox. Holds the same lock as ``get()`` so teardown cannot race
120
+ with a concurrent create.
121
+ """
122
+ async with self._lock:
123
+ if self._sandbox is not None:
124
+ try:
125
+ await self._sandbox.kill()
126
+ finally:
127
+ self._sandbox = None