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,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
|
+
...
|