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 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
@@ -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]