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.
- e2b_adk-0.1.0/.env.example +2 -0
- e2b_adk-0.1.0/.gitignore +33 -0
- e2b_adk-0.1.0/LICENSE +21 -0
- e2b_adk-0.1.0/PKG-INFO +184 -0
- e2b_adk-0.1.0/README.md +151 -0
- e2b_adk-0.1.0/e2b_adk/__init__.py +34 -0
- e2b_adk-0.1.0/e2b_adk/_results.py +96 -0
- e2b_adk-0.1.0/e2b_adk/_sandbox.py +127 -0
- e2b_adk-0.1.0/e2b_adk/plugin.py +204 -0
- e2b_adk-0.1.0/e2b_adk/py.typed +0 -0
- e2b_adk-0.1.0/e2b_adk/tools/__init__.py +43 -0
- e2b_adk-0.1.0/e2b_adk/tools/_common.py +77 -0
- e2b_adk-0.1.0/e2b_adk/tools/list_files.py +82 -0
- e2b_adk-0.1.0/e2b_adk/tools/read_file.py +74 -0
- e2b_adk-0.1.0/e2b_adk/tools/run_code.py +128 -0
- e2b_adk-0.1.0/e2b_adk/tools/run_command.py +112 -0
- e2b_adk-0.1.0/e2b_adk/tools/start_background_command.py +150 -0
- e2b_adk-0.1.0/e2b_adk/tools/write_file.py +83 -0
- e2b_adk-0.1.0/examples/code_generator.py +63 -0
- e2b_adk-0.1.0/examples/data_analysis.py +93 -0
- e2b_adk-0.1.0/pyproject.toml +91 -0
- e2b_adk-0.1.0/tests/__init__.py +0 -0
- e2b_adk-0.1.0/tests/conftest.py +29 -0
- e2b_adk-0.1.0/tests/test_live_smoke.py +251 -0
- e2b_adk-0.1.0/tests/test_plugin.py +329 -0
- e2b_adk-0.1.0/tests/test_results.py +118 -0
- e2b_adk-0.1.0/tests/test_sandbox.py +329 -0
- e2b_adk-0.1.0/tests/test_tools.py +629 -0
e2b_adk-0.1.0/.gitignore
ADDED
|
@@ -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).
|
e2b_adk-0.1.0/README.md
ADDED
|
@@ -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
|