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.
@@ -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
@@ -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
+ )