fix(core): resolve mermaid node id collisions when special chars are used (#32857)

### Description

* Replace the Mermaid graph node label escaping logic
(`_escape_node_label`) with `_to_safe_id`, which converts a string into
a unique, Mermaid-compatible node id. Ensures nodes with special
characters always render correctly.

**Before**
* Invalid characters (e.g. `开`) replaced with `_`. Causes collisions
between nodes with names that are the same length and contain all
non-safe characters:
```python
_escape_node_label("开") # '_'
_escape_node_label("始") # '_'  same as above, but different character passed in. not a unique mapping.
```

**After**
```python
_to_safe_id("开") # \5f00
_to_safe_id("始") # \59cb  unique!
```

### Tests
* Rename `test_graph_mermaid_escape_node_label()` to
`test_graph_mermaid_to_safe_id()` and update function logic to use
`_to_safe_id`
* Add `test_graph_mermaid_special_chars()`

### Issue

Fixes langchain-ai/langgraph#6036
This commit is contained in:
Caspar Broekhuizen
2025-09-11 14:15:17 -07:00
committed by GitHub
parent 9cc85387d1
commit 15d558ff16
4 changed files with 335 additions and 284 deletions

View File

@@ -6,6 +6,7 @@ import asyncio
import base64
import random
import re
import string
import time
from dataclasses import asdict
from pathlib import Path
@@ -148,7 +149,7 @@ def draw_mermaid(
+ "</em></small>"
)
node_label = format_dict.get(key, format_dict[default_class_label]).format(
_escape_node_label(key), label
_to_safe_id(key), label
)
return f"{indent}{node_label}\n"
@@ -211,8 +212,7 @@ def draw_mermaid(
edge_label = " -.-> " if edge.conditional else " --> "
mermaid_graph += (
f"\t{_escape_node_label(source)}{edge_label}"
f"{_escape_node_label(target)};\n"
f"\t{_to_safe_id(source)}{edge_label}{_to_safe_id(target)};\n"
)
# Recursively add nested subgraphs
@@ -256,9 +256,18 @@ def draw_mermaid(
return mermaid_graph
def _escape_node_label(node_label: str) -> str:
"""Escapes the node label for Mermaid syntax."""
return re.sub(r"[^a-zA-Z-_0-9]", "_", node_label)
def _to_safe_id(label: str) -> str:
"""Convert a string into a Mermaid-compatible node id.
Keep [a-zA-Z0-9_-] characters unchanged.
Map every other character -> backslash + lowercase hex codepoint.
Result is guaranteed to be unique and Mermaid-compatible,
so nodes with special characters always render correctly.
"""
allowed = string.ascii_letters + string.digits + "_-"
out = [ch if ch in allowed else "\\" + format(ord(ch), "x") for ch in label]
return "".join(out)
def _generate_mermaid_graph_styles(node_colors: NodeStyles) -> str: