Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions dimos/core/introspection/blueprint/dot.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,19 @@ class LayoutAlgo(Enum):
# "FoxgloveBridge",
}

# Modules only ignored when show_disconnected is False (compact view)
_COMPACT_ONLY_IGNORED_MODULES = {
"WebsocketVisModule",
}


def render(
blueprint_set: Blueprint,
*,
layout: set[LayoutAlgo] | None = None,
ignored_streams: set[tuple[str, str]] | None = None,
ignored_modules: set[str] | None = None,
show_disconnected: bool = False,
) -> str:
"""Generate a hub-style DOT graph from a Blueprint.

Expand All @@ -69,6 +75,8 @@ def render(
layout: Set of layout algorithms to apply. Default is none (let graphviz decide).
ignored_streams: Set of (name, type_name) tuples to ignore.
ignored_modules: Set of module names to ignore.
show_disconnected: If True, show streams that have a producer but no consumer
(or vice versa) as dashed stub nodes.

Returns:
A string in DOT format showing modules as nodes, type nodes as
Expand All @@ -79,7 +87,10 @@ def render(
if ignored_streams is None:
ignored_streams = DEFAULT_IGNORED_CONNECTIONS
if ignored_modules is None:
ignored_modules = DEFAULT_IGNORED_MODULES
if show_disconnected:
ignored_modules = DEFAULT_IGNORED_MODULES - _COMPACT_ONLY_IGNORED_MODULES
else:
ignored_modules = DEFAULT_IGNORED_MODULES

# Collect all outputs: (name, type) -> list of producer modules
producers: dict[tuple[str, type], list[type[Module]]] = defaultdict(list)
Expand Down Expand Up @@ -116,6 +127,23 @@ def render(
label = f"{name}:{type_name}"
active_channels[key] = color_for_string(TYPE_COLORS, label)

# Find disconnected channels (producer-only or consumer-only)
disconnected_channels: dict[tuple[str, type], str] = {}
if show_disconnected:
all_keys = set(producers.keys()) | set(consumers.keys())
for key in all_keys:
if key in active_channels:
continue
name, type_ = key
type_name = type_.__name__
if (name, type_name) in ignored_streams:
continue
relevant_modules = producers.get(key, []) + consumers.get(key, [])
if all(m.__name__ in ignored_modules for m in relevant_modules):
continue
label = f"{name}:{type_name}"
disconnected_channels[key] = color_for_string(TYPE_COLORS, label)

# Group modules by package
def get_group(mod_class: type[Module]) -> str:
module_path = mod_class.__module__
Expand Down Expand Up @@ -218,6 +246,37 @@ def get_group(mod_class: type[Module]) -> str:
continue
lines.append(f' {node_id} -> {consumer.__name__} [color="{color}"];')

# Disconnected channels (dashed stub nodes)
if disconnected_channels:
lines.append("")
lines.append(" // Disconnected streams")
for key, color in sorted(
disconnected_channels.items(), key=lambda x: f"{x[0][0]}:{x[0][1].__name__}"
):
name, type_ = key
type_name = type_.__name__
node_id = sanitize_id(f"chan_{name}_{type_name}")
label = f"{name}:{type_name}"
lines.append(
f' {node_id} [label="{label}", shape=note, '
f'style="filled,dashed", fillcolor="{color}15", color="{color}", '
f'fontcolor="{color}", width=0, height=0, margin="0.1,0.05", fontsize=10];'
)

for producer in producers.get(key, []):
if producer.__name__ in ignored_modules:
continue
lines.append(
f" {producer.__name__} -> {node_id} "
f'[color="{color}", style=dashed, arrowhead=none];'
)
for consumer in consumers.get(key, []):
if consumer.__name__ in ignored_modules:
continue
lines.append(
f' {node_id} -> {consumer.__name__} [color="{color}", style=dashed];'
)

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

Expand All @@ -227,20 +286,22 @@ def render_svg(
output_path: str,
*,
layout: set[LayoutAlgo] | None = None,
show_disconnected: bool = False,
) -> None:
"""Generate an SVG file from a Blueprint using graphviz.

