stirrup 0.1.1__py3-none-any.whl → 0.1.3__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 +2 -0
- stirrup/clients/chat_completions_client.py +0 -3
- stirrup/clients/litellm_client.py +20 -11
- stirrup/clients/utils.py +6 -1
- stirrup/constants.py +6 -2
- stirrup/core/agent.py +206 -57
- stirrup/core/cache.py +479 -0
- stirrup/core/models.py +53 -7
- stirrup/prompts/base_system_prompt.txt +1 -1
- stirrup/skills/__init__.py +24 -0
- stirrup/skills/skills.py +145 -0
- stirrup/tools/__init__.py +2 -0
- stirrup/tools/calculator.py +1 -1
- stirrup/tools/code_backends/base.py +7 -0
- stirrup/tools/code_backends/docker.py +16 -4
- stirrup/tools/code_backends/e2b.py +32 -13
- stirrup/tools/code_backends/local.py +16 -4
- stirrup/tools/finish.py +1 -1
- stirrup/tools/user_input.py +130 -0
- stirrup/tools/web.py +1 -0
- stirrup/utils/logging.py +24 -0
- {stirrup-0.1.1.dist-info → stirrup-0.1.3.dist-info}/METADATA +36 -16
- stirrup-0.1.3.dist-info/RECORD +36 -0
- {stirrup-0.1.1.dist-info → stirrup-0.1.3.dist-info}/WHEEL +1 -1
- stirrup-0.1.1.dist-info/RECORD +0 -32
stirrup/skills/skills.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Skills loader for agent capabilities.
|
|
2
|
+
|
|
3
|
+
Skills are modular packages that extend agent capabilities with instructions,
|
|
4
|
+
scripts, and resources. Each skill is a directory containing a SKILL.md file
|
|
5
|
+
with YAML frontmatter (name, description) and detailed instructions.
|
|
6
|
+
|
|
7
|
+
Based on: https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SkillMetadata:
|
|
20
|
+
"""Metadata extracted from a skill's SKILL.md frontmatter."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
description: str
|
|
24
|
+
path: str # Relative path like "skills/data_analysis"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_frontmatter(content: str) -> dict[str, str]:
|
|
28
|
+
"""Parse YAML frontmatter from markdown content.
|
|
29
|
+
|
|
30
|
+
Extracts metadata between --- markers at the start of the file.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
content: Markdown content with optional YAML frontmatter
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dictionary of frontmatter key-value pairs, empty if no frontmatter found
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
# Match YAML frontmatter between --- markers
|
|
40
|
+
pattern = r"^---\s*\n(.*?)\n---"
|
|
41
|
+
match = re.match(pattern, content, re.DOTALL)
|
|
42
|
+
|
|
43
|
+
if not match:
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
frontmatter_text = match.group(1)
|
|
47
|
+
result: dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
# Simple YAML parsing for key: value pairs
|
|
50
|
+
for line in frontmatter_text.strip().split("\n"):
|
|
51
|
+
line = line.strip()
|
|
52
|
+
if ":" in line:
|
|
53
|
+
key, value = line.split(":", 1)
|
|
54
|
+
result[key.strip()] = value.strip()
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_skills_metadata(skills_dir: Path) -> list[SkillMetadata]:
|
|
60
|
+
"""Scan skills directory for SKILL.md files and extract metadata.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
skills_dir: Path to the skills directory
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of SkillMetadata for each valid skill found.
|
|
67
|
+
Returns empty list if skills_dir doesn't exist or has no skills.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
if not skills_dir.exists():
|
|
71
|
+
logger.debug("Skills directory does not exist: %s", skills_dir)
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
if not skills_dir.is_dir():
|
|
75
|
+
logger.warning("Skills path is not a directory: %s", skills_dir)
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
skills: list[SkillMetadata] = []
|
|
79
|
+
|
|
80
|
+
for skill_path in skills_dir.iterdir():
|
|
81
|
+
if not skill_path.is_dir():
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
skill_md = skill_path / "SKILL.md"
|
|
85
|
+
if not skill_md.exists():
|
|
86
|
+
logger.debug("Skill directory missing SKILL.md: %s", skill_path)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
91
|
+
metadata = parse_frontmatter(content)
|
|
92
|
+
|
|
93
|
+
name = metadata.get("name")
|
|
94
|
+
description = metadata.get("description")
|
|
95
|
+
|
|
96
|
+
if not name or not description:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Skill %s missing required frontmatter (name, description)",
|
|
99
|
+
skill_path.name,
|
|
100
|
+
)
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
skills.append(
|
|
104
|
+
SkillMetadata(
|
|
105
|
+
name=name,
|
|
106
|
+
description=description,
|
|
107
|
+
path=f"skills/{skill_path.name}",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
logger.debug("Loaded skill: %s", name)
|
|
111
|
+
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning("Failed to load skill %s: %s", skill_path.name, e)
|
|
114
|
+
|
|
115
|
+
return skills
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def format_skills_section(skills: list[SkillMetadata]) -> str:
|
|
119
|
+
"""Format skills metadata as a system prompt section.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
skills: List of skill metadata to include
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Formatted string for inclusion in system prompt.
|
|
126
|
+
Returns empty string if no skills provided.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
if not skills:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
lines = [
|
|
133
|
+
"## Available Skills",
|
|
134
|
+
"",
|
|
135
|
+
"You have access to the following skills located in the `skills/` directory. "
|
|
136
|
+
"Each skill contains a SKILL.md file with detailed instructions and potentially bundled scripts.",
|
|
137
|
+
"",
|
|
138
|
+
"To use a skill:",
|
|
139
|
+
"1. Read the full instructions: `cat <skill_path>/SKILL.md`",
|
|
140
|
+
"2. Follow the instructions and use any bundled resources as described",
|
|
141
|
+
"",
|
|
142
|
+
]
|
|
143
|
+
lines.extend([f"- **{skill.name}**: {skill.description} (`{skill.path}/SKILL.md`)" for skill in skills])
|
|
144
|
+
|
|
145
|
+
return "\n".join(lines)
|
stirrup/tools/__init__.py
CHANGED
|
@@ -55,6 +55,7 @@ from stirrup.core.models import Tool, ToolProvider
|
|
|
55
55
|
from stirrup.tools.calculator import CALCULATOR_TOOL
|
|
56
56
|
from stirrup.tools.code_backends import CodeExecToolProvider, LocalCodeExecToolProvider
|
|
57
57
|
from stirrup.tools.finish import SIMPLE_FINISH_TOOL, FinishParams
|
|
58
|
+
from stirrup.tools.user_input import USER_INPUT_TOOL
|
|
58
59
|
from stirrup.tools.view_image import ViewImageToolProvider
|
|
59
60
|
from stirrup.tools.web import WebToolProvider
|
|
60
61
|
|
|
@@ -69,6 +70,7 @@ __all__ = [
|
|
|
69
70
|
"CALCULATOR_TOOL",
|
|
70
71
|
"DEFAULT_TOOLS",
|
|
71
72
|
"SIMPLE_FINISH_TOOL",
|
|
73
|
+
"USER_INPUT_TOOL",
|
|
72
74
|
"CodeExecToolProvider",
|
|
73
75
|
"FinishParams",
|
|
74
76
|
"LocalCodeExecToolProvider",
|
stirrup/tools/calculator.py
CHANGED
|
@@ -21,7 +21,7 @@ def calculator_executor(params: CalculatorParams) -> ToolResult[ToolUseCountMeta
|
|
|
21
21
|
result = eval(params.expression, {"__builtins__": {}}, {})
|
|
22
22
|
return ToolResult(content=f"Result: {result}", metadata=ToolUseCountMetadata())
|
|
23
23
|
except Exception as e:
|
|
24
|
-
return ToolResult(content=f"Error evaluating expression: {e!s}", metadata=ToolUseCountMetadata())
|
|
24
|
+
return ToolResult(content=f"Error evaluating expression: {e!s}", success=False, metadata=ToolUseCountMetadata())
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
CALCULATOR_TOOL: Tool[CalculatorParams, ToolUseCountMetadata] = Tool[CalculatorParams, ToolUseCountMetadata](
|
|
@@ -160,6 +160,11 @@ class CodeExecToolProvider(ToolProvider, ABC):
|
|
|
160
160
|
if allowed_commands is not None:
|
|
161
161
|
self._compiled_allowed = [re.compile(p) for p in allowed_commands]
|
|
162
162
|
|
|
163
|
+
@property
|
|
164
|
+
def temp_dir(self) -> Path | None:
|
|
165
|
+
"""Return the temporary directory for this execution environment, if any."""
|
|
166
|
+
return None
|
|
167
|
+
|
|
163
168
|
def _check_allowed(self, cmd: str) -> bool:
|
|
164
169
|
"""Check if command is allowed based on the allowlist.
|
|
165
170
|
|
|
@@ -419,11 +424,13 @@ class CodeExecToolProvider(ToolProvider, ABC):
|
|
|
419
424
|
except FileNotFoundError:
|
|
420
425
|
return ToolResult(
|
|
421
426
|
content=f"Image `{params.path}` not found.",
|
|
427
|
+
success=False,
|
|
422
428
|
metadata=ToolUseCountMetadata(),
|
|
423
429
|
)
|
|
424
430
|
except ValueError as e:
|
|
425
431
|
return ToolResult(
|
|
426
432
|
content=str(e),
|
|
433
|
+
success=False,
|
|
427
434
|
metadata=ToolUseCountMetadata(),
|
|
428
435
|
)
|
|
429
436
|
|
|
@@ -330,7 +330,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
|
|
|
330
330
|
context_path = self._dockerfile_context.resolve() if self._dockerfile_context else dockerfile_path.parent
|
|
331
331
|
|
|
332
332
|
# Generate unique tag based on dockerfile path
|
|
333
|
-
tag = f"
|
|
333
|
+
tag = f"stirrup-exec-env-{hashlib.md5(str(dockerfile_path).encode()).hexdigest()[:8]}"
|
|
334
334
|
|
|
335
335
|
logger.info("Building image from %s with tag %s", dockerfile_path, tag)
|
|
336
336
|
|
|
@@ -710,8 +710,20 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
|
|
|
710
710
|
logger.debug("Uploaded file: %s -> %s", source, container_path)
|
|
711
711
|
|
|
712
712
|
elif source.is_dir():
|
|
713
|
-
|
|
714
|
-
|
|
713
|
+
# If dest_dir was explicitly provided, copy contents directly to dest_base
|
|
714
|
+
# Otherwise, create a subdirectory with the source's name
|
|
715
|
+
if dest_dir:
|
|
716
|
+
dest = dest_base
|
|
717
|
+
# Copy contents of source directory into dest_base
|
|
718
|
+
for item in source.iterdir():
|
|
719
|
+
item_dest = dest / item.name
|
|
720
|
+
if item.is_file():
|
|
721
|
+
shutil.copy2(item, item_dest)
|
|
722
|
+
else:
|
|
723
|
+
shutil.copytree(item, item_dest, dirs_exist_ok=True)
|
|
724
|
+
else:
|
|
725
|
+
dest = dest_base / source.name
|
|
726
|
+
shutil.copytree(source, dest, dirs_exist_ok=True)
|
|
715
727
|
# Track all individual files uploaded
|
|
716
728
|
for file_path in source.rglob("*"):
|
|
717
729
|
if file_path.is_file():
|
|
@@ -725,7 +737,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
|
|
|
725
737
|
size=file_path.stat().st_size,
|
|
726
738
|
),
|
|
727
739
|
)
|
|
728
|
-
logger.debug("Uploaded directory: %s -> %s
|
|
740
|
+
logger.debug("Uploaded directory: %s -> %s", source, dest)
|
|
729
741
|
|
|
730
742
|
except Exception as exc:
|
|
731
743
|
result.failed[str(source)] = str(exc)
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
5
7
|
try:
|
|
6
8
|
from e2b import InvalidArgumentException, TimeoutException
|
|
7
9
|
from e2b.sandbox.filesystem.filesystem import FileType
|
|
@@ -13,7 +15,7 @@ except ImportError as e:
|
|
|
13
15
|
|
|
14
16
|
import logging
|
|
15
17
|
|
|
16
|
-
from stirrup.constants import
|
|
18
|
+
from stirrup.constants import SANDBOX_REQUEST_TIMEOUT, SANDBOX_TIMEOUT
|
|
17
19
|
from stirrup.core.models import ImageContentBlock, Tool, ToolUseCountMetadata
|
|
18
20
|
|
|
19
21
|
from .base import (
|
|
@@ -51,7 +53,8 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
51
53
|
def __init__(
|
|
52
54
|
self,
|
|
53
55
|
*,
|
|
54
|
-
timeout: int =
|
|
56
|
+
timeout: int = SANDBOX_TIMEOUT,
|
|
57
|
+
request_timeout: int = SANDBOX_REQUEST_TIMEOUT,
|
|
55
58
|
template: str | None = None,
|
|
56
59
|
allowed_commands: list[str] | None = None,
|
|
57
60
|
) -> None:
|
|
@@ -67,6 +70,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
67
70
|
"""
|
|
68
71
|
super().__init__(allowed_commands=allowed_commands)
|
|
69
72
|
self._timeout = timeout
|
|
73
|
+
self._request_timeout = request_timeout
|
|
70
74
|
self._template = template
|
|
71
75
|
self._sbx: AsyncSandbox | None = None
|
|
72
76
|
|
|
@@ -109,7 +113,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
109
113
|
if not await self._sbx.files.exists(path):
|
|
110
114
|
raise FileNotFoundError(f"File not found: {path}")
|
|
111
115
|
|
|
112
|
-
file_bytes = await self._sbx.files.read(path, format="bytes")
|
|
116
|
+
file_bytes = await self._sbx.files.read(path, format="bytes", request_timeout=self._request_timeout)
|
|
113
117
|
return bytes(file_bytes)
|
|
114
118
|
|
|
115
119
|
async def write_file_bytes(self, path: str, content: bytes) -> None:
|
|
@@ -126,7 +130,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
126
130
|
if self._sbx is None:
|
|
127
131
|
raise RuntimeError("ExecutionEnvironment not started.")
|
|
128
132
|
|
|
129
|
-
await self._sbx.files.write(path, content)
|
|
133
|
+
await self._sbx.files.write(path, content, request_timeout=self._request_timeout)
|
|
130
134
|
|
|
131
135
|
async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
|
|
132
136
|
"""Execute command in E2B execution environment, returning raw CommandResult."""
|
|
@@ -146,7 +150,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
146
150
|
)
|
|
147
151
|
|
|
148
152
|
try:
|
|
149
|
-
r = await self._sbx.commands.run(cmd, timeout=timeout)
|
|
153
|
+
r = await self._sbx.commands.run(cmd, timeout=timeout, request_timeout=self._request_timeout)
|
|
150
154
|
|
|
151
155
|
return CommandResult(
|
|
152
156
|
exit_code=getattr(r, "exit_code", 0),
|
|
@@ -231,7 +235,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
231
235
|
continue
|
|
232
236
|
|
|
233
237
|
# Read file content from execution environment
|
|
234
|
-
file_bytes = await self._sbx.files.read(env_path, format="bytes")
|
|
238
|
+
file_bytes = await self._sbx.files.read(env_path, format="bytes", request_timeout=self._request_timeout)
|
|
235
239
|
content = bytes(file_bytes)
|
|
236
240
|
|
|
237
241
|
# Save with original filename directly in output_dir
|
|
@@ -297,6 +301,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
297
301
|
result = UploadFilesResult()
|
|
298
302
|
|
|
299
303
|
for source in paths:
|
|
304
|
+
original_name = Path(source).name # Get name BEFORE resolve
|
|
300
305
|
source = Path(source).resolve()
|
|
301
306
|
|
|
302
307
|
if not source.exists():
|
|
@@ -306,9 +311,10 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
306
311
|
|
|
307
312
|
try:
|
|
308
313
|
if source.is_file():
|
|
309
|
-
|
|
314
|
+
source = Path(source).resolve()
|
|
315
|
+
dest = f"{dest_base}/{original_name}"
|
|
310
316
|
content = source.read_bytes()
|
|
311
|
-
await self._sbx.files.write(dest, content)
|
|
317
|
+
await self._sbx.files.write(dest, content, request_timeout=self._request_timeout)
|
|
312
318
|
result.uploaded.append(
|
|
313
319
|
UploadedFile(
|
|
314
320
|
source_path=source,
|
|
@@ -320,12 +326,14 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
320
326
|
|
|
321
327
|
elif source.is_dir():
|
|
322
328
|
# Upload all files in directory recursively
|
|
329
|
+
# If dest_dir was explicitly provided, copy contents directly to dest_base
|
|
330
|
+
# Otherwise, create a subdirectory with the source's name
|
|
323
331
|
for file_path in source.rglob("*"):
|
|
324
332
|
if file_path.is_file():
|
|
325
333
|
relative = file_path.relative_to(source)
|
|
326
|
-
dest = f"{dest_base}/{source.name}/{relative}"
|
|
334
|
+
dest = f"{dest_base}/{relative}" if dest_dir else f"{dest_base}/{source.name}/{relative}"
|
|
327
335
|
content = file_path.read_bytes()
|
|
328
|
-
await self._sbx.files.write(dest, content)
|
|
336
|
+
await self._sbx.files.write(dest, content, request_timeout=self._request_timeout)
|
|
329
337
|
result.uploaded.append(
|
|
330
338
|
UploadedFile(
|
|
331
339
|
source_path=file_path,
|
|
@@ -333,7 +341,10 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
333
341
|
size=len(content),
|
|
334
342
|
),
|
|
335
343
|
)
|
|
336
|
-
|
|
344
|
+
if dest_dir:
|
|
345
|
+
logger.debug("Uploaded directory contents: %s -> %s", source, dest_base)
|
|
346
|
+
else:
|
|
347
|
+
logger.debug("Uploaded directory: %s -> %s/%s", source, dest_base, source.name)
|
|
337
348
|
|
|
338
349
|
except Exception as exc:
|
|
339
350
|
result.failed[str(source)] = str(exc)
|
|
@@ -355,5 +366,13 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
355
366
|
FileNotFoundError: If file does not exist.
|
|
356
367
|
|
|
357
368
|
"""
|
|
358
|
-
|
|
359
|
-
|
|
369
|
+
if not path.lower().endswith((".png", ".jpg", ".jpeg")):
|
|
370
|
+
raise ValueError(f"Unsupported image type for `{path}`. Only .png, .jpg, or .jpeg are allowed.")
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
file_bytes = await self.read_file_bytes(path)
|
|
374
|
+
image = ImageContentBlock(data=file_bytes)
|
|
375
|
+
except ValidationError as e:
|
|
376
|
+
raise ValueError("You submitted a corrupt/unsupported image file.") from e
|
|
377
|
+
|
|
378
|
+
return image
|
|
@@ -122,7 +122,7 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
|
|
|
122
122
|
if self._temp_base_dir:
|
|
123
123
|
self._temp_base_dir.mkdir(parents=True, exist_ok=True)
|
|
124
124
|
self._temp_dir = Path(tempfile.mkdtemp(prefix="local_exec_env_", dir=self._temp_base_dir))
|
|
125
|
-
logger.
|
|
125
|
+
logger.debug("Created local execution environment temp directory: %s", self._temp_dir)
|
|
126
126
|
return self.get_code_exec_tool(description=self._description)
|
|
127
127
|
|
|
128
128
|
async def __aexit__(
|
|
@@ -359,7 +359,7 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
|
|
|
359
359
|
|
|
360
360
|
# Move file (overwrites if exists)
|
|
361
361
|
shutil.move(str(source_path), str(dest_path))
|
|
362
|
-
logger.
|
|
362
|
+
logger.debug("Moved file: %s -> %s", source_path, dest_path)
|
|
363
363
|
|
|
364
364
|
result.saved.append(
|
|
365
365
|
SavedFile(
|
|
@@ -440,8 +440,20 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
|
|
|
440
440
|
logger.debug("Uploaded file: %s -> %s", source, dest)
|
|
441
441
|
|
|
442
442
|
elif source.is_dir():
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
# If dest_dir was explicitly provided, copy contents directly to dest_base
|
|
444
|
+
# Otherwise, create a subdirectory with the source's name
|
|
445
|
+
if dest_dir:
|
|
446
|
+
dest = dest_base
|
|
447
|
+
# Copy contents of source directory into dest_base
|
|
448
|
+
for item in source.iterdir():
|
|
449
|
+
item_dest = dest / item.name
|
|
450
|
+
if item.is_file():
|
|
451
|
+
shutil.copy2(item, item_dest)
|
|
452
|
+
else:
|
|
453
|
+
shutil.copytree(item, item_dest, dirs_exist_ok=True)
|
|
454
|
+
else:
|
|
455
|
+
dest = dest_base / source.name
|
|
456
|
+
shutil.copytree(source, dest, dirs_exist_ok=True)
|
|
445
457
|
# Track all individual files uploaded
|
|
446
458
|
for file_path in source.rglob("*"):
|
|
447
459
|
if file_path.is_file():
|
stirrup/tools/finish.py
CHANGED
|
@@ -19,5 +19,5 @@ SIMPLE_FINISH_TOOL: Tool[FinishParams, ToolUseCountMetadata] = Tool[FinishParams
|
|
|
19
19
|
name=FINISH_TOOL_NAME,
|
|
20
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
21
|
parameters=FinishParams,
|
|
22
|
-
executor=lambda params: ToolResult(content=params.reason, metadata=ToolUseCountMetadata()),
|
|
22
|
+
executor=lambda params: ToolResult(content=params.reason, metadata=ToolUseCountMetadata(), success=True),
|
|
23
23
|
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""User input tool for interactive clarification during agent execution.
|
|
2
|
+
|
|
3
|
+
This module provides the user_input tool that allows agents to ask questions
|
|
4
|
+
and receive text responses from users during task execution.
|
|
5
|
+
|
|
6
|
+
Example usage:
|
|
7
|
+
from stirrup.clients.chat_completions_client import ChatCompletionsClient
|
|
8
|
+
from stirrup.tools import DEFAULT_TOOLS, USER_INPUT_TOOL
|
|
9
|
+
|
|
10
|
+
client = ChatCompletionsClient(model="gpt-5")
|
|
11
|
+
agent = Agent(
|
|
12
|
+
client=client,
|
|
13
|
+
name="assistant",
|
|
14
|
+
tools=[*DEFAULT_TOOLS, USER_INPUT_TOOL],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
async with agent.session() as session:
|
|
18
|
+
await session.run("Help me configure my project")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Annotated, Literal
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.prompt import Confirm, Prompt
|
|
26
|
+
|
|
27
|
+
from stirrup.core.models import Tool, ToolResult, ToolUseCountMetadata
|
|
28
|
+
from stirrup.utils.logging import AgentLoggerBase, console
|
|
29
|
+
|
|
30
|
+
__all__ = ["USER_INPUT_TOOL", "UserInputParams"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UserInputParams(BaseModel):
|
|
34
|
+
"""Parameters for asking the user a single question.
|
|
35
|
+
|
|
36
|
+
Supports three question types:
|
|
37
|
+
- "text": Free-form text input (default)
|
|
38
|
+
- "choice": Multiple choice from a list of options
|
|
39
|
+
- "confirm": Yes/no confirmation
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
question: Annotated[str, Field(description="A single question to ask the user (*not* multiple questions)")]
|
|
43
|
+
question_type: Annotated[
|
|
44
|
+
Literal["text", "choice", "confirm"],
|
|
45
|
+
Field(
|
|
46
|
+
default="text",
|
|
47
|
+
description="Type of question: 'text' for free-form, 'choice' for multiple choice, 'confirm' for yes/no",
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
choices: Annotated[
|
|
51
|
+
list[str] | None,
|
|
52
|
+
Field(default=None, description="List of valid choices (required when question_type is 'choice')"),
|
|
53
|
+
]
|
|
54
|
+
default: Annotated[
|
|
55
|
+
str,
|
|
56
|
+
Field(default="", description="Default value if user presses Enter without input"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_logger() -> "AgentLoggerBase | None":
|
|
61
|
+
"""Get the current session's logger for pause/resume.
|
|
62
|
+
|
|
63
|
+
Returns the logger from SessionState if available, None otherwise.
|
|
64
|
+
"""
|
|
65
|
+
from stirrup.core.agent import _SESSION_STATE
|
|
66
|
+
|
|
67
|
+
state = _SESSION_STATE.get(None)
|
|
68
|
+
return state.logger if state else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def user_input_executor(params: UserInputParams) -> ToolResult[ToolUseCountMetadata]:
|
|
72
|
+
"""Prompt the user for input and return their response."""
|
|
73
|
+
logger = _get_logger()
|
|
74
|
+
|
|
75
|
+
# Pause spinner before prompting
|
|
76
|
+
if logger:
|
|
77
|
+
logger.pause_live()
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
# Print newline to separate from spinner, then display question in a styled panel
|
|
81
|
+
console.print()
|
|
82
|
+
panel = Panel(
|
|
83
|
+
params.question,
|
|
84
|
+
title="[bold cyan]🤔 Agent Question[/]",
|
|
85
|
+
title_align="left",
|
|
86
|
+
border_style="cyan",
|
|
87
|
+
padding=(0, 1),
|
|
88
|
+
)
|
|
89
|
+
console.print(panel)
|
|
90
|
+
|
|
91
|
+
# Get user input based on question type
|
|
92
|
+
if params.question_type == "confirm":
|
|
93
|
+
# Yes/no confirmation
|
|
94
|
+
default_bool = params.default.lower() in ("yes", "y", "true", "1") if params.default else False
|
|
95
|
+
result = Confirm.ask("[bold]Your answer[/]", default=default_bool, console=console)
|
|
96
|
+
answer = "yes" if result else "no"
|
|
97
|
+
|
|
98
|
+
elif params.question_type == "choice" and params.choices:
|
|
99
|
+
# Multiple choice
|
|
100
|
+
answer = Prompt.ask(
|
|
101
|
+
"[bold]Your answer[/]",
|
|
102
|
+
choices=params.choices,
|
|
103
|
+
default=params.default,
|
|
104
|
+
console=console,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
else:
|
|
108
|
+
# Free-form text (default)
|
|
109
|
+
answer = Prompt.ask("[bold]Your answer[/]", default=params.default or "", console=console)
|
|
110
|
+
|
|
111
|
+
return ToolResult(content=answer, metadata=ToolUseCountMetadata())
|
|
112
|
+
|
|
113
|
+
finally:
|
|
114
|
+
# Always resume spinner, even if an exception occurs
|
|
115
|
+
if logger:
|
|
116
|
+
logger.resume_live()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
USER_INPUT_TOOL: Tool[UserInputParams, ToolUseCountMetadata] = Tool(
|
|
120
|
+
name="user_input",
|
|
121
|
+
description=(
|
|
122
|
+
"Ask the user a question when you need clarification or are uncertain. "
|
|
123
|
+
"Supports three types: 'text' for free-form input, 'choice' for multiple choice "
|
|
124
|
+
"(provide choices list), 'confirm' for yes/no questions. Returns the user's response."
|
|
125
|
+
"There should only EVER be one question per call to this tool."
|
|
126
|
+
"If you need to ask multiple questions, you should call this tool multiple times."
|
|
127
|
+
),
|
|
128
|
+
parameters=UserInputParams,
|
|
129
|
+
executor=user_input_executor,
|
|
130
|
+
)
|
stirrup/tools/web.py
CHANGED
|
@@ -125,6 +125,7 @@ def _get_fetch_web_page_tool(client: httpx.AsyncClient | None = None) -> Tool[Fe
|
|
|
125
125
|
return ToolResult(
|
|
126
126
|
content=f"<web_fetch><url>{params.url}</url><error>"
|
|
127
127
|
f"{truncate_msg(str(exc), MAX_LENGTH_WEB_FETCH_HTML)}</error></web_fetch>",
|
|
128
|
+
success=False,
|
|
128
129
|
metadata=WebFetchMetadata(pages_fetched=[params.url]),
|
|
129
130
|
)
|
|
130
131
|
|
stirrup/utils/logging.py
CHANGED
|
@@ -27,6 +27,7 @@ from stirrup.core.models import AssistantMessage, ToolMessage, UserMessage, _agg
|
|
|
27
27
|
__all__ = [
|
|
28
28
|
"AgentLogger",
|
|
29
29
|
"AgentLoggerBase",
|
|
30
|
+
"console",
|
|
30
31
|
]
|
|
31
32
|
|
|
32
33
|
# Shared console instance
|
|
@@ -247,6 +248,12 @@ class AgentLoggerBase(ABC):
|
|
|
247
248
|
"""Log an error message."""
|
|
248
249
|
...
|
|
249
250
|
|
|
251
|
+
def pause_live(self) -> None: # noqa: B027
|
|
252
|
+
"""Pause live display (e.g., spinner) before user interaction."""
|
|
253
|
+
|
|
254
|
+
def resume_live(self) -> None: # noqa: B027
|
|
255
|
+
"""Resume live display after user interaction."""
|
|
256
|
+
|
|
250
257
|
|
|
251
258
|
class AgentLogger(AgentLoggerBase):
|
|
252
259
|
"""Rich console logger for agent workflows.
|
|
@@ -655,6 +662,23 @@ class AgentLogger(AgentLoggerBase):
|
|
|
655
662
|
if self._live:
|
|
656
663
|
self._live.update(self._make_spinner())
|
|
657
664
|
|
|
665
|
+
def pause_live(self) -> None:
|
|
666
|
+
"""Pause the live spinner display.
|
|
667
|
+
|
|
668
|
+
Call this before prompting for user input to prevent the spinner
|
|
669
|
+
from interfering with the input prompt.
|
|
670
|
+
"""
|
|
671
|
+
if self._live is not None:
|
|
672
|
+
self._live.stop()
|
|
673
|
+
|
|
674
|
+
def resume_live(self) -> None:
|
|
675
|
+
"""Resume the live spinner display.
|
|
676
|
+
|
|
677
|
+
Call this after user input is complete to restart the spinner.
|
|
678
|
+
"""
|
|
679
|
+
if self._live is not None:
|
|
680
|
+
self._live.start()
|
|
681
|
+
|
|
658
682
|
def set_level(self, level: int) -> None:
|
|
659
683
|
"""Set the logging level."""
|
|
660
684
|
self._level = level
|