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,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)
|
stirrup/tools/finish.py
ADDED
|
@@ -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
|
+
)
|