Args:
blueprint_set: The blueprint set to visualize.
output_path: Path to write the SVG file.
layout: Set of layout algorithms to apply.
show_disconnected: If True, show streams with no matching counterpart.
"""
import subprocess

if layout is None:
layout = set()

dot_code = render(blueprint_set, layout=layout)
dot_code = render(blueprint_set, layout=layout, show_disconnected=show_disconnected)
engine = "fdp" if LayoutAlgo.FDP in layout else "dot"
result = subprocess.run(
[engine, "-Tsvg", "-o", output_path],
Expand Down
62 changes: 62 additions & 0 deletions dimos/core/introspection/blueprint/test_dot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright 2025-2026 Dimensional Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


from dimos.core.blueprints import autoconnect
from dimos.core.introspection.blueprint.dot import render
from dimos.core.module import Module
from dimos.core.stream import In, Out


class MsgA:
pass


class MsgB:
pass


class ProducerModule(Module):
output_a: Out[MsgA]
output_b: Out[MsgB]


class ConsumerModule(Module):
output_a: In[MsgA]


# output_a connects (same name+type), output_b is disconnected (no consumer)
_combined = autoconnect(ProducerModule.blueprint(), ConsumerModule.blueprint())


def test_render_without_disconnected() -> None:
dot = render(_combined, ignored_streams=set(), ignored_modules=set(), show_disconnected=False)
# Connected channel should be present
assert "output_a:MsgA" in dot
# Disconnected output_b should NOT appear
assert "output_b:MsgB" not in dot


def test_render_with_disconnected() -> None:
dot = render(_combined, ignored_streams=set(), ignored_modules=set(), show_disconnected=True)
# Connected channel should be present
assert "output_a:MsgA" in dot
# Disconnected output_b SHOULD appear with dashed style
assert "output_b:MsgB" in dot
assert "style=dashed" in dot


def test_disconnected_default_is_false() -> None:
dot = render(_combined, ignored_streams=set(), ignored_modules=set())
assert "output_b:MsgB" not in dot
6 changes: 5 additions & 1 deletion dimos/core/introspection/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def to_svg(
output_path: str,
*,
layout: set[LayoutAlgo] | None = None,
show_disconnected: bool = False,
) -> None:
"""Render a module or blueprint to SVG.

Expand All @@ -40,6 +41,7 @@ def to_svg(
target: Either a ModuleInfo (single module) or Blueprint (blueprint graph).
output_path: Path to write the SVG file.
layout: Layout algorithms (only used for blueprints).
show_disconnected: If True, show streams with no matching counterpart (blueprints only).
"""
# Avoid circular imports by importing here
from dimos.core.blueprints import Blueprint
Expand All @@ -52,6 +54,8 @@ def to_svg(
elif isinstance(target, Blueprint):
from dimos.core.introspection.blueprint import dot as blueprint_dot

blueprint_dot.render_svg(target, output_path, layout=layout)
blueprint_dot.render_svg(
target, output_path, layout=layout, show_disconnected=show_disconnected
)
else:
raise TypeError(f"Expected ModuleInfo or Blueprint, got {type(target).__name__}")
14 changes: 14 additions & 0 deletions dimos/robot/cli/dimos.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,20 @@ def send(
topic_send(topic, message_expr)


@main.command()
def graph(
python_file: str = typer.Argument(..., help="Python file containing Blueprint globals"),
no_disconnected: bool = typer.Option(
False, "--no-disconnected", help="Hide disconnected streams"
),
port: int = typer.Option(0, "--port", help="HTTP server port (0 = random free port)"),
) -> None:
"""Render blueprint graphs from a Python file and open in browser."""
from dimos.utils.cli.graph import main as graph_main

graph_main(python_file, show_disconnected=not no_disconnected, port=port)


@main.command(name="rerun-bridge")
def rerun_bridge_cmd(
viewer_mode: str = typer.Option(
Expand Down
110 changes: 110 additions & 0 deletions dimos/utils/cli/graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Copyright 2025-2026 Dimensional Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Render Blueprint graphs from a Python file and open in the browser."""

from __future__ import annotations

import importlib.util
import os
import shutil
import tempfile
import webbrowser


def _build_html(python_file: str, *, show_disconnected: bool = True) -> str:
"""Import a Python file, find all Blueprint globals, and return rendered HTML."""
filepath = os.path.abspath(python_file)
if not os.path.isfile(filepath):
raise FileNotFoundError(filepath)

spec = importlib.util.spec_from_file_location("_render_target", filepath)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {filepath}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)

from dimos.core.blueprints import Blueprint
from dimos.core.introspection.svg import to_svg

blueprints: list[tuple[str, Blueprint]] = []
for name, obj in vars(mod).items():
if name.startswith("_"):
continue
if isinstance(obj, Blueprint):
blueprints.append((name, obj))

if not blueprints:
raise RuntimeError("No Blueprint instances found in module globals.")

print(f"Found {len(blueprints)} blueprint(s): {', '.join(n for n, _ in blueprints)}")

if not shutil.which("dot"):
raise RuntimeError(
"graphviz is not installed (the 'dot' command was not found).\n"
"Install it with: brew install graphviz (macOS)\n"
" apt install graphviz (Debian/Ubuntu)"
)

