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.
- stirrup/__init__.py +76 -0
- stirrup/clients/__init__.py +14 -0
- stirrup/clients/chat_completions_client.py +219 -0
- stirrup/clients/litellm_client.py +141 -0
- stirrup/clients/utils.py +161 -0
- stirrup/constants.py +14 -0
- stirrup/core/__init__.py +1 -0
- stirrup/core/agent.py +1097 -0
- stirrup/core/exceptions.py +7 -0
- stirrup/core/models.py +599 -0
- stirrup/prompts/__init__.py +22 -0
- stirrup/prompts/base_system_prompt.txt +1 -0
- stirrup/prompts/message_summarizer.txt +27 -0
- stirrup/prompts/message_summarizer_bridge.txt +11 -0
- stirrup/py.typed +0 -0
- stirrup/tools/__init__.py +77 -0
- stirrup/tools/calculator.py +32 -0
- stirrup/tools/code_backends/__init__.py +38 -0
- stirrup/tools/code_backends/base.py +454 -0
- stirrup/tools/code_backends/docker.py +752 -0
- stirrup/tools/code_backends/e2b.py +359 -0
- stirrup/tools/code_backends/local.py +481 -0
- stirrup/tools/finish.py +23 -0
- stirrup/tools/mcp.py +500 -0
- stirrup/tools/view_image.py +83 -0
- stirrup/tools/web.py +336 -0
- stirrup/utils/__init__.py +10 -0
- stirrup/utils/logging.py +944 -0
- stirrup/utils/text.py +11 -0
- stirrup-0.1.0.dist-info/METADATA +318 -0
- stirrup-0.1.0.dist-info/RECORD +32 -0
- stirrup-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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)
|