stirrup 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.
@@ -0,0 +1,359 @@
1
+ """E2B cloud execution environment backend for code execution."""
2
+
3
+ from pathlib import Path
4
+
5
+ try:
6
+ from e2b import InvalidArgumentException, TimeoutException
7
+ from e2b.sandbox.filesystem.filesystem import FileType
8
+ from e2b_code_interpreter import AsyncSandbox, CommandExitException
9
+ except ImportError as e:
10
+ raise ImportError(
11
+ "Requires installation of the e2b extra. Install with (for example): `uv pip install stirrup[e2b]` or `uv add stirrup[e2b]`",
12
+ ) from e
13
+
14
+ import logging
15
+
16
+ from stirrup.constants import SUBMISSION_SANDBOX_TIMEOUT
17
+ from stirrup.core.models import ImageContentBlock, Tool, ToolUseCountMetadata
18
+
19
+ from .base import (
20
+ SHELL_TIMEOUT,
21
+ CodeExecToolProvider,
22
+ CodeExecutionParams,
23
+ CommandResult,
24
+ SavedFile,
25
+ SaveOutputFilesResult,
26
+ UploadedFile,
27
+ UploadFilesResult,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class E2BCodeExecToolProvider(CodeExecToolProvider):
34
+ """E2B cloud code execution tool provider.
35
+
36
+ Usage with Agent:
37
+ from stirrup.clients.chat_completions_client import ChatCompletionsClient
38
+
39
+ provider = E2BCodeExecToolProvider(timeout=600, template="my-template")
40
+ client = ChatCompletionsClient(model="gpt-5")
41
+ agent = Agent(client=client, name="assistant", tools=[provider])
42
+ async with agent.session() as session:
43
+ await session.run("Run Python code")
44
+
45
+ Standalone usage:
46
+ provider = E2BCodeExecToolProvider(allowed_commands=[r"^python", r"^pip"])
47
+ async with provider as tool:
48
+ result = await provider.run_command("python script.py")
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ timeout: int = SUBMISSION_SANDBOX_TIMEOUT,
55
+ template: str | None = None,
56
+ allowed_commands: list[str] | None = None,
57
+ ) -> None:
58
+ """Initialize E2B execution environment configuration.
59
+
60
+ Args:
61
+ timeout: Execution environment lifetime in seconds (default: 10 minutes).
62
+ template: Optional E2B template name/alias.
63
+ allowed_commands: Optional list of regex patterns. If provided, only
64
+ commands matching at least one pattern are allowed.
65
+ If None, all commands are allowed.
66
+
67
+ """
68
+ super().__init__(allowed_commands=allowed_commands)
69
+ self._timeout = timeout
70
+ self._template = template
71
+ self._sbx: AsyncSandbox | None = None
72
+
73
+ async def __aenter__(self) -> Tool[CodeExecutionParams, ToolUseCountMetadata]:
74
+ """Initialize the E2B sandbox environment and return the code_exec tool."""
75
+ if self._template:
76
+ self._sbx = await AsyncSandbox.create(timeout=self._timeout, template=self._template)
77
+ else:
78
+ self._sbx = await AsyncSandbox.create(timeout=self._timeout)
79
+ return self.get_code_exec_tool()
80
+
81
+ async def __aexit__(
82
+ self,
83
+ exc_type: type[BaseException] | None,
84
+ exc_val: BaseException | None,
85
+ exc_tb: object,
86
+ ) -> None:
87
+ """Cleanup the E2B execution environment."""
88
+ if self._sbx:
89
+ await self._sbx.kill() # ty: ignore[no-matching-overload]
90
+ self._sbx = None
91
+
92
+ async def read_file_bytes(self, path: str) -> bytes:
93
+ """Read file content as bytes from the E2B sandbox.
94
+
95
+ Args:
96
+ path: File path within the sandbox.
97
+
98
+ Returns:
99
+ File contents as bytes.
100
+
101
+ Raises:
102
+ RuntimeError: If environment not started.
103
+ FileNotFoundError: If file does not exist.
104
+
105
+ """
106
+ if self._sbx is None:
107
+ raise RuntimeError("ExecutionEnvironment not started.")
108
+
109
+ if not await self._sbx.files.exists(path):
110
+ raise FileNotFoundError(f"File not found: {path}")
111
+
112
+ file_bytes = await self._sbx.files.read(path, format="bytes")
113
+ return bytes(file_bytes)
114
+
115
+ async def write_file_bytes(self, path: str, content: bytes) -> None:
116
+ """Write bytes to a file in the E2B sandbox.
117
+
118
+ Args:
119
+ path: Destination path within the sandbox.
120
+ content: File contents to write.
121
+
122
+ Raises:
123
+ RuntimeError: If environment not started.
124
+
125
+ """
126
+ if self._sbx is None:
127
+ raise RuntimeError("ExecutionEnvironment not started.")
128
+
129
+ await self._sbx.files.write(path, content)
130
+
131
+ async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
132
+ """Execute command in E2B execution environment, returning raw CommandResult."""
133
+ if self._sbx is None:
134
+ raise RuntimeError(
135
+ "ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
136
+ )
137
+
138
+ # Check allowlist
139
+ if not self._check_allowed(cmd):
140
+ return CommandResult(
141
+ exit_code=1,
142
+ stdout="",
143
+ stderr=f"Command not allowed: '{cmd}' does not match any allowed patterns",
144
+ error_kind="command_not_allowed",
145
+ advice="Only commands matching the allowlist patterns are permitted.",
146
+ )
147
+
148
+ try:
149
+ r = await self._sbx.commands.run(cmd, timeout=timeout)
150
+
151
+ return CommandResult(
152
+ exit_code=getattr(r, "exit_code", 0),
153
+ stdout=r.stdout,
154
+ stderr=r.stderr,
155
+ )
156
+ except CommandExitException as exc:
157
+ return CommandResult(
158
+ exit_code=exc.exit_code,
159
+ stdout=exc.stdout,
160
+ stderr=exc.stderr,
161
+ )
162
+ except InvalidArgumentException as exc:
163
+ return CommandResult(
164
+ exit_code=1,
165
+ stdout="",
166
+ stderr=str(exc),
167
+ error_kind="invalid_argument",
168
+ advice="Avoid NUL/control bytes and nested 'bash -lc'; use a quoted heredoc or base64 write.",
169
+ )
170
+ except TimeoutException as exc:
171
+ return CommandResult(
172
+ exit_code=1,
173
+ stdout="",
174
+ stderr=str(exc),
175
+ error_kind="timeout",
176
+ )
177
+
178
+ async def save_output_files(
179
+ self,
180
+ paths: list[str],
181
+ output_dir: Path | str,
182
+ dest_env: "CodeExecToolProvider | None" = None,
183
+ ) -> SaveOutputFilesResult:
184
+ """Save files from the E2B execution environment to a destination.
185
+
186
+ When dest_env is None (local filesystem), files are downloaded from the
187
+ sandbox and saved locally.
188
+
189
+ When dest_env is provided (cross-environment transfer), files are copied
190
+ using the base class implementation via read/write primitives.
191
+
192
+ Args:
193
+ paths: List of file paths in the execution environment to save.
194
+ output_dir: Directory path to save files to.
195
+ dest_env: If provided, output_dir is interpreted as a path within dest_env
196
+ (cross-environment transfer). If None, output_dir is a local
197
+ filesystem path.
198
+
199
+ Returns:
200
+ SaveOutputFilesResult containing lists of saved files and any failures.
201
+
202
+ """
203
+ if self._sbx is None:
204
+ raise RuntimeError(
205
+ "ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
206
+ )
207
+
208
+ # If dest_env is provided, use the base class implementation (cross-env transfer)
209
+ if dest_env is not None:
210
+ return await super().save_output_files(paths, output_dir, dest_env)
211
+
212
+ # Local filesystem - use optimized E2B API
213
+ output_dir_path = Path(output_dir)
214
+ output_dir_path.mkdir(parents=True, exist_ok=True)
215
+
216
+ result = SaveOutputFilesResult()
217
+
218
+ for env_path in paths:
219
+ try:
220
+ # Check if file exists
221
+ if not await self._sbx.files.exists(env_path):
222
+ result.failed[env_path] = "File does not exist"
223
+ logger.warning("Execution environment file does not exist: %s", env_path)
224
+ continue
225
+
226
+ # Get file info to verify it's a file (not a directory)
227
+ info = await self._sbx.files.get_info(env_path)
228
+ if info.type != FileType.FILE:
229
+ result.failed[env_path] = f"Path is not a file (type: {info.type})"
230
+ logger.warning("Execution environment path is not a file: %s (type: %s)", env_path, info.type)
231
+ continue
232
+
233
+ # Read file content from execution environment
234
+ file_bytes = await self._sbx.files.read(env_path, format="bytes")
235
+ content = bytes(file_bytes)
236
+
237
+ # Save with original filename directly in output_dir
238
+ local_path = output_dir_path / Path(env_path).name
239
+
240
+ # Write file
241
+ local_path.write_bytes(content)
242
+
243
+ result.saved.append(
244
+ SavedFile(
245
+ source_path=env_path,
246
+ output_path=local_path,
247
+ size=len(content),
248
+ ),
249
+ )
250
+ logger.debug("Saved file: %s -> %s (%d bytes)", env_path, local_path, len(content))
251
+
252
+ except Exception as exc:
253
+ result.failed[env_path] = str(exc)
254
+ logger.exception("Failed to save execution environment file: %s", env_path)
255
+
256
+ return result
257
+
258
+ async def upload_files(
259
+ self,
260
+ *paths: Path | str,
261
+ source_env: "CodeExecToolProvider | None" = None,
262
+ dest_dir: str | None = None,
263
+ ) -> UploadFilesResult:
264
+ """Upload files to the E2B sandbox.
265
+
266
+ When source_env is None (local filesystem), files are uploaded via the
267
+ E2B files.write() API.
268
+ Directories are uploaded recursively, preserving their structure.
269
+
270
+ When source_env is provided (cross-environment transfer), files are copied
271
+ using the base class implementation via read/write primitives.
272
+
273
+ Args:
274
+ *paths: File or directory paths to upload. If source_env is None, these
275
+ are local filesystem paths. If source_env is provided, these are
276
+ paths within source_env.
277
+ source_env: If provided, paths are within source_env. If None, paths are
278
+ local filesystem paths.
279
+ dest_dir: Destination directory in the sandbox.
280
+ If None, files are placed in /home/user.
281
+
282
+ Returns:
283
+ UploadFilesResult containing lists of uploaded files and any failures.
284
+
285
+ """
286
+ if self._sbx is None:
287
+ raise RuntimeError(
288
+ "ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
289
+ )
290
+
291
+ # If source_env is provided, use the base class implementation (cross-env transfer)
292
+ if source_env is not None:
293
+ return await super().upload_files(*paths, source_env=source_env, dest_dir=dest_dir)
294
+
295
+ # Local filesystem - use optimized E2B API
296
+ dest_base = dest_dir or "/home/user"
297
+ result = UploadFilesResult()
298
+
299
+ for source in paths:
300
+ source = Path(source).resolve()
301
+
302
+ if not source.exists():
303
+ result.failed[str(source)] = "File or directory does not exist"
304
+ logger.warning("Upload source does not exist: %s", source)
305
+ continue
306
+
307
+ try:
308
+ if source.is_file():
309
+ dest = f"{dest_base}/{source.name}"
310
+ content = source.read_bytes()
311
+ await self._sbx.files.write(dest, content)
312
+ result.uploaded.append(
313
+ UploadedFile(
314
+ source_path=source,
315
+ dest_path=dest,
316
+ size=len(content),
317
+ ),
318
+ )
319
+ logger.debug("Uploaded file: %s -> %s", source, dest)
320
+
321
+ elif source.is_dir():
322
+ # Upload all files in directory recursively
323
+ for file_path in source.rglob("*"):
324
+ if file_path.is_file():
325
+ relative = file_path.relative_to(source)
326
+ dest = f"{dest_base}/{source.name}/{relative}"
327
+ content = file_path.read_bytes()
328
+ await self._sbx.files.write(dest, content)
329
+ result.uploaded.append(
330
+ UploadedFile(
331
+ source_path=file_path,
332
+ dest_path=dest,
333
+ size=len(content),
334
+ ),
335
+ )
336
+ logger.debug("Uploaded directory: %s -> %s/%s", source, dest_base, source.name)
337
+
338
+ except Exception as exc:
339
+ result.failed[str(source)] = str(exc)
340
+ logger.exception("Failed to upload: %s", source)
341
+
342
+ return result
343
+
344
+ async def view_image(self, path: str) -> ImageContentBlock:
345
+ """Read and return an image file from the E2B execution environment.
346
+
347
+ Args:
348
+ path: Path to image file in the execution environment filesystem.
349
+
350
+ Returns:
351
+ ImageContentBlock containing the image data.
352
+
353
+ Raises:
354
+ RuntimeError: If execution environment not started.
355
+ FileNotFoundError: If file does not exist.
356
+
357
+ """
358
+ file_bytes = await self.read_file_bytes(path)
359
+ return ImageContentBlock(data=file_bytes)