agentviz 0.3.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.
agentviz/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """
2
+ AgentViz — Real-time 3D visualization for multi-agent AI systems.
3
+
4
+ LangChain-style quick start
5
+ ----------------------------
6
+ import agentviz
7
+
8
+ # Option A: env vars (no code changes needed)
9
+ # export AGENTVIZ_SERVER=https://agentviz.yourdomain.com
10
+ # export AGENTVIZ_PROJECT=my-team
11
+
12
+ # Option B: one-line programmatic config
13
+ agentviz.init(server="https://agentviz.yourdomain.com", project="my-team")
14
+
15
+ # Decorate any function — async or sync
16
+ @agentviz.trace
17
+ async def fetch_data(query: str) -> str:
18
+ return await db.query(query)
19
+
20
+ @agentviz.trace(name="Planner", to="orchestrator", color="#9B59B6")
21
+ async def plan(goal: str) -> str:
22
+ return await llm.plan(goal)
23
+
24
+ # Auto-patch every installed framework at once
25
+ agentviz.instrument_all()
26
+
27
+ Manual SDK (full control)
28
+ --------------------------
29
+ from agentviz import AgentVizClient # WebSocket — local / same-network
30
+ from agentviz import HttpAgentVizClient # HTTP — cloud / serverless
31
+ """
32
+
33
+ # ── LangChain-style top-level API ─────────────────────────────────────────────
34
+ from .config import init, configure
35
+ from .tracing import trace, Agent, instrument_all
36
+
37
+ # ── Low-level SDK clients ─────────────────────────────────────────────────────
38
+ from .sdk import AgentVizClient
39
+ from .http_client import HttpAgentVizClient
40
+
41
+ __all__ = [
42
+ # LangChain-style
43
+ "init",
44
+ "configure",
45
+ "trace",
46
+ "Agent",
47
+ "instrument_all",
48
+ # SDK clients
49
+ "AgentVizClient",
50
+ "HttpAgentVizClient",
51
+ ]
52
+
53
+ __version__ = "0.3.0"
agentviz/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ # Allows: python -m agentviz serve
2
+ from agentviz.cli import cli
3
+
4
+ cli()
@@ -0,0 +1,322 @@
1
+ """
2
+ Standalone AgentViz server.
3
+
4
+ Used by `agentviz serve` (CLI) and can be imported directly:
5
+
6
+ from agentviz._server_app import create_app
7
+ app = create_app()
8
+
9
+ Room isolation
10
+ --------------
11
+ Every WebSocket / HTTP endpoint accepts an optional ?room= query parameter.
12
+ Agents and browser viewers in the same room share one 3D scene; different
13
+ rooms are completely isolated from each other.
14
+
15
+ This makes it safe to host one AgentViz instance on a public server (e.g.
16
+ Linode) and have multiple teams / projects use it simultaneously.
17
+
18
+ # Team A
19
+ AgentVizClient(server="wss://agentviz.example.com/agent-ws?room=team-a")
20
+
21
+ # Team B — sees nothing from Team A
22
+ AgentVizClient(server="wss://agentviz.example.com/agent-ws?room=team-b")
23
+
24
+ # Browser
25
+ http://agentviz.example.com?room=team-a
26
+ """
27
+
28
+ import asyncio
29
+ import json
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Optional
33
+
34
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query
35
+ from fastapi.middleware.cors import CORSMiddleware
36
+ from fastapi.responses import FileResponse, HTMLResponse
37
+ from fastapi.staticfiles import StaticFiles
38
+ from pydantic import BaseModel
39
+
40
+ from agentviz.recorder import SessionRecorder
41
+
42
+ STATIC_DIR = Path(__file__).parent / "static"
43
+
44
+ _CORE_SRC = Path(__file__).parent.parent / "packages" / "core" / "src"
45
+ _CORE_BUILT = STATIC_DIR / "agentviz-core"
46
+ CORE_DIR = _CORE_SRC if _CORE_SRC.exists() else (_CORE_BUILT if _CORE_BUILT.exists() else None)
47
+
48
+ DEFAULT_ROOM = "default"
49
+
50
+
51
+ def create_app() -> FastAPI:
52
+ app = FastAPI(title="AgentViz", version="0.2.0")
53
+ app.add_middleware(
54
+ CORSMiddleware,
55
+ allow_origins=["*"],
56
+ allow_methods=["*"],
57
+ allow_headers=["*"],
58
+ )
59
+
60
+ # ── Per-room state ─────────────────────────────────────────────────────────
61
+
62
+ class Hub:
63
+ def __init__(self):
64
+ self._conns: list[WebSocket] = []
65
+
66
+ async def connect(self, ws: WebSocket):
67
+ await ws.accept()
68
+ self._conns.append(ws)
69
+
70
+ def disconnect(self, ws: WebSocket):
71
+ if ws in self._conns:
72
+ self._conns.remove(ws)
73
+
74
+ async def broadcast(self, data: dict):
75
+ dead = []
76
+ for ws in list(self._conns):
77
+ try:
78
+ await ws.send_json(data)
79
+ except Exception:
80
+ dead.append(ws)
81
+ for ws in dead:
82
+ self.disconnect(ws)
83
+
84
+ def __len__(self):
85
+ return len(self._conns)
86
+
87
+ class AgentRegistry:
88
+ def __init__(self):
89
+ self._agents: dict[str, dict] = {}
90
+
91
+ def register(self, info: dict):
92
+ aid = info["id"]
93
+ self._agents[aid] = {
94
+ "id": aid,
95
+ "name": info.get("name", aid),
96
+ "color": info.get("color", "#4488ff"),
97
+ "role": info.get("role", "worker"),
98
+ "capabilities": info.get("capabilities", []),
99
+ "connected_at": time.time(),
100
+ }
101
+
102
+ def deregister(self, aid: str):
103
+ self._agents.pop(aid, None)
104
+
105
+ def snapshot(self) -> list[dict]:
106
+ return list(self._agents.values())
107
+
108
+ class Room:
109
+ """All state for one isolated project / team room."""
110
+
111
+ def __init__(self, room_id: str):
112
+ self.id = room_id
113
+ self.hub = Hub()
114
+ self.registry = AgentRegistry()
115
+ self.recorder = SessionRecorder()
116
+ self._last_event_ts: float = 0.0
117
+ self._end_task: Optional[asyncio.Task] = None
118
+
119
+ def touch_session(self) -> None:
120
+ self._last_event_ts = time.time()
121
+ if self.recorder._current_session_id is None:
122
+ self.recorder.start_session()
123
+ if self._end_task and not self._end_task.done():
124
+ self._end_task.cancel()
125
+ self._end_task = asyncio.ensure_future(self._idle_close())
126
+
127
+ async def _idle_close(self) -> None:
128
+ await asyncio.sleep(45)
129
+ if time.time() - self._last_event_ts >= 44:
130
+ self.recorder.end_session()
131
+
132
+ # ── Room manager ──────────────────────────────────────────────────────────
133
+
134
+ _rooms: dict[str, Room] = {}
135
+
136
+ def get_room(room_id: str) -> Room:
137
+ room_id = (room_id or DEFAULT_ROOM).strip() or DEFAULT_ROOM
138
+ if room_id not in _rooms:
139
+ _rooms[room_id] = Room(room_id)
140
+ return _rooms[room_id]
141
+
142
+ # ── WebSocket: browser viewer ─────────────────────────────────────────────
143
+
144
+ @app.websocket("/ws")
145
+ async def ws_frontend(ws: WebSocket, room: str = Query(default=DEFAULT_ROOM)):
146
+ r = get_room(room)
147
+ await r.hub.connect(ws)
148
+ if r.registry.snapshot():
149
+ await ws.send_json({"type": "registry_update", "agents": r.registry.snapshot()})
150
+ try:
151
+ while True:
152
+ await ws.receive_text()
153
+ except WebSocketDisconnect:
154
+ r.hub.disconnect(ws)
155
+
156
+ # ── WebSocket: SDK agents ─────────────────────────────────────────────────
157
+
158
+ @app.websocket("/agent-ws")
159
+ async def ws_agent(ws: WebSocket, room: str = Query(default=DEFAULT_ROOM)):
160
+ r = get_room(room)
161
+ await ws.accept()
162
+ agent_id: Optional[str] = None
163
+
164
+ try:
165
+ async for raw in ws.iter_text():
166
+ raw = raw.strip()
167
+ if not raw:
168
+ continue
169
+ try:
170
+ msg = json.loads(raw)
171
+ except json.JSONDecodeError:
172
+ continue
173
+
174
+ etype = msg.get("type", "")
175
+
176
+ if etype == "register":
177
+ agent_id = msg.get("id")
178
+ if agent_id:
179
+ r.registry.register(msg)
180
+ await r.hub.broadcast({
181
+ "type": "registry_update",
182
+ "agents": r.registry.snapshot(),
183
+ })
184
+
185
+ elif etype in ("call", "response", "thinking", "error", "complete"):
186
+ await r.hub.broadcast(msg)
187
+ r.touch_session()
188
+ r.recorder.record_event(msg)
189
+
190
+ except WebSocketDisconnect:
191
+ pass
192
+ finally:
193
+ if agent_id:
194
+ r.registry.deregister(agent_id)
195
+ await r.hub.broadcast({
196
+ "type": "registry_update",
197
+ "agents": r.registry.snapshot(),
198
+ })
199
+
200
+ # ── HTTP ingest (serverless / Lambda / @trace decorator) ──────────────────
201
+
202
+ class IngestPayload(BaseModel):
203
+ events: list[dict]
204
+
205
+ @app.post("/ingest")
206
+ async def ingest(payload: IngestPayload, room: str = Query(default=DEFAULT_ROOM)):
207
+ """
208
+ Batch-ingest AgentViz events over plain HTTP.
209
+ Used by HttpAgentVizClient and the @trace decorator.
210
+
211
+ Pass ?room=<project> to route events to the right isolated scene.
212
+ """
213
+ r = get_room(room)
214
+ ALLOWED = {"register", "call", "response", "thinking", "error", "complete", "token", "stream_end"}
215
+ accepted = 0
216
+
217
+ for msg in payload.events:
218
+ etype = msg.get("type", "")
219
+ if etype not in ALLOWED:
220
+ continue
221
+ if "timestamp" not in msg:
222
+ msg["timestamp"] = time.time()
223
+
224
+ if etype == "register":
225
+ agent_id = msg.get("id")
226
+ if agent_id:
227
+ r.registry.register(msg)
228
+ await r.hub.broadcast({
229
+ "type": "registry_update",
230
+ "agents": r.registry.snapshot(),
231
+ })
232
+
233
+ elif etype in ("call", "response", "thinking", "error", "complete", "token", "stream_end"):
234
+ await r.hub.broadcast(msg)
235
+ r.touch_session()
236
+ r.recorder.record_event(msg)
237
+
238
+ accepted += 1
239
+
240
+ return {"accepted": accepted, "total": len(payload.events), "room": room}
241
+
242
+ # ── Sessions ──────────────────────────────────────────────────────────────
243
+
244
+ @app.get("/sessions")
245
+ async def list_sessions(room: str = Query(default=DEFAULT_ROOM)):
246
+ return {"sessions": get_room(room).recorder.list_sessions(), "room": room}
247
+
248
+ @app.get("/sessions/{session_id}/events")
249
+ async def get_session_events(session_id: str, room: str = Query(default=DEFAULT_ROOM)):
250
+ return {"events": get_room(room).recorder.get_session_events(session_id)}
251
+
252
+ @app.post("/sessions/{session_id}/replay")
253
+ async def replay_session(session_id: str, room: str = Query(default=DEFAULT_ROOM)):
254
+ r = get_room(room)
255
+ events = r.recorder.get_session_events(session_id)
256
+ if not events:
257
+ raise HTTPException(status_code=404, detail="Session not found or empty")
258
+ asyncio.create_task(_replay(events, r.hub))
259
+ return {"status": "replaying", "event_count": len(events)}
260
+
261
+ @app.delete("/sessions/{session_id}")
262
+ async def delete_session(session_id: str, room: str = Query(default=DEFAULT_ROOM)):
263
+ get_room(room).recorder.delete_session(session_id)
264
+ return {"status": "deleted"}
265
+
266
+ # ── Health ────────────────────────────────────────────────────────────────
267
+
268
+ @app.get("/health")
269
+ async def health():
270
+ return {
271
+ "status": "ok",
272
+ "rooms": {
273
+ rid: {
274
+ "agents": len(r.registry.snapshot()),
275
+ "browsers": len(r.hub),
276
+ }
277
+ for rid, r in _rooms.items()
278
+ },
279
+ }
280
+
281
+ @app.get("/rooms")
282
+ async def list_rooms():
283
+ return {
284
+ "rooms": [
285
+ {
286
+ "id": rid,
287
+ "agents": len(r.registry.snapshot()),
288
+ "browsers": len(r.hub),
289
+ }
290
+ for rid, r in _rooms.items()
291
+ ]
292
+ }
293
+
294
+ # ── Static UI ─────────────────────────────────────────────────────────────
295
+
296
+ if CORE_DIR and CORE_DIR.exists():
297
+ app.mount("/static/agentviz-core", StaticFiles(directory=str(CORE_DIR)), name="core")
298
+
299
+ if STATIC_DIR.exists():
300
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
301
+
302
+ @app.get("/")
303
+ async def index():
304
+ index_html = STATIC_DIR / "index.html"
305
+ if index_html.exists():
306
+ return FileResponse(str(index_html))
307
+ return HTMLResponse("<h1>AgentViz server running</h1><p>Static UI not found.</p>")
308
+
309
+ return app
310
+
311
+
312
+ # ── Replay helper ──────────────────────────────────────────────────────────────
313
+
314
+ async def _replay(events: list[dict], hub):
315
+ await hub.broadcast({"type": "replay_start", "event_count": len(events)})
316
+ for i, ev in enumerate(events):
317
+ if i > 0:
318
+ delay = min((ev["timestamp"] - events[i - 1]["timestamp"]), 3.0)
319
+ if delay > 0:
320
+ await asyncio.sleep(delay)
321
+ await hub.broadcast(ev["data"])
322
+ await hub.broadcast({"type": "replay_end"})
agentviz/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ """
2
+ agentviz CLI
3
+
4
+ Usage:
5
+ agentviz serve [--host HOST] [--port PORT] [--no-browser]
6
+ agentviz --version
7
+ agentviz --help
8
+ """
9
+
10
+ import argparse
11
+ import sys
12
+ import threading
13
+ import time
14
+ import webbrowser
15
+
16
+
17
+ def cli():
18
+ parser = argparse.ArgumentParser(
19
+ prog="agentviz",
20
+ description="AgentViz — real-time 3D visualization for multi-agent AI systems",
21
+ )
22
+ parser.add_argument("--version", action="store_true", help="Print version and exit")
23
+
24
+ sub = parser.add_subparsers(dest="command")
25
+
26
+ serve_p = sub.add_parser("serve", help="Start the AgentViz visualization server")
27
+ serve_p.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
28
+ serve_p.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
29
+ serve_p.add_argument(
30
+ "--no-browser", action="store_true", help="Don't open browser automatically"
31
+ )
32
+ serve_p.add_argument(
33
+ "--demo", action="store_true",
34
+ help="Also start the demo orchestrator + agents on ports 8100-8103",
35
+ )
36
+
37
+ args = parser.parse_args()
38
+
39
+ if args.version:
40
+ from agentviz import __version__
41
+ print(f"agentviz {__version__}")
42
+ sys.exit(0)
43
+
44
+ if args.command == "serve":
45
+ _serve(args)
46
+ else:
47
+ parser.print_help()
48
+ sys.exit(1)
49
+
50
+
51
+ def _serve(args):
52
+ import uvicorn
53
+ from agentviz._server_app import create_app
54
+
55
+ app = create_app()
56
+
57
+ url = f"http://localhost:{args.port}"
58
+
59
+ if not args.no_browser:
60
+ # Open browser after a short delay so the server has time to bind
61
+ def _open():
62
+ time.sleep(1.2)
63
+ webbrowser.open(url)
64
+
65
+ threading.Thread(target=_open, daemon=True).start()
66
+
67
+ print(f"\n AgentViz v{_version()}")
68
+ print(f" Listening on {url}")
69
+ print(f"\n Connect your agents:")
70
+ print(f" Python SDK → ws://localhost:{args.port}/agent-ws")
71
+ print(f" JS SDK → ws://localhost:{args.port}/agent-ws")
72
+ print(f" HTTP ingest → POST http://localhost:{args.port}/ingest\n")
73
+
74
+ if args.demo:
75
+ _start_demo_processes()
76
+
77
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
78
+
79
+
80
+ def _start_demo_processes():
81
+ """Launch the demo orchestrator + agents as subprocesses."""
82
+ import subprocess
83
+ from pathlib import Path
84
+
85
+ agents_dir = Path(__file__).parent.parent / "agents"
86
+ if not agents_dir.exists():
87
+ print(" [demo] agents/ directory not found — skipping demo agents")
88
+ return
89
+
90
+ scripts = [
91
+ ("orchestrator", agents_dir / "orchestrator.py"),
92
+ ("alpha", agents_dir / "alpha_agent.py"),
93
+ ("beta", agents_dir / "beta_agent.py"),
94
+ ("gamma", agents_dir / "gamma_agent.py"),
95
+ ]
96
+
97
+ for name, script in scripts:
98
+ if script.exists():
99
+ subprocess.Popen(
100
+ [sys.executable, str(script)],
101
+ stdout=subprocess.DEVNULL,
102
+ stderr=subprocess.DEVNULL,
103
+ )
104
+ print(f" [demo] started {name}")
105
+
106
+
107
+ def _version():
108
+ try:
109
+ from agentviz import __version__
110
+ return __version__
111
+ except Exception:
112
+ return "?"
113
+
114
+
115
+ if __name__ == "__main__":
116
+ cli()
agentviz/config.py ADDED
@@ -0,0 +1,121 @@
1
+ """
2
+ AgentViz global configuration
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Reads from environment variables so no code changes are needed to enable
5
+ visualization — just set the env vars before running your agents.
6
+
7
+ Environment variables
8
+ ---------------------
9
+ AGENTVIZ_SERVER Base URL of the AgentViz server.
10
+ Default: http://localhost:8000
11
+ Example: https://agentviz.yourdomain.com
12
+
13
+ AGENTVIZ_PROJECT Project / room name. Each project gets its own isolated
14
+ 3D scene. Multiple agents in the same project share the
15
+ same visualization.
16
+ Default: "default"
17
+
18
+ AGENTVIZ_ENABLED Set to "false" to disable all telemetry without removing
19
+ @trace decorators from your code.
20
+ Default: "true"
21
+
22
+ Usage
23
+ -----
24
+ # Option A — env vars only (no code changes)
25
+ # export AGENTVIZ_SERVER=https://agentviz.yourdomain.com
26
+ # export AGENTVIZ_PROJECT=my-team
27
+
28
+ # Option B — programmatic (call before importing agents)
29
+ import agentviz
30
+ agentviz.init(server="https://agentviz.yourdomain.com", project="my-team")
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ from typing import Optional
37
+
38
+
39
+ # ── Defaults ─────────────────────────────────────────────────────────────────
40
+
41
+ _DEFAULTS = {
42
+ "server": "http://localhost:8000",
43
+ "project": "default",
44
+ "enabled": True,
45
+ }
46
+
47
+ # ── Live config dict (mutated by init / configure) ───────────────────────────
48
+
49
+ _cfg: dict = {
50
+ "server": os.getenv("AGENTVIZ_SERVER", _DEFAULTS["server"]),
51
+ "project": os.getenv("AGENTVIZ_PROJECT", _DEFAULTS["project"]),
52
+ "enabled": os.getenv("AGENTVIZ_ENABLED", "true").strip().lower() != "false",
53
+ }
54
+
55
+
56
+ # ── Public API ────────────────────────────────────────────────────────────────
57
+
58
+ def init(
59
+ server: Optional[str] = None,
60
+ project: Optional[str] = None,
61
+ enabled: Optional[bool] = None,
62
+ ) -> None:
63
+ """
64
+ Configure AgentViz once at the top of your program.
65
+
66
+ Parameters
67
+ ----------
68
+ server Base URL of the AgentViz server.
69
+ e.g. "https://agentviz.yourdomain.com"
70
+ project Project / room name. Agents with the same project appear in
71
+ the same 3D scene. Agents in different projects are isolated.
72
+ enabled Set False to silence all telemetry without removing decorators.
73
+ """
74
+ if server is not None: _cfg["server"] = server.rstrip("/")
75
+ if project is not None: _cfg["project"] = project
76
+ if enabled is not None: _cfg["enabled"] = enabled
77
+
78
+
79
+ # configure is an alias for init — same ergonomics as langsmith.configure()
80
+ configure = init
81
+
82
+
83
+ def get() -> dict:
84
+ """Return a snapshot of the current configuration."""
85
+ return dict(_cfg)
86
+
87
+
88
+ def is_enabled() -> bool:
89
+ return bool(_cfg["enabled"])
90
+
91
+
92
+ def server_url() -> str:
93
+ return _cfg["server"]
94
+
95
+
96
+ def project() -> str:
97
+ return _cfg["project"]
98
+
99
+
100
+ def ws_url() -> str:
101
+ """WebSocket URL derived from the base server URL."""
102
+ base = _cfg["server"]
103
+ # Normalise scheme
104
+ if base.startswith("https://"):
105
+ ws_base = "wss://" + base[len("https://"):]
106
+ elif base.startswith("http://"):
107
+ ws_base = "ws://" + base[len("http://"):]
108
+ else:
109
+ ws_base = base # already ws:// or wss://
110
+ return f"{ws_base}/agent-ws?room={_cfg['project']}"
111
+
112
+
113
+ def http_ingest_url() -> str:
114
+ """HTTP ingest URL derived from the base server URL."""
115
+ base = _cfg["server"]
116
+ # Normalise to http(s)
117
+ if base.startswith("wss://"):
118
+ base = "https://" + base[len("wss://"):]
119
+ elif base.startswith("ws://"):
120
+ base = "http://" + base[len("ws://"):]
121
+ return f"{base}/ingest?room={_cfg['project']}"