sections = []
for name, bp in blueprints:
fd, svg_path = tempfile.mkstemp(suffix=".svg", prefix=f"dimos_{name}_")
os.close(fd)
to_svg(bp, svg_path, show_disconnected=show_disconnected)
with open(svg_path) as f:
svg_content = f.read()
os.unlink(svg_path)
Comment on lines +62 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temp file leaked if to_svg raises an exception

If to_svg(...) raises (e.g. graphviz fails, a bad blueprint, etc.) the temporary file created by tempfile.mkstemp is never deleted — the os.unlink on line 67 is only reached on the happy path. Wrap this in a try/finally:

fd, svg_path = tempfile.mkstemp(suffix=".svg", prefix=f"dimos_{name}_")
os.close(fd)
try:
    to_svg(bp, svg_path, show_disconnected=show_disconnected)
    with open(svg_path) as f:
        svg_content = f.read()
finally:
    os.unlink(svg_path)
sections.append(f'<h2>{name}</h2>\n<div class="diagram">{svg_content}</div>')

sections.append(f'<h2>{name}</h2>\n<div class="diagram">{svg_content}</div>')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blueprint name is embedded into HTML without escaping

name comes from Python module attribute names (so angle-bracket injection isn't possible today), but embedding it raw is a fragile pattern. If any future code path produces a name string with HTML special characters, this will produce broken or malicious HTML. Consider using html.escape(name) here:

Suggested change
sections.append(f'<h2>{name}</h2>\n<div class="diagram">{svg_content}</div>')
sections.append(f'<h2>{__import__("html").escape(name)}</h2>\n<div class="diagram">{svg_content}</div>')

Or import html at the top of the file and use html.escape(name).


return f"""\
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<title>Blueprint Diagrams</title>
<style>
body {{ background: #1e1e1e; color: #ccc; font-family: sans-serif; margin: 2em; }}
h2 {{ border-bottom: 1px solid #444; padding-bottom: 0.3em; }}
.diagram {{ margin-bottom: 3em; }}
.diagram svg {{ max-width: 100%; height: auto; }}
</style>
</head><body>
{"".join(sections)}
</body></html>"""


def main(python_file: str, *, show_disconnected: bool = True, port: int = 0) -> None:
"""Render Blueprint SVG diagrams and display them via a one-shot HTTP server."""
from http.server import BaseHTTPRequestHandler, HTTPServer

html = _build_html(python_file, show_disconnected=show_disconnected)
html_bytes = html.encode("utf-8")

class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(html_bytes)))
self.end_headers()
self.wfile.write(html_bytes)

def log_message(self, format: str, *args: object) -> None:
pass

server = HTTPServer(("0.0.0.0", port), Handler)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP server exposed on all network interfaces

The server binds to "0.0.0.0", which makes it reachable from any machine on the same network, not just the local developer's browser. This exposes the rendered blueprint diagrams (which may include internal architecture details) to anyone on the LAN.

Change the bind address to "127.0.0.1" (loopback only):

Suggested change
server = HTTPServer(("0.0.0.0", port), Handler)
server = HTTPServer(("127.0.0.1", port), Handler)

actual_port = server.server_address[1]
url = f"http://localhost:{actual_port}"
print(f"Serving at {url} (will exit after first request)")
webbrowser.open(url)
server.handle_request()
print("Served. Exiting.")
41 changes: 41 additions & 0 deletions dimos/utils/cli/test_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright 2025-2026 Dimensional Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


import pytest

from dimos.utils.cli.graph import main


def test_file_not_found() -> None:
with pytest.raises(FileNotFoundError):
main("/nonexistent/path.py")


def test_no_blueprints(tmp_path: object) -> None:
import pathlib

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect type annotation for pytest tmp_path fixture

The pytest tmp_path fixture injects a pathlib.Path, but the parameter is typed as object. This makes the pathlib.Path(str(tmp_path)) on the next line an unnecessary round-trip. The same pattern appears at line 37.

Suggested change
def test_no_blueprints(tmp_path: "pathlib.Path") -> None:

Or import pathlib at the top and use pathlib.Path directly, eliminating the Path(str(tmp_path)) workaround.

p = pathlib.Path(str(tmp_path)) / "empty.py"
p.write_text("x = 42\n")
with pytest.raises(RuntimeError, match="No Blueprint instances"):
main(str(p))


def test_module_load_failure(tmp_path: object) -> None:
import pathlib

p = pathlib.Path(str(tmp_path)) / "bad.py"
p.write_text("raise ImportError('boom')\n")
with pytest.raises(ImportError, match="boom"):
main(str(p))