agentlatch 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.
- agentlatch/__init__.py +34 -0
- agentlatch/_types.py +26 -0
- agentlatch/banner.py +336 -0
- agentlatch/config.py +45 -0
- agentlatch/decorators.py +250 -0
- agentlatch/middleware.py +152 -0
- agentlatch/renderer.py +227 -0
- agentlatch/sampler.py +126 -0
- agentlatch/tracker.py +157 -0
- agentlatch-0.1.0.dist-info/METADATA +138 -0
- agentlatch-0.1.0.dist-info/RECORD +14 -0
- agentlatch-0.1.0.dist-info/WHEEL +5 -0
- agentlatch-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentlatch-0.1.0.dist-info/top_level.txt +1 -0
agentlatch/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""AgentLatch — Terminal-native agent resilience middleware.
|
|
2
|
+
|
|
3
|
+
Two decorators are all you need::
|
|
4
|
+
|
|
5
|
+
from agentlatch import profile_agent, safe_tool
|
|
6
|
+
|
|
7
|
+
@safe_tool
|
|
8
|
+
def query_db(sql: str) -> str:
|
|
9
|
+
...
|
|
10
|
+
|
|
11
|
+
@profile_agent
|
|
12
|
+
def run_agent():
|
|
13
|
+
result = query_db("SELECT ...")
|
|
14
|
+
...
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from agentlatch.config import is_dev_mode, set_dev_mode
|
|
18
|
+
from agentlatch.decorators import profile_agent, safe_tool
|
|
19
|
+
from agentlatch.renderer import render_flamegraph
|
|
20
|
+
from agentlatch.sampler import sample_response
|
|
21
|
+
from agentlatch.tracker import TraceEvent, get_trace
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"profile_agent",
|
|
26
|
+
"safe_tool",
|
|
27
|
+
"render_flamegraph",
|
|
28
|
+
"sample_response",
|
|
29
|
+
"is_dev_mode",
|
|
30
|
+
"set_dev_mode",
|
|
31
|
+
"TraceEvent",
|
|
32
|
+
"get_trace",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
agentlatch/_types.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared types and enumerations for AgentLatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from typing import Any, TypeAlias
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Enums
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventStatus(enum.Enum):
|
|
14
|
+
"""Status of a traced execution event."""
|
|
15
|
+
|
|
16
|
+
SUCCESS = "success"
|
|
17
|
+
ERROR = "error"
|
|
18
|
+
TIMEOUT = "timeout"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Type Aliases
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
ErrorPayload: TypeAlias = dict[str, Any]
|
|
26
|
+
"""Structured error information returned to the LLM instead of raising."""
|
agentlatch/banner.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Startup banner animation — Claude Code-style cosmic reveal.
|
|
2
|
+
|
|
3
|
+
Displays a large ASCII art banner spelling AGENT / LATCH in bold block
|
|
4
|
+
letters, surrounded by cosmic cloud formations and scattered stars.
|
|
5
|
+
The entire scene progressively "decrypts" from noise into the final art
|
|
6
|
+
via a diagonal sweep, followed by a typing effect for the welcome message.
|
|
7
|
+
|
|
8
|
+
Gracefully degrades in non-TTY environments (CI, Docker, piped output).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.live import Live
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Block Letter Definitions (each char is exactly 6 columns wide)
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_LETTER_DATA: dict[str, list[str]] = {
|
|
27
|
+
"A": [" ████ ", "██ ██", "██████", "██ ██", "██ ██"],
|
|
28
|
+
"G": [" █████", "██ ", "██ ███", "██ ██", " █████"],
|
|
29
|
+
"E": ["██████", "██ ", "████ ", "██ ", "██████"],
|
|
30
|
+
"N": ["██ ██", "███ ██", "██████", "██ ███", "██ ██"],
|
|
31
|
+
"T": ["██████", " ██ ", " ██ ", " ██ ", " ██ "],
|
|
32
|
+
"L": ["██ ", "██ ", "██ ", "██ ", "██████"],
|
|
33
|
+
"C": [" █████", "██ ", "██ ", "██ ", " █████"],
|
|
34
|
+
"H": ["██ ██", "██ ██", "██████", "██ ██", "██ ██"],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_word_lines(word: str, indent: int = 4, gap: int = 2) -> list[str]:
|
|
39
|
+
"""Render a word as 5 lines of block-letter ASCII art."""
|
|
40
|
+
pad = " " * indent
|
|
41
|
+
sep = " " * gap
|
|
42
|
+
return [pad + sep.join(_LETTER_DATA[ch][row] for ch in word) for row in range(5)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Build the Full Art Scene
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_ATMOS_TOP: list[str] = [
|
|
50
|
+
"...............................................................",
|
|
51
|
+
"",
|
|
52
|
+
" * ░░░░░░ █████▓▓░ ",
|
|
53
|
+
" ░░░░░░░░░░ * ███▓░ ░░ ",
|
|
54
|
+
" ░░░░░░░░░░░░░░░░ ███▓░ ",
|
|
55
|
+
" * * ██▓░░ ▓ ",
|
|
56
|
+
" ░▓▓██▓▓░ ",
|
|
57
|
+
"",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
_AGENT_LINES: list[str] = _build_word_lines("AGENT")
|
|
61
|
+
_DIVIDER: list[str] = [" ⚡"]
|
|
62
|
+
_LATCH_LINES: list[str] = _build_word_lines("LATCH")
|
|
63
|
+
|
|
64
|
+
_ATMOS_BOTTOM: list[str] = [
|
|
65
|
+
"",
|
|
66
|
+
" * ░░░░ * ",
|
|
67
|
+
" ░░░░░░░░ ",
|
|
68
|
+
" ░░░░░░░░░░░░░░ ",
|
|
69
|
+
" * ",
|
|
70
|
+
"...............................................................",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
_ART_LINES: list[str] = (
|
|
74
|
+
_ATMOS_TOP + _AGENT_LINES + _DIVIDER + _LATCH_LINES + _ATMOS_BOTTOM
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Pre-compute which rows are ASCII-art text (not atmosphere).
|
|
78
|
+
_TEXT_ROW_START = len(_ATMOS_TOP)
|
|
79
|
+
_TEXT_ROW_END = _TEXT_ROW_START + len(_AGENT_LINES) + len(_DIVIDER) + len(_LATCH_LINES)
|
|
80
|
+
_TEXT_ROWS: set[int] = set(range(_TEXT_ROW_START, _TEXT_ROW_END))
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Welcome / Tagline
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
_WELCOME_LINE = " Terminal-native agent resilience middleware v0.1.0"
|
|
87
|
+
_READY_LINE = " Let's get started."
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Noise / Animation Config
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
_NOISE_CHARS: list[str] = [
|
|
94
|
+
"█",
|
|
95
|
+
"▓",
|
|
96
|
+
"▒",
|
|
97
|
+
"░",
|
|
98
|
+
"#",
|
|
99
|
+
"@",
|
|
100
|
+
"%",
|
|
101
|
+
"&",
|
|
102
|
+
"╬",
|
|
103
|
+
"╠",
|
|
104
|
+
"╣",
|
|
105
|
+
"╋",
|
|
106
|
+
"┃",
|
|
107
|
+
"┫",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
_TOTAL_FRAMES = 32 # total decryption frames
|
|
111
|
+
_FRAME_DELAY = 0.015 # ~15ms per frame → ~480ms total
|
|
112
|
+
_TYPING_DELAY = 0.025 # per character for welcome text
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# State
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
_banner_shown: bool = False
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Style Mapping
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _char_style(char: str, row: int) -> str:
|
|
126
|
+
"""Determine the Rich style for a resolved character."""
|
|
127
|
+
if char == "⚡":
|
|
128
|
+
return "bold bright_yellow"
|
|
129
|
+
if char == "*":
|
|
130
|
+
return "bright_yellow"
|
|
131
|
+
if char == ".":
|
|
132
|
+
return "dim white"
|
|
133
|
+
|
|
134
|
+
# ASCII-art text rows → bold block letters
|
|
135
|
+
if row in _TEXT_ROWS:
|
|
136
|
+
return "bold bright_cyan"
|
|
137
|
+
|
|
138
|
+
# Atmosphere gradient (clouds with depth)
|
|
139
|
+
if char == "█":
|
|
140
|
+
return "bright_cyan"
|
|
141
|
+
if char == "▓":
|
|
142
|
+
return "cyan"
|
|
143
|
+
if char == "▒":
|
|
144
|
+
return "dim cyan"
|
|
145
|
+
if char == "░":
|
|
146
|
+
return "dim bright_white"
|
|
147
|
+
|
|
148
|
+
return "white"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Resolve Map — when each character "decrypts"
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _build_resolve_map(
|
|
157
|
+
lines: list[str],
|
|
158
|
+
total_frames: int,
|
|
159
|
+
) -> list[list[int]]:
|
|
160
|
+
"""Assign a resolve-frame to every non-space character.
|
|
161
|
+
|
|
162
|
+
Dot borders flicker in first (frames 0–3).
|
|
163
|
+
The rest decrypts in a diagonal sweep (top-left → bottom-right)
|
|
164
|
+
with random jitter for an organic feel.
|
|
165
|
+
"""
|
|
166
|
+
max_row = len(lines)
|
|
167
|
+
max_col = max((len(ln) for ln in lines), default=1)
|
|
168
|
+
|
|
169
|
+
rmap: list[list[int]] = []
|
|
170
|
+
for row, line in enumerate(lines):
|
|
171
|
+
row_map: list[int] = []
|
|
172
|
+
for col, ch in enumerate(line):
|
|
173
|
+
if ch == " ":
|
|
174
|
+
row_map.append(-1) # always transparent
|
|
175
|
+
elif ch == ".":
|
|
176
|
+
row_map.append(random.randint(0, 3)) # dots early
|
|
177
|
+
else:
|
|
178
|
+
row_pct = row / max(max_row - 1, 1)
|
|
179
|
+
col_pct = col / max(max_col - 1, 1)
|
|
180
|
+
progress = row_pct * 0.55 + col_pct * 0.45
|
|
181
|
+
base = int(progress * (total_frames - 6)) + 3
|
|
182
|
+
jitter = random.randint(-3, 3)
|
|
183
|
+
frame = max(2, min(total_frames - 1, base + jitter))
|
|
184
|
+
row_map.append(frame)
|
|
185
|
+
rmap.append(row_map)
|
|
186
|
+
return rmap
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Frame Renderer
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _render_frame(
|
|
195
|
+
frame: int,
|
|
196
|
+
lines: list[str],
|
|
197
|
+
resolve_map: list[list[int]],
|
|
198
|
+
) -> Text:
|
|
199
|
+
"""Produce one animation frame as a Rich Text object."""
|
|
200
|
+
output = Text()
|
|
201
|
+
|
|
202
|
+
for row, line in enumerate(lines):
|
|
203
|
+
for col, ch in enumerate(line):
|
|
204
|
+
if ch == " ":
|
|
205
|
+
output.append(" ")
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
resolve_at = resolve_map[row][col]
|
|
209
|
+
|
|
210
|
+
if frame >= resolve_at:
|
|
211
|
+
style = _char_style(ch, row)
|
|
212
|
+
output.append(ch, style=style)
|
|
213
|
+
else:
|
|
214
|
+
noise = random.choice(_NOISE_CHARS)
|
|
215
|
+
output.append(noise, style="dim bright_white")
|
|
216
|
+
|
|
217
|
+
output.append("\n")
|
|
218
|
+
|
|
219
|
+
return output
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Typing Effect
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _type_text(console: Console, text: str, style: str, delay: float) -> None:
|
|
228
|
+
"""Print text character-by-character with a typing effect."""
|
|
229
|
+
for ch in text:
|
|
230
|
+
console.print(ch, end="", style=style, highlight=False)
|
|
231
|
+
time.sleep(delay)
|
|
232
|
+
console.print() # newline
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Interactive Animation
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _play_animation(console: Console) -> None:
|
|
241
|
+
"""Run the full cosmic reveal sequence."""
|
|
242
|
+
resolve_map = _build_resolve_map(_ART_LINES, _TOTAL_FRAMES)
|
|
243
|
+
|
|
244
|
+
# Phase 1: Decryption sweep of the ASCII art.
|
|
245
|
+
with Live(
|
|
246
|
+
_render_frame(0, _ART_LINES, resolve_map),
|
|
247
|
+
console=console,
|
|
248
|
+
refresh_per_second=62,
|
|
249
|
+
transient=True,
|
|
250
|
+
) as live:
|
|
251
|
+
for frame in range(_TOTAL_FRAMES):
|
|
252
|
+
live.update(_render_frame(frame, _ART_LINES, resolve_map))
|
|
253
|
+
time.sleep(_FRAME_DELAY)
|
|
254
|
+
|
|
255
|
+
# Print the final, fully-resolved art (stays on screen).
|
|
256
|
+
final_art = _render_frame(_TOTAL_FRAMES, _ART_LINES, resolve_map)
|
|
257
|
+
console.print(final_art, end="")
|
|
258
|
+
|
|
259
|
+
# Phase 2: Welcome text types in.
|
|
260
|
+
console.print()
|
|
261
|
+
_type_text(console, _WELCOME_LINE, "bright_white", _TYPING_DELAY)
|
|
262
|
+
time.sleep(0.12)
|
|
263
|
+
_type_text(console, _READY_LINE, "dim bright_green", _TYPING_DELAY * 0.7)
|
|
264
|
+
console.print()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Non-TTY Fallback
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _print_fallback(console: Console) -> None:
|
|
273
|
+
"""Clean output for CI / Docker / piped environments."""
|
|
274
|
+
# Still show the ASCII art, just without animation.
|
|
275
|
+
for row, line in enumerate(_ART_LINES):
|
|
276
|
+
styled = Text()
|
|
277
|
+
for ch in line:
|
|
278
|
+
if ch == " ":
|
|
279
|
+
styled.append(" ")
|
|
280
|
+
else:
|
|
281
|
+
styled.append(ch, style=_char_style(ch, row))
|
|
282
|
+
console.print(styled)
|
|
283
|
+
|
|
284
|
+
console.print()
|
|
285
|
+
console.print(_WELCOME_LINE, style="bright_white")
|
|
286
|
+
console.print(_READY_LINE, style="dim bright_green")
|
|
287
|
+
console.print()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Environment Detection
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _is_interactive() -> bool:
|
|
296
|
+
"""Return True only if stdout is a real interactive terminal."""
|
|
297
|
+
if not sys.stdout.isatty():
|
|
298
|
+
return False
|
|
299
|
+
if os.environ.get("CI", "").lower() in ("true", "1"):
|
|
300
|
+
return False
|
|
301
|
+
if os.environ.get("TERM", "") == "dumb":
|
|
302
|
+
return False
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Public API
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def initialize_latch(console: Console | None = None) -> None:
|
|
312
|
+
"""Display the AgentLatch startup banner (once per process).
|
|
313
|
+
|
|
314
|
+
- **Interactive terminal**: plays the full cosmic decryption animation
|
|
315
|
+
with block-letter ASCII art reveal and typing effect.
|
|
316
|
+
- **Non-TTY / CI / Docker**: prints the static colored art without animation.
|
|
317
|
+
|
|
318
|
+
Calling this more than once per process is a no-op.
|
|
319
|
+
"""
|
|
320
|
+
global _banner_shown
|
|
321
|
+
if _banner_shown:
|
|
322
|
+
return
|
|
323
|
+
_banner_shown = True
|
|
324
|
+
|
|
325
|
+
con = console or Console()
|
|
326
|
+
|
|
327
|
+
if _is_interactive():
|
|
328
|
+
_play_animation(con)
|
|
329
|
+
else:
|
|
330
|
+
_print_fallback(con)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def reset_banner() -> None:
|
|
334
|
+
"""Reset the banner flag. Useful for tests."""
|
|
335
|
+
global _banner_shown
|
|
336
|
+
_banner_shown = False
|
agentlatch/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Environment detection and runtime configuration.
|
|
2
|
+
|
|
3
|
+
Controls whether AgentLatch renders ASCII visuals (banner, flamegraph) to
|
|
4
|
+
the terminal. In production / server environments, visuals are suppressed
|
|
5
|
+
automatically — only the structured data (headers, JSON profile) is emitted.
|
|
6
|
+
|
|
7
|
+
**Rules:**
|
|
8
|
+
|
|
9
|
+
* ``AGENTLATCH_ENV=production`` → visuals OFF
|
|
10
|
+
* ``AGENTLATCH_ENV=development`` → visuals ON (default when unset)
|
|
11
|
+
* Programmatic override via ``set_dev_mode(True/False)``
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
_dev_mode_override: bool | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_dev_mode() -> bool:
|
|
22
|
+
"""Return ``True`` if ASCII visuals should be rendered.
|
|
23
|
+
|
|
24
|
+
Checks (in priority order):
|
|
25
|
+
1. Programmatic override via :func:`set_dev_mode`.
|
|
26
|
+
2. ``AGENTLATCH_ENV`` environment variable (``production`` → False).
|
|
27
|
+
3. Default: ``True`` (development mode).
|
|
28
|
+
"""
|
|
29
|
+
if _dev_mode_override is not None:
|
|
30
|
+
return _dev_mode_override
|
|
31
|
+
|
|
32
|
+
env = os.environ.get("AGENTLATCH_ENV", "development").lower().strip()
|
|
33
|
+
return env != "production"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_dev_mode(enabled: bool) -> None:
|
|
37
|
+
"""Programmatically force dev mode on or off."""
|
|
38
|
+
global _dev_mode_override
|
|
39
|
+
_dev_mode_override = enabled
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def reset_dev_mode() -> None:
|
|
43
|
+
"""Clear the programmatic override. Useful for tests."""
|
|
44
|
+
global _dev_mode_override
|
|
45
|
+
_dev_mode_override = None
|
agentlatch/decorators.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Decorators that form AgentLatch's public API.
|
|
2
|
+
|
|
3
|
+
* ``@safe_tool`` — wraps tool functions with error interception & timing.
|
|
4
|
+
* ``@profile_agent`` — wraps the outer agent loop with tracing & visualization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import concurrent.futures
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any, TypeVar, overload
|
|
16
|
+
|
|
17
|
+
from agentlatch._types import ErrorPayload, EventStatus
|
|
18
|
+
from agentlatch.sampler import sample_response
|
|
19
|
+
from agentlatch.tracker import (
|
|
20
|
+
end_child,
|
|
21
|
+
finalize_trace,
|
|
22
|
+
get_trace,
|
|
23
|
+
init_trace,
|
|
24
|
+
start_child,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
28
|
+
|
|
29
|
+
# =====================================================================
|
|
30
|
+
# @safe_tool
|
|
31
|
+
# =====================================================================
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_error_payload(exc: Exception) -> ErrorPayload:
|
|
35
|
+
"""Translate a raw Python exception into an LLM-friendly JSON dict."""
|
|
36
|
+
return {
|
|
37
|
+
"status": "error",
|
|
38
|
+
"error_type": type(exc).__name__,
|
|
39
|
+
"message": str(exc),
|
|
40
|
+
"instruction": (
|
|
41
|
+
"The tool execution failed. Review your parameters and "
|
|
42
|
+
"retry with corrected inputs."
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_timeout_payload(name: str, timeout: float) -> ErrorPayload:
|
|
48
|
+
"""Payload returned when a tool exceeds its allowed time budget."""
|
|
49
|
+
return {
|
|
50
|
+
"status": "error",
|
|
51
|
+
"error_type": "TimeoutError",
|
|
52
|
+
"message": f"Tool '{name}' exceeded the {timeout}s timeout.",
|
|
53
|
+
"instruction": (
|
|
54
|
+
"The tool timed out. Consider simplifying the request or "
|
|
55
|
+
"breaking it into smaller steps."
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
def safe_tool(func: F) -> F: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@overload
|
|
65
|
+
def safe_tool(
|
|
66
|
+
*,
|
|
67
|
+
timeout: float | None = ...,
|
|
68
|
+
on_fail: str = ...,
|
|
69
|
+
max_response_tokens: int | None = ...,
|
|
70
|
+
sample_rows: int | None = ...,
|
|
71
|
+
) -> Callable[[F], F]: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def safe_tool(
|
|
75
|
+
func: F | None = None,
|
|
76
|
+
*,
|
|
77
|
+
timeout: float | None = None,
|
|
78
|
+
on_fail: str = "instruct_llm",
|
|
79
|
+
max_response_tokens: int | None = None,
|
|
80
|
+
sample_rows: int | None = None,
|
|
81
|
+
) -> F | Callable[[F], F]:
|
|
82
|
+
"""Decorator that makes a tool function resilient and observable.
|
|
83
|
+
|
|
84
|
+
Can be used bare (``@safe_tool``) or with arguments
|
|
85
|
+
(``@safe_tool(timeout=5.0, sample_rows=10)``).
|
|
86
|
+
|
|
87
|
+
On exception the decorated function returns a JSON error string to the
|
|
88
|
+
caller (typically the LLM) instead of raising, preventing silent crashes.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
timeout: Optional wall-clock budget in seconds.
|
|
92
|
+
on_fail: Error strategy (currently ``"instruct_llm"``).
|
|
93
|
+
max_response_tokens: Approximate token ceiling for responses.
|
|
94
|
+
sample_rows: If the response contains a list, keep only
|
|
95
|
+
the first N elements.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def decorator(fn: F) -> F:
|
|
99
|
+
if inspect.iscoroutinefunction(fn):
|
|
100
|
+
|
|
101
|
+
@functools.wraps(fn)
|
|
102
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
103
|
+
# If no active trace, still protect — just skip timing.
|
|
104
|
+
trace_active = get_trace() is not None
|
|
105
|
+
event = start_child(fn.__name__) if trace_active else None
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
if timeout is not None:
|
|
109
|
+
result = await asyncio.wait_for(
|
|
110
|
+
fn(*args, **kwargs), timeout=timeout
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
result = await fn(*args, **kwargs)
|
|
114
|
+
except asyncio.TimeoutError:
|
|
115
|
+
payload = _build_timeout_payload(fn.__name__, timeout or 0.0)
|
|
116
|
+
if event:
|
|
117
|
+
end_child(event, EventStatus.TIMEOUT, payload)
|
|
118
|
+
return json.dumps(payload)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
payload = _build_error_payload(exc)
|
|
121
|
+
if event:
|
|
122
|
+
end_child(event, EventStatus.ERROR, payload)
|
|
123
|
+
return json.dumps(payload)
|
|
124
|
+
else:
|
|
125
|
+
result = sample_response(
|
|
126
|
+
result,
|
|
127
|
+
max_tokens=max_response_tokens,
|
|
128
|
+
sample_rows=sample_rows,
|
|
129
|
+
)
|
|
130
|
+
if event:
|
|
131
|
+
end_child(event, EventStatus.SUCCESS)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
return async_wrapper # type: ignore[return-value]
|
|
135
|
+
|
|
136
|
+
else:
|
|
137
|
+
|
|
138
|
+
@functools.wraps(fn)
|
|
139
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
140
|
+
trace_active = get_trace() is not None
|
|
141
|
+
event = start_child(fn.__name__) if trace_active else None
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
if timeout is not None:
|
|
145
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
146
|
+
max_workers=1
|
|
147
|
+
) as pool:
|
|
148
|
+
future = pool.submit(fn, *args, **kwargs)
|
|
149
|
+
result = future.result(timeout=timeout)
|
|
150
|
+
else:
|
|
151
|
+
result = fn(*args, **kwargs)
|
|
152
|
+
except concurrent.futures.TimeoutError:
|
|
153
|
+
payload = _build_timeout_payload(fn.__name__, timeout or 0.0)
|
|
154
|
+
if event:
|
|
155
|
+
end_child(event, EventStatus.TIMEOUT, payload)
|
|
156
|
+
return json.dumps(payload)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
payload = _build_error_payload(exc)
|
|
159
|
+
if event:
|
|
160
|
+
end_child(event, EventStatus.ERROR, payload)
|
|
161
|
+
return json.dumps(payload)
|
|
162
|
+
else:
|
|
163
|
+
result = sample_response(
|
|
164
|
+
result,
|
|
165
|
+
max_tokens=max_response_tokens,
|
|
166
|
+
sample_rows=sample_rows,
|
|
167
|
+
)
|
|
168
|
+
if event:
|
|
169
|
+
end_child(event, EventStatus.SUCCESS)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
return sync_wrapper # type: ignore[return-value]
|
|
173
|
+
|
|
174
|
+
# Handle bare @safe_tool (no parentheses) vs @safe_tool(timeout=5)
|
|
175
|
+
if func is not None:
|
|
176
|
+
return decorator(func)
|
|
177
|
+
return decorator # type: ignore[return-value]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# =====================================================================
|
|
181
|
+
# @profile_agent
|
|
182
|
+
# =====================================================================
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def profile_agent(
|
|
186
|
+
func: F | None = None,
|
|
187
|
+
*,
|
|
188
|
+
name: str | None = None,
|
|
189
|
+
) -> F | Callable[[F], F]:
|
|
190
|
+
"""Decorator for the main agent loop.
|
|
191
|
+
|
|
192
|
+
Initializes the trace context, runs the agent, then renders the
|
|
193
|
+
execution flamegraph to the terminal.
|
|
194
|
+
|
|
195
|
+
Can be used bare (``@profile_agent``) or with a custom name
|
|
196
|
+
(``@profile_agent(name="MyAgent")``).
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def decorator(fn: F) -> F:
|
|
200
|
+
label = name or fn.__name__
|
|
201
|
+
|
|
202
|
+
if inspect.iscoroutinefunction(fn):
|
|
203
|
+
|
|
204
|
+
@functools.wraps(fn)
|
|
205
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
206
|
+
# Import here to avoid circular imports at module level.
|
|
207
|
+
from agentlatch.banner import initialize_latch
|
|
208
|
+
from agentlatch.config import is_dev_mode
|
|
209
|
+
from agentlatch.renderer import render_flamegraph
|
|
210
|
+
|
|
211
|
+
if is_dev_mode():
|
|
212
|
+
initialize_latch()
|
|
213
|
+
init_trace(label)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
result = await fn(*args, **kwargs)
|
|
217
|
+
finally:
|
|
218
|
+
trace = finalize_trace()
|
|
219
|
+
if is_dev_mode():
|
|
220
|
+
render_flamegraph(trace)
|
|
221
|
+
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
return async_wrapper # type: ignore[return-value]
|
|
225
|
+
else:
|
|
226
|
+
|
|
227
|
+
@functools.wraps(fn)
|
|
228
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
229
|
+
from agentlatch.banner import initialize_latch
|
|
230
|
+
from agentlatch.config import is_dev_mode
|
|
231
|
+
from agentlatch.renderer import render_flamegraph
|
|
232
|
+
|
|
233
|
+
if is_dev_mode():
|
|
234
|
+
initialize_latch()
|
|
235
|
+
init_trace(label)
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
result = fn(*args, **kwargs)
|
|
239
|
+
finally:
|
|
240
|
+
trace = finalize_trace()
|
|
241
|
+
if is_dev_mode():
|
|
242
|
+
render_flamegraph(trace)
|
|
243
|
+
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
return sync_wrapper # type: ignore[return-value]
|
|
247
|
+
|
|
248
|
+
if func is not None:
|
|
249
|
+
return decorator(func)
|
|
250
|
+
return decorator # type: ignore[return-value]
|