aeyeagent 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.
Files changed (63) hide show
  1. aeyeagent-0.1.0/.gitignore +18 -0
  2. aeyeagent-0.1.0/.python-version +1 -0
  3. aeyeagent-0.1.0/Makefile +47 -0
  4. aeyeagent-0.1.0/PKG-INFO +13 -0
  5. aeyeagent-0.1.0/README.md +236 -0
  6. aeyeagent-0.1.0/aeyeagent/__init__.py +201 -0
  7. aeyeagent-0.1.0/backend/__init__.py +0 -0
  8. aeyeagent-0.1.0/backend/adapters/__init__.py +23 -0
  9. aeyeagent-0.1.0/backend/adapters/base.py +89 -0
  10. aeyeagent-0.1.0/backend/adapters/pydantic_ai.py +348 -0
  11. aeyeagent-0.1.0/backend/collector.py +111 -0
  12. aeyeagent-0.1.0/backend/config.py +19 -0
  13. aeyeagent-0.1.0/backend/demo_agent.py +236 -0
  14. aeyeagent-0.1.0/backend/graph.py +432 -0
  15. aeyeagent-0.1.0/backend/main.py +59 -0
  16. aeyeagent-0.1.0/backend/models.py +94 -0
  17. aeyeagent-0.1.0/backend/routers/__init__.py +1 -0
  18. aeyeagent-0.1.0/backend/routers/health.py +12 -0
  19. aeyeagent-0.1.0/backend/routers/layout.py +26 -0
  20. aeyeagent-0.1.0/backend/routers/runs.py +39 -0
  21. aeyeagent-0.1.0/backend/routers/topology.py +21 -0
  22. aeyeagent-0.1.0/backend/store.py +579 -0
  23. aeyeagent-0.1.0/debug_introspect.py +86 -0
  24. aeyeagent-0.1.0/frontend/index.html +15 -0
  25. aeyeagent-0.1.0/frontend/package-lock.json +3009 -0
  26. aeyeagent-0.1.0/frontend/package.json +29 -0
  27. aeyeagent-0.1.0/frontend/postcss.config.js +6 -0
  28. aeyeagent-0.1.0/frontend/public/favicon.png +0 -0
  29. aeyeagent-0.1.0/frontend/src/App.tsx +36 -0
  30. aeyeagent-0.1.0/frontend/src/components/AgentNode.tsx +97 -0
  31. aeyeagent-0.1.0/frontend/src/components/DelegationEdge.tsx +79 -0
  32. aeyeagent-0.1.0/frontend/src/components/FloatingEdge.tsx +95 -0
  33. aeyeagent-0.1.0/frontend/src/components/GraphCanvas.tsx +205 -0
  34. aeyeagent-0.1.0/frontend/src/components/InspectorPanel.tsx +287 -0
  35. aeyeagent-0.1.0/frontend/src/components/RunSelector.tsx +272 -0
  36. aeyeagent-0.1.0/frontend/src/components/Timeline.tsx +501 -0
  37. aeyeagent-0.1.0/frontend/src/components/ToolNode.tsx +94 -0
  38. aeyeagent-0.1.0/frontend/src/components/Tooltip.tsx +47 -0
  39. aeyeagent-0.1.0/frontend/src/components/TopologyAgentNode.tsx +194 -0
  40. aeyeagent-0.1.0/frontend/src/components/UnnamedAgentsAlert.tsx +148 -0
  41. aeyeagent-0.1.0/frontend/src/index.css +171 -0
  42. aeyeagent-0.1.0/frontend/src/lib/api.ts +108 -0
  43. aeyeagent-0.1.0/frontend/src/main.tsx +10 -0
  44. aeyeagent-0.1.0/frontend/src/store/useTraceStore.ts +181 -0
  45. aeyeagent-0.1.0/frontend/tailwind.config.ts +13 -0
  46. aeyeagent-0.1.0/frontend/tsconfig.json +20 -0
  47. aeyeagent-0.1.0/frontend/vite.config.ts +25 -0
  48. aeyeagent-0.1.0/main.py +6 -0
  49. aeyeagent-0.1.0/pyproject.toml +29 -0
  50. aeyeagent-0.1.0/tests/README.md +41 -0
  51. aeyeagent-0.1.0/tests/__init__.py +6 -0
  52. aeyeagent-0.1.0/tests/_harness.py +81 -0
  53. aeyeagent-0.1.0/tests/_models.py +42 -0
  54. aeyeagent-0.1.0/tests/aeyeagent_test.db +0 -0
  55. aeyeagent-0.1.0/tests/run_all.py +40 -0
  56. aeyeagent-0.1.0/tests/scenarios/__init__.py +16 -0
  57. aeyeagent-0.1.0/tests/scenarios/a_shapes.py +64 -0
  58. aeyeagent-0.1.0/tests/scenarios/b_tools.py +69 -0
  59. aeyeagent-0.1.0/tests/scenarios/c_subagents.py +78 -0
  60. aeyeagent-0.1.0/tests/scenarios/d_multiagent.py +28 -0
  61. aeyeagent-0.1.0/tests/scenarios/e_outcomes.py +60 -0
  62. aeyeagent-0.1.0/tests/scenarios/f_rename.py +38 -0
  63. aeyeagent-0.1.0/uv.lock +3428 -0
