File size: 25,628 Bytes
3193174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
"""
Visualisation of agent graphs.

Supports:
- Mermaid (for Markdown/GitHub/documentation)
- ASCII art (for the terminal)
- Graphviz DOT (for external tools)
- Rich Console (coloured terminal output)

Usage:
    from core.visualization import GraphVisualizer

    viz = GraphVisualizer(graph)
    print(viz.to_mermaid())
    print(viz.to_ascii())
    viz.print_colored()  # Rich console output
"""

import contextlib
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, Field

# Constants for magic values
MAX_TOOLS_PREVIEW = 3
MAX_SHORT_NAME_LENGTH = 8
SHORT_NAME_PREFIX_LENGTH = 6
MAX_DESCRIPTION_LENGTH = 60
MAX_EDGES_DISPLAY = 15

__all__ = [
    "EdgeStyle",
    "GraphVisualizer",
    "ImageFormat",
    "MermaidDirection",
    "NodeStyle",
    "VisualizationStyle",
    "print_graph",
    "render_to_image",
    "show_graph_interactive",
    "to_ascii",
    "to_dot",
    "to_mermaid",
]

if TYPE_CHECKING:
    from core.graph import RoleGraph


class MermaidDirection(str, Enum):
    """Graph direction in Mermaid."""

    TOP_BOTTOM = "TB"
    BOTTOM_TOP = "BT"
    LEFT_RIGHT = "LR"
    RIGHT_LEFT = "RL"


class ImageFormat(str, Enum):
    """
    Supported image formats for Graphviz.

    Used in render_image() / render_to_image().
    The format can be omitted β€” it will be inferred from the file extension.
    """

    PNG = "png"
    SVG = "svg"
    PDF = "pdf"
    JPEG = "jpg"

    @classmethod
    def from_path(cls, path: "str | Path") -> "ImageFormat":
        """Determine format from the file extension, default PNG."""
        suffix = Path(path).suffix.lstrip(".").lower()
        if suffix == "jpeg":
            suffix = "jpg"
        with contextlib.suppress(ValueError):
            return cls(suffix)
        return cls.PNG


class NodeShape(str, Enum):
    """Node shapes in Mermaid."""

    RECTANGLE = "rect"
    ROUND = "round"
    STADIUM = "stadium"
    CIRCLE = "circle"
    DIAMOND = "diamond"
    HEXAGON = "hexagon"
    PARALLELOGRAM = "parallelogram"
    TRAPEZOID = "trapezoid"


class NodeStyle(BaseModel):
    """Node display style."""

    shape: NodeShape = NodeShape.ROUND
    fill_color: str = "#e1f5fe"
    stroke_color: str = "#01579b"
    text_color: str = "#000000"
    icon: str = ""  # Emoji or symbol


class EdgeStyle(BaseModel):
    """Edge display style."""

    line_style: str = "solid"  # solid, dashed, dotted
    arrow_head: str = "normal"  # normal, none, diamond
    color: str = "#666666"
    label_color: str = "#333333"


class VisualizationStyle(BaseModel):
    """General visualisation style."""

    direction: MermaidDirection = MermaidDirection.TOP_BOTTOM
    agent_style: NodeStyle = Field(
        default_factory=lambda: NodeStyle(
            shape=NodeShape.ROUND,
            fill_color="#e3f2fd",
            stroke_color="#1976d2",
            icon="πŸ€–",
        )
    )
    task_style: NodeStyle = Field(
        default_factory=lambda: NodeStyle(
            shape=NodeShape.DIAMOND,
            fill_color="#fff3e0",
            stroke_color="#f57c00",
            icon="πŸ“‹",
        )
    )
    workflow_edge_style: EdgeStyle = Field(
        default_factory=lambda: EdgeStyle(
            line_style="solid",
            color="#1976d2",
        )
    )
    task_edge_style: EdgeStyle = Field(
        default_factory=lambda: EdgeStyle(
            line_style="dashed",
            color="#f57c00",
        )
    )
    show_weights: bool = False
    show_probabilities: bool = False
    show_tools: bool = True
    show_descriptions: bool = False
    max_label_length: int = 30


