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,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())