visualagents 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.
- visualagents/__init__.py +20 -0
- visualagents/__main__.py +3 -0
- visualagents/_autoinstrument/__init__.py +0 -0
- visualagents/_autoinstrument/sitecustomize.py +40 -0
- visualagents/_autoinstrument/visualagents_runtime.py +864 -0
- visualagents/_hook/visualagents_boot.py +42 -0
- visualagents/blueprint.py +276 -0
- visualagents/cli.py +726 -0
- visualagents/collector.py +146 -0
- visualagents/demo/__init__.py +0 -0
- visualagents/demo/blueprint.json +68 -0
- visualagents/demo/research_crew.py +146 -0
- visualagents/detect.py +337 -0
- visualagents/home.py +139 -0
- visualagents/hooks.py +63 -0
- visualagents/hub.py +143 -0
- visualagents/launcher.py +87 -0
- visualagents/llm.py +314 -0
- visualagents/narrator.py +58 -0
- visualagents/patterns.py +81 -0
- visualagents/projects.py +140 -0
- visualagents/runner.py +355 -0
- visualagents/scanner.py +91 -0
- visualagents/sdk.py +18 -0
- visualagents/server.py +209 -0
- visualagents/static_scan.py +677 -0
- visualagents/termui.py +94 -0
- visualagents/tracker.py +501 -0
- visualagents/tui.py +240 -0
- visualagents/web/app.js +1753 -0
- visualagents/web/index.html +117 -0
- visualagents/web/styles.css +551 -0
- visualagents/wizard.py +331 -0
- visualagents-0.1.0.dist-info/METADATA +438 -0
- visualagents-0.1.0.dist-info/RECORD +38 -0
- visualagents-0.1.0.dist-info/WHEEL +4 -0
- visualagents-0.1.0.dist-info/entry_points.txt +3 -0
- visualagents-0.1.0.dist-info/licenses/LICENSE +21 -0
visualagents/server.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Local web server: serves the dashboard and streams live state over Server-Sent Events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
from http import HTTPStatus
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qs, urlparse
|
|
13
|
+
|
|
14
|
+
from .hub import Hub
|
|
15
|
+
from .projects import Projects
|
|
16
|
+
|
|
17
|
+
_TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
18
|
+
".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _asset(name: str) -> bytes | None:
|
|
22
|
+
if "/" in name or "\\" in name or name.startswith("."):
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return (resources.files("visualagents") / "web" / name).read_bytes()
|
|
26
|
+
except (FileNotFoundError, OSError):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
31
|
+
projects: Projects
|
|
32
|
+
server_version = "VisualAgents"
|
|
33
|
+
protocol_version = "HTTP/1.1"
|
|
34
|
+
|
|
35
|
+
def log_message(self, fmt: str, *args: Any) -> None: # keep the terminal clean for the agent's output
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
# -- helpers -----------------------------------------------------------
|
|
39
|
+
def _send(self, status: int, body: bytes, ctype: str = "application/json", extra: dict[str, str] | None = None) -> None:
|
|
40
|
+
self.send_response(status)
|
|
41
|
+
self.send_header("Content-Type", ctype)
|
|
42
|
+
self.send_header("Content-Length", str(len(body)))
|
|
43
|
+
self.send_header("Cache-Control", "no-store")
|
|
44
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
45
|
+
for k, v in (extra or {}).items():
|
|
46
|
+
self.send_header(k, v)
|
|
47
|
+
self.end_headers()
|
|
48
|
+
if self.command != "HEAD":
|
|
49
|
+
self.wfile.write(body)
|
|
50
|
+
|
|
51
|
+
def _json(self, obj: Any, status: int = 200) -> None:
|
|
52
|
+
self._send(status, json.dumps(obj, ensure_ascii=False).encode("utf-8"))
|
|
53
|
+
|
|
54
|
+
def _hub(self, url) -> Hub:
|
|
55
|
+
return self.projects.resolve(parse_qs(url.query).get("project", [None])[0])
|
|
56
|
+
|
|
57
|
+
def _same_origin(self) -> bool:
|
|
58
|
+
origin = self.headers.get("Origin")
|
|
59
|
+
return origin is None or urlparse(origin).netloc == self.headers.get("Host")
|
|
60
|
+
|
|
61
|
+
# -- routes ------------------------------------------------------------
|
|
62
|
+
def do_HEAD(self) -> None: # noqa: N802
|
|
63
|
+
self.do_GET()
|
|
64
|
+
|
|
65
|
+
def do_GET(self) -> None: # noqa: N802
|
|
66
|
+
url = urlparse(self.path)
|
|
67
|
+
path = url.path
|
|
68
|
+
if path in ("/", "/index.html"):
|
|
69
|
+
body = _asset("index.html") or b"missing index.html"
|
|
70
|
+
self._send(200, body, _TYPES[".html"])
|
|
71
|
+
elif path.startswith("/assets/"):
|
|
72
|
+
name = path[len("/assets/"):]
|
|
73
|
+
body = _asset(name)
|
|
74
|
+
if body is None:
|
|
75
|
+
self._send(404, b"not found", "text/plain")
|
|
76
|
+
else:
|
|
77
|
+
ext = name[name.rfind("."):]
|
|
78
|
+
self._send(200, body, _TYPES.get(ext, "application/octet-stream"))
|
|
79
|
+
elif path == "/api/state":
|
|
80
|
+
hub = self._hub(url)
|
|
81
|
+
snap = hub.snapshot()
|
|
82
|
+
snap["meta"]["daemon"] = self.projects.daemon
|
|
83
|
+
self._json(snap)
|
|
84
|
+
elif path == "/api/projects":
|
|
85
|
+
self._json({"daemon": self.projects.daemon, "projects": self.projects.listing()})
|
|
86
|
+
elif path == "/api/blueprint":
|
|
87
|
+
self._json(self._hub(url).blueprint)
|
|
88
|
+
elif path == "/api/stream":
|
|
89
|
+
self._stream(url)
|
|
90
|
+
elif path == "/favicon.ico":
|
|
91
|
+
self._send(204, b"", "image/x-icon")
|
|
92
|
+
else:
|
|
93
|
+
self._send(404, b"not found", "text/plain")
|
|
94
|
+
|
|
95
|
+
def do_POST(self) -> None: # noqa: N802
|
|
96
|
+
path = urlparse(self.path).path
|
|
97
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
98
|
+
raw = self.rfile.read(min(length, 8_000_000)) if length else b""
|
|
99
|
+
if path == "/api/ingest":
|
|
100
|
+
if self.headers.get("X-VisualAgents-Token") != self.projects.token:
|
|
101
|
+
self._json({"error": "bad token"}, HTTPStatus.FORBIDDEN)
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
payload = json.loads(raw or b"null")
|
|
105
|
+
except ValueError:
|
|
106
|
+
self._json({"error": "invalid json"}, HTTPStatus.BAD_REQUEST)
|
|
107
|
+
return
|
|
108
|
+
items = payload if isinstance(payload, list) else [payload]
|
|
109
|
+
for item in items:
|
|
110
|
+
if not isinstance(item, dict):
|
|
111
|
+
continue
|
|
112
|
+
hub = self.projects.for_item(item)
|
|
113
|
+
if item.get("type") == "hello": # a project was just hooked: the hub now exists
|
|
114
|
+
continue
|
|
115
|
+
if item.get("type") == "line":
|
|
116
|
+
hub.feed_line(str(item.get("text", "")), str(item.get("stream", "stdout")))
|
|
117
|
+
else:
|
|
118
|
+
hub.feed_structured(item)
|
|
119
|
+
self._json({"ok": True, "count": len(items)})
|
|
120
|
+
elif path == "/api/start":
|
|
121
|
+
if not self._same_origin():
|
|
122
|
+
self._json({"error": "cross-origin"}, HTTPStatus.FORBIDDEN)
|
|
123
|
+
return
|
|
124
|
+
hub = self._hub(urlparse(self.path))
|
|
125
|
+
if hub.launcher is None:
|
|
126
|
+
self._json({"ok": False, "message": "Start it the way you usually do - it shows up here."})
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
body = json.loads(raw or b"{}")
|
|
130
|
+
except ValueError:
|
|
131
|
+
body = {}
|
|
132
|
+
task = str(body.get("task") or "").strip() or None
|
|
133
|
+
ok, message = hub.launcher.start(task)
|
|
134
|
+
self._json({"ok": ok, "message": message, "launch": hub.launcher.info()})
|
|
135
|
+
elif path == "/api/reset":
|
|
136
|
+
if not self._same_origin():
|
|
137
|
+
self._json({"error": "cross-origin"}, HTTPStatus.FORBIDDEN)
|
|
138
|
+
return
|
|
139
|
+
self._hub(urlparse(self.path)).reset_view()
|
|
140
|
+
self._json({"ok": True})
|
|
141
|
+
else:
|
|
142
|
+
self._send(404, b"not found", "text/plain")
|
|
143
|
+
|
|
144
|
+
def _stream(self, url) -> None:
|
|
145
|
+
q = parse_qs(url.query)
|
|
146
|
+
try:
|
|
147
|
+
last_seq = int(self.headers.get("Last-Event-ID") or q.get("since", ["0"])[0])
|
|
148
|
+
except ValueError:
|
|
149
|
+
last_seq = 0
|
|
150
|
+
self.send_response(200)
|
|
151
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
152
|
+
self.send_header("Cache-Control", "no-store")
|
|
153
|
+
self.send_header("Connection", "keep-alive")
|
|
154
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
155
|
+
self.end_headers()
|
|
156
|
+
self.close_connection = True
|
|
157
|
+
version = -1
|
|
158
|
+
hub = self._hub(url)
|
|
159
|
+
try:
|
|
160
|
+
self.wfile.write(b"retry: 1500\n\n")
|
|
161
|
+
while True:
|
|
162
|
+
events, state, version = hub.wait(last_seq, version, timeout=15.0)
|
|
163
|
+
chunks = []
|
|
164
|
+
for ev in events:
|
|
165
|
+
last_seq = ev["seq"]
|
|
166
|
+
chunks.append(f"id: {ev['seq']}\nevent: ev\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n")
|
|
167
|
+
if state is not None:
|
|
168
|
+
chunks.append(f"event: state\ndata: {json.dumps(state, ensure_ascii=False)}\n\n")
|
|
169
|
+
if not chunks:
|
|
170
|
+
chunks.append(": ping\n\n")
|
|
171
|
+
self.wfile.write("".join(chunks).encode("utf-8"))
|
|
172
|
+
self.wfile.flush()
|
|
173
|
+
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Server:
|
|
178
|
+
def __init__(self, target: Hub | Projects, host: str = "127.0.0.1", port: int = 7420):
|
|
179
|
+
self.projects = target if isinstance(target, Projects) else Projects.single(target)
|
|
180
|
+
handler = type("Handler", (_Handler,), {"projects": self.projects})
|
|
181
|
+
self.httpd = _bind(host, port, handler)
|
|
182
|
+
self.httpd.daemon_threads = True
|
|
183
|
+
self.host, self.port = self.httpd.server_address[:2]
|
|
184
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, name="visualagents-http", daemon=True)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def url(self) -> str:
|
|
188
|
+
host = "127.0.0.1" if self.host in ("0.0.0.0", "") else self.host
|
|
189
|
+
return f"http://{host}:{self.port}"
|
|
190
|
+
|
|
191
|
+
def start(self) -> "Server":
|
|
192
|
+
self.thread.start()
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def stop(self) -> None:
|
|
196
|
+
self.httpd.shutdown()
|
|
197
|
+
self.httpd.server_close()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _bind(host: str, port: int, handler) -> ThreadingHTTPServer:
|
|
201
|
+
last: OSError | None = None
|
|
202
|
+
for p in [port + i for i in range(20)] if port else [0]:
|
|
203
|
+
try:
|
|
204
|
+
return ThreadingHTTPServer((host, p), handler)
|
|
205
|
+
except OSError as exc: # port in use - try the next one
|
|
206
|
+
last = exc
|
|
207
|
+
if getattr(exc, "errno", None) not in (48, 98, 10048) and not isinstance(exc, socket.error):
|
|
208
|
+
raise
|
|
209
|
+
raise last or OSError("could not bind a port")
|