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 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
+ ]
@@ -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
+ )