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/projects.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Hubs keyed by project. `run`/`pipe`/`watch` serve one; the `up` daemon serves every hooked project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import blueprint as bpmod
|
|
13
|
+
from . import home
|
|
14
|
+
from .hub import Hub
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Projects:
|
|
18
|
+
def __init__(self, *, token: str | None = None, daemon: bool = False):
|
|
19
|
+
self.token = token or secrets.token_urlsafe(16)
|
|
20
|
+
self.daemon = daemon
|
|
21
|
+
self.hubs: dict[str, Hub] = {}
|
|
22
|
+
self.paths: dict[str, str] = {}
|
|
23
|
+
self.order: list[str] = []
|
|
24
|
+
self.lock = threading.RLock()
|
|
25
|
+
self._placeholder: Hub | None = None
|
|
26
|
+
if daemon:
|
|
27
|
+
threading.Thread(target=self._reap, name="visualagents-reaper", daemon=True).start()
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def single(cls, hub: Hub, project_id: str = "") -> "Projects":
|
|
31
|
+
p = cls(token=hub.token)
|
|
32
|
+
pid = project_id or bpmod.slugify(hub.blueprint.get("project") or "project").replace("_", "-")
|
|
33
|
+
p.add(pid, hub, hub.meta.get("project", ""))
|
|
34
|
+
return p
|
|
35
|
+
|
|
36
|
+
def add(self, pid: str, hub: Hub, path: str = "") -> Hub:
|
|
37
|
+
with self.lock:
|
|
38
|
+
hub.token = self.token
|
|
39
|
+
hub.meta.setdefault("project_id", pid)
|
|
40
|
+
self.hubs[pid] = hub
|
|
41
|
+
self.paths[pid] = path
|
|
42
|
+
if pid not in self.order:
|
|
43
|
+
self.order.append(pid)
|
|
44
|
+
return hub
|
|
45
|
+
|
|
46
|
+
# -- lookup ------------------------------------------------------------------
|
|
47
|
+
def resolve(self, key: str | None) -> Hub:
|
|
48
|
+
with self.lock:
|
|
49
|
+
if key and key in self.hubs:
|
|
50
|
+
return self.hubs[key]
|
|
51
|
+
if key:
|
|
52
|
+
for pid, path in self.paths.items():
|
|
53
|
+
if path and os.path.realpath(key) == os.path.realpath(path):
|
|
54
|
+
return self.hubs[pid]
|
|
55
|
+
if self.hubs:
|
|
56
|
+
return self.hubs[self._most_active()]
|
|
57
|
+
return self.placeholder()
|
|
58
|
+
|
|
59
|
+
def for_item(self, item: dict[str, Any]) -> Hub:
|
|
60
|
+
path = item.get("project")
|
|
61
|
+
if not self.daemon or not path:
|
|
62
|
+
return self.resolve(None)
|
|
63
|
+
with self.lock:
|
|
64
|
+
for pid, p in self.paths.items():
|
|
65
|
+
if p and os.path.realpath(p) == os.path.realpath(str(path)):
|
|
66
|
+
return self.hubs[pid]
|
|
67
|
+
return self.open(Path(str(path)))
|
|
68
|
+
|
|
69
|
+
def open(self, path: Path) -> Hub:
|
|
70
|
+
"""Hub for a project folder, created on first use (blueprint from disk, or a quick static scan)."""
|
|
71
|
+
path = Path(path).resolve()
|
|
72
|
+
with self.lock:
|
|
73
|
+
for pid, p in self.paths.items():
|
|
74
|
+
if p == str(path):
|
|
75
|
+
return self.hubs[pid]
|
|
76
|
+
registered = next((k for k, v in home.registry().items() if v.get("path") == str(path)), None)
|
|
77
|
+
pid = registered if registered and registered not in self.hubs else \
|
|
78
|
+
home.project_id(path, {k: {"path": v} for k, v in self.paths.items()})
|
|
79
|
+
bp = _load_or_scan(path)
|
|
80
|
+
record = bpmod.state_dir(path) / "runs"
|
|
81
|
+
try:
|
|
82
|
+
record.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
except OSError:
|
|
84
|
+
record = None
|
|
85
|
+
hub = Hub(bp, record_dir=record, meta={"mode": "hook", "project": str(path), "project_id": pid})
|
|
86
|
+
return self.add(pid, hub, str(path))
|
|
87
|
+
|
|
88
|
+
def placeholder(self) -> Hub:
|
|
89
|
+
if self._placeholder is None:
|
|
90
|
+
self._placeholder = Hub(bpmod.empty("VisualAgents"), meta={"mode": "daemon-empty"})
|
|
91
|
+
self._placeholder.token = self.token
|
|
92
|
+
return self._placeholder
|
|
93
|
+
|
|
94
|
+
def _most_active(self) -> str:
|
|
95
|
+
def last(pid: str) -> float:
|
|
96
|
+
evs = self.hubs[pid].events
|
|
97
|
+
return evs[-1]["ts"] if evs else 0.0
|
|
98
|
+
return max(self.order, key=last)
|
|
99
|
+
|
|
100
|
+
def listing(self) -> list[dict[str, Any]]:
|
|
101
|
+
with self.lock:
|
|
102
|
+
out = []
|
|
103
|
+
for pid in self.order:
|
|
104
|
+
hub = self.hubs[pid]
|
|
105
|
+
run = hub.tracker.run
|
|
106
|
+
out.append({
|
|
107
|
+
"id": pid, "name": hub.blueprint.get("project") or pid, "path": self.paths.get(pid, ""),
|
|
108
|
+
"status": run["status"], "agents": len(hub.blueprint["agents"]),
|
|
109
|
+
"last": hub.events[-1]["ts"] if hub.events else None,
|
|
110
|
+
})
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
# -- process bookkeeping (daemon) ----------------------------------------------
|
|
114
|
+
def _reap(self) -> None:
|
|
115
|
+
"""Processes killed without a goodbye still end their run."""
|
|
116
|
+
while True:
|
|
117
|
+
time.sleep(5)
|
|
118
|
+
with self.lock:
|
|
119
|
+
hubs = list(self.hubs.values())
|
|
120
|
+
for hub in hubs:
|
|
121
|
+
for pid in list(hub.procs):
|
|
122
|
+
if not home._alive(pid):
|
|
123
|
+
hub.feed_structured({"type": "process_end", "pid": pid, "reaped": True})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _load_or_scan(path: Path) -> dict[str, Any]:
|
|
127
|
+
bp_path = bpmod.blueprint_path(path)
|
|
128
|
+
if bp_path.exists():
|
|
129
|
+
try:
|
|
130
|
+
return bpmod.load(bp_path)
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
pass
|
|
133
|
+
from .static_scan import scan
|
|
134
|
+
|
|
135
|
+
bp = scan(path)
|
|
136
|
+
try:
|
|
137
|
+
bpmod.save(bp, bp_path)
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
return bp
|
visualagents/runner.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Input sources: run a command (tee its output), tail a log file, read stdin, or replay a recording."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, Iterable
|
|
17
|
+
|
|
18
|
+
from .hub import Hub
|
|
19
|
+
from .patterns import MARKER
|
|
20
|
+
|
|
21
|
+
_MARKER_CORE = MARKER.strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LineSplitter:
|
|
25
|
+
"""Incrementally decodes output chunks, echoes them to the terminal, and yields complete lines.
|
|
26
|
+
|
|
27
|
+
Marker lines (structured events printed by any language) are hidden from the echo.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, on_line: Callable[[str], None], echo: Callable[[str], None] | None):
|
|
31
|
+
self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
32
|
+
self.on_line = on_line
|
|
33
|
+
self.echo = echo
|
|
34
|
+
self.buf = ""
|
|
35
|
+
self.disp = ""
|
|
36
|
+
|
|
37
|
+
def feed(self, data: bytes) -> None:
|
|
38
|
+
text = self.decoder.decode(data)
|
|
39
|
+
if not text:
|
|
40
|
+
return
|
|
41
|
+
if self.echo is not None:
|
|
42
|
+
self._echo(text)
|
|
43
|
+
self.buf += text.replace("\r\n", "\n")
|
|
44
|
+
while True:
|
|
45
|
+
i = self.buf.find("\n")
|
|
46
|
+
if i < 0:
|
|
47
|
+
break
|
|
48
|
+
line, self.buf = self.buf[:i], self.buf[i + 1:]
|
|
49
|
+
self.on_line(line)
|
|
50
|
+
if len(self.buf) > 20000: # a very long line without a newline (progress bars)
|
|
51
|
+
self.on_line(self.buf)
|
|
52
|
+
self.buf = ""
|
|
53
|
+
|
|
54
|
+
def _echo(self, text: str) -> None:
|
|
55
|
+
self.disp += text
|
|
56
|
+
out = []
|
|
57
|
+
while True:
|
|
58
|
+
i = self.disp.find("\n")
|
|
59
|
+
if i < 0:
|
|
60
|
+
break
|
|
61
|
+
line, self.disp = self.disp[: i + 1], self.disp[i + 1:]
|
|
62
|
+
if _MARKER_CORE not in line:
|
|
63
|
+
out.append(line)
|
|
64
|
+
rem = self.disp
|
|
65
|
+
if rem and _MARKER_CORE not in rem and "@" not in rem[-len(_MARKER_CORE):]:
|
|
66
|
+
out.append(rem)
|
|
67
|
+
self.disp = ""
|
|
68
|
+
if out:
|
|
69
|
+
self.echo("".join(out))
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
tail = self.decoder.decode(b"", final=True)
|
|
73
|
+
if tail:
|
|
74
|
+
self.buf += tail
|
|
75
|
+
if self.echo is not None and self.disp and _MARKER_CORE not in self.disp:
|
|
76
|
+
self.echo(self.disp)
|
|
77
|
+
self.disp = ""
|
|
78
|
+
if self.buf.strip():
|
|
79
|
+
self.on_line(self.buf)
|
|
80
|
+
self.buf = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _terminal_echo(text: str) -> None:
|
|
84
|
+
try:
|
|
85
|
+
sys.stdout.write(text)
|
|
86
|
+
sys.stdout.flush()
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def autoinstrument_dir() -> str:
|
|
92
|
+
return str(resources.files("visualagents") / "_autoinstrument")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_env(server_url: str, token: str, *, instrument: bool, use_pty: bool) -> dict[str, str]:
|
|
96
|
+
env = dict(os.environ)
|
|
97
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
98
|
+
env["PYTHONIOENCODING"] = env.get("PYTHONIOENCODING", "utf-8")
|
|
99
|
+
env["VISUALAGENTS_URL"] = server_url
|
|
100
|
+
env["VISUALAGENTS_TOKEN"] = token
|
|
101
|
+
env.pop("VISUALAGENTS_STREAM_ID", None) # set per child once its pipe / pty exists
|
|
102
|
+
if instrument:
|
|
103
|
+
parts = [autoinstrument_dir()] + [p for p in env.get("PYTHONPATH", "").split(os.pathsep) if p]
|
|
104
|
+
env["PYTHONPATH"] = os.pathsep.join(parts)
|
|
105
|
+
if not use_pty:
|
|
106
|
+
env.setdefault("FORCE_COLOR", "0")
|
|
107
|
+
return env
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_command(
|
|
111
|
+
command: list[str],
|
|
112
|
+
hub: Hub,
|
|
113
|
+
*,
|
|
114
|
+
cwd: Path,
|
|
115
|
+
server_url: str,
|
|
116
|
+
instrument: bool = True,
|
|
117
|
+
use_pty: bool | None = None,
|
|
118
|
+
echo: bool = True,
|
|
119
|
+
label: str | None = None,
|
|
120
|
+
) -> int:
|
|
121
|
+
"""Run `command`, mirror its output to this terminal, and feed every line to the hub."""
|
|
122
|
+
if use_pty is None:
|
|
123
|
+
use_pty = os.name == "posix" and sys.stdout.isatty()
|
|
124
|
+
env = build_env(server_url, hub.token, instrument=instrument, use_pty=use_pty)
|
|
125
|
+
hub.run_started(label or shlex.join(command))
|
|
126
|
+
echo_fn = _terminal_echo if echo else None
|
|
127
|
+
try:
|
|
128
|
+
if use_pty:
|
|
129
|
+
code = _run_pty(command, hub, cwd, env, echo_fn)
|
|
130
|
+
else:
|
|
131
|
+
code = _run_pipes(command, hub, cwd, env, echo_fn)
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
hub.system(f"Command not found: {command[0]}")
|
|
134
|
+
code = 127
|
|
135
|
+
hub.run_finished(code)
|
|
136
|
+
return code
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _wait_interruptibly(proc: subprocess.Popen) -> int:
|
|
140
|
+
"""Wait for the child; Ctrl+C reaches it directly (same process group), a second one kills it."""
|
|
141
|
+
interrupts = 0
|
|
142
|
+
while True:
|
|
143
|
+
try:
|
|
144
|
+
return proc.wait()
|
|
145
|
+
except KeyboardInterrupt:
|
|
146
|
+
interrupts += 1
|
|
147
|
+
if interrupts >= 2:
|
|
148
|
+
proc.kill()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_pty(command, hub, cwd, env, echo_fn) -> int:
|
|
152
|
+
import fcntl
|
|
153
|
+
import pty
|
|
154
|
+
import struct
|
|
155
|
+
import termios
|
|
156
|
+
|
|
157
|
+
master, slave = pty.openpty()
|
|
158
|
+
env["VISUALAGENTS_STREAM_ID"] = _stream_id(slave)
|
|
159
|
+
try:
|
|
160
|
+
size = os.get_terminal_size(sys.stdout.fileno())
|
|
161
|
+
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", size.lines, size.columns, 0, 0))
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
# stdin stays the real terminal so prompts like input("Query: ") work as usual.
|
|
165
|
+
proc = subprocess.Popen(command, cwd=cwd, env=env, stdout=slave, stderr=slave, close_fds=True)
|
|
166
|
+
os.close(slave)
|
|
167
|
+
splitter = LineSplitter(lambda line: hub.feed_line(line, "stdout"), echo_fn)
|
|
168
|
+
|
|
169
|
+
def pump():
|
|
170
|
+
while True:
|
|
171
|
+
try:
|
|
172
|
+
data = os.read(master, 65536)
|
|
173
|
+
except OSError: # EIO once every writer has closed the pty
|
|
174
|
+
break
|
|
175
|
+
if not data:
|
|
176
|
+
break
|
|
177
|
+
splitter.feed(data)
|
|
178
|
+
splitter.close()
|
|
179
|
+
|
|
180
|
+
t = threading.Thread(target=pump, name="visualagents-pty", daemon=True)
|
|
181
|
+
t.start()
|
|
182
|
+
code = _wait_interruptibly(proc)
|
|
183
|
+
t.join(timeout=2.0)
|
|
184
|
+
try:
|
|
185
|
+
os.close(master)
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|
|
188
|
+
return code
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _stream_id(fd: int) -> str:
|
|
192
|
+
st = os.fstat(fd)
|
|
193
|
+
return f"{st.st_dev}:{st.st_ino}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _run_pipes(command, hub, cwd, env, echo_fn) -> int:
|
|
197
|
+
# stderr is merged into stdout (as on a terminal) so lines and event markers stay in order.
|
|
198
|
+
read_fd, write_fd = os.pipe()
|
|
199
|
+
env["VISUALAGENTS_STREAM_ID"] = _stream_id(write_fd)
|
|
200
|
+
try:
|
|
201
|
+
proc = subprocess.Popen(command, cwd=cwd, env=env, stdout=write_fd, stderr=write_fd)
|
|
202
|
+
finally:
|
|
203
|
+
os.close(write_fd)
|
|
204
|
+
splitter = LineSplitter(lambda line: hub.feed_line(line, "stdout"), echo_fn)
|
|
205
|
+
|
|
206
|
+
def pump():
|
|
207
|
+
fd = read_fd
|
|
208
|
+
while True:
|
|
209
|
+
try:
|
|
210
|
+
data = os.read(fd, 65536)
|
|
211
|
+
except OSError:
|
|
212
|
+
break
|
|
213
|
+
if not data:
|
|
214
|
+
break
|
|
215
|
+
splitter.feed(data)
|
|
216
|
+
splitter.close()
|
|
217
|
+
|
|
218
|
+
t = threading.Thread(target=pump, name="visualagents-pipe", daemon=True)
|
|
219
|
+
t.start()
|
|
220
|
+
code = _wait_interruptibly(proc)
|
|
221
|
+
t.join(timeout=2.0)
|
|
222
|
+
try:
|
|
223
|
+
os.close(read_fd)
|
|
224
|
+
except OSError:
|
|
225
|
+
pass
|
|
226
|
+
return code
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def follow_file(path: Path, hub: Hub, *, from_start: bool = False, echo: bool = False,
|
|
230
|
+
stop: threading.Event | None = None, announce: bool = True, stream: str = "stdout") -> None:
|
|
231
|
+
"""`tail -F` a log file into the hub until `stop` is set (or Ctrl+C)."""
|
|
232
|
+
stop = stop or threading.Event()
|
|
233
|
+
pos = 0
|
|
234
|
+
inode = None
|
|
235
|
+
first = True
|
|
236
|
+
buf = ""
|
|
237
|
+
if announce:
|
|
238
|
+
hub.run_started(f"tail {path}")
|
|
239
|
+
while not stop.is_set():
|
|
240
|
+
try:
|
|
241
|
+
st = path.stat()
|
|
242
|
+
except FileNotFoundError:
|
|
243
|
+
time.sleep(0.3)
|
|
244
|
+
continue
|
|
245
|
+
if inode != st.st_ino or st.st_size < pos: # new or rotated / truncated file
|
|
246
|
+
inode, pos = st.st_ino, 0 if (from_start or not first) else st.st_size
|
|
247
|
+
first = False
|
|
248
|
+
if st.st_size > pos:
|
|
249
|
+
with open(path, "rb") as fh:
|
|
250
|
+
fh.seek(pos)
|
|
251
|
+
data = fh.read(st.st_size - pos)
|
|
252
|
+
pos += len(data)
|
|
253
|
+
buf += data.decode("utf-8", errors="replace")
|
|
254
|
+
*lines, buf = buf.split("\n")
|
|
255
|
+
for line in lines:
|
|
256
|
+
if echo:
|
|
257
|
+
_terminal_echo(line + "\n")
|
|
258
|
+
hub.feed_line(line, stream)
|
|
259
|
+
else:
|
|
260
|
+
stop.wait(0.2)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
_LOG_SKIP = {".git", "node_modules", "__pycache__", ".visualagents", "venv", ".venv", "site-packages", "dist", "build"}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def follow_new_logs(project: Path, hub: Hub, *, since: float, stop: threading.Event, max_depth: int = 3) -> None:
|
|
267
|
+
"""Follow *.log files the app writes during the run (e.g. a start script sending the backend to .logs/)."""
|
|
268
|
+
project = Path(project).resolve()
|
|
269
|
+
following: set[Path] = set()
|
|
270
|
+
while not stop.wait(1.0):
|
|
271
|
+
for dirpath, dirnames, filenames in os.walk(project):
|
|
272
|
+
depth = len(Path(dirpath).relative_to(project).parts)
|
|
273
|
+
dirnames[:] = [] if depth >= max_depth else [d for d in dirnames if d not in _LOG_SKIP]
|
|
274
|
+
for name in filenames:
|
|
275
|
+
if not name.endswith((".log", ".out")):
|
|
276
|
+
continue
|
|
277
|
+
p = Path(dirpath) / name
|
|
278
|
+
try:
|
|
279
|
+
if p in following or p.stat().st_mtime < since:
|
|
280
|
+
continue
|
|
281
|
+
except OSError:
|
|
282
|
+
continue
|
|
283
|
+
following.add(p)
|
|
284
|
+
rel = str(p.relative_to(project))
|
|
285
|
+
hub.system(f"Following {rel}")
|
|
286
|
+
threading.Thread(target=follow_file, args=(p, hub), daemon=True,
|
|
287
|
+
kwargs={"from_start": True, "stop": stop, "announce": False, "stream": rel}).start()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def read_stream(stream: Iterable[bytes] | None, hub: Hub, *, echo: bool = True) -> None:
|
|
291
|
+
"""Consume stdin (e.g. `python agent.py 2>&1 | visualagents pipe`)."""
|
|
292
|
+
hub.run_started("stdin")
|
|
293
|
+
src = sys.stdin.buffer if stream is None else stream
|
|
294
|
+
splitter = LineSplitter(hub.feed_line, _terminal_echo if echo else None)
|
|
295
|
+
fd = src.fileno() if hasattr(src, "fileno") else None
|
|
296
|
+
try:
|
|
297
|
+
if fd is not None:
|
|
298
|
+
while True:
|
|
299
|
+
data = os.read(fd, 65536)
|
|
300
|
+
if not data:
|
|
301
|
+
break
|
|
302
|
+
splitter.feed(data)
|
|
303
|
+
else:
|
|
304
|
+
for chunk in src:
|
|
305
|
+
splitter.feed(chunk)
|
|
306
|
+
except KeyboardInterrupt:
|
|
307
|
+
pass
|
|
308
|
+
splitter.close()
|
|
309
|
+
hub.run_finished(0)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def replay(path: Path, hub: Hub, *, speed: float = 1.0, instant: bool = False, stop: threading.Event | None = None) -> None:
|
|
313
|
+
"""Re-feed a recorded run through the tracker at (scaled) original timing."""
|
|
314
|
+
stop = stop or threading.Event()
|
|
315
|
+
started = time.time()
|
|
316
|
+
with open(path, encoding="utf-8") as fh:
|
|
317
|
+
for raw in fh:
|
|
318
|
+
if stop.is_set():
|
|
319
|
+
return
|
|
320
|
+
try:
|
|
321
|
+
rec = json.loads(raw)
|
|
322
|
+
except ValueError:
|
|
323
|
+
continue
|
|
324
|
+
if not instant and speed > 0:
|
|
325
|
+
due = started + float(rec.get("t", 0)) / speed
|
|
326
|
+
delay = due - time.time()
|
|
327
|
+
if delay > 0 and stop.wait(min(delay, 10.0)):
|
|
328
|
+
return
|
|
329
|
+
typ = rec.get("type")
|
|
330
|
+
if typ == "line":
|
|
331
|
+
hub.feed_line(rec.get("text", ""), rec.get("stream", "stdout"))
|
|
332
|
+
elif typ == "structured":
|
|
333
|
+
hub.feed_structured(rec.get("payload") or {})
|
|
334
|
+
elif typ == "narration":
|
|
335
|
+
hub.narrate(rec.get("agent"), rec.get("activity", ""))
|
|
336
|
+
elif typ == "run_start":
|
|
337
|
+
hub.run_started(rec.get("command", ""))
|
|
338
|
+
elif typ == "run_end":
|
|
339
|
+
hub.run_finished(rec.get("code"))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def read_recording_meta(path: Path) -> dict:
|
|
343
|
+
with open(path, encoding="utf-8") as fh:
|
|
344
|
+
first = json.loads(fh.readline() or "{}")
|
|
345
|
+
return first if first.get("type") == "meta" else {}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def install_sigterm_handler() -> None:
|
|
349
|
+
def _term(signum, frame):
|
|
350
|
+
raise KeyboardInterrupt
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
signal.signal(signal.SIGTERM, _term)
|
|
354
|
+
except (ValueError, OSError):
|
|
355
|
+
pass
|
visualagents/scanner.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Builds a blueprint for a project: static analysis first, then (optionally) an LLM pass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from . import blueprint as bpmod
|
|
10
|
+
from . import llm
|
|
11
|
+
from .collector import collect, pack
|
|
12
|
+
from .static_scan import scan as static_scan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scan_project(
|
|
16
|
+
project: Path,
|
|
17
|
+
*,
|
|
18
|
+
mode: str = "auto",
|
|
19
|
+
provider: str = "auto",
|
|
20
|
+
model: str | None = None,
|
|
21
|
+
max_chars: int = 180_000,
|
|
22
|
+
log: Callable[[str], None] = print,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""mode: 'static' (offline), 'llm' (require an LLM), or 'auto' (LLM when a key is configured)."""
|
|
25
|
+
project = Path(project).resolve()
|
|
26
|
+
llm.load_dotenv_keys(project if project.is_dir() else project.parent)
|
|
27
|
+
files = collect(project)
|
|
28
|
+
log(f"Read {len(files)} candidate file(s) from {project}")
|
|
29
|
+
static = static_scan(project, files)
|
|
30
|
+
log(f"Static analysis: {len(static['agents'])} agent(s), {len(static['edges'])} edge(s), framework={static['framework']}")
|
|
31
|
+
if mode == "static":
|
|
32
|
+
return static
|
|
33
|
+
|
|
34
|
+
chosen = llm.detect_provider(provider)
|
|
35
|
+
if chosen is None:
|
|
36
|
+
if mode == "llm":
|
|
37
|
+
raise llm.LLMError("no LLM credentials found - set ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY")
|
|
38
|
+
log("No ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY found - using the static blueprint "
|
|
39
|
+
"(add a key for a richer map; see `visualagents models`).")
|
|
40
|
+
return static
|
|
41
|
+
|
|
42
|
+
source, included = pack(files, budget_chars=max_chars)
|
|
43
|
+
hints = {k: static[k] for k in ("framework", "agents", "edges", "entry", "exits", "steps")}
|
|
44
|
+
for a in hints["agents"]:
|
|
45
|
+
a.pop("keywords", None)
|
|
46
|
+
prompt = (
|
|
47
|
+
f"Project name: {static['project']}\n\n"
|
|
48
|
+
f"<static_analysis_hints>\n{json.dumps(hints, indent=1)}\n</static_analysis_hints>\n\n"
|
|
49
|
+
f"<source>\n{source}</source>\n\n"
|
|
50
|
+
"Return the blueprint for this project."
|
|
51
|
+
)
|
|
52
|
+
used_model = model or llm.default_model(chosen)
|
|
53
|
+
log(f"Asking {chosen}:{used_model} to map the agents ({len(included)} file(s), {len(source):,} chars)…")
|
|
54
|
+
try:
|
|
55
|
+
raw = llm.complete_json(
|
|
56
|
+
provider=chosen, model=used_model, system=llm.SCAN_SYSTEM, prompt=prompt, schema=bpmod.BLUEPRINT_JSON_SCHEMA
|
|
57
|
+
)
|
|
58
|
+
except llm.LLMError as exc:
|
|
59
|
+
if mode == "llm":
|
|
60
|
+
raise
|
|
61
|
+
log(f"LLM scan failed ({exc}) - falling back to the static blueprint.")
|
|
62
|
+
return static
|
|
63
|
+
|
|
64
|
+
raw["project"] = raw.get("project") or static["project"]
|
|
65
|
+
bp = bpmod.normalize(raw)
|
|
66
|
+
if not bp["agents"]:
|
|
67
|
+
log("The LLM found no agents - keeping the static blueprint.")
|
|
68
|
+
return static
|
|
69
|
+
_merge_static_aliases(bp, static)
|
|
70
|
+
return bpmod.stamp(bp, f"{chosen}:{used_model}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _merge_static_aliases(bp: dict[str, Any], static: dict[str, Any]) -> None:
|
|
74
|
+
"""Static analysis knows exact node/variable names; keep them as aliases so log matching stays precise."""
|
|
75
|
+
by_key = {}
|
|
76
|
+
for a in bp["agents"]:
|
|
77
|
+
for k in [a["id"], a["name"], *a["aliases"]]:
|
|
78
|
+
by_key.setdefault(bpmod.norm_key(k), a)
|
|
79
|
+
for s in static["agents"]:
|
|
80
|
+
target = None
|
|
81
|
+
for k in [s["id"], s["name"], *s["aliases"]]:
|
|
82
|
+
target = by_key.get(bpmod.norm_key(k))
|
|
83
|
+
if target:
|
|
84
|
+
break
|
|
85
|
+
if not target:
|
|
86
|
+
continue
|
|
87
|
+
for alias in [s["id"], s["name"], *s["aliases"]]:
|
|
88
|
+
if alias not in target["aliases"] and alias != target["name"]:
|
|
89
|
+
target["aliases"].append(alias)
|
|
90
|
+
if not target["file"]:
|
|
91
|
+
target["file"], target["line"] = s["file"], s["line"]
|
visualagents/sdk.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Public instrumentation API (stdlib only). See visualagents/_autoinstrument/visualagents_runtime.py."""
|
|
2
|
+
|
|
3
|
+
from ._autoinstrument.visualagents_runtime import ( # noqa: F401
|
|
4
|
+
agent,
|
|
5
|
+
agent_end,
|
|
6
|
+
agent_start,
|
|
7
|
+
emit,
|
|
8
|
+
flush,
|
|
9
|
+
handoff,
|
|
10
|
+
instrument,
|
|
11
|
+
langchain_handler,
|
|
12
|
+
log,
|
|
13
|
+
openai_agents_processor,
|
|
14
|
+
thought,
|
|
15
|
+
tool_call,
|
|
16
|
+
tool_result,
|
|
17
|
+
track,
|
|
18
|
+
)
|