class GraphVisualizer:
    """RoleGraph visualiser in various formats."""

    def __init__(
        self,
        graph: "RoleGraph",
        style: VisualizationStyle | None = None,
    ):
        """
        Create a visualiser for the graph.

        Args:
            graph: RoleGraph to visualise
            style: Visualisation style (a new one is created by default)

        """
        self.graph = graph
        self.style = style or VisualizationStyle()

    def to_mermaid(
        self,
        direction: MermaidDirection | None = None,
        title: str | None = None,
    ) -> str:
        """
        Export the graph to Mermaid format.

        Args:
            direction: Graph direction (TB, LR, etc.)
            title: Diagram title

        Returns:
            Mermaid diagram code

        Example:
            ```mermaid
            flowchart TB
                researcher[πŸ€– Researcher]
                analyzer[πŸ€– Analyzer]
                researcher --> analyzer
            ```

        """
        direction = direction or self.style.direction
        lines = []

        # Title
        if title:
            lines.append("---")
            lines.append(f"title: {title}")
            lines.append("---")

        lines.append(f"flowchart {direction.value}")

        # Nodes
        for agent in self.graph.agents:
            node_id = self._safe_id(agent.agent_id)
            is_task = getattr(agent, "type", None) == "task"
            style = self.style.task_style if is_task else self.style.agent_style

            label = self._format_node_label(agent, style)

            if is_task:
                # Diamond shape for task: {label}
                lines.append(f"    {node_id}{{{label}}}")
            else:
                # Round rectangle for agents: (label)
                lines.append(f"    {node_id}({label})")

        lines.append("")

        # Edges
        edges_added = set()
        for edge in self.graph.edges:
            src = self._safe_id(edge.get("source", ""))
            tgt = self._safe_id(edge.get("target", ""))

            if not src or not tgt:
                continue

            edge_key = (src, tgt)
            if edge_key in edges_added:
                continue
            edges_added.add(edge_key)

            edge_type = edge.get("type", "workflow")
            weight = edge.get("weight", 1.0)

            # Determine line style
            arrow = "-.->" if "task" in edge_type.lower() else "-->"

            # Edge label
            if self.style.show_weights and weight != 1.0:
                lines.append(f"    {src} {arrow}|w={weight:.2f}| {tgt}")
            else:
                lines.append(f"    {src} {arrow} {tgt}")

        # Styles
        lines.append("")
        lines.append("    %% Styles")

        # Style for agents
        agent_ids = [self._safe_id(a.agent_id) for a in self.graph.agents if getattr(a, "type", None) != "task"]
        if agent_ids:
            s = self.style.agent_style
            lines.append(f"    classDef agent fill:{s.fill_color},stroke:{s.stroke_color},stroke-width:2px")
            lines.append(f"    class {','.join(agent_ids)} agent")

        # Style for task nodes
        task_ids = [self._safe_id(a.agent_id) for a in self.graph.agents if getattr(a, "type", None) == "task"]
        if task_ids:
            s = self.style.task_style
            lines.append(f"    classDef task fill:{s.fill_color},stroke:{s.stroke_color},stroke-width:2px")
            lines.append(f"    class {','.join(task_ids)} task")

        return "\n".join(lines)

    def to_ascii(
        self,
        show_edges: bool = True,
        box_width: int = 20,
    ) -> str:
        """
        Export the graph to ASCII art.

        Args:
            show_edges: Whether to show the edge list
            box_width: Width of node blocks

        Returns:
            ASCII representation of the graph

        """
        lines = []

        # Title
        title = f" Graph: {len(self.graph.agents)} nodes, {self.graph.num_edges} edges "
        border = "═" * (box_width + 4)
        lines.append(f"β•”{border}β•—")
        lines.append(f"β•‘{title:^{box_width + 4}}β•‘")
        lines.append(f"β• {border}β•£")

        # Nodes
        for agent in self.graph.agents:
            is_task = getattr(agent, "type", None) == "task"
            icon = "πŸ“‹" if is_task else "πŸ€–"
            name = agent.display_name or agent.agent_id

            # Trim long names
            if len(name) > box_width - 4:
                name = name[: box_width - 7] + "..."

            node_line = f"{icon} {name}"
            lines.append(f"β•‘  {node_line:<{box_width + 2}}β•‘")

            # Tools
            if self.style.show_tools and hasattr(agent, "tools") and agent.tools:
                tools_str = ", ".join(agent.tools[:MAX_TOOLS_PREVIEW])
                if len(agent.tools) > MAX_TOOLS_PREVIEW:
                    tools_str += f" (+{len(agent.tools) - MAX_TOOLS_PREVIEW})"
                if len(tools_str) > box_width - 2:
                    tools_str = tools_str[: box_width - 5] + "..."
                lines.append(f"β•‘    πŸ”§ {tools_str:<{box_width}}β•‘")

        lines.append(f"β• {border}β•£")

        # Edges
        if show_edges:
            lines.append(f"β•‘{'  Edges:':<{box_width + 4}}β•‘")

            edges_shown = 0
            max_edges = 10

            for edge in self.graph.edges:
                if edges_shown >= max_edges:
                    remaining = len(self.graph.edges) - max_edges
                    lines.append(f"β•‘    ... +{remaining} more{' ' * (box_width - 10)}β•‘")
                    break

                src = edge.get("source", "?")
                tgt = edge.get("target", "?")
                edge_type = edge.get("type", "")

                # Shorten names if needed
                if len(src) > MAX_SHORT_NAME_LENGTH:
                    src = src[:SHORT_NAME_PREFIX_LENGTH] + ".."
                if len(tgt) > MAX_SHORT_NAME_LENGTH:
                    tgt = tgt[:SHORT_NAME_PREFIX_LENGTH] + ".."

                arrow = "β€³" if "task" in edge_type.lower() else "β†’"
                edge_str = f"{src} {arrow} {tgt}"
                lines.append(f"β•‘    {edge_str:<{box_width}}β•‘")

        lines.append(f"β•š{border}╝")

        return "\n".join(lines)

    def to_dot(
        self,
        graph_name: str = "AgentGraph",
        rankdir: str = "TB",
        dpi: int | None = None,
    ) -> str:
        """
        Export the graph to Graphviz DOT format.

        Args:
            graph_name: Graph name
            rankdir: Direction (TB, LR, BT, RL)
            dpi: DPI for raster formats (None β€” use Graphviz default)

        Returns:
            DOT code for Graphviz

        """
        lines = [
            f"digraph {graph_name} {{",
            f"    rankdir={rankdir};",
        ]
        if dpi is not None:
            lines.append(f"    dpi={dpi};")
        lines += [
            '    node [fontname="Helvetica", fontsize=12];',
            '    edge [fontname="Helvetica", fontsize=10];',
            "",
        ]

        # Nodes
        for agent in self.graph.agents:
            node_id = self._safe_id(agent.agent_id)
            is_task = getattr(agent, "type", None) == "task"

            label = agent.display_name or agent.agent_id
            if self.style.show_tools and hasattr(agent, "tools") and agent.tools:
                tools = ", ".join(agent.tools[:3])
                label = f"{label}\\n[{tools}]"

            if is_task:
                style = self.style.task_style
                shape = "diamond"
            else:
                style = self.style.agent_style
                shape = "box"

            lines.append(
                f"    {node_id} ["
                f'label="{label}", '
                f"shape={shape}, "
                f"style=filled, "
                f'fillcolor="{style.fill_color}", '
                f'color="{style.stroke_color}"'
                f"];"
            )

        lines.append("")

        # Edges
        for edge in self.graph.edges:
            src = self._safe_id(edge.get("source", ""))
            tgt = self._safe_id(edge.get("target", ""))

            if not src or not tgt:
                continue

            edge_type = edge.get("type", "workflow")
            weight = edge.get("weight", 1.0)

            attrs = []
            if "task" in edge_type.lower():
                attrs.append("style=dashed")
                attrs.append(f'color="{self.style.task_edge_style.color}"')
            else:
                attrs.append(f'color="{self.style.workflow_edge_style.color}"')

            if self.style.show_weights and weight != 1.0:
                attrs.append(f'label="{weight:.2f}"')

            attr_str = ", ".join(attrs) if attrs else ""
            lines.append(f"    {src} -> {tgt} [{attr_str}];")

        lines.append("}")
        return "\n".join(lines)

    def to_adjacency_matrix(self, show_labels: bool = True) -> str:
        """
        Show the adjacency matrix in text form.

        Args:
            show_labels: Whether to show node labels

        Returns:
            Text representation of the matrix

        """
        a_com = self.graph.A_com
        if a_com.size == 0:
            return "Empty adjacency matrix"

        lines = []
        n = a_com.shape[0]

        # Short labels
        labels = []
        for agent in self.graph.agents[:n]:
            name = agent.agent_id[:6]
            labels.append(name)

        # Title
        if show_labels:
            header = "       " + " ".join(f"{label:>6}" for label in labels)
            lines.append(header)
            lines.append("       " + "-" * (7 * n))

        # Matrix rows
        for i in range(n):
            row_label = f"{labels[i]:>6} |" if show_labels else ""
            row_values = " ".join(f"{a_com[i, j]:>6.2f}" if a_com[i, j] != 0 else "     ." for j in range(n))
            lines.append(f"{row_label}{row_values}")

        return "\n".join(lines)

    def print_colored(self) -> None:
        """Print the graph to the console with colours (requires rich)."""
        try:
            from rich.console import Console
            from rich.table import Table
            from rich.tree import Tree
        except ImportError:
            # Fallback to ASCII if rich not available
            return

        console = Console()

        # Build tree
        tree = Tree(f"[bold blue]🌐 Graph[/bold blue] ({len(self.graph.agents)} nodes, {self.graph.num_edges} edges)")

        # Group agents and tasks
        agents_branch = tree.add("[bold cyan]πŸ€– Agents[/bold cyan]")
        tasks_branch = tree.add("[bold yellow]πŸ“‹ Tasks[/bold yellow]")

        for agent in self.graph.agents:
            is_task = getattr(agent, "type", None) == "task"
            branch = tasks_branch if is_task else agents_branch

            name = agent.display_name or agent.agent_id
            node = branch.add(f"[bold]{name}[/bold] ({agent.agent_id})")

            if hasattr(agent, "description") and agent.description:
                desc = agent.description[:MAX_DESCRIPTION_LENGTH]
                if len(agent.description) > MAX_DESCRIPTION_LENGTH:
                    desc += "..."
                node.add(f"[dim]{desc}[/dim]")

            if hasattr(agent, "tools") and agent.tools:
                tools_str = ", ".join(agent.tools)
                node.add(f"[green]πŸ”§ {tools_str}[/green]")

            # Show connections
            neighbors = self.graph.get_neighbors(agent.agent_id, direction="out")
            if neighbors:
                conns = ", ".join(neighbors)
                node.add(f"[blue]β†’ {conns}[/blue]")

        console.print(tree)

        # Edge table
        if self.graph.num_edges > 0:
            console.print()
            table = Table(title="Edges", show_header=True)
            table.add_column("Source", style="cyan")
            table.add_column("Target", style="green")
            table.add_column("Type", style="yellow")
            table.add_column("Weight", style="magenta")

            for edge in self.graph.edges[:MAX_EDGES_DISPLAY]:
                table.add_row(
                    str(edge.get("source", "")),
                    str(edge.get("target", "")),
                    str(edge.get("type", "workflow")),
                    f"{edge.get('weight', 1.0):.2f}",
                )

            if len(self.graph.edges) > MAX_EDGES_DISPLAY:
                table.add_row("...", "...", "...", f"+{len(self.graph.edges) - MAX_EDGES_DISPLAY} more")

            console.print(table)

    def save_mermaid(self, filepath: "str | Path", title: str | None = None) -> None:
        """
        Save the Mermaid diagram to a file.

        Args:
            filepath: Path to the file (.md or .mmd)
            title: Diagram title

        """
        filepath = Path(filepath)
        content = self.to_mermaid(title=title)

        # Wrap in markdown code block if .md file
        if filepath.suffix == ".md":
            content = f"```mermaid\n{content}\n```"

        filepath.write_text(content, encoding="utf-8")

    def save_dot(self, filepath: "str | Path", graph_name: str = "AgentGraph") -> None:
        """
        Save the DOT file for Graphviz.

        Args:
            filepath: Path to the file (.dot or .gv)
            graph_name: Graph name

        """
        content = self.to_dot(graph_name=graph_name)
        Path(filepath).write_text(content, encoding="utf-8")

    def render_image(
        self,
        filepath: "str | Path",
        image_format: ImageFormat | None = None,
        dpi: int | None = None,
        graph_name: str = "AgentGraph",
    ) -> None:
        """
        Render the graph to an image using Graphviz.

        Args:
            filepath: Path to the output file. The extension is used for
                      automatic format detection if image_format is not set.
            image_format: Image format. If None β€” determined from the extension of
                          filepath (png/svg/pdf/jpg). Without extension β€” PNG.
            dpi: DPI for raster formats (png, jpg). None β€” Graphviz default.
                 Ignored for vector formats (svg, pdf).
            graph_name: Graph name

        Raises:
            ImportError: If graphviz is not installed
            RuntimeError: If rendering failed

        Example:
            viz = GraphVisualizer(graph)
            viz.render_image("my_graph.png")            # format from extension
            viz.render_image("output", ImageFormat.SVG)  # explicit format
            viz.render_image("report.png", dpi=300)

        """
        try:
            import graphviz
        except ImportError:
            msg = "Graphviz is not installed. Install with: pip install graphviz"
            raise ImportError(msg) from None

        filepath = Path(filepath)

        # Determine format: explicit > from extension > PNG default
        fmt = image_format if image_format is not None else ImageFormat.from_path(filepath)

        # DPI is only meaningful for raster formats
        raster_formats = {ImageFormat.PNG, ImageFormat.JPEG}
        effective_dpi = dpi if fmt in raster_formats else None

        dot_source = self.to_dot(graph_name=graph_name, dpi=effective_dpi)
        source = graphviz.Source(dot_source)

        # graphviz.render() adds the extension itself, pass path without it
        output_stem = str(filepath.with_suffix(""))

        try:
            source.render(
                filename=output_stem,
                format=fmt.value,
                cleanup=True,  # removes the intermediate .dot file
            )
        except Exception as e:
            msg = f"Failed to render image: {e}"
            raise RuntimeError(msg) from e

    def show_interactive(self, graph_name: str = "AgentGraph") -> None:
        """
        Show the graph interactively in a window (using Graphviz).

        Args:
            graph_name: Graph name

        Raises:
            ImportError: If graphviz is not installed

        Note:
            Requires Graphviz installed with GUI support

        """
        try:
            import graphviz
        except ImportError:
            msg = "Graphviz is not installed. Install with: pip install graphviz"
            raise ImportError(msg) from None

        dot_source = self.to_dot(graph_name=graph_name)
        source = graphviz.Source(dot_source)

        with contextlib.suppress(Exception):
            source.view(cleanup=True)

    def _safe_id(self, identifier: str) -> str:
        """Convert an identifier to one safe for Mermaid/DOT."""
        # Replace special characters
        safe = identifier.replace("-", "_").replace(" ", "_").replace(".", "_")
        # Remove double underscores
        while "__" in safe:
            safe = safe.replace("__", "_")
        # Remove leading/trailing underscores
        safe = safe.strip("_")
        # If starts with a digit, add a prefix
        if safe and safe[0].isdigit():
            safe = "n_" + safe
        return safe or "unknown"

    def _format_node_label(self, agent: Any, style: NodeStyle) -> str:
        """Format a node label."""
        name = agent.display_name or agent.agent_id

        # Trim long names
        if len(name) > self.style.max_label_length:
            name = name[: self.style.max_label_length - 3] + "..."

        # Add icon
        if style.icon:
            name = f"{style.icon} {name}"

        # Add tools
        max_tools_in_label = 2
        if self.style.show_tools and hasattr(agent, "tools") and agent.tools:
            tools = agent.tools[:max_tools_in_label]
            tools_str = ", ".join(tools)
            if len(agent.tools) > max_tools_in_label:
                tools_str += "..."
            name = f"{name}<br/>πŸ”§ {tools_str}"

        return name