@@ -0,0 +1,18 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Database
13
+ aeyeagent.db
14
+
15
+ # Node
16
+ frontend/node_modules/
17
+ frontend/dist/
18
+ backend/static/
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,47 @@
1
+ .PHONY: dev backend frontend install seed test test-backend test-reset build package
2
+
3
+ # Isolated DB for the end-to-end test harness (keeps real traces untouched)
4
+ TESTDB ?= $(PWD)/tests/aeyeagent_test.db
5
+
6
+ # Start both servers concurrently (Ctrl+C kills both)
7
+ dev:
8
+ @make -j2 backend frontend
9
+
10
+ # Backend — FastAPI on :7891 with auto-reload
11
+ backend:
12
+ uv run uvicorn backend.main:app --port 7891 --reload
13
+
14
+ # Frontend — Vite dev server on :5173
15
+ frontend:
16
+ cd frontend && npm run dev -- --port 8008
17
+
18
+ # Install all deps (Python + Node)
19
+ install:
20
+ uv sync
21
+ cd frontend && npm install
22
+
23
+ # Build the React frontend into backend/static/ for bundled pip distribution
24
+ build:
25
+ cd frontend && npm install && npm run build
26
+
27
+ # Build a distributable wheel (run `make build` first)
28
+ package: build
29
+ uv build
30
+
31
+ # Seed the DB with demo traces
32
+ seed:
33
+ uv run python -m backend.demo_agent
34
+
35
+ # ── End-to-end test harness ──────────────────────────────────────────────────
36
+ # 1) make test-backend → dashboard API on :7891 against the isolated test DB
37
+ # 2) make frontend → UI on :5173
38
+ # 3) make test → run every agent scenario into that DB, then refresh UI
39
+ test-backend:
40
+ AEYEAGENT_DB="$(TESTDB)" uv run uvicorn backend.main:app --port 7891 --reload
41
+
42
+ test:
43
+ AEYEAGENT_DB="$(TESTDB)" uv run python -m tests.run_all
44
+
45
+ # Wipe the test DB for a clean slate
46
+ test-reset:
47
+ rm -f "$(TESTDB)" "$(TESTDB)-wal" "$(TESTDB)-shm"
@@ -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,236 @@
1
+ # AeyeAgent
2
+
3
+ > **Visual debugger and observability dashboard for PydanticAI agent pipelines**
4
+
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://python.org)
6
+ [![PydanticAI 1.x](https://img.shields.io/badge/pydantic--ai-1.x-purple.svg)](https://docs.pydantic.dev/ai)
7
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
8
+
9
+ AeyeAgent instruments your PydanticAI agents via OpenTelemetry, stores every span locally in SQLite, and serves a React dashboard at `http://localhost:7891`. Zero config. Nothing leaves your machine.
10
+
11
+ ---
12
+
13
+ ## Features
14
+
15
+ - **Run Graph** — React Flow graph of each run: agent node, tool nodes clustered by toolset, animated data-flow edges, sub-agent delegation chains
16
+ - **Topology View** — full architecture map of all agents and tools ever seen across all runs; unused nodes dimmed
17
+ - **Timeline** — per-run causal story: `INPUT → [agent band + named tool pills] → OUTPUT`. Tool pills sized by real duration, parallel tool calls stacked on separate rows. Scroll to zoom, scroll left/right to pan.
18
+ - **Inspector Panel** — click any node to open a right-side panel with all spans for that node, plus a **Tool Calls** section listing every tool's input/output (expandable, Text/JSON toggle) when viewing an agent
19
+ - **Conversation Grouping** — multi-turn runs sharing a `message_history` are grouped in the run selector (Turn 1 → Turn N), so you can trace a full conversation, not just isolated runs
20
+ - **Error Highlighting** — red glow on failing nodes and edges; `ERROR` output cap on the timeline
21
+ - **Toolset Clustering** — tools from the same `FunctionToolset` share a color across graph, timeline, and inspector
22
+ - **Light / Dark Mode** — one-click toggle
23
+ - **Live Runs** — auto-refreshes every 5s; animated live indicator for in-progress runs
24
+ - **Persistent Layouts** — node positions dragged on the graph are saved to DB per agent
25
+ - **Local-first** — all data in `~/.aeyeagent/aeyeagent.db`; no cloud, no external service
26
+
27
+ ---
28
+
29
+ ## Quick Start
30
+
31
+ ```bash
32
+ pip install aeyeagent
33
+ ```
34
+
35
+ ```python
36
+ # At the top of your script, before any agents are defined or run:
37
+ from aeyeagent import instrument_pydantic_ai
38
+ instrument_pydantic_ai()
39
+
40
+ from pydantic_ai import Agent
41
+
42
+ agent = Agent("openai:gpt-4o", name="my_agent")
43
+ result = agent.run_sync("Hello!")
44
+ # → AeyeAgent dashboard: http://localhost:7891
45
+ ```
46
+
47
+ > **Using Logfire or another OTEL tool?**
48
+ > Call `instrument_pydantic_ai()` **after** any other OpenTelemetry setup. AeyeAgent detects an existing `TracerProvider` and attaches to it rather than replacing it, so both tools capture spans side-by-side without conflict.
49
+ >
50
+ > ```python
51
+ > import logfire
52
+ > logfire.configure() # set up Logfire first
53
+ >
54
+ > from aeyeagent import instrument_pydantic_ai
55
+ > instrument_pydantic_ai() # attaches to Logfire's existing provider
56
+ > ```
57
+
58
+ **Always name your agents.** AeyeAgent auto-generates a name if you omit one (e.g. `str_a3f2`), but an explicit name keeps the dashboard readable and run history consistent:
59
+
60
+ ```python
61
+ agent = Agent("openai:gpt-4o", name="onboarding_agent")
62
+ ```
63
+
64
+ ---
65
+
66
+ ## How It Works
67
+
68
+ ```
69
+ Your App
70
+ └─ instrument_pydantic_ai()
71
+ ├─ OTEL SDK → AeyeAgentExporter → SQLite (~/.aeyeagent/aeyeagent.db)
72
+ └─ Background scanner (every 5s) → topology discovery
73
+ Dashboard (http://localhost:7891)
74
+ └─ FastAPI backend ←→ React + React Flow frontend
75
+ ```
76
+
77
+ - **Agent naming** — monkeypatches `pydantic_ai.Agent.__init__` to assign a deterministic name to unnamed agents at construction time, keeping topology and run views consistent
78
+ - **Span classification** — OTEL spans are classified by `gen_ai.operation.name`: `invoke_agent` → agent, `execute_tool` → tool, `chat` → model
79
+ - **Run splitting** — one OTEL trace may contain multiple independent top-level agents (e.g. siblings under a Logfire request span); AeyeAgent splits them into separate logical runs automatically
80
+ - **Conversation grouping** — PydanticAI's `gen_ai.conversation.id` (shared across runs that reuse `message_history`) is read from span attributes and used to group multi-turn conversations in the run selector
81
+
82
+ ---
83
+
84
+ ## Dashboard Walkthrough
85
+
86
+ ### Top Bar
87
+
88
+ - **Run selector** — dropdown grouped by conversation. Multi-turn runs appear as `AgentName · 14:03:21 · 3 turns` with each turn listed as `Turn 1 · 14:03 (12 spans)`. Includes a live indicator for in-progress runs.
89
+ - **Show Topology** — switch to the full architecture overview
90
+ - **Refresh / Theme toggle** — manual re-fetch; light/dark switch
91
+
92
+ ### Run View (Graph)
93
+
94
+ - **Agent node** at center — label, model name, LLM call count; click to open the inspector
95
+ - **Tool nodes** around it, clustered and color-coded by `FunctionToolset`
96
+ - **Animated dashed edges** — marching dashes show data flowing tool → agent
97
+ - **Dimmed nodes/edges** — tools wired in topology but not called in this run appear faded
98
+ - **Error state** — failing node gets a red border + glow; edge leading into it glows red
99
+
100
+ ### Topology View
101
+
102
+ All agents and tools seen across every run, laid out as an architecture diagram. Used nodes are fully opaque; unused are dimmed. Click **Show Run View** to return.
103
+
104
+ ### Timeline Strip
105
+
106
+ The strip at the bottom is a **causal story**, not a raw span dump. It reads left-to-right:
107
+
108
+ ```
109
+ INPUT ┌─ AgentName · 112ms ───────────────────────────────────┐ OUTPUT
110
+ "..." │ ▢ tool_a ▢ tool_b (parallel calls, row-stacked) │ "result"
111
+ └───────────────────────────────────────────────────────┘
112
+ ```
113
+
114
+ - **INPUT cap** (far left) — the prompt that started the run. Hover → preview; click → agent in inspector.
115
+ - **Agent band** — translucent band spanning the agent's full duration. Sub-agents nest as indented sub-bands with a shared time axis, so you can see delegation and where a sub-agent failed.
116
+ - **Tool pills** — the primary events. Named on the pill, colored by toolset (matching graph nodes), sized by actual duration. Concurrent calls stack on separate rows — parallelism is visible at a glance.
117
+ - **OUTPUT cap** (far right) — the final result. Green = success; turns red and shows `ERROR` on failure.
118
+ - **Scroll up/down** → zoom in/out (cursor-anchored)
119
+ - **Scroll left/right** → pan when zoomed
120
+ - **Hover any element** → peek card with input, output, duration
121
+ - **Click any pill or band** → selects that node in the inspector
122
+
123
+ ### Inspector Panel
124
+
125
+ Opens on the right when a node is selected:
126
+
127
+ - **Stats** — call count, error count, avg latency, total time
128
+ - **SPANS** — this node's raw spans with full INPUT/OUTPUT (click to expand; Text/JSON toggle when value is valid JSON)
129
+ - **TOOL CALLS** *(agent nodes only)* — every tool called in this run, in order, each with expandable INPUT/OUTPUT and Text/JSON toggle. See all tool I/O in one place without clicking node-by-node.
130
+
131
+ ---
132
+
133
+ ## Configuration
134
+
135
+ ```python
136
+ instrument_pydantic_ai(
137
+ port=7891, # dashboard port (auto-increments if taken)
138
+ db_path="~/.aeyeagent/aeyeagent.db", # or set AEYEAGENT_DB env var
139
+ open_browser=False, # open the dashboard automatically on start
140
+ )
141
+ ```
142
+
143
+ | Option | Default | Description |
144
+ |--------|---------|-------------|
145
+ | `port` | `7891` | Dashboard port; finds next free port if already in use |
146
+ | `db_path` | `~/.aeyeagent/aeyeagent.db` | SQLite file path |
147
+ | `open_browser` | `False` | Open `http://localhost:<port>` in browser on startup |
148
+
149
+ **Environment variable:** `AEYEAGENT_DB=/path/to/file.db` overrides `db_path`.
150
+
151
+ ---
152
+
153
+ ## Extending to Other Frameworks
154
+
155
+ AeyeAgent's capture layer is built around a `FrameworkAdapter` interface. Adding support for LangChain, CrewAI, or any other framework means creating one file — no edits to shared code.
156
+
157
+ ```python
158
+ # backend/adapters/my_framework.py
159
+ from backend.adapters.base import FrameworkAdapter
160
+
161
+ class MyFrameworkAdapter(FrameworkAdapter):
162
+ name = "my_framework"
163
+
164
+ def owns_span(self, raw) -> bool:
165
+ # Return True only for spans emitted by this framework
166
+ ...
167
+
168
+ def parse_span(self, trace_id: str, raw):
169
+ # Convert a ReadableSpan → Span model
170
+ ...
171
+
172
+ def discover_topology(self) -> dict[str, dict]:
173
+ # Introspect live objects to build the agent/tool map
174
+ ...
175
+
176
+ # aeyeagent/__init__.py
177
+ def instrument_my_framework(**kwargs):
178
+ from backend.adapters import register
179
+ register(MyFrameworkAdapter())
180
+ ...
181
+ ```
182
+
183
+ See `backend/adapters/pydantic_ai.py` as the reference implementation.
184
+
185
+ ---
186
+
187
+ ## Development Setup
188
+
189
+ **Requirements:** Python 3.10+, Node 18+, [`uv`](https://github.com/astral-sh/uv) (recommended) or `pip`
190
+
191
+ ```bash
192
+ git clone https://github.com/emumba/aeyeagent
193
+ cd AeyeAgent
194
+
195
+ # Python deps
196
+ uv sync
197
+
198
+ # Backend (port 7891)
199
+ uvicorn backend.main:app --reload --port 7891
200
+
201
+ # Frontend (separate terminal) — proxies API calls to :7891
202
+ cd frontend && npm install && npm run dev
203
+ # → http://localhost:5173
204
+ ```
205
+
206
+ **Test harness** — 24 end-to-end scenarios covering basic runs, tool chains, multi-toolset agents, sub-agent delegation, error handling, and agent renaming:
207
+
208
+ ```bash
209
+ make test # run all scenarios against an isolated test DB
210
+ make test-reset # wipe test DB and re-run
211
+ make test-backend # start backend pointed at the test DB
212
+ ```
213
+
214
+ Frontend type check:
215
+
216
+ ```bash
217
+ cd frontend && npx tsc --noEmit
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Contributing
223
+
224
+ - Fork → branch → PR
225
+ - **New framework adapter** — implement `FrameworkAdapter`, register it, add scenarios in `tests/scenarios/`
226
+ - **Frontend changes** — `npx tsc --noEmit` must pass before submitting
227
+
228
+ ---
229
+
230
+ ## Roadmap
231
+
232
+ - [ ] LangChain adapter
233
+ - [ ] CrewAI adapter
234
+ - [ ] Span search and filter in the timeline
235
+ - [ ] Export run as JSON / HTML report
236
+ - [ ] Multi-machine / shared DB mode
@@ -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
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