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,481 @@
1
+ """Local execution environment backend for code execution in an isolated temp directory."""
2
+
3
+ import logging
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import anyio
11
+
12
+ from stirrup.core.models import ImageContentBlock, Tool, ToolUseCountMetadata
13
+
14
+ from .base import (
15
+ SHELL_TIMEOUT,
16
+ CodeExecToolProvider,
17
+ CodeExecutionParams,
18
+ CommandResult,
19
+ SavedFile,
20
+ SaveOutputFilesResult,
21
+ UploadedFile,
22
+ UploadFilesResult,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class LocalCodeExecToolProvider(CodeExecToolProvider):
29
+ """Local code execution tool provider using an isolated temp directory.
30
+
31
+ Commands are executed with the temp directory as the working directory.
32
+ An optional allowlist can restrict which commands are permitted.
33
+
34
+ Usage with Agent:
35
+ from stirrup.clients.chat_completions_client import ChatCompletionsClient
36
+
37
+ client = ChatCompletionsClient(model="gpt-5")
38
+ agent = Agent(
39
+ client=client,
40
+ name="assistant",
41
+ tools=[LocalCodeExecToolProvider(), CALCULATOR_TOOL],
42
+ )
43
+
44
+ async with agent.session(output_dir="./output") as session:
45
+ await session.run("Run some Python code")
46
+
47
+ Standalone usage:
48
+ provider = LocalCodeExecToolProvider()
49
+
50
+ async with provider as tool:
51
+ # tool is a Tool instance for code execution
52
+ result = await provider.run_command("python script.py")
53
+ await provider.save_output_files(["output.txt"], "/path/to/output")
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ *,
59
+ allowed_commands: list[str] | None = None,
60
+ temp_base_dir: Path | str | None = None,
61
+ description: str | None = None,
62
+ ) -> None:
63
+ """Initialize LocalCodeExecToolProvider configuration.
64
+
65
+ Args:
66
+ allowed_commands: Optional list of regex patterns. If provided, only
67
+ commands matching at least one pattern are allowed.
68
+ If None, all commands are allowed.
69
+ temp_base_dir: Optional base directory for creating the execution environment
70
+ temp directory. If None, uses the system default temp directory.
71
+ description: Optional description of the tool. If None, uses the default description.
72
+
73
+ """
74
+ super().__init__(allowed_commands=allowed_commands)
75
+ self._temp_dir: Path | None = None
76
+ self._temp_base_dir: Path | None = Path(temp_base_dir) if temp_base_dir else None
77
+ self._description = (
78
+ description
79
+ or "Execute a shell command in the execution environment. Returns exit code, stdout, and stderr as XML. Use `uv` to manage packages."
80
+ )
81
+
82
+ @property
83
+ def temp_dir(self) -> Path | None:
84
+ """Return the temp directory path, or None if not started."""
85
+ return self._temp_dir
86
+
87
+ def _check_absolute_paths(self, cmd: str) -> CommandResult | None:
88
+ """Check if command contains absolute paths that could escape the temp directory.
89
+
90
+ Returns:
91
+ CommandResult with error if absolute paths detected, None otherwise.
92
+
93
+ Note:
94
+ This check is specific to LocalCodeExecToolProvider since Docker and E2B
95
+ providers are already sandboxed and absolute paths are safe within them.
96
+ """
97
+ absolute_patterns = [
98
+ r"~/", # ~/path - home directory shortcut
99
+ r"/(?:home|Users|tmp|var|etc)/", # /home/, /Users/, /tmp/, etc.
100
+ r"\$HOME/", # $HOME/path
101
+ r"\$\{HOME\}/", # ${HOME}/path
102
+ ]
103
+ for pattern in absolute_patterns:
104
+ if re.search(pattern, cmd):
105
+ return CommandResult(
106
+ exit_code=1,
107
+ stdout="",
108
+ stderr=(
109
+ "Command appears to use absolute paths which could write outside "
110
+ "the execution environment. Use relative paths instead."
111
+ ),
112
+ error_kind="absolute_path_detected",
113
+ advice=(
114
+ "Use relative paths (e.g., './output.txt' instead of '~/output.txt'). "
115
+ "For full filesystem access, use DockerCodeExecToolProvider or E2BCodeExecToolProvider."
116
+ ),
117
+ )
118
+ return None
119
+
120
+ async def __aenter__(self) -> "Tool[CodeExecutionParams, ToolUseCountMetadata]":
121
+ """Create temp directory and return the code_exec tool."""
122
+ if self._temp_base_dir:
123
+ self._temp_base_dir.mkdir(parents=True, exist_ok=True)
124
+ self._temp_dir = Path(tempfile.mkdtemp(prefix="local_exec_env_", dir=self._temp_base_dir))
125
+ logger.info("Created local execution environment temp directory: %s", self._temp_dir)
126
+ return self.get_code_exec_tool(description=self._description)
127
+
128
+ async def __aexit__(
129
+ self,
130
+ exc_type: type[BaseException] | None,
131
+ exc_val: BaseException | None,
132
+ exc_tb: object,
133
+ ) -> None:
134
+ """Cleanup the local execution environment."""
135
+ if self._temp_dir and self._temp_dir.exists():
136
+ try:
137
+ shutil.rmtree(self._temp_dir)
138
+ except Exception as exc:
139
+ logger.warning("Failed to cleanup temp directory %s: %s", self._temp_dir, exc)
140
+ self._temp_dir = None
141
+
142
+ def _resolve_and_validate_path(self, path: str) -> Path:
143
+ """Resolve a path and validate it's within the temp directory.
144
+
145
+ Args:
146
+ path: File path (relative or absolute within the temp dir).
147
+
148
+ Returns:
149
+ Resolved absolute Path.
150
+
151
+ Raises:
152
+ RuntimeError: If environment not started.
153
+ ValueError: If path is outside temp directory.
154
+ FileNotFoundError: If path does not exist (for reads).
155
+
156
+ """
157
+ if self._temp_dir is None:
158
+ raise RuntimeError("ExecutionEnvironment not started.")
159
+
160
+ resolved = Path(path)
161
+ if not resolved.is_absolute():
162
+ resolved = self._temp_dir / resolved
163
+
164
+ # Security: ensure path is within temp directory
165
+ try:
166
+ resolved.resolve().relative_to(self._temp_dir.resolve())
167
+ except ValueError as e:
168
+ raise ValueError(f"Path is outside execution environment: {path}") from e
169
+
170
+ return resolved
171
+
172
+ async def read_file_bytes(self, path: str) -> bytes:
173
+ """Read file content as bytes from the temp directory.
174
+
175
+ Args:
176
+ path: File path (relative or absolute within the temp dir).
177
+
178
+ Returns:
179
+ File contents as bytes.
180
+
181
+ Raises:
182
+ RuntimeError: If environment not started.
183
+ ValueError: If path is outside temp directory.
184
+ FileNotFoundError: If file does not exist.
185
+
186
+ """
187
+ resolved = self._resolve_and_validate_path(path)
188
+ if not resolved.exists():
189
+ raise FileNotFoundError(f"File not found: {path}")
190
+ return resolved.read_bytes()
191
+
192
+ async def write_file_bytes(self, path: str, content: bytes) -> None:
193
+ """Write bytes to a file in the temp directory.
194
+
195
+ Args:
196
+ path: Destination path (relative or absolute within the temp dir).
197
+ content: File contents to write.
198
+
199
+ Raises:
200
+ RuntimeError: If environment not started.
201
+ ValueError: If path is outside temp directory.
202
+
203
+ """
204
+ resolved = self._resolve_and_validate_path(path)
205
+ resolved.parent.mkdir(parents=True, exist_ok=True)
206
+ resolved.write_bytes(content)
207
+
208
+ async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
209
+ """Execute command in the temp directory.
210
+
211
+ Args:
212
+ cmd: Shell command to execute (bash syntax).
213
+ timeout: Maximum time in seconds to wait for command completion.
214
+
215
+ Returns:
216
+ CommandResult with exit_code, stdout, stderr, and optional error info.
217
+
218
+ """
219
+ if self._temp_dir is None:
220
+ raise RuntimeError(
221
+ "ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
222
+ )
223
+
224
+ # Check allowlist
225
+ if not self._check_allowed(cmd):
226
+ return CommandResult(
227
+ exit_code=1,
228
+ stdout="",
229
+ stderr=f"Command not allowed: '{cmd}' does not match any allowed patterns",
230
+ error_kind="command_not_allowed",
231
+ advice="Only commands matching the allowlist patterns are permitted.",
232
+ )
233
+
234
+ # Check for absolute paths (local environment is not sandboxed)
235
+ absolute_path_error = self._check_absolute_paths(cmd)
236
+ if absolute_path_error:
237
+ return absolute_path_error
238
+
239
+ process = None
240
+ try:
241
+ with anyio.fail_after(timeout):
242
+ # Use shell=True by wrapping in a shell command
243
+ process = await anyio.open_process(
244
+ ["bash", "-c", cmd],
245
+ stdout=subprocess.PIPE,
246
+ stderr=subprocess.PIPE,
247
+ cwd=self._temp_dir,
248
+ )
249
+
250
+ # Read all output from streams concurrently
251
+ stdout_chunks: list[bytes] = []
252
+ stderr_chunks: list[bytes] = []
253
+
254
+ async def read_stdout() -> None:
255
+ if process.stdout:
256
+ stdout_chunks.extend([chunk async for chunk in process.stdout])
257
+
258
+ async def read_stderr() -> None:
259
+ if process.stderr:
260
+ stderr_chunks.extend([chunk async for chunk in process.stderr])
261
+
262
+ async with anyio.create_task_group() as tg:
263
+ tg.start_soon(read_stdout)
264
+ tg.start_soon(read_stderr)
265
+
266
+ await process.wait()
267
+
268
+ return CommandResult(
269
+ exit_code=process.returncode or 0,
270
+ stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
271
+ stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"),
272
+ )
273
+
274
+ except TimeoutError:
275
+ if process:
276
+ process.kill()
277
+ return CommandResult(
278
+ exit_code=1,
279
+ stdout="",
280
+ stderr=f"Command timed out after {timeout} seconds",
281
+ error_kind="timeout",
282
+ )
283
+ except Exception as exc:
284
+ return CommandResult(
285
+ exit_code=1,
286
+ stdout="",
287
+ stderr=str(exc),
288
+ error_kind="execution_error",
289
+ )
290
+
291
+ async def save_output_files(
292
+ self,
293
+ paths: list[str],
294
+ output_dir: Path | str,
295
+ dest_env: "CodeExecToolProvider | None" = None,
296
+ ) -> SaveOutputFilesResult:
297
+ """Move files from the temp directory to a destination.
298
+
299
+ When dest_env is None (local filesystem), files are MOVED (not copied) -
300
+ originals are deleted from the execution environment.
301
+ Existing files in output_dir are silently overwritten.
302
+
303
+ When dest_env is provided (cross-environment transfer), files are copied
304
+ using the base class implementation via read/write primitives.
305
+
306
+ Args:
307
+ paths: List of file paths in the execution environment (relative or absolute).
308
+ Relative paths are resolved against the execution environment temp directory.
309
+ output_dir: Directory path to save files to.
310
+ dest_env: If provided, output_dir is interpreted as a path within dest_env
311
+ (cross-environment transfer). If None, output_dir is a local
312
+ filesystem path.
313
+
314
+ Returns:
315
+ SaveOutputFilesResult containing lists of saved files and any failures.
316
+
317
+ """
318
+ if self._temp_dir is None:
319
+ raise RuntimeError(
320
+ "ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
321
+ )
322
+
323
+ # If dest_env is provided, use the base class implementation (cross-env transfer)
324
+ if dest_env is not None:
325
+ return await super().save_output_files(paths, output_dir, dest_env)
326
+
327
+ # Local filesystem - use optimized move operation
328
+ output_dir_path = Path(output_dir)
329
+ output_dir_path.mkdir(parents=True, exist_ok=True)
330
+
331
+ result = SaveOutputFilesResult()
332
+
333
+ for source_path_str in paths:
334
+ try:
335
+ source_path = Path(source_path_str)
336
+ if not source_path.is_absolute():
337
+ source_path = self._temp_dir / source_path
338
+
339
+ # Security: ensure path is within temp directory
340
+ try:
341
+ source_path.resolve().relative_to(self._temp_dir.resolve())
342
+ except ValueError:
343
+ result.failed[source_path_str] = "Path is outside execution environment directory"
344
+ logger.warning("Attempted to access path outside execution environment: %s", source_path_str)
345
+ continue
346
+
347
+ if not source_path.exists():
348
+ result.failed[source_path_str] = "File does not exist"
349
+ logger.warning("Execution environment file does not exist: %s", source_path_str)
350
+ continue
351
+
352
+ if not source_path.is_file():
353
+ result.failed[source_path_str] = "Path is not a file"
354
+ logger.warning("Execution environment path is not a file: %s", source_path_str)
355
+ continue
356
+
357
+ file_size = source_path.stat().st_size
358
+ dest_path = output_dir_path / source_path.name
359
+
360
+ # Move file (overwrites if exists)
361
+ shutil.move(str(source_path), str(dest_path))
362
+ logger.info("Moved file: %s -> %s", source_path, dest_path)
363
+
364
+ result.saved.append(
365
+ SavedFile(
366
+ source_path=source_path_str,
367
+ output_path=dest_path,
368
+ size=file_size,
369
+ ),
370
+ )
371
+
372
+ except Exception as exc:
373
+ result.failed[source_path_str] = str(exc)
374
+ logger.exception("Failed to move file: %s", source_path_str)
375
+
376
+ return result
377
+
378
+ async def upload_files(
379
+ self,
380
+ *paths: Path | str,
381
+ source_env: "CodeExecToolProvider | None" = None,
382
+ dest_dir: str | None = None,
383
+ ) -> UploadFilesResult:
384
+ """Upload files to the execution environment.
385
+
386
+ When source_env is None (local filesystem), files are COPIED (not moved) -
387
+ originals remain on the local filesystem.
388
+ Directories are uploaded recursively, preserving their structure.
389
+
390
+ When source_env is provided (cross-environment transfer), files are copied
391
+ using the base class implementation via read/write primitives.
392
+
393
+ Args:
394
+ *paths: File or directory paths to upload. If source_env is None, these
395
+ are local filesystem paths. If source_env is provided, these are
396
+ paths within source_env.
397
+ source_env: If provided, paths are within source_env. If None, paths are
398
+ local filesystem paths.
399
+ dest_dir: Destination subdirectory within the temp directory.
400
+ If None, files are placed directly in the temp directory.
401
+
402
+ Returns:
403
+ UploadFilesResult containing lists of uploaded files and any failures.
404
+
405
+ """
406
+ if self._temp_dir is None:
407
+ raise RuntimeError(
408
+ "ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
409
+ )
410
+
411
+ # If source_env is provided, use the base class implementation (cross-env transfer)
412
+ if source_env is not None:
413
+ return await super().upload_files(*paths, source_env=source_env, dest_dir=dest_dir)
414
+
415
+ # Local filesystem - use optimized copy operation
416
+ dest_base = self._temp_dir / dest_dir if dest_dir else self._temp_dir
417
+ dest_base.mkdir(parents=True, exist_ok=True)
418
+
419
+ result = UploadFilesResult()
420
+
421
+ for source in paths:
422
+ source = Path(source).resolve()
423
+
424
+ if not source.exists():
425
+ result.failed[str(source)] = "File or directory does not exist"
426
+ logger.warning("Upload source does not exist: %s", source)
427
+ continue
428
+
429
+ try:
430
+ if source.is_file():
431
+ dest = dest_base / source.name
432
+ shutil.copy2(source, dest)
433
+ result.uploaded.append(
434
+ UploadedFile(
435
+ source_path=source,
436
+ dest_path=str(dest.relative_to(self._temp_dir)),
437
+ size=source.stat().st_size,
438
+ ),
439
+ )
440
+ logger.debug("Uploaded file: %s -> %s", source, dest)
441
+
442
+ elif source.is_dir():
443
+ dest = dest_base / source.name
444
+ shutil.copytree(source, dest, dirs_exist_ok=True)
445
+ # Track all individual files uploaded
446
+ for file_path in source.rglob("*"):
447
+ if file_path.is_file():
448
+ relative = file_path.relative_to(source)
449
+ dest_file = dest / relative
450
+ result.uploaded.append(
451
+ UploadedFile(
452
+ source_path=file_path,
453
+ dest_path=str(dest_file.relative_to(self._temp_dir)),
454
+ size=file_path.stat().st_size,
455
+ ),
456
+ )
457
+ logger.debug("Uploaded directory: %s -> %s", source, dest)
458
+
459
+ except Exception as exc:
460
+ result.failed[str(source)] = str(exc)
461
+ logger.exception("Failed to upload: %s", source)
462
+
463
+ return result
464
+
465
+ async def view_image(self, path: str) -> ImageContentBlock:
466
+ """Read and return an image file from the local execution environment.
467
+
468
+ Args:
469
+ path: Path to image file (relative to temp directory, or absolute within it).
470
+
471
+ Returns:
472
+ ImageContentBlock containing the image data.
473
+
474
+ Raises:
475
+ RuntimeError: If execution environment not started.
476
+ FileNotFoundError: If file does not exist.
477
+ ValueError: If path is outside temp directory, is a directory, or not a valid image.
478
+
479
+ """
480
+ file_bytes = await self.read_file_bytes(path)
481
+ return ImageContentBlock(data=file_bytes)
@@ -0,0 +1,23 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from stirrup.constants import FINISH_TOOL_NAME
6
+ from stirrup.core.models import Tool, ToolResult, ToolUseCountMetadata
7
+
8
+
9
+ class FinishParams(BaseModel):
10
+ """Explanation for why the task is complete or cannot proceed."""
11
+
12
+ reason: Annotated[str, Field(description="Reason for finishing.")]
13
+ paths: Annotated[
14
+ list[str], Field(description="List of file paths created or modified. Do not include directories, only files.")
15
+ ]
16
+
17
+
18
+ SIMPLE_FINISH_TOOL: Tool[FinishParams, ToolUseCountMetadata] = Tool[FinishParams, ToolUseCountMetadata](
19
+ name=FINISH_TOOL_NAME,
20
+ description="Signal task completion with a reason. Use when the task is finished or cannot proceed further. Note that you will need a separate turn to finish.",
21
+ parameters=FinishParams,
22
+ executor=lambda params: ToolResult(content=params.reason, metadata=ToolUseCountMetadata()),
23
+ )