# ============================================================================
# Convenience functions
# ============================================================================


def to_mermaid(
    graph: "RoleGraph",
    direction: MermaidDirection = MermaidDirection.TOP_BOTTOM,
    title: str | None = None,
    style: VisualizationStyle | None = None,
) -> str:
    """
    Quick export of the graph to Mermaid.

    Args:
        graph: RoleGraph to visualise
        direction: Graph direction
        title: Diagram title
        style: Visualisation style

    Returns:
        Mermaid code

    Example:
        mermaid_code = to_mermaid(graph, direction=MermaidDirection.LR)
        print(mermaid_code)

    """
    viz = GraphVisualizer(graph, style)
    return viz.to_mermaid(direction=direction, title=title)


def to_ascii(
    graph: "RoleGraph",
    show_edges: bool = True,
    style: VisualizationStyle | None = None,
) -> str:
    """
    Quick export of the graph to ASCII.

    Args:
        graph: RoleGraph to visualise
        show_edges: Whether to show edges
        style: Visualisation style

    Returns:
        ASCII representation of the graph

    """
    viz = GraphVisualizer(graph, style)
    return viz.to_ascii(show_edges=show_edges)


def to_dot(
    graph: "RoleGraph",
    graph_name: str = "AgentGraph",
    style: VisualizationStyle | None = None,
) -> str:
    """
    Quick export of the graph to Graphviz DOT.

    Args:
        graph: RoleGraph to visualise
        graph_name: Graph name
        style: Visualisation style

    Returns:
        DOT code

    """
    viz = GraphVisualizer(graph, style)
    return viz.to_dot(graph_name=graph_name)


