agentworkflow-viz 0.2.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.
agentviz/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from agentviz.config import (
5
+ VizConfig,
6
+ VizStyle,
7
+ NodeStyle,
8
+ SectionVisibility,
9
+ ThemeColors,
10
+ VizCustomization,
11
+ NodeCustomProps,
12
+ NodeRenderer,
13
+ )
14
+ from agentviz.client import VizClient
15
+ from agentviz.server import _run_server, mount_app
16
+ from agentviz.tracer import LangGraphTracer, VizTracer
17
+
18
+
19
+ def _read_env_config():
20
+ enabled = os.environ.get("AGENT_VIZ_ENABLED", "true").lower() not in ("false", "0", "no")
21
+ port = int(os.environ.get("AGENT_VIZ_PORT", "8100"))
22
+ host = os.environ.get("AGENT_VIZ_HOST", "0.0.0.0")
23
+ mount = os.environ.get("AGENT_VIZ_MOUNT", "false").lower() in ("true", "1", "yes")
24
+ return VizConfig(enable=enabled, port=port, host=host, mount=mount)
25
+
26
+
27
+ def _merge_config(user_cfg, env_cfg):
28
+ return VizConfig(
29
+ enable=user_cfg.enable if user_cfg.enable is not None else env_cfg.enable,
30
+ port=user_cfg.port if user_cfg.port is not None else env_cfg.port,
31
+ host=user_cfg.host if user_cfg.host is not None else env_cfg.host,
32
+ mount=user_cfg.mount if user_cfg.mount is not None else env_cfg.mount,
33
+ style=user_cfg.style if user_cfg.style is not None else env_cfg.style,
34
+ customization=user_cfg.customization if user_cfg.customization is not None else env_cfg.customization,
35
+ )
36
+
37
+
38
+ class VizPlugin:
39
+ def __init__(self, config: Optional[VizConfig] = None):
40
+ env_cfg = _read_env_config()
41
+ if config is None:
42
+ self.config = env_cfg
43
+ else:
44
+ self.config = _merge_config(config, env_cfg)
45
+ self._ready = False
46
+ self._client = VizClient(
47
+ host=self.config.host,
48
+ port=self.config.port,
49
+ style=self.config.style,
50
+ customization=self.config.customization,
51
+ )
52
+ self._tracer = LangGraphTracer(client=self._client) if self.config.enable else None
53
+
54
+ def start(self):
55
+ if not self.config.enable:
56
+ self._ready = True
57
+ return
58
+ if self.config.mount:
59
+ self._ready = True
60
+ return
61
+ _run_server(self.config, client=self._client)
62
+ self._client.push_style()
63
+ self._client.push_customization()
64
+ self._ready = True
65
+
66
+ def stop(self):
67
+ self._client.flush()
68
+ self._client.close()
69
+
70
+ def wrap(self, graph):
71
+ if not self.config.enable:
72
+ return graph
73
+ return self._tracer.wrap(graph)
74
+
75
+
76
+ __all__ = [
77
+ "VizPlugin",
78
+ "VizConfig",
79
+ "VizStyle",
80
+ "NodeStyle",
81
+ "SectionVisibility",
82
+ "ThemeColors",
83
+ "VizCustomization",
84
+ "NodeCustomProps",
85
+ "NodeRenderer",
86
+ "VizTracer",
87
+ "mount_app",
88
+ ]
agentviz/client.py ADDED
@@ -0,0 +1,151 @@
1
+ import threading
2
+ import time
3
+ from collections import deque
4
+ from dataclasses import asdict
5
+ from typing import Optional
6
+
7
+ import requests
8
+
9
+ from agentviz.config import VizStyle, VizCustomization, NodeRenderer
10
+
11
+
12
+ class VizClient:
13
+ MAX_BUFFER = 200
14
+ MAX_RETRIES = 3
15
+ BACKOFF_BASE = 0.1
16
+
17
+ def __init__(self, host: str = "0.0.0.0", port: int = 8100, style: Optional[VizStyle] = None, customization: Optional[VizCustomization] = None):
18
+ self._host = host
19
+ self._port = port
20
+ self._url = f"http://{host}:{port}"
21
+ self._lock = threading.Lock()
22
+ self._trace_log: deque[dict] = deque(maxlen=self.MAX_BUFFER)
23
+ self._graph_data: Optional[dict] = None
24
+ self._trace_id = 0
25
+ self._closed = False
26
+ self._style = style
27
+ self._customization = customization
28
+
29
+ @property
30
+ def trace_log(self):
31
+ return list(self._trace_log)
32
+
33
+ @property
34
+ def graph_data(self):
35
+ return self._graph_data
36
+
37
+ @graph_data.setter
38
+ def graph_data(self, value: dict):
39
+ self._graph_data = value
40
+
41
+ @property
42
+ def style_config(self) -> Optional[dict]:
43
+ if self._style is None:
44
+ return None
45
+ return self._style_to_dict(self._style)
46
+
47
+ @staticmethod
48
+ def _style_to_dict(style: VizStyle) -> dict:
49
+ out = {}
50
+ ns = {}
51
+ for node_id, nst in style.node_styles.items():
52
+ ns[node_id] = asdict(nst)
53
+ out["node_styles"] = ns
54
+ out["sections"] = asdict(style.sections)
55
+ tc = {}
56
+ for k, v in asdict(style.theme).items():
57
+ if v is not None:
58
+ tc[k] = v
59
+ out["theme"] = tc
60
+ out["title"] = style.title
61
+ out["show_groups"] = style.show_groups
62
+ return out
63
+
64
+ def _build_customization_payload(self) -> Optional[dict]:
65
+ if self._customization is None:
66
+ return None
67
+ cfg = self._customization
68
+ out = {}
69
+ out["title"] = cfg.title
70
+ out["show_groups"] = cfg.show_groups
71
+ out["custom_css"] = cfg.custom_css
72
+ out["custom_js"] = cfg.custom_js
73
+ out["custom_html_header"] = cfg.custom_html_header
74
+ out["custom_html_body"] = cfg.custom_html_body
75
+ if cfg.theme_colors is not None:
76
+ tc = {}
77
+ for k, v in asdict(cfg.theme_colors).items():
78
+ if v is not None:
79
+ tc[k] = v
80
+ out["theme"] = tc
81
+ if cfg.section_visibility is not None:
82
+ out["sections"] = asdict(cfg.section_visibility)
83
+ node_props = {}
84
+ for node_id, ncp in cfg.node_props.items():
85
+ node_props[node_id] = asdict(ncp)
86
+ if cfg.node_renderer is not None:
87
+ node_props["_renderer"] = True
88
+ out["node_props"] = node_props
89
+ return out
90
+
91
+ def _apply_node_renderer(self, nodes: list) -> list:
92
+ if self._customization is None or self._customization.node_renderer is None:
93
+ return nodes
94
+ renderer: NodeRenderer = self._customization.node_renderer
95
+ result = []
96
+ for node in nodes:
97
+ n = dict(node)
98
+ nid = n.get("id", "")
99
+ rendered = renderer.render(nid, n)
100
+ if rendered:
101
+ existing = n.get("_custom_props") or {}
102
+ existing.update(rendered)
103
+ n["_custom_props"] = existing
104
+ result.append(n)
105
+ return result
106
+
107
+ def _next_id(self):
108
+ with self._lock:
109
+ self._trace_id += 1
110
+ return self._trace_id
111
+
112
+ def push_graph(self, nodes: list, edges: list):
113
+ enriched = self._apply_node_renderer(nodes)
114
+ self.graph_data = {"nodes": enriched, "edges": edges}
115
+ self._http_post("/api/graph/push", {"nodes": enriched, "edges": edges})
116
+
117
+ def push_style(self):
118
+ if self._style is not None:
119
+ payload = self.style_config
120
+ self._http_post("/api/style/push", payload)
121
+
122
+ def push_customization(self):
123
+ payload = self._build_customization_payload()
124
+ if payload is not None:
125
+ self._http_post("/api/customize/push", payload)
126
+
127
+ def push_trace(self, node: str, duration: Optional[float] = None):
128
+ tid = self._next_id()
129
+ entry = {"id": tid, "node": node, "duration": duration}
130
+ with self._lock:
131
+ self._trace_log.append(entry)
132
+ self._http_post("/api/trace/push", entry)
133
+
134
+ def _http_post(self, path: str, payload: dict):
135
+ for attempt in range(self.MAX_RETRIES):
136
+ try:
137
+ requests.post(
138
+ f"{self._url}{path}",
139
+ json=payload,
140
+ timeout=2,
141
+ )
142
+ return
143
+ except Exception:
144
+ if attempt < self.MAX_RETRIES - 1:
145
+ time.sleep(self.BACKOFF_BASE * (2 ** attempt))
146
+
147
+ def flush(self):
148
+ pass
149
+
150
+ def close(self):
151
+ self._closed = True
agentviz/config.py ADDED
@@ -0,0 +1,123 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Callable, Optional
3
+
4
+
5
+ @dataclass
6
+ class NodeStyle:
7
+ fill: Optional[str] = None
8
+ stroke: Optional[str] = None
9
+ text_color: Optional[str] = None
10
+ stroke_width: Optional[float] = None
11
+ shape: Optional[str] = None
12
+
13
+
14
+ @dataclass
15
+ class SectionVisibility:
16
+ graph: bool = True
17
+ nodes_table: bool = True
18
+ trace_panel: bool = True
19
+ header: bool = True
20
+
21
+
22
+ @dataclass
23
+ class ThemeColors:
24
+ bg: Optional[str] = None
25
+ surface: Optional[str] = None
26
+ text: Optional[str] = None
27
+ accent: Optional[str] = None
28
+ border: Optional[str] = None
29
+
30
+
31
+ @dataclass
32
+ class VizStyle:
33
+ node_styles: dict[str, NodeStyle] = field(default_factory=dict)
34
+ sections: SectionVisibility = field(default_factory=SectionVisibility)
35
+ theme: ThemeColors = field(default_factory=ThemeColors)
36
+ title: str = "Agent Graph"
37
+ show_groups: bool = True
38
+
39
+
40
+ @dataclass
41
+ class NodeCustomProps:
42
+ """Rich per-node customization sent to the frontend renderer."""
43
+ width: Optional[int] = None
44
+ height: Optional[int] = None
45
+ icon: Optional[str] = None
46
+ css_class: Optional[str] = None
47
+ html_template: Optional[str] = None
48
+ metadata: dict = field(default_factory=dict)
49
+
50
+
51
+ class NodeRenderer:
52
+ """Callable-based Python node renderer.
53
+
54
+ Users implement ``render(self, node_id, node_data)`` to return a dict
55
+ of custom properties that override the default rendering for that node.
56
+
57
+ Returns a dict with any subset of keys from :class:`NodeCustomProps`.
58
+ Example::
59
+
60
+ class MyRenderer(NodeRenderer):
61
+ def render(self, node_id, node_data):
62
+ if node_id == "agent":
63
+ return {
64
+ "icon": "🤖",
65
+ "css_class": "custom-agent",
66
+ "width": 160,
67
+ "height": 60,
68
+ "metadata": {"custom_key": "value"},
69
+ }
70
+ return {}
71
+ """
72
+
73
+ def render(self, node_id: str, node_data: dict) -> dict:
74
+ return {}
75
+
76
+
77
+ @dataclass
78
+ class VizCustomization:
79
+ """Full customization config that bridges Python and the frontend.
80
+
81
+ Parameters
82
+ ----------
83
+ node_renderer : NodeRenderer | None
84
+ Python-side renderer called for each node to compute custom props.
85
+ node_props : dict[str, NodeCustomProps]
86
+ Static per-node overrides (merged with renderer output).
87
+ custom_css : str | None
88
+ Raw CSS injected into the page via a ``<style>`` tag.
89
+ custom_js : str | None
90
+ Raw JS injected into the page via a ``<script>`` tag.
91
+ custom_html_header : str | None
92
+ HTML injected into ``<head>`` (useful for external CSS/JS links).
93
+ custom_html_body : str | None
94
+ HTML injected at the end of ``<body>``.
95
+ theme_colors : ThemeColors | None
96
+ Theme overrides.
97
+ title : str
98
+ Page and header title.
99
+ show_groups : bool
100
+ Whether to draw group containers in the SVG graph.
101
+ section_visibility : SectionVisibility | None
102
+ Which UI sections to show/hide.
103
+ """
104
+ node_renderer: Optional[NodeRenderer] = None
105
+ node_props: dict[str, NodeCustomProps] = field(default_factory=dict)
106
+ custom_css: Optional[str] = None
107
+ custom_js: Optional[str] = None
108
+ custom_html_header: Optional[str] = None
109
+ custom_html_body: Optional[str] = None
110
+ theme_colors: Optional[ThemeColors] = None
111
+ title: str = "Agent Graph"
112
+ show_groups: bool = True
113
+ section_visibility: Optional[SectionVisibility] = None
114
+
115
+
116
+ @dataclass
117
+ class VizConfig:
118
+ enable: bool = True
119
+ port: int = 8100
120
+ host: str = "0.0.0.0"
121
+ mount: bool = False
122
+ style: Optional[VizStyle] = None
123
+ customization: Optional[VizCustomization] = None
agentviz/server.py ADDED
@@ -0,0 +1,104 @@
1
+ import os
2
+ import threading
3
+ import time
4
+ from typing import Optional
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
9
+
10
+ from agentviz.client import VizClient
11
+
12
+
13
+ _static_dir = os.path.join(os.path.dirname(__file__), "static")
14
+
15
+
16
+ def _create_app(client: Optional[VizClient] = None) -> FastAPI:
17
+ app = FastAPI(title="Agent Viz", version="0.1.0")
18
+ _style_cache: dict = {}
19
+ _customize_cache: dict = {}
20
+
21
+ @app.get("/health")
22
+ async def health():
23
+ return {"status": "ok"}
24
+
25
+ @app.get("/api/config")
26
+ async def api_config():
27
+ customize = _customize_cache if _customize_cache else None
28
+ return {
29
+ "style": _style_cache if _style_cache else None,
30
+ "customize": customize,
31
+ }
32
+
33
+ @app.post("/api/style/push")
34
+ async def style_push(data: dict):
35
+ nonlocal _style_cache
36
+ _style_cache = data
37
+ return {"status": "ok"}
38
+
39
+ @app.post("/api/customize/push")
40
+ async def customize_push(data: dict):
41
+ nonlocal _customize_cache
42
+ _customize_cache = data
43
+ return {"status": "ok"}
44
+
45
+ @app.get("/api/graph")
46
+ async def api_graph():
47
+ if client is None or client.graph_data is None:
48
+ raise HTTPException(status_code=503, detail="No graph data available")
49
+ return client.graph_data
50
+
51
+ @app.get("/api/trace")
52
+ async def api_trace(after: int = 0):
53
+ if client is None:
54
+ return []
55
+ return [t for t in client.trace_log if t["id"] > after]
56
+
57
+ @app.post("/api/trace/push")
58
+ async def trace_push(entry: dict):
59
+ if client is not None:
60
+ with client._lock:
61
+ client._trace_log.append(entry)
62
+ return {"status": "ok"}
63
+
64
+ @app.post("/api/graph/push")
65
+ async def graph_push(data: dict):
66
+ if client is not None:
67
+ client.graph_data = data
68
+ return {"status": "ok"}
69
+
70
+ app.mount("/static", StaticFiles(directory=_static_dir), name="static")
71
+
72
+ @app.get("/graph")
73
+ async def graph_page():
74
+ html_path = os.path.join(_static_dir, "graph.html")
75
+ return FileResponse(html_path)
76
+
77
+ return app
78
+
79
+
80
+ def mount_app(client: Optional[VizClient] = None) -> FastAPI:
81
+ return _create_app(client)
82
+
83
+
84
+ def _run_server(config, client: Optional[VizClient] = None):
85
+ import uvicorn
86
+
87
+ app = _create_app(client)
88
+
89
+ cfg = uvicorn.Config(
90
+ app,
91
+ host=config.host,
92
+ port=config.port,
93
+ log_level="warning",
94
+ loop="asyncio",
95
+ )
96
+ server = uvicorn.Server(cfg)
97
+
98
+ def _run():
99
+ server.run()
100
+
101
+ thread = threading.Thread(target=_run, daemon=True)
102
+ thread.start()
103
+ time.sleep(0.5)
104
+ return server