e2b-adk 0.1.0__py3-none-any.whl
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/__init__.py +34 -0
- e2b_adk/_results.py +96 -0
- e2b_adk/_sandbox.py +127 -0
- e2b_adk/plugin.py +204 -0
- e2b_adk/py.typed +0 -0
- e2b_adk/tools/__init__.py +43 -0
- e2b_adk/tools/_common.py +77 -0
- e2b_adk/tools/list_files.py +82 -0
- e2b_adk/tools/read_file.py +74 -0
- e2b_adk/tools/run_code.py +128 -0
- e2b_adk/tools/run_command.py +112 -0
- e2b_adk/tools/start_background_command.py +150 -0
- e2b_adk/tools/write_file.py +83 -0
- e2b_adk-0.1.0.dist-info/METADATA +184 -0
- e2b_adk-0.1.0.dist-info/RECORD +17 -0
- e2b_adk-0.1.0.dist-info/WHEEL +4 -0
- e2b_adk-0.1.0.dist-info/licenses/LICENSE +21 -0
e2b_adk/__init__.py
ADDED
|
@@ -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
|
+
]
|
e2b_adk/_results.py
ADDED
|
@@ -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
|
e2b_adk/_sandbox.py
ADDED
|
@@ -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
|
e2b_adk/plugin.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""E2BPlugin — a ``google.adk.plugins.BasePlugin`` backed by an E2B sandbox.
|
|
2
|
+
|
|
3
|
+
The plugin owns a single :class:`~e2b_adk._sandbox.SandboxManager` built from
|
|
4
|
+
its constructor options. No sandbox is created at construction time — the
|
|
5
|
+
manager creates one lazily on the first tool call and reuses it until
|
|
6
|
+
:meth:`close` tears it down.
|
|
7
|
+
|
|
8
|
+
``get_tools()`` is a package convenience (not an ADK override) that returns the
|
|
9
|
+
execution tools, all sharing the plugin's single ``SandboxManager`` so every
|
|
10
|
+
tool call targets the same sandbox.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from google.adk.plugins import BasePlugin
|
|
19
|
+
from google.adk.tools import BaseTool, ToolContext
|
|
20
|
+
|
|
21
|
+
from ._sandbox import SandboxManager
|
|
22
|
+
from .tools import (
|
|
23
|
+
ListFiles,
|
|
24
|
+
ReadFile,
|
|
25
|
+
RunCode,
|
|
26
|
+
RunCommand,
|
|
27
|
+
StartBackgroundCommand,
|
|
28
|
+
WriteFile,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
#: Tool-arg keys whose *values* are masked before logging: ``command`` and
|
|
34
|
+
#: ``envs`` may carry credentials, while ``content`` / ``code`` may be large or
|
|
35
|
+
#: sensitive payloads. Other args (path, cwd, port, ...) are logged as-is to
|
|
36
|
+
#: keep the trace useful.
|
|
37
|
+
_SENSITIVE_ARG_KEYS = frozenset({"command", "content", "code", "envs"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _redact_args(tool_args: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
"""Return a shallow copy of ``tool_args`` with sensitive values masked."""
|
|
42
|
+
if not isinstance(tool_args, dict):
|
|
43
|
+
return tool_args
|
|
44
|
+
return {
|
|
45
|
+
key: ("<redacted>" if key in _SENSITIVE_ARG_KEYS else value)
|
|
46
|
+
for key, value in tool_args.items()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class E2BPlugin(BasePlugin):
|
|
51
|
+
"""ADK plugin that runs tool calls inside a shared E2B sandbox.
|
|
52
|
+
|
|
53
|
+
Constructor options (all optional) are forwarded to the owned
|
|
54
|
+
``SandboxManager`` and, through it, to ``AsyncSandbox.create(...)`` on first
|
|
55
|
+
use. Only the options that were actually supplied (non-``None``) are
|
|
56
|
+
forwarded, so E2B's own defaults apply otherwise.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
api_key: str | None = None,
|
|
63
|
+
template: str | None = None,
|
|
64
|
+
metadata: dict[str, str] | None = None,
|
|
65
|
+
envs: dict[str, str] | None = None,
|
|
66
|
+
timeout: int | None = None,
|
|
67
|
+
secure: bool | None = None,
|
|
68
|
+
allow_internet_access: bool | None = None,
|
|
69
|
+
mcp: Any | None = None,
|
|
70
|
+
network: Any | None = None,
|
|
71
|
+
lifecycle: Any | None = None,
|
|
72
|
+
volume_mounts: Any | None = None,
|
|
73
|
+
plugin_name: str = "e2b_plugin",
|
|
74
|
+
**opts: Any,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Build the plugin and its (lazy) ``SandboxManager``.
|
|
77
|
+
|
|
78
|
+
Every option except ``plugin_name`` maps to an ``AsyncSandbox.create``
|
|
79
|
+
parameter and is forwarded verbatim; only options the caller actually
|
|
80
|
+
set (non-``None``) are passed, so E2B's own defaults apply otherwise.
|
|
81
|
+
|
|
82
|
+
The named parameters cover ``create``'s sandbox options. Any further
|
|
83
|
+
keyword arguments (``**opts``) are forwarded verbatim too, covering the
|
|
84
|
+
connection-level ``ApiParams`` (``proxy``, ``request_timeout``,
|
|
85
|
+
``headers``, ...) and any future ``create`` parameter without a plugin
|
|
86
|
+
change — the plugin stays a thin, neutral passthrough.
|
|
87
|
+
|
|
88
|
+
``api_key`` is optional and normally omitted: when it is ``None`` the
|
|
89
|
+
E2B SDK reads ``E2B_API_KEY`` from the environment automatically, which
|
|
90
|
+
is the recommended way to supply the credential (keep it out of source).
|
|
91
|
+
|
|
92
|
+
``lifecycle`` controls what happens when the sandbox times out. Omitting
|
|
93
|
+
it keeps E2B's default (``on_timeout: "kill"``); pass e.g.
|
|
94
|
+
``{"on_timeout": {"action": "pause"}, "auto_resume": True}`` to preserve
|
|
95
|
+
state across idle gaps instead.
|
|
96
|
+
"""
|
|
97
|
+
super().__init__(name=plugin_name)
|
|
98
|
+
|
|
99
|
+
named: dict[str, Any] = {
|
|
100
|
+
"api_key": api_key,
|
|
101
|
+
"template": template,
|
|
102
|
+
"metadata": metadata,
|
|
103
|
+
"envs": envs,
|
|
104
|
+
"timeout": timeout,
|
|
105
|
+
"secure": secure,
|
|
106
|
+
"allow_internet_access": allow_internet_access,
|
|
107
|
+
"mcp": mcp,
|
|
108
|
+
"network": network,
|
|
109
|
+
"lifecycle": lifecycle,
|
|
110
|
+
"volume_mounts": volume_mounts,
|
|
111
|
+
}
|
|
112
|
+
# Forward only the named options the caller actually set, plus any extra
|
|
113
|
+
# keyword args verbatim (e.g. connection-level ApiParams).
|
|
114
|
+
forwarded = {key: value for key, value in named.items() if value is not None}
|
|
115
|
+
forwarded.update(opts)
|
|
116
|
+
self._manager = SandboxManager(**forwarded)
|
|
117
|
+
|
|
118
|
+
def get_tools(self) -> list[BaseTool]:
|
|
119
|
+
"""Return the execution tools, all sharing this plugin's sandbox.
|
|
120
|
+
|
|
121
|
+
Not an ADK override — a convenience so callers can wire the tools into
|
|
122
|
+
an ``Agent``. Each tool receives the plugin's single ``SandboxManager``
|
|
123
|
+
instance, so every tool call targets the same lazily-created sandbox.
|
|
124
|
+
"""
|
|
125
|
+
return [
|
|
126
|
+
RunCode(self._manager),
|
|
127
|
+
RunCommand(self._manager),
|
|
128
|
+
WriteFile(self._manager),
|
|
129
|
+
ReadFile(self._manager),
|
|
130
|
+
ListFiles(self._manager),
|
|
131
|
+
StartBackgroundCommand(self._manager),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
async def close(self) -> None:
|
|
135
|
+
"""Tear down the sandbox on plugin shutdown (ADK teardown hook)."""
|
|
136
|
+
await self._manager.shutdown()
|
|
137
|
+
|
|
138
|
+
def _owns(self, tool: BaseTool) -> bool:
|
|
139
|
+
"""Whether ``tool`` is one of this plugin's own sandbox tools.
|
|
140
|
+
|
|
141
|
+
ADK dispatches plugin callbacks for *every* tool in the app, including
|
|
142
|
+
tools this plugin didn't create. Ownership is checked by identity on the
|
|
143
|
+
shared ``SandboxManager`` so foreign tools are left alone — no misleading
|
|
144
|
+
"E2B tool" log lines and no warnings about other tools' result shapes.
|
|
145
|
+
"""
|
|
146
|
+
return getattr(tool, "manager", None) is self._manager
|
|
147
|
+
|
|
148
|
+
async def before_tool_callback(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
tool: BaseTool,
|
|
152
|
+
tool_args: dict[str, Any],
|
|
153
|
+
tool_context: ToolContext,
|
|
154
|
+
) -> dict[str, Any] | None:
|
|
155
|
+
"""Log the owned sandbox tool about to run (non-intrusive)."""
|
|
156
|
+
if not self._owns(tool):
|
|
157
|
+
return None
|
|
158
|
+
logger.debug("E2B tool starting: %s args=%s", tool.name, _redact_args(tool_args))
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
async def after_tool_callback(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
tool: BaseTool,
|
|
165
|
+
tool_args: dict[str, Any],
|
|
166
|
+
tool_context: ToolContext,
|
|
167
|
+
result: dict[str, Any],
|
|
168
|
+
) -> dict[str, Any] | None:
|
|
169
|
+
"""Log the outcome of a sandbox tool call (non-intrusive).
|
|
170
|
+
|
|
171
|
+
A tool that returns ``success: False`` (a failure it *handled* per the
|
|
172
|
+
result contract) is surfaced at warning level; an exception that escapes
|
|
173
|
+
a tool is handled separately in :meth:`on_tool_error_callback`.
|
|
174
|
+
"""
|
|
175
|
+
if not self._owns(tool):
|
|
176
|
+
return None
|
|
177
|
+
if isinstance(result, dict) and result.get("success") is False:
|
|
178
|
+
logger.warning(
|
|
179
|
+
"E2B tool %s returned a failure result: %s",
|
|
180
|
+
tool.name,
|
|
181
|
+
result.get("error"),
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
logger.debug("E2B tool finished: %s", tool.name)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
async def on_tool_error_callback(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
tool: BaseTool,
|
|
191
|
+
tool_args: dict[str, Any],
|
|
192
|
+
tool_context: ToolContext,
|
|
193
|
+
error: Exception,
|
|
194
|
+
) -> dict[str, Any] | None:
|
|
195
|
+
"""Log an *uncaught* tool exception.
|
|
196
|
+
|
|
197
|
+
The tools return failures via the result contract rather than raising,
|
|
198
|
+
so reaching here indicates a bug or an unexpected SDK error worth a
|
|
199
|
+
louder log line. Returns ``None`` (no result substitution).
|
|
200
|
+
"""
|
|
201
|
+
if not self._owns(tool):
|
|
202
|
+
return None
|
|
203
|
+
logger.error("E2B tool %s raised an uncaught exception: %s", tool.name, error)
|
|
204
|
+
return None
|
e2b_adk/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""ADK ``BaseTool`` subclasses that execute inside the E2B sandbox.
|
|
2
|
+
|
|
3
|
+
One tool per module, re-exported here so ``from e2b_adk.tools import RunCode``
|
|
4
|
+
(and the plugin's own imports) keep working. The tools:
|
|
5
|
+
|
|
6
|
+
* ``RunCode`` (``run_code``) — run a code snippet in the sandbox interpreter.
|
|
7
|
+
* ``RunCommand`` (``run_command``) — run a shell command in the sandbox.
|
|
8
|
+
* ``WriteFile`` (``write_file``) — write a file into the sandbox filesystem.
|
|
9
|
+
* ``ReadFile`` (``read_file``) — read a file from the sandbox filesystem.
|
|
10
|
+
* ``ListFiles`` (``list_files``) — list a directory in the sandbox filesystem.
|
|
11
|
+
* ``StartBackgroundCommand`` (``start_background_command``) — start a
|
|
12
|
+
long-running command (e.g. a dev server) and return immediately.
|
|
13
|
+
|
|
14
|
+
All follow the single result contract from :mod:`e2b_adk._results`:
|
|
15
|
+
|
|
16
|
+
* The tool *ran* (even if the user's code raised or the command exited
|
|
17
|
+
non-zero) → ``success: True`` with the captured output / normalized error.
|
|
18
|
+
* The tool *could not run* (sandbox unavailable, create failure, timeout) →
|
|
19
|
+
``success: False`` with an ``error`` and any ``partial_output``.
|
|
20
|
+
|
|
21
|
+
Failures are always **returned**, never raised out of ``run_async`` — a raised
|
|
22
|
+
exception would abort the whole agent run.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from ._common import SUPPORTED_LANGUAGES
|
|
28
|
+
from .list_files import ListFiles
|
|
29
|
+
from .read_file import ReadFile
|
|
30
|
+
from .run_code import RunCode
|
|
31
|
+
from .run_command import RunCommand
|
|
32
|
+
from .start_background_command import StartBackgroundCommand
|
|
33
|
+
from .write_file import WriteFile
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"SUPPORTED_LANGUAGES",
|
|
37
|
+
"ListFiles",
|
|
38
|
+
"ReadFile",
|
|
39
|
+
"RunCode",
|
|
40
|
+
"RunCommand",
|
|
41
|
+
"StartBackgroundCommand",
|
|
42
|
+
"WriteFile",
|
|
43
|
+
]
|
e2b_adk/tools/_common.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared helpers for the E2B tool classes.
|
|
2
|
+
|
|
3
|
+
Small, pure helpers used by more than one tool (or that derive a constant from
|
|
4
|
+
the SDK). Each tool class lives in its own module and imports what it needs
|
|
5
|
+
from here; the package ``__init__`` re-exports the public surface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, get_args
|
|
11
|
+
|
|
12
|
+
from e2b_code_interpreter import FileType, RunCodeLanguage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _supported_languages() -> list[str]:
|
|
16
|
+
"""Derive the known languages from E2B's ``RunCodeLanguage`` type alias.
|
|
17
|
+
|
|
18
|
+
E2B declares it as ``Union[Literal["python", ...], str]`` — an *open* union
|
|
19
|
+
(any string is accepted; the literals are the known set). We unpack those
|
|
20
|
+
literal members so the enum we advertise stays in lockstep with the pinned
|
|
21
|
+
SDK and is never maintained in two places. Falls back to ``["python"]`` if
|
|
22
|
+
the alias shape ever changes (``run_code`` still accepts any string).
|
|
23
|
+
"""
|
|
24
|
+
args = get_args(RunCodeLanguage)
|
|
25
|
+
for arg in args:
|
|
26
|
+
members = get_args(arg)
|
|
27
|
+
if members and all(isinstance(m, str) for m in members):
|
|
28
|
+
return list(members)
|
|
29
|
+
return ["python"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
#: Languages E2B's ``run_code`` supports natively, sourced from the SDK's own
|
|
33
|
+
#: ``RunCodeLanguage``. E2B runs all of them through a single ``run_code`` call,
|
|
34
|
+
#: so this tool needs no per-language branching.
|
|
35
|
+
SUPPORTED_LANGUAGES = _supported_languages()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _join(chunks: list[str] | None) -> str:
|
|
39
|
+
"""Join a list of E2B log chunks into a single text blob.
|
|
40
|
+
|
|
41
|
+
E2B ``Logs.stdout`` / ``Logs.stderr`` are lists of chunks whose entries
|
|
42
|
+
already carry their own newlines, so they are concatenated with no
|
|
43
|
+
separator to avoid doubling line breaks. ``None`` collapses to ``""``.
|
|
44
|
+
"""
|
|
45
|
+
if not chunks:
|
|
46
|
+
return ""
|
|
47
|
+
return "".join(chunks)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize_type(file_type: FileType | None) -> str:
|
|
51
|
+
"""Normalize an E2B ``FileType`` entry to a plain string.
|
|
52
|
+
|
|
53
|
+
``FileType.FILE`` / ``FileType.DIR`` map to their ``.value`` (``"file"`` /
|
|
54
|
+
``"dir"``). A ``None`` type (E2B may omit it for an entry it cannot classify)
|
|
55
|
+
collapses to ``"unknown"`` so every entry carries a JSON-friendly string
|
|
56
|
+
``type`` and callers never have to handle ``None``.
|
|
57
|
+
"""
|
|
58
|
+
if file_type is None:
|
|
59
|
+
return "unknown"
|
|
60
|
+
return str(file_type.value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _require_str(args: dict[str, Any], key: str, *, allow_empty: bool = False) -> str | None:
|
|
64
|
+
"""Return ``args[key]`` when it is a valid string, else ``None``.
|
|
65
|
+
|
|
66
|
+
Guards a *required* argument so a malformed tool call (a missing or
|
|
67
|
+
non-string value) is turned into a returned failure rather than a ``KeyError``
|
|
68
|
+
raised out of ``run_async`` — the tools must never raise. ``allow_empty=True``
|
|
69
|
+
permits ``""`` (e.g. writing an empty file); otherwise an empty string is
|
|
70
|
+
rejected like a missing value.
|
|
71
|
+
"""
|
|
72
|
+
value = args.get(key)
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
return None
|
|
75
|
+
if not value and not allow_empty:
|
|
76
|
+
return None
|
|
77
|
+
return value
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""``list_files`` — list a directory in the sandbox filesystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from google.adk.tools import BaseTool, ToolContext
|
|
9
|
+
from google.genai import types
|
|
10
|
+
|
|
11
|
+
from .._results import failure_result, success_result
|
|
12
|
+
from .._sandbox import SandboxManager
|
|
13
|
+
from ._common import _normalize_type
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ListFiles(BaseTool):
|
|
19
|
+
"""List a directory in the E2B sandbox filesystem.
|
|
20
|
+
|
|
21
|
+
Backed by ``AsyncSandbox.files.list``. Returns one ``entries`` element per
|
|
22
|
+
child, each ``{"name": ..., "type": ...}`` where ``type`` is the normalized
|
|
23
|
+
string ``"file"`` / ``"dir"`` (or ``"unknown"`` when E2B omits it). A
|
|
24
|
+
non-existent directory or SDK error yields ``success: False`` with the
|
|
25
|
+
echoed ``path``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, manager: SandboxManager) -> None:
|
|
29
|
+
super().__init__(
|
|
30
|
+
name="list_files",
|
|
31
|
+
description=(
|
|
32
|
+
"List the entries of a directory in an isolated E2B sandbox. "
|
|
33
|
+
"Returns each child's name and type (file or dir). Defaults to "
|
|
34
|
+
"the current directory. Use this to explore the sandbox "
|
|
35
|
+
"filesystem."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
self.manager = manager
|
|
39
|
+
|
|
40
|
+
def _get_declaration(self) -> types.FunctionDeclaration:
|
|
41
|
+
return types.FunctionDeclaration(
|
|
42
|
+
name=self.name,
|
|
43
|
+
description=self.description,
|
|
44
|
+
parameters=types.Schema(
|
|
45
|
+
type=types.Type.OBJECT,
|
|
46
|
+
properties={
|
|
47
|
+
"path": types.Schema(
|
|
48
|
+
type=types.Type.STRING,
|
|
49
|
+
description=(
|
|
50
|
+
"Directory to list in the sandbox. Optional; "
|
|
51
|
+
"defaults to '.' (the current directory)."
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def run_async(
|
|
59
|
+
self, *, args: dict[str, Any], tool_context: ToolContext
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
path: str = args.get("path") or "."
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
sandbox = await self.manager.get()
|
|
65
|
+
except Exception as exc: # noqa: BLE001 — never raise out of run_async
|
|
66
|
+
logger.debug("list_files: sandbox unavailable: %s", exc)
|
|
67
|
+
return failure_result(f"Sandbox unavailable: {exc}", path=path)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
async with self.manager.keep_alive(sandbox):
|
|
71
|
+
entries = await sandbox.files.list(path)
|
|
72
|
+
except Exception as exc: # noqa: BLE001 — missing dir/SDK failure → success:false
|
|
73
|
+
logger.debug("list_files: could not list %s: %s", path, exc)
|
|
74
|
+
return failure_result(f"Failed to list directory: {exc}", path=path)
|
|
75
|
+
|
|
76
|
+
return success_result(
|
|
77
|
+
path=path,
|
|
78
|
+
entries=[
|
|
79
|
+
{"name": entry.name, "type": _normalize_type(entry.type)}
|
|
80
|
+
for entry in entries
|
|
81
|
+
],
|
|
82
|
+
)
|