def print_graph(
    graph: "RoleGraph",
    output_format: str = "auto",
    style: VisualizationStyle | None = None,
) -> None:
    """
    Print the graph to the console.

    Args:
        graph: RoleGraph to visualise
        output_format: Output format ("auto", "colored", "ascii", "mermaid")
        style: Visualisation style

    """
    viz = GraphVisualizer(graph, style)

    if output_format == "auto":
        # Try rich, fall back to ASCII
        try:
            from rich.console import Console  # noqa: F401

            viz.print_colored()
        except ImportError:
            pass
    elif output_format == "colored":
        viz.print_colored()
    elif output_format in {"ascii", "mermaid"}:
        pass


def render_to_image(
    graph: "RoleGraph",
    filepath: "str | Path",
    image_format: ImageFormat | None = None,
    dpi: int | None = None,
    graph_name: str = "AgentGraph",
    style: VisualizationStyle | None = None,
) -> None:
    """
    Render the graph to an image.

    Args:
        graph: RoleGraph to visualise
        filepath: Path to the output file. Extension determines the format
                  if image_format is not explicitly specified.
        image_format: Image format. If None β€” inferred from the filepath extension.
        dpi: DPI for raster formats (png, jpg). None β€” Graphviz default.
        graph_name: Graph name
        style: Visualisation style

    Raises:
        ImportError: If graphviz is not installed

    Example:
        render_to_image(graph, "output.png")              # format from extension
        render_to_image(graph, "diagram", ImageFormat.SVG)
        render_to_image(graph, "report.png", dpi=300)

    """
    viz = GraphVisualizer(graph, style)
    viz.render_image(filepath, image_format=image_format, dpi=dpi, graph_name=graph_name)


def show_graph_interactive(
    graph: "RoleGraph",
    graph_name: str = "AgentGraph",
    style: VisualizationStyle | None = None,
) -> None:
    """
    Show the graph interactively.

    Args:
        graph: RoleGraph to visualise
        graph_name: Graph name
        style: Visualisation style

    Raises:
        ImportError: If graphviz is not installed

    """
    viz = GraphVisualizer(graph, style)
    viz.show_interactive(graph_name=graph_name)