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,77 @@
1
+ """Tool implementations and providers.
2
+
3
+ This module provides tools and tool providers for the Agent.
4
+
5
+ ## Tool vs ToolProvider
6
+
7
+ - **Tool**: A simple, stateless callable with a name, description, parameters, and executor.
8
+ Use for tools that don't require setup/teardown.
9
+
10
+ - **ToolProvider**: A class that manages resources and returns Tool(s) via async context manager.
11
+ Use for tools requiring lifecycle management (connections, temp directories, etc.).
12
+
13
+ ## DEFAULT_TOOLS
14
+
15
+ DEFAULT_TOOLS provides a standard set of tool providers:
16
+ - LocalCodeExecToolProvider: Code execution in isolated temp directory
17
+ - WebToolProvider: Web fetch and search (search requires BRAVE_API_KEY)
18
+
19
+ Example usage:
20
+ from stirrup import Agent, DEFAULT_TOOLS
21
+ from stirrup.clients.chat_completions_client import ChatCompletionsClient
22
+ from stirrup.tools.mcp import MCPToolProvider
23
+
24
+ # Create a client for your LLM provider
25
+ client = ChatCompletionsClient(model="gpt-5")
26
+
27
+ # Use default tools
28
+ agent = Agent(client=client, name="assistant")
29
+
30
+ # Extend default tools
31
+ agent = Agent(
32
+ client=client,
33
+ name="assistant",
34
+ tools=[*DEFAULT_TOOLS, MCPToolProvider.from_config("mcp.json")],
35
+ )
36
+
37
+ # Custom tools only (no defaults)
38
+ agent = Agent(
39
+ client=client,
40
+ name="assistant",
41
+ tools=[CALCULATOR_TOOL, my_custom_tool],
42
+ )
43
+
44
+ ## Optional Dependencies
45
+
46
+ Optional tool providers require explicit imports from their submodules:
47
+ - DockerCodeExecToolProvider: `from stirrup.tools.code_backends.docker import DockerCodeExecToolProvider`
48
+ - E2BCodeExecToolProvider: `from stirrup.tools.code_backends.e2b import E2BCodeExecToolProvider`
49
+ - MCPToolProvider: `from stirrup.tools.mcp import MCPToolProvider`
50
+ """
51
+
52
+ from typing import Any
53
+
54
+ from stirrup.core.models import Tool, ToolProvider
55
+ from stirrup.tools.calculator import CALCULATOR_TOOL
56
+ from stirrup.tools.code_backends import CodeExecToolProvider, LocalCodeExecToolProvider
57
+ from stirrup.tools.finish import SIMPLE_FINISH_TOOL, FinishParams
58
+ from stirrup.tools.view_image import ViewImageToolProvider
59
+ from stirrup.tools.web import WebToolProvider
60
+
61
+ # DEFAULT_TOOLS provides a standard set of tool providers for the Agent.
62
+ # ToolProviders are automatically set up and torn down by Agent.session().
63
+ DEFAULT_TOOLS: list[Tool[Any, Any] | ToolProvider] = [
64
+ LocalCodeExecToolProvider(), # ToolProvider, returns code_exec tool
65
+ WebToolProvider(), # ToolProvider, returns web_fetch + web_search (if API key)
66
+ ]
67
+
68
+ __all__ = [
69
+ "CALCULATOR_TOOL",
70
+ "DEFAULT_TOOLS",
71
+ "SIMPLE_FINISH_TOOL",
72
+ "CodeExecToolProvider",
73
+ "FinishParams",
74
+ "LocalCodeExecToolProvider",
75
+ "ViewImageToolProvider",
76
+ "WebToolProvider",
77
+ ]
@@ -0,0 +1,32 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from stirrup.core.models import Tool, ToolResult, ToolUseCountMetadata
6
+
7
+
8
+ class CalculatorParams(BaseModel):
9
+ """Mathematical expression to be evaluated."""
10
+
11
+ expression: Annotated[
12
+ str,
13
+ Field(description="Mathematical expression to evaluate (Python syntax, e.g., '2 + 2 * 3')"),
14
+ ]
15
+
16
+
17
+ def calculator_executor(params: CalculatorParams) -> ToolResult[ToolUseCountMetadata]:
18
+ """Evaluate mathematical expression in a limited eval environment."""
19
+ try:
20
+ # Safely evaluate the expression using Python's eval with restricted globals
21
+ result = eval(params.expression, {"__builtins__": {}}, {})
22
+ return ToolResult(content=f"Result: {result}", metadata=ToolUseCountMetadata())
23
+ except Exception as e:
24
+ return ToolResult(content=f"Error evaluating expression: {e!s}", metadata=ToolUseCountMetadata())
25
+
26
+
27
+ CALCULATOR_TOOL: Tool[CalculatorParams, ToolUseCountMetadata] = Tool[CalculatorParams, ToolUseCountMetadata](
28
+ name="calculator",
29
+ description="Evaluate mathematical expressions. Supports basic arithmetic operations (+, -, *, /, **, %, //).",
30
+ parameters=CalculatorParams,
31
+ executor=calculator_executor, # ty: ignore[invalid-argument-type]
32
+ )
@@ -0,0 +1,38 @@
1
+ """Code execution backends.
2
+
3
+ This module provides code execution backends for the Agent.
4
+
5
+ Available here (no optional dependencies):
6
+ - Base classes and utilities from .base
7
+ - LocalCodeExecToolProvider (uses subprocess)
8
+
9
+ Optional backends require explicit imports:
10
+ - DockerCodeExecToolProvider: `from stirrup.tools.code_backends.docker import DockerCodeExecToolProvider`
11
+ - E2BCodeExecToolProvider: `from stirrup.tools.code_backends.e2b import E2BCodeExecToolProvider`
12
+ """
13
+
14
+ from .base import (
15
+ SHELL_TIMEOUT,
16
+ CodeExecToolProvider,
17
+ CodeExecutionParams,
18
+ CommandResult,
19
+ SavedFile,
20
+ SaveOutputFilesResult,
21
+ UploadedFile,
22
+ UploadFilesResult,
23
+ format_result,
24
+ )
25
+ from .local import LocalCodeExecToolProvider
26
+
27
+ __all__ = [
28
+ "SHELL_TIMEOUT",
29
+ "CodeExecToolProvider",
30
+ "CodeExecutionParams",
31
+ "CommandResult",
32
+ "LocalCodeExecToolProvider",
33
+ "SaveOutputFilesResult",
34
+ "SavedFile",
35
+ "UploadFilesResult",
36
+ "UploadedFile",
37
+ "format_result",
38
+ ]
@@ -0,0 +1,454 @@
1
+ """Base types and abstract class for code execution backends."""
2
+
3
+ import logging
4
+ import re
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from stirrup.core.models import ImageContentBlock, Tool, ToolProvider, ToolResult, ToolUseCountMetadata
13
+ from stirrup.utils.text import truncate_msg
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ MAX_LENGTH_SHELL_STDOUT = 20_000
18
+ MAX_LENGTH_SHELL_STDERR = 20_000
19
+ SHELL_TIMEOUT = 60 * 5
20
+
21
+
22
+ class CodeExecutionParams(BaseModel):
23
+ """Shell command to execute in the execution environment."""
24
+
25
+ cmd: Annotated[
26
+ str,
27
+ Field(
28
+ description=(
29
+ "Shell command to execute (bash syntax). "
30
+ "IMPORTANT: Use only relative paths. Do not use absolute paths "
31
+ "(starting with / or ~) or reference directories outside the working directory."
32
+ )
33
+ ),
34
+ ]
35
+
36
+
37
+ @dataclass
38
+ class CommandResult:
39
+ """Raw result from command execution (before formatting)."""
40
+
41
+ exit_code: int
42
+ stdout: str
43
+ stderr: str
44
+ error_kind: str | None = None # "invalid_argument", "timeout", etc.
45
+ advice: str | None = None # Optional advice for error cases
46
+
47
+
48
+ @dataclass
49
+ class SavedFile:
50
+ """Information about a file saved from the execution environment."""
51
+
52
+ source_path: str # Original path in execution environment
53
+ output_path: Path # Path where file was saved
54
+ size: int
55
+
56
+
57
+ @dataclass
58
+ class SaveOutputFilesResult:
59
+ """Result of saving output files from the execution environment."""
60
+
61
+ saved: list[SavedFile] = field(default_factory=list)
62
+ failed: dict[str, str] = field(default_factory=dict) # source_path -> error message
63
+
64
+
65
+ @dataclass
66
+ class UploadedFile:
67
+ """Information about a file uploaded to the execution environment."""
68
+
69
+ source_path: Path # Original path on local filesystem
70
+ dest_path: str # Path in the execution environment
71
+ size: int
72
+
73
+
74
+ class ViewImageParams(BaseModel):
75
+ """Parameters for viewing an image from the execution environment."""
76
+
77
+ path: Annotated[
78
+ str,
79
+ Field(
80
+ description="Path to the image file within the execution environment filesystem (supports .png, .jpg, .jpeg, .gif, .bmp, .tiff, .psd)"
81
+ ),
82
+ ]
83
+
84
+
85
+ @dataclass
86
+ class UploadFilesResult:
87
+ """Result of uploading files to the execution environment."""
88
+
89
+ uploaded: list[UploadedFile] = field(default_factory=list)
90
+ failed: dict[str, str] = field(default_factory=dict) # source_path -> error message
91
+
92
+
93
+ def format_result(result: CommandResult) -> ToolResult[ToolUseCountMetadata]:
94
+ """Format a CommandResult as XML ToolResult (shared by all backends)."""
95
+ if result.error_kind:
96
+ # Error case
97
+ content = (
98
+ f"<shell_results>"
99
+ f"\n<error_kind>{result.error_kind}</error_kind>"
100
+ f"\n<details>{truncate_msg(result.stderr, MAX_LENGTH_SHELL_STDERR)}</details>"
101
+ )
102
+ if result.advice:
103
+ content += f"\n<advice>{result.advice}</advice>"
104
+ content += "\n</shell_results>"
105
+ else:
106
+ # Success case
107
+ content = (
108
+ f"<shell_results>"
109
+ f"\n<exit_code>{result.exit_code}</exit_code>"
110
+ f"\n<stdout>{truncate_msg(result.stdout, MAX_LENGTH_SHELL_STDOUT)}</stdout>"
111
+ f"\n<stderr>{truncate_msg(result.stderr, MAX_LENGTH_SHELL_STDERR)}</stderr>"
112
+ f"\n</shell_results>"
113
+ )
114
+ return ToolResult(content=content, metadata=ToolUseCountMetadata())
115
+
116
+
117
+ class CodeExecToolProvider(ToolProvider, ABC):
118
+ """Abstract base class for code execution tool providers.
119
+
120
+ CodeExecToolProvider is a ToolProvider that manages code execution environments
121
+ (sandboxes, containers, local temp directories) and returns a code_exec Tool.
122
+
123
+ Subclasses must implement:
124
+ - __aenter__(): Initialize environment and return the code_exec tool
125
+ - __aexit__(): Cleanup the execution environment
126
+ - run_command(): Execute a command and return raw result
127
+ - read_file_bytes(): Read file content as bytes from the environment
128
+ - write_file_bytes(): Write bytes to a file in the environment
129
+
130
+ Default implementations are provided for:
131
+ - save_output_files(): Save files to local dir or another exec env (uses primitives)
132
+ - upload_files(): Upload files from local or another exec env (uses primitives)
133
+
134
+ All code execution providers support an optional allowlist of command patterns.
135
+ If provided, only commands matching at least one pattern are allowed.
136
+ If None, all commands are allowed.
137
+
138
+ Usage with Agent:
139
+ from stirrup.clients.chat_completions_client import ChatCompletionsClient
140
+
141
+ client = ChatCompletionsClient(model="gpt-5")
142
+ agent = Agent(
143
+ client=client,
144
+ name="assistant",
145
+ tools=[LocalCodeExecToolProvider(), CALCULATOR_TOOL],
146
+ )
147
+ """
148
+
149
+ def __init__(self, *, allowed_commands: list[str] | None = None) -> None:
150
+ """Initialize execution environment with optional command allowlist.
151
+
152
+ Args:
153
+ allowed_commands: Optional list of regex patterns. If provided, only
154
+ commands matching at least one pattern are allowed.
155
+ If None, all commands are allowed.
156
+
157
+ """
158
+ self._allowed_commands = allowed_commands
159
+ self._compiled_allowed: list[re.Pattern[str]] | None = None
160
+ if allowed_commands is not None:
161
+ self._compiled_allowed = [re.compile(p) for p in allowed_commands]
162
+
163
+ def _check_allowed(self, cmd: str) -> bool:
164
+ """Check if command is allowed based on the allowlist.
165
+
166
+ Returns:
167
+ True if the command is allowed, False otherwise.
168
+
169
+ """
170
+ if self._compiled_allowed is None:
171
+ return True # No allowlist = allow all
172
+ return any(p.search(cmd) for p in self._compiled_allowed)
173
+
174
+ @abstractmethod
175
+ async def __aenter__(self) -> Tool[CodeExecutionParams, ToolUseCountMetadata]:
176
+ """Enter async context: set up environment and return code_exec tool."""
177
+ ...
178
+
179
+ @abstractmethod
180
+ async def __aexit__(
181
+ self,
182
+ exc_type: type[BaseException] | None,
183
+ exc_val: BaseException | None,
184
+ exc_tb: object,
185
+ ) -> None:
186
+ """Exit async context: cleanup the execution environment."""
187
+ ...
188
+
189
+ @abstractmethod
190
+ async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
191
+ """Execute a shell command and return raw CommandResult."""
192
+ ...
193
+
194
+ @abstractmethod
195
+ async def read_file_bytes(self, path: str) -> bytes:
196
+ """Read file content as bytes from this execution environment.
197
+
198
+ Args:
199
+ path: File path within this execution environment (relative or absolute
200
+ within the env's working directory).
201
+
202
+ Returns:
203
+ File contents as bytes.
204
+
205
+ Raises:
206
+ FileNotFoundError: If file does not exist.
207
+ RuntimeError: If execution environment not started.
208
+
209
+ """
210
+ ...
211
+
212
+ @abstractmethod
213
+ async def write_file_bytes(self, path: str, content: bytes) -> None:
214
+ """Write bytes to a file in this execution environment.
215
+
216
+ Args:
217
+ path: Destination path within this execution environment.
218
+ content: File contents to write.
219
+
220
+ Raises:
221
+ RuntimeError: If execution environment not started.
222
+
223
+ """
224
+ ...
225
+
226
+ async def save_output_files(
227
+ self,
228
+ paths: list[str],
229
+ output_dir: Path | str,
230
+ dest_env: "CodeExecToolProvider | None" = None,
231
+ ) -> SaveOutputFilesResult:
232
+ """Save files from this execution environment to a destination.
233
+
234
+ Args:
235
+ paths: List of file paths in this execution environment to save.
236
+ output_dir: Directory path to save files to.
237
+ dest_env: If provided, output_dir is interpreted as a path within dest_env
238
+ (cross-environment transfer). If None, output_dir is a local
239
+ filesystem path.
240
+
241
+ Returns:
242
+ SaveOutputFilesResult containing lists of saved files and any failures.
243
+
244
+ """
245
+ result = SaveOutputFilesResult()
246
+ output_dir_str = str(output_dir)
247
+
248
+ for source_path in paths:
249
+ try:
250
+ content = await self.read_file_bytes(source_path)
251
+ filename = Path(source_path).name
252
+ dest_path = f"{output_dir_str}/{filename}"
253
+
254
+ if dest_env:
255
+ # Transfer to another exec env (cross-environment)
256
+ logger.debug(
257
+ "CROSS-ENV TRANSFER: %s (%d bytes) -> %s (dest_env: %s)",
258
+ source_path,
259
+ len(content),
260
+ dest_path,
261
+ type(dest_env).__name__,
262
+ )
263
+ await dest_env.write_file_bytes(dest_path, content)
264
+ result.saved.append(SavedFile(source_path, Path(dest_path), len(content)))
265
+ else:
266
+ # Save to local filesystem
267
+ output_path = Path(output_dir) / filename
268
+ output_path.parent.mkdir(parents=True, exist_ok=True)
269
+ logger.debug(
270
+ "SAVE TO LOCAL: %s (%d bytes) -> %s",
271
+ source_path,
272
+ len(content),
273
+ output_path,
274
+ )
275
+ output_path.write_bytes(content)
276
+ result.saved.append(SavedFile(source_path, output_path, len(content)))
277
+ except Exception as e:
278
+ logger.debug("TRANSFER FAILED: %s -> %s: %s", source_path, output_dir_str, e)
279
+ result.failed[source_path] = str(e)
280
+
281
+ return result
282
+
283
+ async def upload_files(
284
+ self,
285
+ *paths: Path | str,
286
+ source_env: "CodeExecToolProvider | None" = None,
287
+ dest_dir: str | None = None,
288
+ ) -> UploadFilesResult:
289
+ """Upload files to this execution environment.
290
+
291
+ Args:
292
+ *paths: File or directory paths to upload. If source_env is None, these
293
+ are local filesystem paths. If source_env is provided, these are
294
+ paths within source_env (cross-environment transfer).
295
+ source_env: If provided, paths are within source_env. If None, paths are
296
+ local filesystem paths.
297
+ dest_dir: Destination directory in this environment.
298
+ If None, uses the environment's working directory.
299
+
300
+ Returns:
301
+ UploadFilesResult containing lists of uploaded files and any failures.
302
+
303
+ Raises:
304
+ RuntimeError: If execution environment not started.
305
+
306
+ """
307
+ result = UploadFilesResult()
308
+ dest_dir_str = dest_dir or ""
309
+
310
+ for path in paths:
311
+ path_str = str(path)
312
+ try:
313
+ if source_env:
314
+ # Cross-environment transfer: read from source_env
315
+ content = await source_env.read_file_bytes(path_str)
316
+ filename = Path(path_str).name
317
+ dest_path = f"{dest_dir_str}/{filename}" if dest_dir_str else filename
318
+ logger.debug(
319
+ "UPLOAD CROSS-ENV: %s (%d bytes) from %s -> %s",
320
+ path_str,
321
+ len(content),
322
+ type(source_env).__name__,
323
+ dest_path,
324
+ )
325
+ await self.write_file_bytes(dest_path, content)
326
+ result.uploaded.append(UploadedFile(Path(path_str), dest_path, len(content)))
327
+ else:
328
+ # Local filesystem upload - must be handled by subclass
329
+ # This is a fallback that reads from local fs and writes to env
330
+ local_path = Path(path)
331
+ if local_path.is_dir():
332
+ # Handle directory recursively
333
+ for file_path in local_path.rglob("*"):
334
+ if file_path.is_file():
335
+ rel_path = file_path.relative_to(local_path)
336
+ dest_path = f"{dest_dir_str}/{rel_path}" if dest_dir_str else str(rel_path)
337
+ content = file_path.read_bytes()
338
+ logger.debug(
339
+ "UPLOAD FROM LOCAL: %s (%d bytes) -> %s",
340
+ file_path,
341
+ len(content),
342
+ dest_path,
343
+ )
344
+ await self.write_file_bytes(dest_path, content)
345
+ result.uploaded.append(UploadedFile(file_path, dest_path, len(content)))
346
+ else:
347
+ filename = local_path.name
348
+ dest_path = f"{dest_dir_str}/{filename}" if dest_dir_str else filename
349
+ content = local_path.read_bytes()
350
+ logger.debug(
351
+ "UPLOAD FROM LOCAL: %s (%d bytes) -> %s",
352
+ local_path,
353
+ len(content),
354
+ dest_path,
355
+ )
356
+ await self.write_file_bytes(dest_path, content)
357
+ result.uploaded.append(UploadedFile(local_path, dest_path, len(content)))
358
+ except Exception as e:
359
+ logger.debug("UPLOAD FAILED: %s -> %s: %s", path_str, dest_dir_str, e)
360
+ result.failed[path_str] = str(e)
361
+
362
+ return result
363
+
364
+ def get_code_exec_tool(
365
+ self,
366
+ *,
367
+ name: str = "code_exec",
368
+ description: str | None = None,
369
+ ) -> Tool[CodeExecutionParams, ToolUseCountMetadata]:
370
+ """Create a code execution tool for this environment.
371
+
372
+ Args:
373
+ name: Tool name
374
+ description: Tool description
375
+
376
+ Returns:
377
+ Tool[CodeExecutionParams] that executes commands in this environment
378
+
379
+ """
380
+ env = self
381
+
382
+ async def executor(params: CodeExecutionParams) -> ToolResult[ToolUseCountMetadata]:
383
+ result = await env.run_command(params.cmd)
384
+ return format_result(result)
385
+
386
+ return Tool[CodeExecutionParams, ToolUseCountMetadata](
387
+ name=name,
388
+ description=description
389
+ or "Execute a shell command in the execution environment. Returns exit code, stdout, and stderr as XML.",
390
+ parameters=CodeExecutionParams,
391
+ executor=executor, # ty: ignore[invalid-argument-type]
392
+ )
393
+
394
+ def get_view_image_tool(
395
+ self,
396
+ *,
397
+ name: str = "view_image",
398
+ description: str | None = None,
399
+ ) -> Tool[ViewImageParams, ToolUseCountMetadata]:
400
+ """Create a view_image tool for this environment.
401
+
402
+ Args:
403
+ name: Tool name
404
+ description: Tool description
405
+
406
+ Returns:
407
+ Tool[ViewImageParams, ToolUseCountMetadata] that views images in this environment
408
+
409
+ """
410
+ env = self
411
+
412
+ async def executor(params: ViewImageParams) -> ToolResult[ToolUseCountMetadata]:
413
+ try:
414
+ image = await env.view_image(params.path)
415
+ return ToolResult(
416
+ content=["Viewing image at path: " + params.path, image],
417
+ metadata=ToolUseCountMetadata(),
418
+ )
419
+ except FileNotFoundError:
420
+ return ToolResult(
421
+ content=f"Image `{params.path}` not found.",
422
+ metadata=ToolUseCountMetadata(),
423
+ )
424
+ except ValueError as e:
425
+ return ToolResult(
426
+ content=str(e),
427
+ metadata=ToolUseCountMetadata(),
428
+ )
429
+
430
+ return Tool[ViewImageParams, ToolUseCountMetadata](
431
+ name=name,
432
+ description=description or "View an image file from the execution environment's filesystem.",
433
+ parameters=ViewImageParams,
434
+ executor=executor, # ty: ignore[invalid-argument-type]
435
+ )
436
+
437
+ @abstractmethod
438
+ async def view_image(self, path: str) -> ImageContentBlock:
439
+ """Read and return an image file from the execution environment.
440
+
441
+ Args:
442
+ path: Path to image file in the execution environment (relative or absolute).
443
+
444
+ Returns:
445
+ ImageContentBlock containing the image data.
446
+
447
+ Raises:
448
+ RuntimeError: If execution environment not started.
449
+ FileNotFoundError: If file does not exist.
450
+ ValueError: If path is outside the execution environment, is a directory,
451
+ or the file is not a valid image.
452
+
453
+ """
454
+ ...