aeyeagent 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.
- aeyeagent/__init__.py +201 -0
- aeyeagent-0.1.0.dist-info/METADATA +13 -0
- aeyeagent-0.1.0.dist-info/RECORD +20 -0
- aeyeagent-0.1.0.dist-info/WHEEL +4 -0
- backend/__init__.py +0 -0
- backend/adapters/__init__.py +23 -0
- backend/adapters/base.py +89 -0
- backend/adapters/pydantic_ai.py +348 -0
- backend/collector.py +111 -0
- backend/config.py +19 -0
- backend/demo_agent.py +236 -0
- backend/graph.py +432 -0
- backend/main.py +59 -0
- backend/models.py +94 -0
- backend/routers/__init__.py +1 -0
- backend/routers/health.py +12 -0
- backend/routers/layout.py +26 -0
- backend/routers/runs.py +39 -0
- backend/routers/topology.py +21 -0
- backend/store.py +579 -0
aeyeagent/__init__.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""AeyeAgent — local-first visual debugger for AI agent pipelines.
|
|
2
|
+
|
|
3
|
+
Usage in your project:
|
|
4
|
+
import aeyeagent
|
|
5
|
+
aeyeagent.instrument_pydantic_ai()
|
|
6
|
+
|
|
7
|
+
# Then run your PydanticAI agents as normal.
|
|
8
|
+
# Open http://localhost:7891 (or your chosen port) to view traces.
|
|
9
|
+
|
|
10
|
+
For future frameworks:
|
|
11
|
+
aeyeagent.instrument_langchain() # not yet implemented
|
|
12
|
+
aeyeagent.instrument_crewai() # not yet implemented
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
_initialized = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Base instrument — framework-agnostic ─────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def instrument(
|
|
27
|
+
port: int = 7891,
|
|
28
|
+
db_path: str | Path | None = None,
|
|
29
|
+
open_browser: bool = False,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Start the AeyeAgent backend server and configure the database.
|
|
32
|
+
|
|
33
|
+
This is the framework-agnostic base. It sets up the DB and web UI
|
|
34
|
+
but does NOT wire up any OTEL or agent discovery. Use a framework-
|
|
35
|
+
specific function like ``instrument_pydantic_ai()`` instead.
|
|
36
|
+
|
|
37
|
+
Returns the actual port the server is listening on.
|
|
38
|
+
"""
|
|
39
|
+
global _initialized
|
|
40
|
+
|
|
41
|
+
from backend import store
|
|
42
|
+
|
|
43
|
+
# ── 1. Point the store at the right DB file ──────────────────────────
|
|
44
|
+
if db_path is not None:
|
|
45
|
+
resolved = Path(db_path)
|
|
46
|
+
else:
|
|
47
|
+
resolved = Path.home() / ".aeyeagent" / "aeyeagent.db"
|
|
48
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
store.configure_db(resolved)
|
|
50
|
+
|
|
51
|
+
# ── 2. Start the web UI backend in the background ────────────────────
|
|
52
|
+
actual_port = _start_server(port)
|
|
53
|
+
|
|
54
|
+
_initialized = True
|
|
55
|
+
time.sleep(0.3)
|
|
56
|
+
print(f"[aeyeagent] dashboard → http://localhost:{actual_port}")
|
|
57
|
+
|
|
58
|
+
if open_browser:
|
|
59
|
+
import webbrowser
|
|
60
|
+
webbrowser.open(f"http://localhost:{actual_port}")
|
|
61
|
+
|
|
62
|
+
return actual_port
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── PydanticAI-specific instrumentation ──────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def instrument_pydantic_ai(
|
|
68
|
+
port: int = 7891,
|
|
69
|
+
db_path: str | Path | None = None,
|
|
70
|
+
open_browser: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""One-line setup for PydanticAI projects.
|
|
73
|
+
|
|
74
|
+
Wires up OpenTelemetry span capture, starts PydanticAI-specific
|
|
75
|
+
agent/tool auto-discovery, and launches the AeyeAgent web UI.
|
|
76
|
+
|
|
77
|
+
Call once at the top of your script, before any agents run.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
port: Port for the AeyeAgent web UI (default 7891).
|
|
81
|
+
db_path: Where to store the SQLite database.
|
|
82
|
+
Defaults to ~/.aeyeagent/aeyeagent.db
|
|
83
|
+
open_browser: Open http://localhost:<port> automatically after startup.
|
|
84
|
+
"""
|
|
85
|
+
# ── 1. Base setup (DB + server) ──────────────────────────────────────
|
|
86
|
+
instrument(port=port, db_path=db_path, open_browser=open_browser)
|
|
87
|
+
|
|
88
|
+
# ── 2. Wire up OpenTelemetry ─────────────────────────────────────────
|
|
89
|
+
from opentelemetry import trace
|
|
90
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
91
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
92
|
+
|
|
93
|
+
from backend.collector import AeyeAgentExporter
|
|
94
|
+
|
|
95
|
+
exporter = AeyeAgentExporter()
|
|
96
|
+
processor = SimpleSpanProcessor(exporter)
|
|
97
|
+
|
|
98
|
+
existing = trace.get_tracer_provider()
|
|
99
|
+
if hasattr(existing, 'add_span_processor'):
|
|
100
|
+
existing.add_span_processor(processor)
|
|
101
|
+
else:
|
|
102
|
+
provider = TracerProvider()
|
|
103
|
+
provider.add_span_processor(processor)
|
|
104
|
+
trace.set_tracer_provider(provider)
|
|
105
|
+
|
|
106
|
+
# ── 3. Register the PydanticAI adapter, then start topology discovery ─
|
|
107
|
+
from backend import adapters
|
|
108
|
+
from backend.adapters.pydantic_ai import PydanticAIAdapter
|
|
109
|
+
|
|
110
|
+
adapters.register(PydanticAIAdapter()) # setup() applies the auto-naming patch
|
|
111
|
+
_start_topology_scanner()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Topology auto-discovery (framework-neutral) ──────────────────────────────
|
|
115
|
+
|
|
116
|
+
def _start_topology_scanner() -> None:
|
|
117
|
+
"""Background thread: every 5s merge each registered adapter's discovered
|
|
118
|
+
topology and persist it when it changes.
|
|
119
|
+
|
|
120
|
+
Framework specifics (how agents are found, named, introspected) live in the
|
|
121
|
+
adapters — this orchestrator is framework-neutral.
|
|
122
|
+
"""
|
|
123
|
+
import hashlib
|
|
124
|
+
import json
|
|
125
|
+
|
|
126
|
+
from backend import adapters, store
|
|
127
|
+
|
|
128
|
+
_last_hash: list[str | None] = [None]
|
|
129
|
+
|
|
130
|
+
def _scan() -> None:
|
|
131
|
+
while True:
|
|
132
|
+
time.sleep(5)
|
|
133
|
+
try:
|
|
134
|
+
topology: dict[str, dict] = {}
|
|
135
|
+
for adapter in adapters.registered():
|
|
136
|
+
try:
|
|
137
|
+
topology.update(adapter.discover_topology())
|
|
138
|
+
except Exception:
|
|
139
|
+
continue
|
|
140
|
+
if not topology:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
h = hashlib.md5(json.dumps(topology, sort_keys=True).encode()).hexdigest()
|
|
144
|
+
if h != _last_hash[0]:
|
|
145
|
+
store.save_topology(topology)
|
|
146
|
+
_last_hash[0] = h
|
|
147
|
+
except Exception:
|
|
148
|
+
pass # never crash the background thread
|
|
149
|
+
|
|
150
|
+
threading.Thread(target=_scan, daemon=True).start()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Server management ────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def _is_aeyeagent(port: int) -> bool:
|
|
156
|
+
import urllib.request
|
|
157
|
+
try:
|
|
158
|
+
with urllib.request.urlopen(f"http://localhost:{port}/_health", timeout=1) as r:
|
|
159
|
+
import json
|
|
160
|
+
return json.loads(r.read()).get("service") == "aeyeagent"
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _port_in_use(port: int) -> bool:
|
|
166
|
+
import socket
|
|
167
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
168
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
169
|
+
return s.connect_ex(('localhost', port)) == 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _find_free_port(start: int) -> int:
|
|
173
|
+
"""Find the first free port at or after `start`."""
|
|
174
|
+
import socket
|
|
175
|
+
port = start
|
|
176
|
+
while True:
|
|
177
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
178
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
179
|
+
try:
|
|
180
|
+
s.bind(('localhost', port))
|
|
181
|
+
return port # bind succeeded → port is free
|
|
182
|
+
except OSError:
|
|
183
|
+
port += 1
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _start_server(port: int) -> int:
|
|
187
|
+
if _port_in_use(port):
|
|
188
|
+
if _is_aeyeagent(port):
|
|
189
|
+
return port # our server already running (e.g. hot-reload) — reuse silently
|
|
190
|
+
free = _find_free_port(port + 1)
|
|
191
|
+
print(f"[aeyeagent] port {port} in use, using {free} instead")
|
|
192
|
+
port = free
|
|
193
|
+
|
|
194
|
+
import uvicorn
|
|
195
|
+
from backend.main import app
|
|
196
|
+
|
|
197
|
+
def _run() -> None:
|
|
198
|
+
uvicorn.run(app, host="localhost", port=port, log_level="error")
|
|
199
|
+
|
|
200
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
201
|
+
return port
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aeyeagent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-first visual debugger for PydanticAI agent pipelines
|
|
5
|
+
Requires-Python: <3.15,>=3.11
|
|
6
|
+
Requires-Dist: fastapi>=0.111.0
|
|
7
|
+
Requires-Dist: opentelemetry-api>=1.24.0
|
|
8
|
+
Requires-Dist: opentelemetry-sdk>=1.24.0
|
|
9
|
+
Requires-Dist: pydantic-ai>=0.0.13
|
|
10
|
+
Requires-Dist: pydantic>=2.7.0
|
|
11
|
+
Requires-Dist: uvicorn[standard]>=0.29.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
aeyeagent/__init__.py,sha256=kWm8ElK8njZgjplrj1yGlIGC4vDg65Y67KErN6NxLOs,7160
|
|
2
|
+
backend/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
backend/collector.py,sha256=VUge_aGNDu_3Wo1Lhyc4K9dwX-jj96oqqQkjA-sAGuQ,3814
|
|
4
|
+
backend/config.py,sha256=TXQCKOpswKYEUpqfl_FdauJhdGvE6-UdZ1FBvOTcIJc,560
|
|
5
|
+
backend/demo_agent.py,sha256=mmXa7zTRff-gOTNiYWpJF3D-QjTOFYRTXr3MvN4ImBY,10091
|
|
6
|
+
backend/graph.py,sha256=GrgzPyRfDmEqBJwEpe5ne9q1NnQ8xJZRohTfr3WWyB4,17762
|
|
7
|
+
backend/main.py,sha256=hL3v64x1xqXKJOX0LCzoAdlgH3AFtMgH1MaszYqmJ3A,2105
|
|
8
|
+
backend/models.py,sha256=cXGMI8ZgWhtghiCeoqk-br-2IZKOxGZslJqw6xMzPww,2457
|
|
9
|
+
backend/store.py,sha256=85oc-8WKgHHcXAzsCZbs00sUjrJwssUkUHg4n333hO8,23805
|
|
10
|
+
backend/adapters/__init__.py,sha256=1ZVOl5qWfj6zvISnV_w3CW7xfXMFiHbE6qJcXuTqdXU,432
|
|
11
|
+
backend/adapters/base.py,sha256=ZO2D4ohoctZHg3Nsd88yVEiVlurOe_JwtKKP-KItSq4,3283
|
|
12
|
+
backend/adapters/pydantic_ai.py,sha256=fSOzWtHlvYmlcwz5ej9f3xS4uDBPj_1mzjNR0Rl68DA,14166
|
|
13
|
+
backend/routers/__init__.py,sha256=g4T93NGpmq95O-voyfV81YTCHQDUECTRgvUJsVxD7X8,46
|
|
14
|
+
backend/routers/health.py,sha256=aZLGSp4vGOO5COsCDB28KwZx9lUFqeybMm0Y0L_tmFw,228
|
|
15
|
+
backend/routers/layout.py,sha256=lq2_w73tZ45xFT9wiXbOMzNJZPHtstdBx6VmXOHVUUQ,561
|
|
16
|
+
backend/routers/runs.py,sha256=dkdaQbXnObeVxeh3cZJyBk_GN_o9-BrCj8fOE5mdaGg,1070
|
|
17
|
+
backend/routers/topology.py,sha256=yCk1seIetEnbYlarouxDnl_cSCcUbk5UIP4qDd5lcBU,530
|
|
18
|
+
aeyeagent-0.1.0.dist-info/METADATA,sha256=u3GMprJyUnKl2qtWkuovSiYU7yjxOhQXMqW5RzhehSs,437
|
|
19
|
+
aeyeagent-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
aeyeagent-0.1.0.dist-info/RECORD,,
|
backend/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Framework adapters.
|
|
2
|
+
|
|
3
|
+
Public surface for the rest of the app. Adding a framework = add a module here
|
|
4
|
+
with a FrameworkAdapter subclass and register it from its instrument_* entrypoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .base import (
|
|
8
|
+
FrameworkAdapter,
|
|
9
|
+
attr,
|
|
10
|
+
ns_to_dt,
|
|
11
|
+
owning_adapter,
|
|
12
|
+
register,
|
|
13
|
+
registered,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FrameworkAdapter",
|
|
18
|
+
"attr",
|
|
19
|
+
"ns_to_dt",
|
|
20
|
+
"owning_adapter",
|
|
21
|
+
"register",
|
|
22
|
+
"registered",
|
|
23
|
+
]
|
backend/adapters/base.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Framework adapter interface + registry.
|
|
2
|
+
|
|
3
|
+
Each agentic framework plugs in by subclassing :class:`FrameworkAdapter` and
|
|
4
|
+
registering an instance via :func:`register`. Shared code — the OTEL collector
|
|
5
|
+
and the topology scanner — only talks to this registry, so adding a framework
|
|
6
|
+
is purely additive and can't break the existing ones.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
15
|
+
|
|
16
|
+
from ..models import Span
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Shared helpers for adapters ──────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def ns_to_dt(ns: int) -> datetime:
|
|
22
|
+
"""Nanosecond epoch → timezone-aware datetime."""
|
|
23
|
+
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def attr(span: ReadableSpan, key: str) -> str | None:
|
|
27
|
+
"""Read a span attribute as a string (or None)."""
|
|
28
|
+
val = span.attributes.get(key) if span.attributes else None
|
|
29
|
+
return str(val) if val is not None else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Adapter interface ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
class FrameworkAdapter(ABC):
|
|
35
|
+
"""Captures one agentic framework into AeyeAgent's generic data model.
|
|
36
|
+
|
|
37
|
+
Subclasses live in their own file under ``backend/adapters/`` and implement
|
|
38
|
+
the methods below; nothing else in the codebase needs to change to add one.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str = "framework"
|
|
42
|
+
|
|
43
|
+
def setup(self) -> None:
|
|
44
|
+
"""One-time side effects (e.g. monkeypatching). Default: nothing."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def owns_span(self, raw: ReadableSpan) -> bool:
|
|
48
|
+
"""Return True if this OTEL span was emitted by this framework."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def parse_span(self, trace_id: str, raw: ReadableSpan) -> Span | None:
|
|
52
|
+
"""Convert a ReadableSpan into a generic :class:`Span` (None to skip)."""
|
|
53
|
+
|
|
54
|
+
def discover_topology(self) -> dict[str, dict]:
|
|
55
|
+
"""Discover the static architecture as ``{agent_name: info}``.
|
|
56
|
+
|
|
57
|
+
``info`` shape: ``{model_name, output_type, deps_type, has_instructions,
|
|
58
|
+
name_inferred, tools: [{name, toolset_index, max_retries}]}``.
|
|
59
|
+
Default: nothing (frameworks without static discovery can rely on spans).
|
|
60
|
+
"""
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Registry ─────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
_ADAPTERS: list[FrameworkAdapter] = []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register(adapter: FrameworkAdapter) -> None:
|
|
70
|
+
"""Register an adapter (idempotent per type) and run its one-time setup."""
|
|
71
|
+
if any(type(a) is type(adapter) for a in _ADAPTERS):
|
|
72
|
+
return
|
|
73
|
+
adapter.setup()
|
|
74
|
+
_ADAPTERS.append(adapter)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def registered() -> list[FrameworkAdapter]:
|
|
78
|
+
return list(_ADAPTERS)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def owning_adapter(raw: ReadableSpan) -> FrameworkAdapter | None:
|
|
82
|
+
"""First registered adapter that claims this span, or None."""
|
|
83
|
+
for a in _ADAPTERS:
|
|
84
|
+
try:
|
|
85
|
+
if a.owns_span(raw):
|
|
86
|
+
return a
|
|
87
|
+
except Exception:
|
|
88
|
+
continue
|
|
89
|
+
return None
|