agentlatch 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- agentlatch-0.1.0/PKG-INFO +138 -0
- agentlatch-0.1.0/README.md +107 -0
- agentlatch-0.1.0/agentlatch/__init__.py +34 -0
- agentlatch-0.1.0/agentlatch/_types.py +26 -0
- agentlatch-0.1.0/agentlatch/banner.py +336 -0
- agentlatch-0.1.0/agentlatch/config.py +45 -0
- agentlatch-0.1.0/agentlatch/decorators.py +250 -0
- agentlatch-0.1.0/agentlatch/middleware.py +152 -0
- agentlatch-0.1.0/agentlatch/renderer.py +227 -0
- agentlatch-0.1.0/agentlatch/sampler.py +126 -0
- agentlatch-0.1.0/agentlatch/tracker.py +157 -0
- agentlatch-0.1.0/agentlatch.egg-info/PKG-INFO +138 -0
- agentlatch-0.1.0/agentlatch.egg-info/SOURCES.txt +23 -0
- agentlatch-0.1.0/agentlatch.egg-info/dependency_links.txt +1 -0
- agentlatch-0.1.0/agentlatch.egg-info/requires.txt +9 -0
- agentlatch-0.1.0/agentlatch.egg-info/top_level.txt +1 -0
- agentlatch-0.1.0/pyproject.toml +80 -0
- agentlatch-0.1.0/setup.cfg +4 -0
- agentlatch-0.1.0/tests/test_banner.py +88 -0
- agentlatch-0.1.0/tests/test_decorators.py +197 -0
- agentlatch-0.1.0/tests/test_middleware.py +152 -0
- agentlatch-0.1.0/tests/test_renderer.py +146 -0
- agentlatch-0.1.0/tests/test_sampler.py +129 -0
- agentlatch-0.1.0/tests/test_tracker.py +172 -0
agentlatch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentLatch Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentlatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency, terminal-native Python middleware for resilient and observable AI agents.
|
|
5
|
+
Author: AgentLatch Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/arav7781/AgentLatch
|
|
8
|
+
Project-URL: Repository, https://github.com/arav7781/AgentLatch
|
|
9
|
+
Project-URL: Issues, https://github.com/arav7781/AgentLatch/issues
|
|
10
|
+
Keywords: agents,llm,observability,middleware,terminal
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
27
|
+
Provides-Extra: server
|
|
28
|
+
Requires-Dist: starlette>=0.27; extra == "server"
|
|
29
|
+
Requires-Dist: httpx>=0.24; extra == "server"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
**Terminal-native agent resilience middleware for Python.**
|
|
35
|
+
|
|
36
|
+
AgentLatch is a zero-dependency framework that makes AI agents resilient and observable. It solves two massive pain points in agent development:
|
|
37
|
+
|
|
38
|
+
1. **Silent Tool Failures** — When an LLM executes a tool that crashes, AgentLatch intercepts the Python exception, prevents a runtime crash, and feeds a structured JSON error back to the LLM so it can self-correct.
|
|
39
|
+
|
|
40
|
+
2. **Blind Latency** — It tracks millisecond execution time of LLM vs. tools and prints a color-coded ASCII flamegraph directly in the terminal using the `rich` library. No API keys, no dashboards, no cloud.
|
|
41
|
+
|
|
42
|
+
## Quick Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv pip install -e ".[dev]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from agentlatch import profile_agent, safe_tool
|
|
52
|
+
|
|
53
|
+
@safe_tool
|
|
54
|
+
def query_database(sql: str) -> str:
|
|
55
|
+
"""This tool is now protected — exceptions become JSON errors."""
|
|
56
|
+
import sqlite3
|
|
57
|
+
conn = sqlite3.connect("my.db")
|
|
58
|
+
return str(conn.execute(sql).fetchall())
|
|
59
|
+
|
|
60
|
+
@safe_tool(timeout=5.0)
|
|
61
|
+
def call_api(url: str) -> str:
|
|
62
|
+
"""This tool has a 5-second timeout."""
|
|
63
|
+
import requests
|
|
64
|
+
return requests.get(url).text
|
|
65
|
+
|
|
66
|
+
@profile_agent
|
|
67
|
+
def run_agent():
|
|
68
|
+
"""The agent loop — traced and visualized automatically."""
|
|
69
|
+
result = query_database("SELECT * FROM users")
|
|
70
|
+
weather = call_api("https://api.weather.com/sf")
|
|
71
|
+
return f"Got {result} and {weather}"
|
|
72
|
+
|
|
73
|
+
run_agent()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## What Happens
|
|
77
|
+
|
|
78
|
+
1. **Execution**: Every `@safe_tool` call is timed and protected.
|
|
79
|
+
2. **On Error**: Instead of crashing, the tool returns a JSON error string:
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"status": "error",
|
|
83
|
+
"error_type": "ProgrammingError",
|
|
84
|
+
"message": "column 'age' does not exist",
|
|
85
|
+
"instruction": "The tool execution failed. Review your parameters and retry with corrected inputs."
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
3. **On Completion**: A rich flamegraph is printed to the terminal:
|
|
89
|
+
```
|
|
90
|
+
┌─────────────────────────────────────────────────────────┐
|
|
91
|
+
│ ⚡ AGENTLATCH EXECUTION PROFILE │
|
|
92
|
+
│ Total: 1.23s │ Tools: 0.85s │ LLM Reasoning: 0.38s │
|
|
93
|
+
├─────────────────────────────────────────────────────────┤
|
|
94
|
+
│ ████████████████████████████████████████████ 1.23s │
|
|
95
|
+
│ ░░░░████████████░░░████░░░░░░░░░░░░░░░░░░░░ │
|
|
96
|
+
│ query_db 0.5s call_api 0.2s │
|
|
97
|
+
│ ▲ ERROR │
|
|
98
|
+
│ Legend: █ LLM █ Tool (OK) █ Tool (ERROR) │
|
|
99
|
+
└─────────────────────────────────────────────────────────┘
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Features
|
|
103
|
+
|
|
104
|
+
| Feature | Description |
|
|
105
|
+
|---------|-------------|
|
|
106
|
+
| `@safe_tool` | Wraps any function — catches exceptions, returns JSON errors |
|
|
107
|
+
| `@safe_tool(timeout=N)` | Adds a thread-based timeout (cross-platform) |
|
|
108
|
+
| `@profile_agent` | Traces the full agent loop and renders the flamegraph |
|
|
109
|
+
| Async support | Both decorators work with `async def` functions |
|
|
110
|
+
| Framework agnostic | Works with LangGraph, AutoGen, CrewAI, or vanilla scripts |
|
|
111
|
+
| CI-safe | Banner auto-disables in non-TTY environments |
|
|
112
|
+
|
|
113
|
+
## Running Examples
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
# Vanilla agent with a forced failure + self-correction
|
|
117
|
+
python examples/vanilla_agent.py
|
|
118
|
+
|
|
119
|
+
# LangGraph-style state machine
|
|
120
|
+
python examples/langgraph_agent.py
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Running Tests
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
uv pip install -e ".[dev]"
|
|
127
|
+
pytest tests/ -v
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Architecture
|
|
131
|
+
|
|
132
|
+
- **`contextvars`** — Thread-safe trace propagation without manual trace IDs
|
|
133
|
+
- **`concurrent.futures`** — Cross-platform timeouts (no `signal.alarm`)
|
|
134
|
+
- **`rich`** — Premium terminal rendering (the only external dependency)
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
**Terminal-native agent resilience middleware for Python.**
|
|
4
|
+
|
|
5
|
+
AgentLatch is a zero-dependency framework that makes AI agents resilient and observable. It solves two massive pain points in agent development:
|
|
6
|
+
|
|
7
|
+
1. **Silent Tool Failures** — When an LLM executes a tool that crashes, AgentLatch intercepts the Python exception, prevents a runtime crash, and feeds a structured JSON error back to the LLM so it can self-correct.
|
|
8
|
+
|
|
9
|
+
2. **Blind Latency** — It tracks millisecond execution time of LLM vs. tools and prints a color-coded ASCII flamegraph directly in the terminal using the `rich` library. No API keys, no dashboards, no cloud.
|
|
10
|
+
|
|
11
|
+
## Quick Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv pip install -e ".[dev]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from agentlatch import profile_agent, safe_tool
|
|
21
|
+
|
|
22
|
+
@safe_tool
|
|
23
|
+
def query_database(sql: str) -> str:
|
|
24
|
+
"""This tool is now protected — exceptions become JSON errors."""
|
|
25
|
+
import sqlite3
|
|
26
|
+
conn = sqlite3.connect("my.db")
|
|
27
|
+
return str(conn.execute(sql).fetchall())
|
|
28
|
+
|
|
29
|
+
@safe_tool(timeout=5.0)
|
|
30
|
+
def call_api(url: str) -> str:
|
|
31
|
+
"""This tool has a 5-second timeout."""
|
|
32
|
+
import requests
|
|
33
|
+
return requests.get(url).text
|
|
34
|
+
|
|
35
|
+
@profile_agent
|
|
36
|
+
def run_agent():
|
|
37
|
+
"""The agent loop — traced and visualized automatically."""
|
|
38
|
+
result = query_database("SELECT * FROM users")
|
|
39
|
+
weather = call_api("https://api.weather.com/sf")
|
|
40
|
+
return f"Got {result} and {weather}"
|
|
41
|
+
|
|
42
|
+
run_agent()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What Happens
|
|
46
|
+
|
|
47
|
+
1. **Execution**: Every `@safe_tool` call is timed and protected.
|
|
48
|
+
2. **On Error**: Instead of crashing, the tool returns a JSON error string:
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"status": "error",
|
|
52
|
+
"error_type": "ProgrammingError",
|
|
53
|
+
"message": "column 'age' does not exist",
|
|
54
|
+
"instruction": "The tool execution failed. Review your parameters and retry with corrected inputs."
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
3. **On Completion**: A rich flamegraph is printed to the terminal:
|
|
58
|
+
```
|
|
59
|
+
┌─────────────────────────────────────────────────────────┐
|
|
60
|
+
│ ⚡ AGENTLATCH EXECUTION PROFILE │
|
|
61
|
+
│ Total: 1.23s │ Tools: 0.85s │ LLM Reasoning: 0.38s │
|
|
62
|
+
├─────────────────────────────────────────────────────────┤
|
|
63
|
+
│ ████████████████████████████████████████████ 1.23s │
|
|
64
|
+
│ ░░░░████████████░░░████░░░░░░░░░░░░░░░░░░░░ │
|
|
65
|
+
│ query_db 0.5s call_api 0.2s │
|
|
66
|
+
│ ▲ ERROR │
|
|
67
|
+
│ Legend: █ LLM █ Tool (OK) █ Tool (ERROR) │
|
|
68
|
+
└─────────────────────────────────────────────────────────┘
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Features
|
|
72
|
+
|
|
73
|
+
| Feature | Description |
|
|
74
|
+
|---------|-------------|
|
|
75
|
+
| `@safe_tool` | Wraps any function — catches exceptions, returns JSON errors |
|
|
76
|
+
| `@safe_tool(timeout=N)` | Adds a thread-based timeout (cross-platform) |
|
|
77
|
+
| `@profile_agent` | Traces the full agent loop and renders the flamegraph |
|
|
78
|
+
| Async support | Both decorators work with `async def` functions |
|
|
79
|
+
| Framework agnostic | Works with LangGraph, AutoGen, CrewAI, or vanilla scripts |
|
|
80
|
+
| CI-safe | Banner auto-disables in non-TTY environments |
|
|
81
|
+
|
|
82
|
+
## Running Examples
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# Vanilla agent with a forced failure + self-correction
|
|
86
|
+
python examples/vanilla_agent.py
|
|
87
|
+
|
|
88
|
+
# LangGraph-style state machine
|
|
89
|
+
python examples/langgraph_agent.py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Running Tests
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv pip install -e ".[dev]"
|
|
96
|
+
pytest tests/ -v
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Architecture
|
|
100
|
+
|
|
101
|
+
- **`contextvars`** — Thread-safe trace propagation without manual trace IDs
|
|
102
|
+
- **`concurrent.futures`** — Cross-platform timeouts (no `signal.alarm`)
|
|
103
|
+
- **`rich`** — Premium terminal rendering (the only external dependency)
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -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
|
+
]
|
|
@@ -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."""
|
|
@@ -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
|