diff --git a/dimos/core/coordination/blueprint_config/parser.py b/dimos/core/coordination/blueprint_config/parser.py index ce8338ec66..a97422040f 100644 --- a/dimos/core/coordination/blueprint_config/parser.py +++ b/dimos/core/coordination/blueprint_config/parser.py @@ -75,6 +75,7 @@ plain, plain_mapping, snapshot_mapping, + validated_model_values, ) from dimos.core.coordination.blueprints import ( Blueprint, @@ -421,7 +422,7 @@ def _validate_modules( raise BlueprintConfigError( format_validation_error(module.atom.name, error) ) from error - dumped = model.model_dump(mode="python", exclude_unset=True) + dumped = validated_model_values(model) dumped.pop("g", None) dumped.pop("instance_name", None) parsed[module.atom.name] = dumped diff --git a/dimos/core/coordination/blueprint_config/test_parser.py b/dimos/core/coordination/blueprint_config/test_parser.py index 6b24322489..bcfdf272fb 100644 --- a/dimos/core/coordination/blueprint_config/test_parser.py +++ b/dimos/core/coordination/blueprint_config/test_parser.py @@ -13,7 +13,9 @@ # limitations under the License. from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path +import pickle from typing import Annotated, Any, Literal from pydantic import BaseModel, Field @@ -452,6 +454,14 @@ def __str__(self) -> str: return "Anchor:\n multi\n line" +@dataclass(frozen=True) +class CallableAnchor: + prefix: str + + def __call__(self, value: Any) -> str: + return f"{self.prefix}:{value}" + + class ArbitraryConfig(ModuleConfig): scaling: Anchor = Field(default_factory=Anchor) hybrid: Anchor | str = "fallback" @@ -490,6 +500,18 @@ def test_blueprint_pinned_arbitrary_value_survives_filtering() -> None: assert isinstance(parsed.module_kwargs("arbitrarymodule")["scaling"], Anchor) +def test_blueprint_pinned_callable_dataclass_survives_worker_serialization() -> None: + blueprint = ArbitraryModule.blueprint(handlers={"scene": CallableAnchor("render")}) + + parsed = BlueprintConfigParser(blueprint).parse(environ={}) + worker_kwargs = pickle.loads(pickle.dumps(parsed.module_kwargs("arbitrarymodule"))) + worker_config = ArbitraryConfig.model_validate(worker_kwargs) + handler = worker_config.handlers["scene"] + + assert isinstance(handler, CallableAnchor) + assert handler("apartment") == "render:apartment" + + def test_format_help_uses_nested_parent_default_instance() -> None: class NestedRequiredConfig(BaseModel): value: int diff --git a/dimos/core/coordination/blueprint_config/values.py b/dimos/core/coordination/blueprint_config/values.py index 39cab50442..b5654d6bbc 100644 --- a/dimos/core/coordination/blueprint_config/values.py +++ b/dimos/core/coordination/blueprint_config/values.py @@ -55,6 +55,31 @@ def plain(value: Any) -> Any: return _copy_opaque(value) +def validated_model_values(model: BaseModel) -> dict[str, Any]: + """Copy explicitly set validated fields without serializing runtime objects.""" + return { + name: _validated_value(getattr(model, name)) + for name in type(model).model_fields + if name in model.model_fields_set + } + + +def _validated_value(value: Any) -> Any: + if isinstance(value, BaseModel): + return validated_model_values(value) + if isinstance(value, Mapping): + return {_copy_opaque(key): _validated_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_validated_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_validated_value(item) for item in value) + if isinstance(value, set): + return {_validated_value(item) for item in value} + if isinstance(value, frozenset): + return frozenset(_validated_value(item) for item in value) + return _copy_opaque(value) + + def deep_merge(destination: dict[str, Any], incoming: Mapping[str, Any]) -> None: for key, value in incoming.items(): if key in destination and isinstance(destination[key], dict) and isinstance(value, Mapping):