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
stirrup/core/models.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
import warnings
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from datetime import date, datetime, time, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from io import BytesIO
|
|
9
|
+
from math import isinf, isnan, sqrt
|
|
10
|
+
from tempfile import NamedTemporaryFile
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Annotated, Any, ClassVar, Literal, Protocol, Self, overload, runtime_checkable
|
|
13
|
+
|
|
14
|
+
import filetype
|
|
15
|
+
from moviepy import AudioFileClip, VideoFileClip
|
|
16
|
+
from moviepy.video.fx import Resize
|
|
17
|
+
from PIL import Image
|
|
18
|
+
from pydantic import BaseModel, Field, model_validator
|
|
19
|
+
|
|
20
|
+
from stirrup.constants import RESOLUTION_1MP, RESOLUTION_480P
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Addable",
|
|
24
|
+
"AssistantMessage",
|
|
25
|
+
"AudioContentBlock",
|
|
26
|
+
"BinaryContentBlock",
|
|
27
|
+
"ChatMessage",
|
|
28
|
+
"Content",
|
|
29
|
+
"ContentBlock",
|
|
30
|
+
"ImageContentBlock",
|
|
31
|
+
"LLMClient",
|
|
32
|
+
"SubAgentMetadata",
|
|
33
|
+
"SystemMessage",
|
|
34
|
+
"TokenUsage",
|
|
35
|
+
"Tool",
|
|
36
|
+
"ToolCall",
|
|
37
|
+
"ToolMessage",
|
|
38
|
+
"ToolProvider",
|
|
39
|
+
"ToolResult",
|
|
40
|
+
"ToolUseCountMetadata",
|
|
41
|
+
"UserMessage",
|
|
42
|
+
"VideoContentBlock",
|
|
43
|
+
"aggregate_metadata",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def downscale_image(w: int, h: int, max_pixels: int | None = 1_000_000) -> tuple[int, int]:
|
|
48
|
+
"""Downscale image dimensions to fit within max pixel count while maintaining aspect ratio.
|
|
49
|
+
|
|
50
|
+
Returns even dimensions with minimum 2x2 size.
|
|
51
|
+
"""
|
|
52
|
+
s = 1.0 if max_pixels is None or w * h <= max_pixels else sqrt(max_pixels / (w * h))
|
|
53
|
+
nw, nh = int(w * s) // 2 * 2, int(h * s) // 2 * 2
|
|
54
|
+
return max(nw, 2), max(nh, 2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Content
|
|
58
|
+
class BinaryContentBlock(BaseModel, ABC):
|
|
59
|
+
"""Base class for binary content (images, video, audio) with MIME type validation."""
|
|
60
|
+
|
|
61
|
+
data: bytes
|
|
62
|
+
allowed_mime_types: ClassVar[set[str]]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def mime_type(self) -> str:
|
|
66
|
+
"""MIME type for data based on headers."""
|
|
67
|
+
match: filetype.Type = filetype.guess(self.data)
|
|
68
|
+
if match is None:
|
|
69
|
+
raise ValueError(f"Unsupported file type {self.data!r}")
|
|
70
|
+
return match.mime
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def extension(self) -> str:
|
|
74
|
+
"""File extension for the content (e.g., 'png', 'mp4', 'mp3') without leading dot."""
|
|
75
|
+
_extension = mimetypes.guess_extension(self.mime_type)
|
|
76
|
+
if _extension is None:
|
|
77
|
+
raise ValueError(f"Unsupported mime_type {self.mime_type!r}")
|
|
78
|
+
return _extension[1:]
|
|
79
|
+
|
|
80
|
+
@model_validator(mode="after")
|
|
81
|
+
def _check_mime(self) -> Self:
|
|
82
|
+
"""Validate MIME type against allowed list and verify content is readable."""
|
|
83
|
+
if self.allowed_mime_types and self.mime_type not in self.allowed_mime_types:
|
|
84
|
+
raise ValueError("Unsupported mime_type {self.mime_type!r}; allowed: {allowed}")
|
|
85
|
+
self._probe() # light corruption check; no heavy work
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def _probe(self) -> None:
|
|
90
|
+
"""Verify content can be opened and read; subclasses implement format-specific checks."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ImageContentBlock(BinaryContentBlock):
|
|
94
|
+
"""Image content supporting PNG, JPEG, WebP, PSD formats with automatic downscaling."""
|
|
95
|
+
|
|
96
|
+
kind: Literal["image_content_block"] = "image_content_block"
|
|
97
|
+
allowed_mime_types: ClassVar[set[str]] = {
|
|
98
|
+
"image/jpeg", # JPEG
|
|
99
|
+
"image/png", # PNG
|
|
100
|
+
"image/gif", # GIF
|
|
101
|
+
"image/bmp", # BMP
|
|
102
|
+
"image/tiff", # TIFF
|
|
103
|
+
"image/vnd.adobe.photoshop", # PSD
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def _probe(self) -> None:
|
|
107
|
+
"""Verify image data is valid by attempting to open and verify it with PIL."""
|
|
108
|
+
with Image.open(BytesIO(self.data)) as im:
|
|
109
|
+
im.verify()
|
|
110
|
+
|
|
111
|
+
def to_base64_url(self, max_pixels: int | None = RESOLUTION_1MP) -> str:
|
|
112
|
+
"""Convert image to base64 data URL, optionally resizing to max pixel count."""
|
|
113
|
+
img: Image.Image = Image.open(BytesIO(self.data))
|
|
114
|
+
if max_pixels is not None and img.width * img.height > max_pixels:
|
|
115
|
+
tw, th = downscale_image(img.width, img.height, max_pixels)
|
|
116
|
+
img.thumbnail((tw, th), Image.Resampling.LANCZOS)
|
|
117
|
+
if img.mode != "RGB":
|
|
118
|
+
img = img.convert("RGB")
|
|
119
|
+
buf = BytesIO()
|
|
120
|
+
img.save(buf, format="PNG")
|
|
121
|
+
return f"data:image/png;base64,{b64encode(buf.getvalue()).decode()}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class VideoContentBlock(BinaryContentBlock):
|
|
125
|
+
"""MP4 video content with automatic transcoding and resolution downscaling."""
|
|
126
|
+
|
|
127
|
+
kind: Literal["video_content_block"] = "video_content_block"
|
|
128
|
+
allowed_mime_types: ClassVar[set[str]] = {
|
|
129
|
+
"video/x-msvideo", # AVI
|
|
130
|
+
"video/mp4", # MP4
|
|
131
|
+
"video/quicktime", # MOV
|
|
132
|
+
"video/x-matroska", # MKV
|
|
133
|
+
"video/x-ms-wmv", # WMV
|
|
134
|
+
"video/x-flv", # FLV
|
|
135
|
+
"video/mpeg", # MPEG
|
|
136
|
+
"video/webm", # WebM
|
|
137
|
+
"video/gif", # GIF (animated)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def _probe(self) -> None:
|
|
141
|
+
"""Verify video data is valid by attempting to open it as a VideoFileClip."""
|
|
142
|
+
with warnings.catch_warnings():
|
|
143
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="moviepy.*")
|
|
144
|
+
with NamedTemporaryFile(suffix=".bin") as f:
|
|
145
|
+
f.write(self.data)
|
|
146
|
+
f.flush()
|
|
147
|
+
clip = VideoFileClip(f.name)
|
|
148
|
+
clip.close()
|
|
149
|
+
|
|
150
|
+
def to_base64_url(self, max_pixels: int | None = RESOLUTION_480P, fps: int | None = None) -> str:
|
|
151
|
+
"""Transcode to MP4 and return base64 data URL."""
|
|
152
|
+
with warnings.catch_warnings():
|
|
153
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="moviepy.*")
|
|
154
|
+
with NamedTemporaryFile(suffix=".mp4") as fin, NamedTemporaryFile(suffix=".mp4") as fout:
|
|
155
|
+
fin.write(self.data)
|
|
156
|
+
fin.flush()
|
|
157
|
+
clip = VideoFileClip(fin.name)
|
|
158
|
+
tw, th = downscale_image(int(clip.w), int(clip.h), max_pixels)
|
|
159
|
+
clip = clip.with_effects([Resize(new_size=(tw, th))])
|
|
160
|
+
|
|
161
|
+
clip.write_videofile(
|
|
162
|
+
fout.name,
|
|
163
|
+
codec="libx264",
|
|
164
|
+
fps=fps,
|
|
165
|
+
audio=clip.audio is not None,
|
|
166
|
+
audio_codec="aac",
|
|
167
|
+
preset="veryfast",
|
|
168
|
+
logger=None,
|
|
169
|
+
)
|
|
170
|
+
clip.close()
|
|
171
|
+
return f"data:video/mp4;base64,{b64encode(fout.read()).decode()}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class AudioContentBlock(BinaryContentBlock):
|
|
175
|
+
"""Audio content supporting MPEG, WAV, AAC, and other common audio formats."""
|
|
176
|
+
|
|
177
|
+
kind: Literal["audio_content_block"] = "audio_content_block"
|
|
178
|
+
allowed_mime_types: ClassVar[set[str]] = {
|
|
179
|
+
"audio/x-aac",
|
|
180
|
+
"audio/flac",
|
|
181
|
+
"audio/mp3",
|
|
182
|
+
"audio/m4a",
|
|
183
|
+
"audio/mpeg",
|
|
184
|
+
"audio/mpga",
|
|
185
|
+
"audio/mp4",
|
|
186
|
+
"audio/ogg",
|
|
187
|
+
"audio/pcm",
|
|
188
|
+
"audio/wav",
|
|
189
|
+
"audio/webm",
|
|
190
|
+
"audio/x-wav",
|
|
191
|
+
"audio/aac",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def _probe(self) -> None:
|
|
195
|
+
"""Verify audio data is valid by attempting to open it as an AudioFileClip."""
|
|
196
|
+
with warnings.catch_warnings():
|
|
197
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="moviepy.*")
|
|
198
|
+
with NamedTemporaryFile(suffix=".bin") as fin:
|
|
199
|
+
fin.write(self.data)
|
|
200
|
+
fin.flush()
|
|
201
|
+
clip = AudioFileClip(fin.name)
|
|
202
|
+
clip.close()
|
|
203
|
+
|
|
204
|
+
def to_base64_url(self, bitrate: str = "192k") -> str:
|
|
205
|
+
"""Transcode to MP3 and return base64 data URL."""
|
|
206
|
+
with warnings.catch_warnings():
|
|
207
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="moviepy.*")
|
|
208
|
+
with NamedTemporaryFile(suffix=".bin") as fin, NamedTemporaryFile(suffix=".mp3") as fout:
|
|
209
|
+
fin.write(self.data)
|
|
210
|
+
fin.flush()
|
|
211
|
+
clip = AudioFileClip(fin.name)
|
|
212
|
+
clip.write_audiofile(fout.name, codec="libmp3lame", bitrate=bitrate, logger=None)
|
|
213
|
+
clip.close()
|
|
214
|
+
return f"data:audio/mpeg;base64,{b64encode(fout.read()).decode()}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
type ContentBlock = ImageContentBlock | VideoContentBlock | AudioContentBlock | str
|
|
218
|
+
"""Union of all content block types (image, video, audio, or text)."""
|
|
219
|
+
|
|
220
|
+
type Content = list[ContentBlock] | str
|
|
221
|
+
"""Message content: either a plain string or list of mixed content blocks."""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# Metadata Protocol and Aggregation
|
|
225
|
+
@runtime_checkable
|
|
226
|
+
class Addable(Protocol):
|
|
227
|
+
"""Protocol for types that support aggregation via __add__."""
|
|
228
|
+
|
|
229
|
+
def __add__(self, other: Self) -> Self: ...
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _aggregate_list[T: Addable](metadata_list: list[T]) -> T | None:
|
|
233
|
+
"""Aggregate a list of metadata using __add__."""
|
|
234
|
+
if not metadata_list:
|
|
235
|
+
return None
|
|
236
|
+
aggregated = metadata_list[0]
|
|
237
|
+
for m in metadata_list[1:]:
|
|
238
|
+
aggregated = aggregated + m
|
|
239
|
+
return aggregated
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def to_json_serializable(value: object) -> object:
|
|
243
|
+
# None and JSON primitives
|
|
244
|
+
if value is None or isinstance(value, str | int | bool):
|
|
245
|
+
return value
|
|
246
|
+
|
|
247
|
+
# Floats need special handling for nan/inf
|
|
248
|
+
if isinstance(value, float):
|
|
249
|
+
if isnan(value) or isinf(value):
|
|
250
|
+
raise ValueError(f"Cannot serialize {value} to JSON")
|
|
251
|
+
return value
|
|
252
|
+
|
|
253
|
+
# Pydantic models
|
|
254
|
+
if isinstance(value, BaseModel):
|
|
255
|
+
return value.model_dump(mode="json")
|
|
256
|
+
|
|
257
|
+
# Common non-serializable types
|
|
258
|
+
if isinstance(value, datetime | date | time):
|
|
259
|
+
return value.isoformat()
|
|
260
|
+
|
|
261
|
+
if isinstance(value, timedelta):
|
|
262
|
+
return value.total_seconds()
|
|
263
|
+
|
|
264
|
+
if isinstance(value, Decimal):
|
|
265
|
+
return float(value)
|
|
266
|
+
|
|
267
|
+
if isinstance(value, dict):
|
|
268
|
+
return {k: to_json_serializable(v) for k, v in value.items()}
|
|
269
|
+
|
|
270
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
271
|
+
return [to_json_serializable(v) for v in value]
|
|
272
|
+
|
|
273
|
+
# We have not implemented other cases (e.g. Bytes, Enum, etc.)
|
|
274
|
+
raise TypeError(f"Cannot serialize {type(value).__name__} to JSON: {value!r}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@overload
|
|
278
|
+
def aggregate_metadata(
|
|
279
|
+
metadata_dict: dict[str, list[Any]], prefix: str = "", return_json_serializable: Literal[True] = True
|
|
280
|
+
) -> object: ...
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@overload
|
|
284
|
+
def aggregate_metadata(
|
|
285
|
+
metadata_dict: dict[str, list[Any]], prefix: str = "", return_json_serializable: Literal[False] = ...
|
|
286
|
+
) -> dict: ...
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _collect_all_token_usage(result: dict) -> "TokenUsage":
|
|
290
|
+
"""Recursively collect all token_usage from a flattened aggregate_metadata result.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
result: The flattened dict from aggregate_metadata (before JSON serialization)
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Combined TokenUsage from all entries (direct and nested sub-agents)
|
|
297
|
+
"""
|
|
298
|
+
total = TokenUsage()
|
|
299
|
+
|
|
300
|
+
for key, value in result.items():
|
|
301
|
+
if key == "token_usage" and isinstance(value, TokenUsage):
|
|
302
|
+
# Direct token_usage at this level
|
|
303
|
+
total = total + value
|
|
304
|
+
elif isinstance(value, dict):
|
|
305
|
+
# This could be a sub-agent's tool dict - check for nested token_usage
|
|
306
|
+
nested_token_usage = value.get("token_usage")
|
|
307
|
+
if isinstance(nested_token_usage, TokenUsage):
|
|
308
|
+
total = total + nested_token_usage
|
|
309
|
+
|
|
310
|
+
return total
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def aggregate_metadata(
|
|
314
|
+
metadata_dict: dict[str, list[Any]], prefix: str = "", return_json_serializable: bool = True
|
|
315
|
+
) -> dict | object:
|
|
316
|
+
"""Aggregate metadata lists and flatten sub-agents into a single-level dict with hierarchical keys.
|
|
317
|
+
|
|
318
|
+
For entries with nested run_metadata (e.g., SubAgentMetadata), flattens sub-agents using dot notation.
|
|
319
|
+
Each sub-agent's value is a dict mapping its direct tool names to their aggregated metadata
|
|
320
|
+
(excluding nested sub-agent data, which gets its own top-level key).
|
|
321
|
+
|
|
322
|
+
At the root level, token_usage is rolled up to include all sub-agent token usage.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
metadata_dict: Dict mapping names (tools or agents) to lists of metadata instances
|
|
326
|
+
prefix: Key prefix for nested calls (used internally for recursion)
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Flat dict with dot-notation keys for sub-agents.
|
|
330
|
+
Example: {
|
|
331
|
+
"token_usage": <combined from all agents>,
|
|
332
|
+
"web_browsing_sub_agent": {"web_search": <aggregated>, "token_usage": <aggregated>},
|
|
333
|
+
"web_browsing_sub_agent.web_fetch_sub_agent": {"fetch_web_page": <aggregated>, "token_usage": <aggregated>}
|
|
334
|
+
}
|
|
335
|
+
"""
|
|
336
|
+
result: dict = {}
|
|
337
|
+
|
|
338
|
+
# First pass: aggregate all entries in this level
|
|
339
|
+
aggregated_level: dict = {}
|
|
340
|
+
for name, metadata_list in metadata_dict.items():
|
|
341
|
+
if not metadata_list:
|
|
342
|
+
continue
|
|
343
|
+
aggregated_level[name] = _aggregate_list(metadata_list)
|
|
344
|
+
|
|
345
|
+
# Second pass: separate nested sub-agents from direct tools, and recurse
|
|
346
|
+
direct_tools: dict = {}
|
|
347
|
+
for name, aggregated in aggregated_level.items():
|
|
348
|
+
if hasattr(aggregated, "run_metadata") and isinstance(aggregated.run_metadata, dict):
|
|
349
|
+
# This is a sub-agent - recurse into it
|
|
350
|
+
full_key = f"{prefix}.{name}" if prefix else name
|
|
351
|
+
nested = aggregate_metadata(aggregated.run_metadata, prefix=full_key, return_json_serializable=False)
|
|
352
|
+
result.update(nested)
|
|
353
|
+
else:
|
|
354
|
+
# This is a direct tool/metadata - keep it at this level
|
|
355
|
+
direct_tools[name] = aggregated
|
|
356
|
+
|
|
357
|
+
# Store direct tools under the current prefix
|
|
358
|
+
if prefix:
|
|
359
|
+
result[prefix] = direct_tools
|
|
360
|
+
else:
|
|
361
|
+
# At root level, merge direct tools into result
|
|
362
|
+
result.update(direct_tools)
|
|
363
|
+
|
|
364
|
+
# At root level, roll up all token_usage from sub-agents
|
|
365
|
+
if not prefix:
|
|
366
|
+
total_token_usage = _collect_all_token_usage(result)
|
|
367
|
+
if total_token_usage.total > 0:
|
|
368
|
+
result["token_usage"] = [total_token_usage]
|
|
369
|
+
|
|
370
|
+
if return_json_serializable:
|
|
371
|
+
# Convert all Pydantic models to JSON-serializable dicts
|
|
372
|
+
return to_json_serializable(result)
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Messages
|
|
377
|
+
class TokenUsage(BaseModel):
|
|
378
|
+
"""Token counts for LLM usage (input, output, reasoning tokens)."""
|
|
379
|
+
|
|
380
|
+
input: int = 0
|
|
381
|
+
output: int = 0
|
|
382
|
+
reasoning: int = 0
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def total(self) -> int:
|
|
386
|
+
"""Total token count across input, output, and reasoning."""
|
|
387
|
+
return self.input + self.output + self.reasoning
|
|
388
|
+
|
|
389
|
+
def __add__(self, other: "TokenUsage") -> "TokenUsage":
|
|
390
|
+
"""Add two TokenUsage objects together, summing each field independently."""
|
|
391
|
+
return TokenUsage(
|
|
392
|
+
input=self.input + other.input,
|
|
393
|
+
output=self.output + other.output,
|
|
394
|
+
reasoning=self.reasoning + other.reasoning,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class ToolUseCountMetadata(BaseModel):
|
|
399
|
+
"""Generic metadata tracking tool usage count.
|
|
400
|
+
|
|
401
|
+
Implements Addable protocol for aggregation. Use this for tools that only need
|
|
402
|
+
to track how many times they were called.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
num_uses: int = 1
|
|
406
|
+
|
|
407
|
+
def __add__(self, other: "ToolUseCountMetadata") -> "ToolUseCountMetadata":
|
|
408
|
+
return ToolUseCountMetadata(num_uses=self.num_uses + other.num_uses)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class ToolResult[M](BaseModel):
|
|
412
|
+
"""Result from a tool executor with optional metadata.
|
|
413
|
+
|
|
414
|
+
Generic over metadata type M. M should implement Addable protocol for aggregation support,
|
|
415
|
+
but this is not enforced at the class level due to Pydantic schema generation limitations.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
content: Content
|
|
419
|
+
metadata: M | None = None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class Tool[P: BaseModel, M](BaseModel):
|
|
423
|
+
"""Tool definition with name, description, parameter schema, and executor function.
|
|
424
|
+
|
|
425
|
+
Generic over:
|
|
426
|
+
P: Parameter model type (must be a Pydantic BaseModel, or None for parameterless tools)
|
|
427
|
+
M: Metadata type (should implement Addable for aggregation; use None for tools without metadata)
|
|
428
|
+
|
|
429
|
+
Tools are simple, stateless callables. For tools requiring lifecycle management
|
|
430
|
+
(setup/teardown, resource pooling), use a ToolProvider instead.
|
|
431
|
+
|
|
432
|
+
Example with parameters:
|
|
433
|
+
class CalcParams(BaseModel):
|
|
434
|
+
expression: str
|
|
435
|
+
|
|
436
|
+
calc_tool = Tool[CalcParams, None](
|
|
437
|
+
name="calc",
|
|
438
|
+
description="Evaluate math",
|
|
439
|
+
parameters=CalcParams,
|
|
440
|
+
executor=lambda p: ToolResult(content=str(eval(p.expression))),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
Example without parameters:
|
|
444
|
+
time_tool = Tool[None, None](
|
|
445
|
+
name="time",
|
|
446
|
+
description="Get current time",
|
|
447
|
+
executor=lambda _: ToolResult(content=datetime.now().isoformat()),
|
|
448
|
+
)
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
name: str
|
|
452
|
+
description: str
|
|
453
|
+
parameters: type[P] | None = None
|
|
454
|
+
executor: Callable[[P], ToolResult[M] | Awaitable[ToolResult[M]]]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class ToolProvider(ABC):
|
|
458
|
+
"""Abstract base class for tool providers with lifecycle management.
|
|
459
|
+
|
|
460
|
+
ToolProviders manage resources (HTTP clients, sandboxes, server connections)
|
|
461
|
+
and return Tool instances when entering their async context. They implement
|
|
462
|
+
the async context manager protocol.
|
|
463
|
+
|
|
464
|
+
Use ToolProvider for:
|
|
465
|
+
- Tools requiring setup/teardown (connections, temp directories)
|
|
466
|
+
- Tools that return multiple Tool instances (e.g., MCP servers)
|
|
467
|
+
- Tools with shared state across calls (e.g., HTTP client pooling)
|
|
468
|
+
|
|
469
|
+
Example:
|
|
470
|
+
class MyToolProvider(ToolProvider):
|
|
471
|
+
async def __aenter__(self) -> Tool | list[Tool]:
|
|
472
|
+
# Setup resources and return tool(s)
|
|
473
|
+
return self._create_tool()
|
|
474
|
+
|
|
475
|
+
# __aexit__ is optional - default is no-op
|
|
476
|
+
|
|
477
|
+
Agent automatically manages ToolProvider lifecycle via its session() context.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
@abstractmethod
|
|
481
|
+
async def __aenter__(self) -> "Tool | list[Tool]":
|
|
482
|
+
"""Enter async context: setup resources and return tool(s).
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
A single Tool instance, or a list of Tool instances for providers
|
|
486
|
+
that expose multiple tools (e.g., MCP servers).
|
|
487
|
+
"""
|
|
488
|
+
...
|
|
489
|
+
|
|
490
|
+
async def __aexit__( # noqa: B027
|
|
491
|
+
self,
|
|
492
|
+
exc_type: type[BaseException] | None,
|
|
493
|
+
exc_val: BaseException | None,
|
|
494
|
+
exc_tb: TracebackType | None,
|
|
495
|
+
) -> None:
|
|
496
|
+
"""Exit async context: cleanup resources. Default: no-op."""
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@runtime_checkable
|
|
500
|
+
class LLMClient(Protocol):
|
|
501
|
+
"""Protocol defining the interface for LLM client implementations.
|
|
502
|
+
|
|
503
|
+
Any LLM client must implement this protocol to work with the Agent class.
|
|
504
|
+
Provides text generation with tool support and model capability inspection.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
@abstractmethod
|
|
508
|
+
async def generate(self, messages: list["ChatMessage"], tools: dict[str, Tool]) -> "AssistantMessage": ...
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def model_slug(self) -> str: ...
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def max_tokens(self) -> int: ...
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class ToolCall(BaseModel):
|
|
518
|
+
"""Represents a tool invocation request from the LLM.
|
|
519
|
+
|
|
520
|
+
Attributes:
|
|
521
|
+
name: Name of the tool to invoke
|
|
522
|
+
arguments: JSON string containing tool parameters
|
|
523
|
+
tool_call_id: Unique identifier for tracking this tool call and its result
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
name: str
|
|
527
|
+
arguments: str
|
|
528
|
+
tool_call_id: str | None = None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class SystemMessage(BaseModel):
|
|
532
|
+
"""System-level instructions and context for the LLM."""
|
|
533
|
+
|
|
534
|
+
role: Literal["system"] = "system"
|
|
535
|
+
content: Content
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class UserMessage(BaseModel):
|
|
539
|
+
"""User input message to the LLM."""
|
|
540
|
+
|
|
541
|
+
role: Literal["user"] = "user"
|
|
542
|
+
content: Content
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class Reasoning(BaseModel):
|
|
546
|
+
"""Extended thinking/reasoning content from models that support chain-of-thought reasoning."""
|
|
547
|
+
|
|
548
|
+
signature: str | None = None
|
|
549
|
+
content: str
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class AssistantMessage(BaseModel):
|
|
553
|
+
"""LLM response message with optional tool calls and token usage tracking."""
|
|
554
|
+
|
|
555
|
+
role: Literal["assistant"] = "assistant"
|
|
556
|
+
reasoning: Reasoning | None = None
|
|
557
|
+
content: Content
|
|
558
|
+
tool_calls: Annotated[list[ToolCall], Field(default_factory=list)]
|
|
559
|
+
token_usage: Annotated[TokenUsage, Field(default_factory=TokenUsage)]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class ToolMessage(BaseModel):
|
|
563
|
+
"""Tool execution result returned to the LLM."""
|
|
564
|
+
|
|
565
|
+
role: Literal["tool"] = "tool"
|
|
566
|
+
content: Content
|
|
567
|
+
tool_call_id: str | None = None
|
|
568
|
+
name: str | None = None
|
|
569
|
+
args_was_valid: bool = True
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
type ChatMessage = Annotated[SystemMessage | UserMessage | AssistantMessage | ToolMessage, Field(discriminator="role")]
|
|
573
|
+
"""Discriminated union of all message types, automatically parsed based on role field."""
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
class SubAgentMetadata(BaseModel):
|
|
577
|
+
"""Metadata from sub-agent execution including token usage, message history, and child run metadata.
|
|
578
|
+
|
|
579
|
+
Implements Addable protocol to support aggregation across multiple subagent calls.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
message_history: list[list[ChatMessage]]
|
|
583
|
+
run_metadata: Annotated[dict[str, list[Any]], Field(default_factory=dict)]
|
|
584
|
+
|
|
585
|
+
def __add__(self, other: "SubAgentMetadata") -> "SubAgentMetadata":
|
|
586
|
+
"""Combine metadata from multiple subagent calls."""
|
|
587
|
+
# Concatenate message histories
|
|
588
|
+
combined_history = self.message_history + other.message_history
|
|
589
|
+
# Merge run metadata (concatenate lists per key)
|
|
590
|
+
combined_meta: dict[str, list[Any]] = dict(self.run_metadata)
|
|
591
|
+
for key, metadata_list in other.run_metadata.items():
|
|
592
|
+
if key in combined_meta:
|
|
593
|
+
combined_meta[key] = combined_meta[key] + metadata_list
|
|
594
|
+
else:
|
|
595
|
+
combined_meta[key] = list(metadata_list)
|
|
596
|
+
return SubAgentMetadata(
|
|
597
|
+
message_history=combined_history,
|
|
598
|
+
run_metadata=combined_meta,
|
|
599
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Prompt templates for agent framework.
|
|
2
|
+
|
|
3
|
+
All prompts are loaded at module initialization from the prompts directory.
|
|
4
|
+
Templates can be formatted using .format() with the appropriate variables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
|
|
9
|
+
prompts_dir = files("stirrup.prompts")
|
|
10
|
+
|
|
11
|
+
# Templates that need .format() with runtime values
|
|
12
|
+
MESSAGE_SUMMARIZER_BRIDGE_TEMPLATE = (prompts_dir / "message_summarizer_bridge.txt").read_text(encoding="utf-8")
|
|
13
|
+
BASE_SYSTEM_PROMPT_TEMPLATE = (prompts_dir / "base_system_prompt.txt").read_text(encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
# Ready-to-use prompts (no formatting needed)
|
|
16
|
+
MESSAGE_SUMMARIZER = (prompts_dir / "message_summarizer.txt").read_text(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BASE_SYSTEM_PROMPT_TEMPLATE",
|
|
20
|
+
"MESSAGE_SUMMARIZER",
|
|
21
|
+
"MESSAGE_SUMMARIZER_BRIDGE_TEMPLATE",
|
|
22
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
You are an AI agent that will be given a specific task. You are to complete that task using the tools provided in {max_turns} steps. You will need to call the finish tool as your last step, where you will pass your finish reason and paths to any files that you wish to return to the user. You are not able to interact with the user during the task.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
The context window is approaching its limit. Please create a concise summary of the conversation so far to preserve important information.
|
|
2
|
+
|
|
3
|
+
Your summary should include:
|
|
4
|
+
|
|
5
|
+
1. **Task Overview**: What is the main goal or objective?
|
|
6
|
+
|
|
7
|
+
2. **Progress Made**: What has been accomplished so far?
|
|
8
|
+
- Key files created/modified (with paths)
|
|
9
|
+
- Important functions/classes implemented
|
|
10
|
+
- Tools used and their outcomes
|
|
11
|
+
|
|
12
|
+
3. **Current State**: Where are we now?
|
|
13
|
+
- What is currently working?
|
|
14
|
+
- What has been tested/verified?
|
|
15
|
+
|
|
16
|
+
4. **Next Steps**: What still needs to be done?
|
|
17
|
+
- Outstanding TODOs (with specific file paths and line numbers if applicable)
|
|
18
|
+
- Known issues or bugs to address
|
|
19
|
+
- Features or functionality not yet implemented
|
|
20
|
+
|
|
21
|
+
5. **Important Context**: Any critical details that shouldn't be lost
|
|
22
|
+
- Special configurations or setup requirements
|
|
23
|
+
- Important variable names, API endpoints, or data structures
|
|
24
|
+
- Edge cases or constraints to keep in mind
|
|
25
|
+
- Dependencies or relationships between components
|
|
26
|
+
|
|
27
|
+
Keep the summary concise but comprehensive. Do not use any tools. Focus on actionable information that will allow smooth continuation of the work.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
**Context Continuation**
|
|
2
|
+
|
|
3
|
+
Due to context window limitations, the previous conversation has been summarized. Below is a summary of what happened before:
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
{summary}
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
You should continue working on this task from where it was left off. All the progress, current state, and next steps are described in the summary above. Proceed with completing any outstanding work.
|
stirrup/py.typed
ADDED
|
File without changes
|