Skip to content
Merged
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
37 changes: 37 additions & 0 deletions dimos/core/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,41 @@ def _all_name_types(self) -> set[tuple[str, type]]:
def _is_name_unique(self, name: str) -> bool:
return sum(1 for n, _ in self._all_name_types if n == name) == 1

def _verify_no_name_conflicts(self) -> None:
name_to_types = defaultdict(set)
name_to_modules = defaultdict(list)

for blueprint in self.blueprints:
for conn in blueprint.connections:
connection_name = self.remapping_map.get((blueprint.module, conn.name), conn.name)
name_to_types[connection_name].add(conn.type)
name_to_modules[connection_name].append((blueprint.module, conn.type))

conflicts = {}
for conn_name, types in name_to_types.items():
if len(types) > 1:
modules_by_type = defaultdict(list)
for module, conn_type in name_to_modules[conn_name]:
modules_by_type[conn_type].append(module)
conflicts[conn_name] = modules_by_type

if not conflicts:
return

error_lines = ["Blueprint cannot start because there are conflicting connections."]
for name, modules_by_type in conflicts.items():
type_entries = []
for conn_type, modules in modules_by_type.items():
for module in modules:
type_str = f"{conn_type.__module__}.{conn_type.__name__}"
module_str = module.__name__
type_entries.append((type_str, module_str))
if len(type_entries) >= 2:
locations = ", ".join(f"{type_} in {module}" for type_, module in type_entries)
error_lines.append(f" - '{name}' has conflicting types. {locations}")

raise ValueError("\n".join(error_lines))

def _deploy_all_modules(
self, module_coordinator: ModuleCoordinator, global_config: GlobalConfig
) -> None:
Expand Down Expand Up @@ -209,6 +244,8 @@ def build(self, global_config: GlobalConfig | None = None) -> ModuleCoordinator:
global_config = GlobalConfig()
global_config = global_config.model_copy(update=self.global_config_overrides)

self._verify_no_name_conflicts()

module_coordinator = ModuleCoordinator(global_config=global_config)
module_coordinator.start()

Expand Down
77 changes: 77 additions & 0 deletions dimos/core/test_blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import pytest

from dimos.core.blueprints import (
ModuleBlueprint,
ModuleBlueprintSet,
Expand Down Expand Up @@ -185,6 +187,81 @@ def test_build_happy_path() -> None:
coordinator.stop()


def test_name_conflicts_are_reported() -> None:
class ModuleA(Module):
shared_data: Out[Data1] = None

class ModuleB(Module):
shared_data: In[Data2] = None

blueprint_set = autoconnect(ModuleA.blueprint(), ModuleB.blueprint())

try:
blueprint_set._verify_no_name_conflicts()
pytest.fail("Expected ValueError to be raised")
except ValueError as e:
error_message = str(e)
assert "Blueprint cannot start because there are conflicting connections" in error_message
assert "'shared_data' has conflicting types" in error_message
assert "Data1 in ModuleA" in error_message
assert "Data2 in ModuleB" in error_message


def test_multiple_name_conflicts_are_reported() -> None:
class Module1(Module):
sensor_data: Out[Data1] = None
control_signal: Out[Data2] = None

class Module2(Module):
sensor_data: In[Data2] = None
control_signal: In[Data3] = None

blueprint_set = autoconnect(Module1.blueprint(), Module2.blueprint())

try:
blueprint_set._verify_no_name_conflicts()
pytest.fail("Expected ValueError to be raised")
except ValueError as e:
error_message = str(e)
assert "Blueprint cannot start because there are conflicting connections" in error_message
assert "'sensor_data' has conflicting types" in error_message
assert "'control_signal' has conflicting types" in error_message


def test_that_remapping_can_resolve_conflicts() -> None:
class Module1(Module):
data: Out[Data1] = None

class Module2(Module):
data: Out[Data2] = None # Would conflict with Module1.data

class Module3(Module):
data1: In[Data1] = None
data2: In[Data2] = None

# Without remapping, should raise conflict error
blueprint_set = autoconnect(Module1.blueprint(), Module2.blueprint(), Module3.blueprint())

try:
blueprint_set._verify_no_name_conflicts()
pytest.fail("Expected ValueError due to conflict")
except ValueError as e:
assert "'data' has conflicting types" in str(e)

# With remapping to resolve the conflict
blueprint_set_remapped = autoconnect(
Module1.blueprint(), Module2.blueprint(), Module3.blueprint()
).remappings(
[
(Module1, "data", "data1"),
(Module2, "data", "data2"),
]
)

# Should not raise any exception after remapping
blueprint_set_remapped._verify_no_name_conflicts()


def test_remapping() -> None:
"""Test that remapping connections works correctly."""
pubsub.lcm.autoconf()
Expand Down
1 change: 1 addition & 0 deletions dimos/robot/all_blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"demo-google-maps-skill": "dimos.agents2.skills.demo_google_maps_skill:demo_google_maps_skill",
"demo-remapping": "dimos.robot.unitree_webrtc.demo_remapping:remapping",
"demo-remapping-transport": "dimos.robot.unitree_webrtc.demo_remapping:remapping_and_transport",
"demo-error-on-name-conflicts": "dimos.robot.unitree_webrtc.demo_error_on_name_conflicts:blueprint",
}


Expand Down
53 changes: 53 additions & 0 deletions dimos/robot/unitree_webrtc/demo_error_on_name_conflicts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright 2025 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.core import rpc
from dimos.core.module import Module
from dimos.core.stream import In, Out


class Data1:
pass


class Data2:
pass


class ModuleA(Module):
shared_data: Out[Data1] = None

@rpc
def start(self) -> None:
super().start()

@rpc
def stop(self) -> None:
super().stop()


class ModuleB(Module):
shared_data: In[Data2] = None

@rpc
def start(self) -> None:
super().start()

@rpc
def stop(self) -> None:
super().stop()


blueprint = autoconnect(ModuleA.blueprint(), ModuleB.blueprint())
Loading