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.
- aeyeagent-0.1.0/.gitignore +18 -0
- aeyeagent-0.1.0/.python-version +1 -0
- aeyeagent-0.1.0/Makefile +47 -0
- aeyeagent-0.1.0/PKG-INFO +13 -0
- aeyeagent-0.1.0/README.md +236 -0
- aeyeagent-0.1.0/aeyeagent/__init__.py +201 -0
- aeyeagent-0.1.0/backend/__init__.py +0 -0
- aeyeagent-0.1.0/backend/adapters/__init__.py +23 -0
- aeyeagent-0.1.0/backend/adapters/base.py +89 -0
- aeyeagent-0.1.0/backend/adapters/pydantic_ai.py +348 -0
- aeyeagent-0.1.0/backend/collector.py +111 -0
- aeyeagent-0.1.0/backend/config.py +19 -0
- aeyeagent-0.1.0/backend/demo_agent.py +236 -0
- aeyeagent-0.1.0/backend/graph.py +432 -0
- aeyeagent-0.1.0/backend/main.py +59 -0
- aeyeagent-0.1.0/backend/models.py +94 -0
- aeyeagent-0.1.0/backend/routers/__init__.py +1 -0
- aeyeagent-0.1.0/backend/routers/health.py +12 -0
- aeyeagent-0.1.0/backend/routers/layout.py +26 -0
- aeyeagent-0.1.0/backend/routers/runs.py +39 -0
- aeyeagent-0.1.0/backend/routers/topology.py +21 -0
- aeyeagent-0.1.0/backend/store.py +579 -0
- aeyeagent-0.1.0/debug_introspect.py +86 -0
- aeyeagent-0.1.0/frontend/index.html +15 -0
- aeyeagent-0.1.0/frontend/package-lock.json +3009 -0
- aeyeagent-0.1.0/frontend/package.json +29 -0
- aeyeagent-0.1.0/frontend/postcss.config.js +6 -0
- aeyeagent-0.1.0/frontend/public/favicon.png +0 -0
- aeyeagent-0.1.0/frontend/src/App.tsx +36 -0
- aeyeagent-0.1.0/frontend/src/components/AgentNode.tsx +97 -0
- aeyeagent-0.1.0/frontend/src/components/DelegationEdge.tsx +79 -0
- aeyeagent-0.1.0/frontend/src/components/FloatingEdge.tsx +95 -0
- aeyeagent-0.1.0/frontend/src/components/GraphCanvas.tsx +205 -0
- aeyeagent-0.1.0/frontend/src/components/InspectorPanel.tsx +287 -0
- aeyeagent-0.1.0/frontend/src/components/RunSelector.tsx +272 -0
- aeyeagent-0.1.0/frontend/src/components/Timeline.tsx +501 -0
- aeyeagent-0.1.0/frontend/src/components/ToolNode.tsx +94 -0
- aeyeagent-0.1.0/frontend/src/components/Tooltip.tsx +47 -0
- aeyeagent-0.1.0/frontend/src/components/TopologyAgentNode.tsx +194 -0
- aeyeagent-0.1.0/frontend/src/components/UnnamedAgentsAlert.tsx +148 -0
- aeyeagent-0.1.0/frontend/src/index.css +171 -0
- aeyeagent-0.1.0/frontend/src/lib/api.ts +108 -0
- aeyeagent-0.1.0/frontend/src/main.tsx +10 -0
- aeyeagent-0.1.0/frontend/src/store/useTraceStore.ts +181 -0
- aeyeagent-0.1.0/frontend/tailwind.config.ts +13 -0
- aeyeagent-0.1.0/frontend/tsconfig.json +20 -0
- aeyeagent-0.1.0/frontend/vite.config.ts +25 -0
- aeyeagent-0.1.0/main.py +6 -0
- aeyeagent-0.1.0/pyproject.toml +29 -0
- aeyeagent-0.1.0/tests/README.md +41 -0
- aeyeagent-0.1.0/tests/__init__.py +6 -0
- aeyeagent-0.1.0/tests/_harness.py +81 -0
- aeyeagent-0.1.0/tests/_models.py +42 -0
- aeyeagent-0.1.0/tests/aeyeagent_test.db +0 -0
- aeyeagent-0.1.0/tests/run_all.py +40 -0
- aeyeagent-0.1.0/tests/scenarios/__init__.py +16 -0
- aeyeagent-0.1.0/tests/scenarios/a_shapes.py +64 -0
- aeyeagent-0.1.0/tests/scenarios/b_tools.py +69 -0
- aeyeagent-0.1.0/tests/scenarios/c_subagents.py +78 -0
- aeyeagent-0.1.0/tests/scenarios/d_multiagent.py +28 -0
- aeyeagent-0.1.0/tests/scenarios/e_outcomes.py +60 -0
- aeyeagent-0.1.0/tests/scenarios/f_rename.py +38 -0
- aeyeagent-0.1.0/uv.lock +3428 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
aeyeagent-0.1.0/Makefile
ADDED
|
@@ -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"
|
aeyeagent-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://python.org)
|
|
6
|
+
[](https://docs.pydantic.dev/ai)
|
|
7
|
+
[](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
|