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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
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
+ ]
@@ -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