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
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
"""VisualAgents runtime - stdlib only, safe to import in any Python 3.8+ process.
|
|
2
|
+
|
|
3
|
+
`visualagents run` puts this directory on PYTHONPATH so frameworks are instrumented with no
|
|
4
|
+
code changes. You can also emit events yourself:
|
|
5
|
+
|
|
6
|
+
from visualagents import emit, agent
|
|
7
|
+
|
|
8
|
+
with agent("researcher", task="find sources"):
|
|
9
|
+
emit("thought", "researcher", "Looking for 2024 benchmarks")
|
|
10
|
+
emit("tool_start", "researcher", "solid-state batteries", tool="web_search")
|
|
11
|
+
|
|
12
|
+
Events travel in-band as `@@visualagents {json}` lines on stderr when stderr is the pipe VisualAgents is
|
|
13
|
+
reading (ordered exactly with printed output, hidden from the terminal). When it isn't - a start script sent
|
|
14
|
+
the app to the background or redirected its logs, or the app runs on its own under `visualagents hook` -
|
|
15
|
+
they are POSTed to the dashboard (VISUALAGENTS_URL / VISUALAGENTS_TOKEN, or the hook's daemon settings).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import atexit
|
|
21
|
+
import contextlib
|
|
22
|
+
import functools
|
|
23
|
+
import importlib.abc
|
|
24
|
+
import importlib.util
|
|
25
|
+
import inspect
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import queue
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import urllib.request
|
|
33
|
+
|
|
34
|
+
MARKER = "@@visualagents "
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
_queue = None
|
|
37
|
+
_opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) # never route localhost via a proxy
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_config = {} # set by configure(): url, token, project (hook mode)
|
|
41
|
+
_channel = {} # pid -> True when events go in-band on stderr
|
|
42
|
+
_down_until = 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def configure(url=None, token=None, project=None, daemon_file=None, lazy=False):
|
|
46
|
+
"""Send events to a dashboard over HTTP (used by `visualagents hook`).
|
|
47
|
+
|
|
48
|
+
lazy: stay silent until the process does something agent-like (an LLM call or a framework event),
|
|
49
|
+
so pip / pytest / language servers running in the same virtualenv never show up on the dashboard.
|
|
50
|
+
"""
|
|
51
|
+
_config.update({k: v for k, v in (("url", url), ("token", token), ("project", project),
|
|
52
|
+
("daemon_file", daemon_file)) if v})
|
|
53
|
+
_config["http"] = True
|
|
54
|
+
_config["lazy"] = bool(lazy)
|
|
55
|
+
_config.setdefault("armed", not lazy)
|
|
56
|
+
_channel.clear()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _url():
|
|
60
|
+
url = _config.get("url") or os.environ.get("VISUALAGENTS_URL")
|
|
61
|
+
if not url and _config.get("daemon_file"):
|
|
62
|
+
_reload_daemon()
|
|
63
|
+
url = _config.get("url")
|
|
64
|
+
return url
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _reload_daemon():
|
|
68
|
+
"""The dashboard may start after this process: look for it again every few seconds."""
|
|
69
|
+
now = time.time()
|
|
70
|
+
if now - _config.get("daemon_checked", 0) < 5:
|
|
71
|
+
return
|
|
72
|
+
_config["daemon_checked"] = now
|
|
73
|
+
try:
|
|
74
|
+
with open(_config["daemon_file"], encoding="utf-8") as fh:
|
|
75
|
+
info = json.load(fh)
|
|
76
|
+
_config["url"], _config["token"] = info.get("url"), info.get("token")
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _token():
|
|
82
|
+
return _config.get("token") or os.environ.get("VISUALAGENTS_TOKEN", "")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _in_band():
|
|
86
|
+
"""True when fd 2 is the very pipe/pty VisualAgents reads, so markers stay ordered with output."""
|
|
87
|
+
pid = os.getpid()
|
|
88
|
+
if pid not in _channel:
|
|
89
|
+
if _config.get("http"):
|
|
90
|
+
ok = False
|
|
91
|
+
elif os.environ.get("VISUALAGENTS_STREAM_ID"):
|
|
92
|
+
try:
|
|
93
|
+
st = os.fstat(2)
|
|
94
|
+
ok = "%d:%d" % (st.st_dev, st.st_ino) == os.environ["VISUALAGENTS_STREAM_ID"]
|
|
95
|
+
except OSError:
|
|
96
|
+
ok = False
|
|
97
|
+
elif os.environ.get("VISUALAGENTS_CHANNEL") == "stderr":
|
|
98
|
+
ok = True
|
|
99
|
+
else:
|
|
100
|
+
ok = not _url()
|
|
101
|
+
_channel[pid] = ok
|
|
102
|
+
return _channel[pid]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _text(value, limit=4000):
|
|
106
|
+
if value is None:
|
|
107
|
+
return None
|
|
108
|
+
if not isinstance(value, str):
|
|
109
|
+
content = getattr(value, "content", None)
|
|
110
|
+
if isinstance(content, str):
|
|
111
|
+
value = content
|
|
112
|
+
else:
|
|
113
|
+
try:
|
|
114
|
+
value = json.dumps(value, default=str, ensure_ascii=False)
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
value = str(value)
|
|
117
|
+
return value if len(value) <= limit else value[:limit] + "…"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def emit(type, agent=None, text=None, **data):
|
|
121
|
+
"""Send one event. type: agent_start, agent_end, thought, tool_start, tool_end, handoff, output, log, error."""
|
|
122
|
+
if os.environ.get("VISUALAGENTS_DISABLE") == "1":
|
|
123
|
+
return
|
|
124
|
+
payload = {"type": type, "ts": time.time()}
|
|
125
|
+
if agent is not None:
|
|
126
|
+
payload["agent"] = str(agent)
|
|
127
|
+
if text is not None:
|
|
128
|
+
payload["text"] = _text(text)
|
|
129
|
+
for k, v in data.items():
|
|
130
|
+
if v is not None:
|
|
131
|
+
payload[k] = v if isinstance(v, (bool, int, float)) else _text(v, 1000)
|
|
132
|
+
if not _in_band():
|
|
133
|
+
if _config.get("project"):
|
|
134
|
+
payload["project"] = _config["project"]
|
|
135
|
+
if not _config.get("armed", True):
|
|
136
|
+
if type == "line":
|
|
137
|
+
_pending.append(payload) # remember recent output in case this process turns out to matter
|
|
138
|
+
return
|
|
139
|
+
_arm()
|
|
140
|
+
if _url():
|
|
141
|
+
_enqueue(payload)
|
|
142
|
+
return
|
|
143
|
+
line = MARKER + json.dumps(payload, ensure_ascii=False, default=str) + "\n"
|
|
144
|
+
try:
|
|
145
|
+
# Straight to fd 2: keeps exact ordering with printed output and survives sys.stderr redirection.
|
|
146
|
+
os.write(2, line.encode("utf-8", "replace"))
|
|
147
|
+
except Exception:
|
|
148
|
+
try:
|
|
149
|
+
sys.stderr.write(line)
|
|
150
|
+
sys.stderr.flush()
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_pending = __import__("collections").deque(maxlen=300)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _arm():
|
|
159
|
+
"""First agent-like event in a lazily hooked process: announce it, then replay its recent output."""
|
|
160
|
+
_config["armed"] = True
|
|
161
|
+
hello = {"type": "process_start", "ts": time.time(), "pid": os.getpid(),
|
|
162
|
+
"text": " ".join(sys.argv) or sys.executable, "cwd": os.getcwd()}
|
|
163
|
+
if _config.get("project"):
|
|
164
|
+
hello["project"] = _config["project"]
|
|
165
|
+
if _url():
|
|
166
|
+
_enqueue(hello)
|
|
167
|
+
for item in list(_pending):
|
|
168
|
+
_enqueue(item)
|
|
169
|
+
_pending.clear()
|
|
170
|
+
atexit.register(_goodbye)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _goodbye():
|
|
174
|
+
emit("process_end", None, None, pid=os.getpid(), code=0)
|
|
175
|
+
flush()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _enqueue(payload):
|
|
179
|
+
global _queue
|
|
180
|
+
with _lock:
|
|
181
|
+
if _queue is None:
|
|
182
|
+
_queue = queue.Queue()
|
|
183
|
+
threading.Thread(target=_sender, args=(_queue,), name="visualagents-emit", daemon=True).start()
|
|
184
|
+
_queue.put(payload)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _sender(q):
|
|
188
|
+
while True:
|
|
189
|
+
batch = [q.get()]
|
|
190
|
+
try:
|
|
191
|
+
while len(batch) < 200:
|
|
192
|
+
batch.append(q.get_nowait())
|
|
193
|
+
except queue.Empty:
|
|
194
|
+
pass
|
|
195
|
+
_post(batch)
|
|
196
|
+
for _ in batch:
|
|
197
|
+
q.task_done()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _post(batch):
|
|
201
|
+
global _down_until
|
|
202
|
+
url = _url()
|
|
203
|
+
if not url or time.time() < _down_until:
|
|
204
|
+
return # no dashboard listening right now: drop rather than slow the app down
|
|
205
|
+
req = urllib.request.Request(
|
|
206
|
+
url.rstrip("/") + "/api/ingest",
|
|
207
|
+
data=json.dumps(batch, ensure_ascii=False, default=str).encode("utf-8"),
|
|
208
|
+
headers={"Content-Type": "application/json", "X-VisualAgents-Token": _token()},
|
|
209
|
+
method="POST",
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
_opener.open(req, timeout=3).read()
|
|
213
|
+
except Exception:
|
|
214
|
+
_down_until = time.time() + 3.0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def flush(timeout=3.0):
|
|
218
|
+
q = _queue
|
|
219
|
+
if q is None:
|
|
220
|
+
return
|
|
221
|
+
deadline = time.time() + timeout
|
|
222
|
+
while q.unfinished_tasks and time.time() < deadline:
|
|
223
|
+
time.sleep(0.02)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
atexit.register(flush)
|
|
227
|
+
if hasattr(os, "register_at_fork"):
|
|
228
|
+
def _after_fork():
|
|
229
|
+
global _queue
|
|
230
|
+
_queue = None
|
|
231
|
+
_channel.clear()
|
|
232
|
+
if _config.get("lazy"):
|
|
233
|
+
_config["armed"] = False # a forked worker announces itself when it does agent work
|
|
234
|
+
_pending.clear()
|
|
235
|
+
|
|
236
|
+
os.register_at_fork(after_in_child=_after_fork)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# -- output capture (hook mode: the dashboard can't read this process's terminal) ----------
|
|
240
|
+
|
|
241
|
+
class _Tee:
|
|
242
|
+
"""Writes through to the real stream and forwards complete lines to the dashboard."""
|
|
243
|
+
|
|
244
|
+
def __init__(self, stream, name):
|
|
245
|
+
self._stream = stream
|
|
246
|
+
self._name = name
|
|
247
|
+
self._buf = ""
|
|
248
|
+
self._busy = False
|
|
249
|
+
|
|
250
|
+
def write(self, s):
|
|
251
|
+
n = self._stream.write(s)
|
|
252
|
+
if isinstance(s, str) and s and not self._busy:
|
|
253
|
+
self._busy = True
|
|
254
|
+
try:
|
|
255
|
+
self._buf += s
|
|
256
|
+
if "\n" in self._buf:
|
|
257
|
+
*lines, self._buf = self._buf.split("\n")
|
|
258
|
+
for line in lines:
|
|
259
|
+
if line.strip() and MARKER.strip() not in line:
|
|
260
|
+
emit("line", None, line, stream=self._name)
|
|
261
|
+
if len(self._buf) > 8000:
|
|
262
|
+
self._buf = ""
|
|
263
|
+
finally:
|
|
264
|
+
self._busy = False
|
|
265
|
+
return n
|
|
266
|
+
|
|
267
|
+
def __getattr__(self, name):
|
|
268
|
+
return getattr(self._stream, name)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def capture_output():
|
|
272
|
+
for name in ("stdout", "stderr"):
|
|
273
|
+
stream = getattr(sys, name, None)
|
|
274
|
+
if stream is not None and not isinstance(stream, _Tee):
|
|
275
|
+
setattr(sys, name, _Tee(stream, name))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# -- convenience API --------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def agent_start(name, task=None):
|
|
281
|
+
emit("agent_start", name, task)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def agent_end(name, output=None):
|
|
285
|
+
emit("agent_end", name, output)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def thought(name, text):
|
|
289
|
+
emit("thought", name, text)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def tool_call(name, tool, input=None):
|
|
293
|
+
emit("tool_start", name, input, tool=tool)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def tool_result(name, tool, output=None):
|
|
297
|
+
emit("tool_end", name, output, tool=tool)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def handoff(src, dst, reason=None):
|
|
301
|
+
emit("handoff", None, reason, **{"from": src, "to": dst})
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def log(name, text):
|
|
305
|
+
emit("log", name, text)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@contextlib.contextmanager
|
|
309
|
+
def agent(name, task=None):
|
|
310
|
+
"""Mark a block of code as one agent's turn."""
|
|
311
|
+
agent_start(name, task)
|
|
312
|
+
try:
|
|
313
|
+
yield
|
|
314
|
+
except BaseException as exc:
|
|
315
|
+
emit("error", name, "%s: %s" % (type(exc).__name__, exc))
|
|
316
|
+
raise
|
|
317
|
+
finally:
|
|
318
|
+
agent_end(name)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def track(name=None):
|
|
322
|
+
"""Decorator: the wrapped function (sync or async) is an agent's turn."""
|
|
323
|
+
def deco(fn):
|
|
324
|
+
label = name or fn.__name__
|
|
325
|
+
if inspect.iscoroutinefunction(fn):
|
|
326
|
+
@functools.wraps(fn)
|
|
327
|
+
async def awrapper(*a, **kw):
|
|
328
|
+
with agent(label):
|
|
329
|
+
return await fn(*a, **kw)
|
|
330
|
+
return awrapper
|
|
331
|
+
|
|
332
|
+
@functools.wraps(fn)
|
|
333
|
+
def wrapper(*a, **kw):
|
|
334
|
+
with agent(label):
|
|
335
|
+
return fn(*a, **kw)
|
|
336
|
+
return wrapper
|
|
337
|
+
|
|
338
|
+
return deco(name) if callable(name) else deco
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# -- LangChain / LangGraph ------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def _message_text(msg):
|
|
344
|
+
content = getattr(msg, "content", msg)
|
|
345
|
+
if isinstance(content, list):
|
|
346
|
+
parts = []
|
|
347
|
+
for c in content:
|
|
348
|
+
if isinstance(c, dict):
|
|
349
|
+
parts.append(c.get("text") or c.get("thinking") or "")
|
|
350
|
+
else:
|
|
351
|
+
parts.append(str(c))
|
|
352
|
+
content = "\n".join(p for p in parts if p)
|
|
353
|
+
return content if isinstance(content, str) else ""
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def langchain_handler():
|
|
357
|
+
"""A LangChain callback handler that reports LangGraph nodes as agents."""
|
|
358
|
+
from langchain_core.callbacks.base import BaseCallbackHandler
|
|
359
|
+
|
|
360
|
+
class VisualAgentsCallbackHandler(BaseCallbackHandler):
|
|
361
|
+
raise_error = False
|
|
362
|
+
run_inline = True
|
|
363
|
+
|
|
364
|
+
def __init__(self):
|
|
365
|
+
self._node_of = {}
|
|
366
|
+
self._node_runs = {}
|
|
367
|
+
self._soft_runs = {}
|
|
368
|
+
self._tools = {}
|
|
369
|
+
|
|
370
|
+
def _node(self, run_id, parent_run_id, metadata):
|
|
371
|
+
md = metadata or {}
|
|
372
|
+
ns = md.get("langgraph_checkpoint_ns") or md.get("checkpoint_ns") or ""
|
|
373
|
+
node = ns.split("|")[0].split(":")[0] if ns else None
|
|
374
|
+
node = node or md.get("langgraph_node")
|
|
375
|
+
if node in (None, "", "__start__", "__end__"):
|
|
376
|
+
node = self._node_of.get(parent_run_id)
|
|
377
|
+
if node:
|
|
378
|
+
self._node_of[run_id] = node
|
|
379
|
+
return node
|
|
380
|
+
|
|
381
|
+
def on_chain_start(self, serialized, inputs, *, run_id, parent_run_id=None, tags=None, metadata=None, **kw):
|
|
382
|
+
try:
|
|
383
|
+
md = metadata or {}
|
|
384
|
+
name = kw.get("name") or (serialized or {}).get("name") or ""
|
|
385
|
+
node = self._node(run_id, parent_run_id, md)
|
|
386
|
+
ns = md.get("langgraph_checkpoint_ns") or ""
|
|
387
|
+
if node and name == node and "|" not in ns:
|
|
388
|
+
self._node_runs[run_id] = node
|
|
389
|
+
emit("agent_start", node)
|
|
390
|
+
elif not md.get("langgraph_node") and parent_run_id is None and name:
|
|
391
|
+
self._soft_runs[run_id] = name
|
|
392
|
+
emit("agent_start", name, soft=True)
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
def on_chain_end(self, outputs, *, run_id, **kw):
|
|
397
|
+
try:
|
|
398
|
+
if run_id in self._node_runs:
|
|
399
|
+
emit("agent_end", self._node_runs.pop(run_id))
|
|
400
|
+
elif run_id in self._soft_runs:
|
|
401
|
+
emit("agent_end", self._soft_runs.pop(run_id), soft=True)
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
def on_chain_error(self, error, *, run_id, **kw):
|
|
406
|
+
node = self._node_runs.pop(run_id, None) or self._node_of.get(run_id)
|
|
407
|
+
emit("error", node, "%s: %s" % (type(error).__name__, error))
|
|
408
|
+
if node and run_id not in self._soft_runs:
|
|
409
|
+
emit("agent_end", node)
|
|
410
|
+
|
|
411
|
+
def on_chat_model_start(self, serialized, messages, *, run_id, parent_run_id=None, metadata=None, **kw):
|
|
412
|
+
self._node(run_id, parent_run_id, metadata)
|
|
413
|
+
|
|
414
|
+
def on_llm_start(self, serialized, prompts, *, run_id, parent_run_id=None, metadata=None, **kw):
|
|
415
|
+
self._node(run_id, parent_run_id, metadata)
|
|
416
|
+
|
|
417
|
+
def on_llm_end(self, response, *, run_id, **kw):
|
|
418
|
+
try:
|
|
419
|
+
node = self._node_of.get(run_id)
|
|
420
|
+
gen = response.generations[0][0] if response.generations and response.generations[0] else None
|
|
421
|
+
if gen is None:
|
|
422
|
+
return
|
|
423
|
+
msg = getattr(gen, "message", None)
|
|
424
|
+
text = _message_text(msg) if msg is not None else (gen.text or "")
|
|
425
|
+
calls = [c.get("name") for c in (getattr(msg, "tool_calls", None) or []) if isinstance(c, dict)]
|
|
426
|
+
if text.strip():
|
|
427
|
+
emit("thought", node, text.strip())
|
|
428
|
+
elif calls:
|
|
429
|
+
emit("thought", node, "Decided to call " + ", ".join(c for c in calls if c))
|
|
430
|
+
except Exception:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
def on_llm_error(self, error, *, run_id, **kw):
|
|
434
|
+
emit("error", self._node_of.get(run_id), "%s: %s" % (type(error).__name__, error))
|
|
435
|
+
|
|
436
|
+
def on_tool_start(self, serialized, input_str, *, run_id, parent_run_id=None, metadata=None, **kw):
|
|
437
|
+
try:
|
|
438
|
+
node = self._node(run_id, parent_run_id, metadata)
|
|
439
|
+
name = (serialized or {}).get("name") or kw.get("name") or "tool"
|
|
440
|
+
self._tools[run_id] = name
|
|
441
|
+
emit("tool_start", node, input_str, tool=name)
|
|
442
|
+
except Exception:
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
def on_tool_end(self, output, *, run_id, **kw):
|
|
446
|
+
emit("tool_end", self._node_of.get(run_id), _message_text(output) or _text(output), tool=self._tools.pop(run_id, None))
|
|
447
|
+
|
|
448
|
+
def on_tool_error(self, error, *, run_id, **kw):
|
|
449
|
+
emit("error", self._node_of.get(run_id), "%s failed: %s" % (self._tools.pop(run_id, "tool"), error))
|
|
450
|
+
|
|
451
|
+
def on_agent_action(self, action, *, run_id, **kw):
|
|
452
|
+
node = self._node_of.get(run_id)
|
|
453
|
+
log_text = (getattr(action, "log", "") or "").strip()
|
|
454
|
+
emit("thought", node, log_text or "Action: %s" % getattr(action, "tool", "?"))
|
|
455
|
+
|
|
456
|
+
def on_agent_finish(self, finish, *, run_id, **kw):
|
|
457
|
+
node = self._node_of.get(run_id)
|
|
458
|
+
values = getattr(finish, "return_values", {}) or {}
|
|
459
|
+
emit("output", node, values.get("output") or getattr(finish, "log", ""))
|
|
460
|
+
|
|
461
|
+
return VisualAgentsCallbackHandler()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _hook_langchain(_module=None):
|
|
465
|
+
from langchain_core.tracers import context as ctx
|
|
466
|
+
|
|
467
|
+
if getattr(ctx, "_visualagents_hooked", False):
|
|
468
|
+
return
|
|
469
|
+
from contextvars import ContextVar
|
|
470
|
+
|
|
471
|
+
ctx.register_configure_hook(ContextVar("visualagents_handler", default=langchain_handler()), True)
|
|
472
|
+
ctx._visualagents_hooked = True
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# -- OpenAI Agents SDK ------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def openai_agents_processor():
|
|
478
|
+
from agents.tracing import TracingProcessor
|
|
479
|
+
|
|
480
|
+
class VisualAgentsProcessor(TracingProcessor):
|
|
481
|
+
def __init__(self):
|
|
482
|
+
self._agent_of = {}
|
|
483
|
+
|
|
484
|
+
def on_trace_start(self, trace):
|
|
485
|
+
pass
|
|
486
|
+
|
|
487
|
+
def on_trace_end(self, trace):
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
def on_span_start(self, span):
|
|
491
|
+
try:
|
|
492
|
+
sd = span.span_data
|
|
493
|
+
typ = getattr(sd, "type", "")
|
|
494
|
+
if typ == "agent":
|
|
495
|
+
self._agent_of[span.span_id] = sd.name
|
|
496
|
+
emit("agent_start", sd.name)
|
|
497
|
+
return
|
|
498
|
+
agent_name = self._agent_of.get(span.parent_id)
|
|
499
|
+
self._agent_of[span.span_id] = agent_name
|
|
500
|
+
if typ == "function":
|
|
501
|
+
emit("tool_start", agent_name, getattr(sd, "input", None), tool=getattr(sd, "name", "tool"))
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
def on_span_end(self, span):
|
|
506
|
+
try:
|
|
507
|
+
sd = span.span_data
|
|
508
|
+
typ = getattr(sd, "type", "")
|
|
509
|
+
agent_name = self._agent_of.pop(span.span_id, None)
|
|
510
|
+
if typ == "agent":
|
|
511
|
+
emit("agent_end", sd.name)
|
|
512
|
+
elif typ == "function":
|
|
513
|
+
emit("tool_end", agent_name, getattr(sd, "output", None), tool=getattr(sd, "name", "tool"))
|
|
514
|
+
elif typ == "handoff":
|
|
515
|
+
emit("handoff", None, None, **{"from": getattr(sd, "from_agent", None), "to": getattr(sd, "to_agent", None)})
|
|
516
|
+
elif typ == "response":
|
|
517
|
+
text = getattr(getattr(sd, "response", None), "output_text", None)
|
|
518
|
+
if text:
|
|
519
|
+
emit("thought", agent_name, text)
|
|
520
|
+
elif typ == "generation":
|
|
521
|
+
out = getattr(sd, "output", None) or []
|
|
522
|
+
texts = [m.get("content") for m in out if isinstance(m, dict) and isinstance(m.get("content"), str)]
|
|
523
|
+
if texts:
|
|
524
|
+
emit("thought", agent_name, "\n".join(texts))
|
|
525
|
+
err = getattr(span, "error", None)
|
|
526
|
+
if err:
|
|
527
|
+
emit("error", agent_name, err.get("message") if isinstance(err, dict) else str(err))
|
|
528
|
+
except Exception:
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
def shutdown(self):
|
|
532
|
+
flush()
|
|
533
|
+
|
|
534
|
+
def force_flush(self):
|
|
535
|
+
flush()
|
|
536
|
+
|
|
537
|
+
return VisualAgentsProcessor()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _hook_openai_agents(module):
|
|
541
|
+
# "agents" is also a common name for a project's own package - only hook the real SDK.
|
|
542
|
+
if not hasattr(module, "add_trace_processor") or getattr(module, "_visualagents_hooked", False):
|
|
543
|
+
return
|
|
544
|
+
module.add_trace_processor(openai_agents_processor())
|
|
545
|
+
module._visualagents_hooked = True
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# -- raw OpenAI SDK (apps that call chat.completions.create themselves, e.g. LAMBDA) ------
|
|
549
|
+
|
|
550
|
+
# Frameworks that already report their own agents (instrumented above, or printing their own logs).
|
|
551
|
+
_FRAMEWORK_MARKERS = ("langchain", "langgraph", "crewai", "litellm", "autogen", "llama_index", "smolagents",
|
|
552
|
+
os.sep + "agents" + os.sep, "dspy", "haystack", "semantic_kernel")
|
|
553
|
+
_LIB_MARKERS = ("site-packages", "dist-packages")
|
|
554
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
555
|
+
_last_task = {}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _stdlib_dirs():
|
|
559
|
+
import sysconfig
|
|
560
|
+
|
|
561
|
+
paths = sysconfig.get_paths()
|
|
562
|
+
# os.__file__ is the real standard library even inside a virtualenv
|
|
563
|
+
return tuple(p for p in {os.path.dirname(os.__file__), paths.get("stdlib"), paths.get("platstdlib")} if p)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
_STDLIB = _stdlib_dirs()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _is_library(filename):
|
|
570
|
+
if not filename or filename.startswith("<"):
|
|
571
|
+
return True
|
|
572
|
+
return (any(m in filename for m in _LIB_MARKERS) or filename.startswith(_STDLIB)
|
|
573
|
+
or os.path.dirname(os.path.abspath(filename)) == _HERE)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _caller_agent():
|
|
577
|
+
"""Name of the code that made the LLM call: the nearest user class (Programmer, Inspector, ...) or
|
|
578
|
+
function. Returns False when a framework that reports itself is on the stack."""
|
|
579
|
+
frame = sys._getframe(2)
|
|
580
|
+
hint = None
|
|
581
|
+
while frame is not None:
|
|
582
|
+
filename = frame.f_code.co_filename or ""
|
|
583
|
+
if any(m in filename for m in _FRAMEWORK_MARKERS) and _is_library(filename):
|
|
584
|
+
return False
|
|
585
|
+
if hint is None and not _is_library(filename):
|
|
586
|
+
self_obj = frame.f_locals.get("self")
|
|
587
|
+
if self_obj is not None:
|
|
588
|
+
hint = type(self_obj).__name__
|
|
589
|
+
elif frame.f_code.co_name not in ("<module>", "<lambda>", "<listcomp>", "<genexpr>", "<dictcomp>"):
|
|
590
|
+
hint = frame.f_code.co_name
|
|
591
|
+
frame = frame.f_back
|
|
592
|
+
return hint
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _get(obj, key, default=None):
|
|
596
|
+
if isinstance(obj, dict):
|
|
597
|
+
return obj.get(key, default)
|
|
598
|
+
return getattr(obj, key, default)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class _LLMCall:
|
|
602
|
+
def __init__(self, kwargs):
|
|
603
|
+
self.agent = _caller_agent()
|
|
604
|
+
self.off = self.agent is False
|
|
605
|
+
if self.off:
|
|
606
|
+
return
|
|
607
|
+
self.content = []
|
|
608
|
+
self.reasoning = []
|
|
609
|
+
self.tools = {}
|
|
610
|
+
self.finished = False
|
|
611
|
+
messages = kwargs.get("messages") or []
|
|
612
|
+
# results of the tools requested last turn arrive as trailing tool/function messages
|
|
613
|
+
results = []
|
|
614
|
+
for m in reversed(list(messages)):
|
|
615
|
+
if _get(m, "role") in ("tool", "function"):
|
|
616
|
+
results.append(m)
|
|
617
|
+
else:
|
|
618
|
+
break
|
|
619
|
+
for m in reversed(results):
|
|
620
|
+
emit("tool_end", self.agent, _content_text(_get(m, "content")), tool=_get(m, "name"), hint=True)
|
|
621
|
+
task = None
|
|
622
|
+
last_user = next((m for m in reversed(list(messages)) if _get(m, "role") == "user"), None)
|
|
623
|
+
if last_user is not None:
|
|
624
|
+
text = _content_text(_get(last_user, "content"))
|
|
625
|
+
if text and _last_task.get(self.agent) != text:
|
|
626
|
+
_last_task[self.agent] = text
|
|
627
|
+
task = text
|
|
628
|
+
emit("agent_start", self.agent, task, hint=True, model=kwargs.get("model"))
|
|
629
|
+
|
|
630
|
+
def absorb_message(self, msg):
|
|
631
|
+
if msg is None:
|
|
632
|
+
return
|
|
633
|
+
self._add_text(_get(msg, "content"), _get(msg, "reasoning_content") or _get(msg, "reasoning"))
|
|
634
|
+
for i, tc in enumerate(_get(msg, "tool_calls") or []):
|
|
635
|
+
fn = _get(tc, "function")
|
|
636
|
+
self.tools[i] = [_get(fn, "name") or "tool", _get(fn, "arguments") or ""]
|
|
637
|
+
fc = _get(msg, "function_call")
|
|
638
|
+
if fc is not None:
|
|
639
|
+
self.tools["legacy"] = [_get(fc, "name") or "function", _get(fc, "arguments") or ""]
|
|
640
|
+
|
|
641
|
+
def absorb_chunk(self, chunk):
|
|
642
|
+
try:
|
|
643
|
+
choices = _get(chunk, "choices") or []
|
|
644
|
+
if not choices:
|
|
645
|
+
return
|
|
646
|
+
delta = _get(choices[0], "delta")
|
|
647
|
+
if delta is None:
|
|
648
|
+
return
|
|
649
|
+
self._add_text(_get(delta, "content"), _get(delta, "reasoning_content") or _get(delta, "reasoning"))
|
|
650
|
+
for tc in _get(delta, "tool_calls") or []:
|
|
651
|
+
slot = self.tools.setdefault(_get(tc, "index", 0), ["", ""])
|
|
652
|
+
fn = _get(tc, "function")
|
|
653
|
+
if fn is not None:
|
|
654
|
+
slot[0] = slot[0] or _get(fn, "name") or ""
|
|
655
|
+
slot[1] += _get(fn, "arguments") or ""
|
|
656
|
+
fc = _get(delta, "function_call")
|
|
657
|
+
if fc is not None:
|
|
658
|
+
slot = self.tools.setdefault("legacy", ["", ""])
|
|
659
|
+
slot[0] = slot[0] or _get(fc, "name") or ""
|
|
660
|
+
slot[1] += _get(fc, "arguments") or ""
|
|
661
|
+
except Exception:
|
|
662
|
+
pass
|
|
663
|
+
|
|
664
|
+
def _add_text(self, content, reasoning):
|
|
665
|
+
if isinstance(content, str) and content:
|
|
666
|
+
self.content.append(content)
|
|
667
|
+
if isinstance(reasoning, str) and reasoning:
|
|
668
|
+
self.reasoning.append(reasoning)
|
|
669
|
+
|
|
670
|
+
def finish(self, error=None):
|
|
671
|
+
if self.off or self.finished:
|
|
672
|
+
return
|
|
673
|
+
self.finished = True
|
|
674
|
+
if self.reasoning:
|
|
675
|
+
emit("thought", self.agent, "".join(self.reasoning).strip(), hint=True)
|
|
676
|
+
text = "".join(self.content).strip()
|
|
677
|
+
if text:
|
|
678
|
+
emit("thought" if self.tools else "output", self.agent, text, hint=True)
|
|
679
|
+
for name, args in self.tools.values():
|
|
680
|
+
emit("tool_start", self.agent, args, tool=name or "tool", hint=True)
|
|
681
|
+
if error is not None:
|
|
682
|
+
emit("error", self.agent, "%s: %s" % (type(error).__name__, error), hint=True)
|
|
683
|
+
emit("agent_end", self.agent, hint=True)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _content_text(content):
|
|
687
|
+
if isinstance(content, list):
|
|
688
|
+
parts = [_get(p, "text") for p in content]
|
|
689
|
+
content = "\n".join(p for p in parts if isinstance(p, str))
|
|
690
|
+
return content if isinstance(content, str) else (None if content is None else str(content))
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
class _StreamProxy:
|
|
694
|
+
"""Passes a streamed completion through untouched while collecting what the model said."""
|
|
695
|
+
|
|
696
|
+
def __init__(self, stream, call):
|
|
697
|
+
self._stream = stream
|
|
698
|
+
self._call = call
|
|
699
|
+
self._it = None
|
|
700
|
+
|
|
701
|
+
def __iter__(self):
|
|
702
|
+
return self
|
|
703
|
+
|
|
704
|
+
def __next__(self):
|
|
705
|
+
if self._it is None:
|
|
706
|
+
self._it = iter(self._stream)
|
|
707
|
+
try:
|
|
708
|
+
chunk = next(self._it)
|
|
709
|
+
except StopIteration:
|
|
710
|
+
self._call.finish()
|
|
711
|
+
raise
|
|
712
|
+
except BaseException as exc:
|
|
713
|
+
self._call.finish(exc)
|
|
714
|
+
raise
|
|
715
|
+
self._call.absorb_chunk(chunk)
|
|
716
|
+
return chunk
|
|
717
|
+
|
|
718
|
+
def __enter__(self):
|
|
719
|
+
return self
|
|
720
|
+
|
|
721
|
+
def __exit__(self, *exc):
|
|
722
|
+
self._call.finish()
|
|
723
|
+
close = getattr(self._stream, "close", None)
|
|
724
|
+
if close:
|
|
725
|
+
close()
|
|
726
|
+
return False
|
|
727
|
+
|
|
728
|
+
def __getattr__(self, name):
|
|
729
|
+
return getattr(self._stream, name)
|
|
730
|
+
|
|
731
|
+
def __del__(self):
|
|
732
|
+
try:
|
|
733
|
+
self._call.finish()
|
|
734
|
+
except Exception:
|
|
735
|
+
pass
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
class _AsyncStreamProxy(_StreamProxy):
|
|
739
|
+
def __aiter__(self):
|
|
740
|
+
return self
|
|
741
|
+
|
|
742
|
+
async def __anext__(self):
|
|
743
|
+
if self._it is None:
|
|
744
|
+
self._it = self._stream.__aiter__()
|
|
745
|
+
try:
|
|
746
|
+
chunk = await self._it.__anext__()
|
|
747
|
+
except StopAsyncIteration:
|
|
748
|
+
self._call.finish()
|
|
749
|
+
raise
|
|
750
|
+
except BaseException as exc:
|
|
751
|
+
self._call.finish(exc)
|
|
752
|
+
raise
|
|
753
|
+
self._call.absorb_chunk(chunk)
|
|
754
|
+
return chunk
|
|
755
|
+
|
|
756
|
+
async def __aenter__(self):
|
|
757
|
+
return self
|
|
758
|
+
|
|
759
|
+
async def __aexit__(self, *exc):
|
|
760
|
+
self._call.finish()
|
|
761
|
+
close = getattr(self._stream, "close", None)
|
|
762
|
+
if close:
|
|
763
|
+
result = close()
|
|
764
|
+
if inspect.isawaitable(result):
|
|
765
|
+
await result
|
|
766
|
+
return False
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _finish_response(call, result, streaming, is_async):
|
|
770
|
+
if call.off:
|
|
771
|
+
return result
|
|
772
|
+
if streaming:
|
|
773
|
+
return _AsyncStreamProxy(result, call) if is_async else _StreamProxy(result, call)
|
|
774
|
+
try:
|
|
775
|
+
choices = _get(result, "choices") or []
|
|
776
|
+
call.absorb_message(_get(choices[0], "message") if choices else None)
|
|
777
|
+
finally:
|
|
778
|
+
call.finish()
|
|
779
|
+
return result
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _hook_openai(_module=None):
|
|
783
|
+
from openai.resources.chat import completions as cc
|
|
784
|
+
|
|
785
|
+
for name, is_async in (("Completions", False), ("AsyncCompletions", True)):
|
|
786
|
+
cls = getattr(cc, name, None)
|
|
787
|
+
if cls is None or getattr(cls.create, "_visualagents", False):
|
|
788
|
+
continue
|
|
789
|
+
original = cls.create
|
|
790
|
+
if is_async:
|
|
791
|
+
async def create(self, *args, _original=original, **kwargs):
|
|
792
|
+
try:
|
|
793
|
+
call = _LLMCall(kwargs)
|
|
794
|
+
except Exception:
|
|
795
|
+
return await _original(self, *args, **kwargs)
|
|
796
|
+
try:
|
|
797
|
+
result = await _original(self, *args, **kwargs)
|
|
798
|
+
except BaseException as exc:
|
|
799
|
+
call.finish(exc)
|
|
800
|
+
raise
|
|
801
|
+
return _finish_response(call, result, bool(kwargs.get("stream")), True)
|
|
802
|
+
else:
|
|
803
|
+
def create(self, *args, _original=original, **kwargs):
|
|
804
|
+
try:
|
|
805
|
+
call = _LLMCall(kwargs)
|
|
806
|
+
except Exception:
|
|
807
|
+
return _original(self, *args, **kwargs)
|
|
808
|
+
try:
|
|
809
|
+
result = _original(self, *args, **kwargs)
|
|
810
|
+
except BaseException as exc:
|
|
811
|
+
call.finish(exc)
|
|
812
|
+
raise
|
|
813
|
+
return _finish_response(call, result, bool(kwargs.get("stream")), False)
|
|
814
|
+
functools.update_wrapper(create, original)
|
|
815
|
+
create._visualagents = True
|
|
816
|
+
cls.create = create
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
# -- import hooks -----------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
_HOOKS = {"langchain_core.callbacks.manager": _hook_langchain, "agents": _hook_openai_agents, "openai": _hook_openai}
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
class _PostImportFinder(importlib.abc.MetaPathFinder):
|
|
825
|
+
def __init__(self):
|
|
826
|
+
self._busy = set()
|
|
827
|
+
|
|
828
|
+
def find_spec(self, name, path=None, target=None):
|
|
829
|
+
if name not in _HOOKS or name in self._busy:
|
|
830
|
+
return None
|
|
831
|
+
self._busy.add(name)
|
|
832
|
+
try:
|
|
833
|
+
spec = importlib.util.find_spec(name)
|
|
834
|
+
except Exception:
|
|
835
|
+
spec = None
|
|
836
|
+
finally:
|
|
837
|
+
self._busy.discard(name)
|
|
838
|
+
if spec is None or spec.loader is None or not hasattr(spec.loader, "exec_module"):
|
|
839
|
+
return spec
|
|
840
|
+
loader = spec.loader
|
|
841
|
+
original = loader.exec_module
|
|
842
|
+
|
|
843
|
+
def exec_module(module):
|
|
844
|
+
original(module)
|
|
845
|
+
try:
|
|
846
|
+
_HOOKS[name](module)
|
|
847
|
+
except Exception:
|
|
848
|
+
pass
|
|
849
|
+
|
|
850
|
+
loader.exec_module = exec_module
|
|
851
|
+
return spec
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def instrument():
|
|
855
|
+
"""Hook supported frameworks: immediately if already imported, otherwise when they are imported."""
|
|
856
|
+
for name, hook in _HOOKS.items():
|
|
857
|
+
mod = sys.modules.get(name)
|
|
858
|
+
if mod is not None:
|
|
859
|
+
try:
|
|
860
|
+
hook(mod)
|
|
861
|
+
except Exception:
|
|
862
|
+
pass
|
|
863
|
+
if not any(isinstance(f, _PostImportFinder) for f in sys.meta_path):
|
|
864
|
+
sys.meta_path.insert(0, _PostImportFinder())
|