diff --git a/dimos/constants.py b/dimos/constants.py index 4e74ccbe1b..5fa1c55b66 100644 --- a/dimos/constants.py +++ b/dimos/constants.py @@ -12,11 +12,27 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os from pathlib import Path +try: + # Not a dependency, just the best way to get config path if available. + from gi.repository import GLib # type: ignore[import-untyped,import-not-found] +except ImportError: + CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "dimos" +else: + CONFIG_DIR = Path(GLib.get_user_config_dir()) + STATE_DIR = Path(GLib.get_user_state_dir()) / "dimos" + DIMOS_PROJECT_ROOT = Path(__file__).parent.parent -DIMOS_LOG_DIR = DIMOS_PROJECT_ROOT / "logs" +if (DIMOS_PROJECT_ROOT / ".git").exists(): + # Running from Git repository + LOG_DIR = DIMOS_PROJECT_ROOT / "logs" +else: + # Running from an installed package - use XDG_STATE_HOME + LOG_DIR = STATE_DIR / "logs" """ Constants for shared memory diff --git a/dimos/core/blueprints.py b/dimos/core/blueprints.py index 60a172a457..471da24d1e 100644 --- a/dimos/core/blueprints.py +++ b/dimos/core/blueprints.py @@ -13,7 +13,7 @@ # limitations under the License. from collections import defaultdict -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, MutableMapping from dataclasses import dataclass, field, replace from functools import cached_property, reduce import operator @@ -22,6 +22,8 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Literal, Union, cast, get_args, get_origin, get_type_hints +from pydantic import BaseModel, create_model + if TYPE_CHECKING: from dimos.protocol.service.system_configurator.base import SystemConfigurator @@ -164,6 +166,11 @@ def create(cls, module: type[ModuleBase], **kwargs: Any) -> "Blueprint": def disabled_modules(self, *modules: type[ModuleBase]) -> "Blueprint": return replace(self, disabled_modules_tuple=self.disabled_modules_tuple + modules) + def config(self) -> type[BaseModel]: + configs = {b.module.name: (b.module.default_config | None, None) for b in self.blueprints} + configs["g"] = (GlobalConfig | None, None) + return create_model("BlueprintConfig", __config__={"extra": "forbid"}, **configs) # type: ignore[call-overload,no-any-return] + def transports(self, transports: dict[tuple[str, type], Any]) -> "Blueprint": return replace(self, transport_map=MappingProxyType({**self.transport_map, **transports})) @@ -290,13 +297,16 @@ def _verify_no_name_conflicts(self) -> None: raise ValueError("\n".join(error_lines)) def _deploy_all_modules( - self, module_coordinator: ModuleCoordinator, global_config: GlobalConfig + self, + module_coordinator: ModuleCoordinator, + global_config: GlobalConfig, + blueprint_args: Mapping[str, Mapping[str, Any]], ) -> None: module_specs: list[ModuleSpec] = [] for blueprint in self._active_blueprints: - module_specs.append((blueprint.module, global_config, blueprint.kwargs)) + module_specs.append((blueprint.module, global_config, blueprint.kwargs.copy())) - module_coordinator.deploy_parallel(module_specs) + module_coordinator.deploy_parallel(module_specs, blueprint_args) def _connect_streams(self, module_coordinator: ModuleCoordinator) -> None: # dict when given (final/remapped) stream name+type, provides a list of modules + original (non-remapped) stream names @@ -444,12 +454,13 @@ def _connect_module_refs(self, module_coordinator: ModuleCoordinator) -> None: def build( self, - cli_config_overrides: Mapping[str, Any] | None = None, + blueprint_args: MutableMapping[str, Any] | None = None, ) -> ModuleCoordinator: logger.info("Building the blueprint") global_config.update(**dict(self.global_config_overrides)) - if cli_config_overrides: - global_config.update(**dict(cli_config_overrides)) + blueprint_args = blueprint_args or {} + if "g" in blueprint_args: + global_config.update(**blueprint_args.pop("g")) self._run_configurators() self._check_requirements() @@ -460,7 +471,7 @@ def build( module_coordinator.start() # all module constructors are called here (each of them setup their own) - self._deploy_all_modules(module_coordinator, global_config) + self._deploy_all_modules(module_coordinator, global_config, blueprint_args) self._connect_streams(module_coordinator) self._connect_module_refs(module_coordinator) diff --git a/dimos/core/module.py b/dimos/core/module.py index 8436b9f19f..f6f8646eba 100644 --- a/dimos/core/module.py +++ b/dimos/core/module.py @@ -130,6 +130,11 @@ def __init__(self, config_args: dict[str, Any]): except ValueError: ... + @classproperty + def name(self) -> str: + """Name for this module to be used for blueprint configs.""" + return self.__name__.lower() # type: ignore[attr-defined,no-any-return] + @property def frame_id(self) -> str: base = self.config.frame_id or self.__class__.__name__ diff --git a/dimos/core/module_coordinator.py b/dimos/core/module_coordinator.py index a80c1b6f44..f5d06bc4f6 100644 --- a/dimos/core/module_coordinator.py +++ b/dimos/core/module_coordinator.py @@ -14,6 +14,7 @@ from __future__ import annotations +from collections.abc import Mapping import threading from typing import TYPE_CHECKING, Any, TypeAlias @@ -99,7 +100,9 @@ def deploy( self._deployed_modules[module_class] = deployed_module # type: ignore[assignment] return deployed_module # type: ignore[return-value] - def deploy_parallel(self, module_specs: list[ModuleSpec]) -> list[ModuleProxy]: + def deploy_parallel( + self, module_specs: list[ModuleSpec], blueprint_args: Mapping[str, Mapping[str, Any]] + ) -> list[ModuleProxy]: if not self._managers: raise ValueError("Not started") @@ -115,7 +118,7 @@ def deploy_parallel(self, module_specs: list[ModuleSpec]) -> list[ModuleProxy]: results: list[Any] = [None] * len(module_specs) def _deploy_group(dep: str) -> None: - deployed = self._managers[dep].deploy_parallel(specs_by_deployment[dep]) + deployed = self._managers[dep].deploy_parallel(specs_by_deployment[dep], blueprint_args) for index, module in zip(indices_by_deployment[dep], deployed, strict=True): results[index] = module diff --git a/dimos/core/run_registry.py b/dimos/core/run_registry.py index a3807194f6..f25caae535 100644 --- a/dimos/core/run_registry.py +++ b/dimos/core/run_registry.py @@ -24,21 +24,12 @@ import signal import time +from dimos.constants import STATE_DIR from dimos.utils.logging_config import setup_logger logger = setup_logger() - -def _get_state_dir() -> Path: - """XDG_STATE_HOME compliant state directory for dimos.""" - xdg = os.environ.get("XDG_STATE_HOME") - if xdg: - return Path(xdg) / "dimos" - return Path.home() / ".local" / "state" / "dimos" - - -REGISTRY_DIR = _get_state_dir() / "runs" -LOG_BASE_DIR = _get_state_dir() / "logs" +REGISTRY_DIR = STATE_DIR / "runs" @dataclass diff --git a/dimos/core/test_blueprints.py b/dimos/core/test_blueprints.py index cab843b595..5567297bff 100644 --- a/dimos/core/test_blueprints.py +++ b/dimos/core/test_blueprints.py @@ -12,8 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +from types import MappingProxyType from typing import Protocol +from pydantic import ValidationError import pytest from dimos.core._test_future_annotations_helper import ( @@ -38,9 +40,11 @@ from dimos.spec.utils import Spec # Disable Rerun for tests (prevents viewer spawn and gRPC flush errors) -_BUILD_WITHOUT_RERUN = { - "cli_config_overrides": {"viewer": "none"}, -} +_BUILD_WITHOUT_RERUN = MappingProxyType( + { + "g": {"viewer": "none"}, + } +) class Scratch: @@ -141,6 +145,17 @@ def test_autoconnect() -> None: ) +def test_config() -> None: + blueprint = autoconnect(ModuleA.blueprint(), ModuleB.blueprint()) + config = blueprint.config() + assert config.model_fields.keys() == {"modulea", "moduleb", "g"} + assert config.model_fields["modulea"].annotation == ModuleA.default_config | None + assert config.model_fields["moduleb"].annotation == ModuleB.default_config | None + + with pytest.raises(ValidationError, match="invalid_key"): + config(module_a={"invalid_key": 5}) + + def test_transports() -> None: custom_transport = LCMTransport("/custom_topic", Data1) blueprint_set = autoconnect(ModuleA.blueprint(), ModuleB.blueprint()).transports( @@ -166,7 +181,7 @@ def test_global_config() -> None: def test_build_happy_path() -> None: blueprint_set = autoconnect(ModuleA.blueprint(), ModuleB.blueprint(), ModuleC.blueprint()) - coordinator = blueprint_set.build(**_BUILD_WITHOUT_RERUN) + coordinator = blueprint_set.build(_BUILD_WITHOUT_RERUN.copy()) try: assert isinstance(coordinator, ModuleCoordinator) @@ -295,7 +310,7 @@ def test_remapping() -> None: assert ("color_image", Data1) not in blueprint_set._all_name_types # Build and verify streams work - coordinator = blueprint_set.build(**_BUILD_WITHOUT_RERUN) + coordinator = blueprint_set.build(_BUILD_WITHOUT_RERUN.copy()) try: source_instance = coordinator.get_instance(SourceModule) @@ -345,7 +360,7 @@ def test_future_annotations_autoconnect() -> None: blueprint_set = autoconnect(FutureModuleOut.blueprint(), FutureModuleIn.blueprint()) - coordinator = blueprint_set.build(**_BUILD_WITHOUT_RERUN) + coordinator = blueprint_set.build(_BUILD_WITHOUT_RERUN.copy()) try: out_instance = coordinator.get_instance(FutureModuleOut) @@ -437,7 +452,7 @@ def test_module_ref_direct() -> None: coordinator = autoconnect( Calculator1.blueprint(), Mod1.blueprint(), - ).build(**_BUILD_WITHOUT_RERUN) + ).build(_BUILD_WITHOUT_RERUN.copy()) try: mod1 = coordinator.get_instance(Mod1) @@ -453,7 +468,7 @@ def test_module_ref_spec() -> None: coordinator = autoconnect( Calculator1.blueprint(), Mod2.blueprint(), - ).build(**_BUILD_WITHOUT_RERUN) + ).build(_BUILD_WITHOUT_RERUN.copy()) try: mod2 = coordinator.get_instance(Mod2) @@ -470,7 +485,7 @@ def test_disabled_modules_are_skipped_during_build() -> None: ModuleA.blueprint(), ModuleB.blueprint(), ModuleC.blueprint() ).disabled_modules(ModuleC) - coordinator = blueprint_set.build(**_BUILD_WITHOUT_RERUN) + coordinator = blueprint_set.build(_BUILD_WITHOUT_RERUN.copy()) try: assert coordinator.get_instance(ModuleA) is not None @@ -488,7 +503,7 @@ def test_disabled_module_ref_gets_noop_proxy() -> None: Mod2.blueprint(), ).disabled_modules(Calculator1) - coordinator = blueprint_set.build(**_BUILD_WITHOUT_RERUN) + coordinator = blueprint_set.build(_BUILD_WITHOUT_RERUN.copy()) try: mod2 = coordinator.get_instance(Mod2) @@ -528,7 +543,7 @@ def test_module_ref_remap_ambiguous() -> None: (Mod2, "calc", Calculator1), ] ) - .build(**_BUILD_WITHOUT_RERUN) + .build(_BUILD_WITHOUT_RERUN.copy()) ) try: diff --git a/dimos/core/test_worker.py b/dimos/core/test_worker.py index a9217bdd71..6ce5900348 100644 --- a/dimos/core/test_worker.py +++ b/dimos/core/test_worker.py @@ -146,7 +146,8 @@ def test_worker_manager_parallel_deployment(create_worker_manager): (SimpleModule, global_config, {}), (AnotherModule, global_config, {}), (ThirdModule, global_config, {}), - ] + ], + {}, ) assert len(modules) == 3 diff --git a/dimos/core/tests/test_docker_deployment.py b/dimos/core/tests/test_docker_deployment.py index d9707b5390..38c78c480e 100644 --- a/dimos/core/tests/test_docker_deployment.py +++ b/dimos/core/tests/test_docker_deployment.py @@ -101,7 +101,7 @@ def test_deploy_parallel_deploys_docker_module(self, mock_proxy_cls, dimos_clust specs = [ (FakeDockerModule, (), {}), ] - results = dimos_cluster.deploy_parallel(specs) + results = dimos_cluster.deploy_parallel(specs, {}) mock_proxy_cls.assert_called_once() assert results[0] is mock_dm diff --git a/dimos/core/tests/test_parallel_deploy_cleanup.py b/dimos/core/tests/test_parallel_deploy_cleanup.py index 8d0de49cbc..025d717de1 100644 --- a/dimos/core/tests/test_parallel_deploy_cleanup.py +++ b/dimos/core/tests/test_parallel_deploy_cleanup.py @@ -50,9 +50,9 @@ def fake_constructor(cls, *args, **kwargs): mock_docker_module_cls.side_effect = fake_constructor - FakeA = type("A", (), {}) - FakeB = type("B", (), {}) - FakeC = type("C", (), {}) + FakeA = type("A", (), {"name": "A"}) + FakeB = type("B", (), {"name": "B"}) + FakeC = type("C", (), {"name": "C"}) with pytest.raises(ExceptionGroup, match="safe_thread_map failed") as exc_info: WorkerManagerDocker(g=GlobalConfig()).deploy_parallel( @@ -60,7 +60,8 @@ def fake_constructor(cls, *args, **kwargs): (FakeA, (), {}), (FakeB, (), {}), (FakeC, (), {}), - ] + ], + {}, ) assert len(exc_info.value.exceptions) == 1 @@ -88,9 +89,9 @@ def fake_constructor(cls, *args, **kwargs): mock_docker_module_cls.side_effect = fake_constructor - FakeA = type("A", (), {}) - FakeB = type("B", (), {}) - FakeC = type("C", (), {}) + FakeA = type("A", (), {"name": "A"}) + FakeB = type("B", (), {"name": "B"}) + FakeC = type("C", (), {"name": "C"}) with pytest.raises(ExceptionGroup, match="safe_thread_map failed") as exc_info: WorkerManagerDocker(g=GlobalConfig()).deploy_parallel( @@ -98,7 +99,8 @@ def fake_constructor(cls, *args, **kwargs): (FakeA, (), {}), (FakeB, (), {}), (FakeC, (), {}), - ] + ], + {}, ) assert len(exc_info.value.exceptions) == 2 @@ -119,16 +121,17 @@ def fake_constructor(cls, *args, **kwargs): mock_docker_module_cls.side_effect = fake_constructor - FakeA = type("A", (), {}) - FakeB = type("B", (), {}) - FakeC = type("C", (), {}) + FakeA = type("A", (), {"name": "A"}) + FakeB = type("B", (), {"name": "B"}) + FakeC = type("C", (), {"name": "C"}) results = WorkerManagerDocker(g=GlobalConfig()).deploy_parallel( [ (FakeA, (), {}), (FakeB, (), {}), (FakeC, (), {}), - ] + ], + {}, ) assert len(results) == 3 @@ -151,12 +154,12 @@ def fake_constructor(cls, *args, **kwargs): mock_docker_module_cls.side_effect = fake_constructor - FakeA = type("A", (), {}) - FakeB = type("B", (), {}) + FakeA = type("A", (), {"name": "A"}) + FakeB = type("B", (), {"name": "B"}) with pytest.raises(ExceptionGroup, match="safe_thread_map failed"): WorkerManagerDocker(g=GlobalConfig()).deploy_parallel( - [(FakeA, (), {}), (FakeB, (), {})] + [(FakeA, (), {}), (FakeB, (), {})], {} ) # stop was attempted despite it raising @@ -187,9 +190,9 @@ def fake_deploy_module(module_class, args=(), kwargs=None): for w in mock_workers: w.deploy_module = fake_deploy_module - FakeA = type("A", (), {}) - FakeB = type("B", (), {}) - FakeC = type("C", (), {}) + FakeA = type("A", (), {"name": "A"}) + FakeB = type("B", (), {"name": "B"}) + FakeC = type("C", (), {"name": "C"}) with patch("dimos.core.worker_manager.RPCClient"): with pytest.raises(ExceptionGroup, match="safe_thread_map failed"): @@ -198,7 +201,8 @@ def fake_deploy_module(module_class, args=(), kwargs=None): (FakeA, (), {}), (FakeB, (), {}), (FakeC, (), {}), - ] + ], + {}, ) # Workers must have been shut down diff --git a/dimos/core/worker_manager.py b/dimos/core/worker_manager.py index 10b0e0c4bb..ba38ce4b6f 100644 --- a/dimos/core/worker_manager.py +++ b/dimos/core/worker_manager.py @@ -14,7 +14,7 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any from dimos.core.global_config import GlobalConfig @@ -73,7 +73,11 @@ def deploy( actor = worker.deploy_module(module_class, global_config, kwargs=kwargs) return RPCClient(actor, module_class) - def deploy_parallel(self, module_specs: Iterable[ModuleSpec]) -> list[RPCClient]: + def deploy_parallel( + self, + module_specs: Iterable[ModuleSpec], + blueprint_args: Mapping[str, Mapping[str, Any]], + ) -> list[RPCClient]: if self._closed: raise RuntimeError("WorkerManager is closed") @@ -91,6 +95,7 @@ def deploy_parallel(self, module_specs: Iterable[ModuleSpec]) -> list[RPCClient] for module_class, global_config, kwargs in module_specs: worker = self._select_worker() worker.reserve_slot() + kwargs.update(blueprint_args.get(module_class.name, {})) assignments.append((worker, module_class, global_config, kwargs)) try: diff --git a/dimos/core/worker_manager_docker.py b/dimos/core/worker_manager_docker.py index 7087244f1e..6a17aab8ff 100644 --- a/dimos/core/worker_manager_docker.py +++ b/dimos/core/worker_manager_docker.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress from typing import TYPE_CHECKING, Any @@ -51,13 +52,16 @@ def deploy( self._deployed.append(mod) return mod - def deploy_parallel(self, specs: list[ModuleSpec]) -> list[ModuleProxyProtocol]: + def deploy_parallel( + self, specs: list[ModuleSpec], blueprint_args: Mapping[str, Mapping[str, Any]] + ) -> list[ModuleProxyProtocol]: # inlined to prevent circular dependency from dimos.core.docker_module import DockerModuleProxy def _deploy(spec: ModuleSpec) -> DockerModuleProxy: - # spec = (module_class, global_config, kwargs) - mod = DockerModuleProxy(spec[0], g=spec[1], **spec[2]) + module_class, global_config, kwargs = spec + kwargs.update(blueprint_args.get(module_class.name, {})) + mod = DockerModuleProxy(module_class, g=global_config, **kwargs) self._deployed.append(mod) return mod diff --git a/dimos/perception/experimental/temporal_memory/temporal_memory.py b/dimos/perception/experimental/temporal_memory/temporal_memory.py index da9fe62370..40153d6582 100644 --- a/dimos/perception/experimental/temporal_memory/temporal_memory.py +++ b/dimos/perception/experimental/temporal_memory/temporal_memory.py @@ -33,6 +33,7 @@ from reactivex.disposable import Disposable from dimos.agents.annotation import skill +from dimos.constants import STATE_DIR from dimos.core.core import rpc from dimos.core.module import Module, ModuleConfig from dimos.core.stream import In, Out @@ -161,11 +162,7 @@ def __init__(self, **kwargs: Any) -> None: if self.config.db_dir: db_dir = Path(self.config.db_dir) else: - # Default: ~/.local/state/dimos/temporal_memory/ - # XDG state dir — predictable, works for pip install and git clone. - xdg = os.environ.get("XDG_STATE_HOME") - state_root = Path(xdg) if xdg else Path.home() / ".local" / "state" - db_dir = state_root / "dimos" / "temporal_memory" + db_dir = STATE_DIR / "temporal_memory" db_dir.mkdir(parents=True, exist_ok=True) db_path = db_dir / "entity_graph.db" if self.config.new_memory and db_path.exists(): diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 8a2be16668..823e0d0098 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -14,20 +14,28 @@ from __future__ import annotations +from collections.abc import Iterable +from contextlib import suppress from datetime import datetime, timezone import inspect import json import os +from pathlib import Path import sys import time -from typing import Any, get_args, get_origin +import types +from typing import Any, Union, get_args, get_origin import click from dotenv import load_dotenv +from pydantic import BaseModel +from pydantic_core import PydanticUndefined import requests import typer from dimos.agents.mcp.mcp_adapter import McpAdapter, McpError +from dimos.constants import CONFIG_DIR, LOG_DIR +from dimos.core.blueprints import Blueprint, _BlueprintAtom from dimos.core.global_config import GlobalConfig, global_config from dimos.core.run_registry import get_most_recent, is_pid_alive, stop_entry from dimos.utils.logging_config import setup_logger @@ -108,19 +116,92 @@ def callback(**kwargs) -> None: # type: ignore[no-untyped-def] main.callback()(create_dynamic_callback()) # type: ignore[no-untyped-call] +def arg_help( + config: type[BaseModel], + blueprint: Blueprint, + indent: str = " ", + module: str = "", + _atom: _BlueprintAtom | None = None, +) -> str: + output = "" + for k, info in config.model_fields.items(): + if k == "g": + continue + t = info.annotation + if isinstance(t, types.GenericAlias): + # Can't be specified on CLI + continue + + # TODO(PY314): if isinstance(t, Union): + if get_origin(t) in {Union, types.UnionType}: + with suppress(StopIteration): + t = next(u for u in get_args(t) if issubclass(u, BaseModel)) + + if inspect.isclass(t) and issubclass(t, BaseModel): + output += f"{indent}{module}{k}:\n" + # Find blueprint atom + bp = next(bp for bp in blueprint.blueprints if bp.module.name == k) + output += arg_help( + t, blueprint, indent=indent + " ", module=module + k + ".", _atom=bp + ) + else: + assert _atom is not None + # Use __name__ to avoid "" style output on basic types. + display_type = t.__name__ if isinstance(t, type) else t + required = "[Required] " if info.is_required() and k not in _atom.kwargs else "" + d = _atom.kwargs.get(k, info.default) + default = f" (default: {d})" if d is not PydanticUndefined else "" + output += f"{indent}* {required}{module}{k}: {display_type}{default}\n" + return output + + +def load_config_args(config: type[BaseModel], args: Iterable[str], path: Path) -> dict[str, Any]: + try: + kwargs = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + kwargs = {} + + for k, v in os.environ.items(): + parts = k.lower().split("__") + if parts[0] not in config.model_fields: + continue + d = kwargs + for p in parts[:-1]: + d = d.setdefault(p, {}) + d[parts[-1]] = v + + for arg in args: + k, _, v = arg.partition("=") + parts = k.split(".") + d = kwargs + for p in parts[:-1]: + d = d.setdefault(p, {}) + d[parts[-1]] = v + + # We don't need this config, but this atleast validates the user input first. + # This will help catch misspellings and similar mistakes. + config(**kwargs) + + return kwargs # type: ignore[no-any-return] + + @main.command() def run( ctx: typer.Context, robot_types: list[str] = typer.Argument(..., help="Blueprints or modules to run"), daemon: bool = typer.Option(False, "--daemon", "-d", help="Run in background"), disable: list[str] = typer.Option([], "--disable", help="Module names to disable"), + blueprint_args: list[str] = typer.Option((), "--option", "-o"), + config_path: Path = typer.Option( + CONFIG_DIR / "dimos", "--config", "-c", help="Path to config file" + ), + show_help: bool = typer.Option(False, "--help"), ) -> None: """Start a robot blueprint""" logger.info("Starting DimOS") from dimos.core.blueprints import autoconnect from dimos.core.run_registry import ( - LOG_BASE_DIR, RunEntry, check_port_conflicts, cleanup_stale, @@ -132,7 +213,6 @@ def run( setup_exception_handler() cli_config_overrides: dict[str, Any] = ctx.obj - global_config.update(**cli_config_overrides) # Clean stale registry entries stale = cleanup_stale() @@ -151,7 +231,7 @@ def run( blueprint_name = "-".join(robot_types) run_id = generate_run_id(blueprint_name) - log_dir = LOG_BASE_DIR / run_id + log_dir = LOG_DIR / run_id # Route structured logs (main.jsonl) to the per-run directory. # Workers inherit DIMOS_RUN_LOG_DIR env var via forkserver. @@ -163,7 +243,17 @@ def run( disabled_classes = tuple(get_module_by_name(name).blueprints[0].module for name in disable) blueprint = blueprint.disabled_modules(*disabled_classes) - coordinator = blueprint.build(cli_config_overrides=cli_config_overrides) + if show_help: + print("Blueprint arguments:") + print(arg_help(blueprint.config(), blueprint)) + return + + blueprint_config = blueprint.config() + kwargs = load_config_args(blueprint_config, blueprint_args, config_path) + if cli_config_overrides: + kwargs["g"] = cli_config_overrides + + coordinator = blueprint.build(kwargs) if daemon: from dimos.core.daemon import ( diff --git a/dimos/robot/cli/test_dimos.py b/dimos/robot/cli/test_dimos.py new file mode 100644 index 0000000000..5bfe8dbed4 --- /dev/null +++ b/dimos/robot/cli/test_dimos.py @@ -0,0 +1,120 @@ +# Copyright 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 typing import Literal + +from dimos.core.blueprints import autoconnect +from dimos.core.module import Module, ModuleConfig +from dimos.robot.cli.dimos import arg_help + + +def test_blueprint_arg_help(): + class ConfigA(ModuleConfig): + min_interval_sec: float = 0.1 + entity_prefix: str = "world" + viewer_mode: Literal["native", "web", "connect", "none"] = "native" + + class TestModuleA(Module[ConfigA]): + default_config = ConfigA + + class ConfigB(ModuleConfig): + memory_limit: str = "25%" + ip: str = "127.0.0.1" + + class TestModuleB(Module[ConfigB]): + default_config = ConfigB + + blueprint = autoconnect(TestModuleA.blueprint(), TestModuleB.blueprint()) + output = arg_help(blueprint.config(), blueprint) + # List output produces better diff in pytest error output. + assert output.split("\n") == [ + " testmodulea:", + " * testmodulea.default_rpc_timeout: float (default: 120.0)", + " * testmodulea.frame_id_prefix: str | None (default: None)", + " * testmodulea.frame_id: str | None (default: None)", + " * testmodulea.min_interval_sec: float (default: 0.1)", + " * testmodulea.entity_prefix: str (default: world)", + " * testmodulea.viewer_mode: typing.Literal['native', 'web', 'connect', 'none'] (default: native)", + " testmoduleb:", + " * testmoduleb.default_rpc_timeout: float (default: 120.0)", + " * testmoduleb.frame_id_prefix: str | None (default: None)", + " * testmoduleb.frame_id: str | None (default: None)", + " * testmoduleb.memory_limit: str (default: 25%)", + " * testmoduleb.ip: str (default: 127.0.0.1)", + "", + ] + + +def test_blueprint_arg_help_extra_args(): + """Test defaults passed to .blueprint() override.""" + + class ConfigA(ModuleConfig): + frame_id_prefix: str | None = None + min_interval_sec: float = 0.1 + entity_prefix: str = "world" + viewer_mode: Literal["native", "web", "connect", "none"] = "native" + + class TestModuleA(Module[ConfigA]): + default_config = ConfigA + + class ConfigB(ModuleConfig): + memory_limit: str = "25%" + ip: str = "127.0.0.1" + + class TestModuleB(Module[ConfigB]): + default_config = ConfigB + + module_a = TestModuleA.blueprint(frame_id_prefix="foo", viewer_mode="web") + blueprint = autoconnect(module_a, TestModuleB.blueprint(ip="1.1.1.1")) + output = arg_help(blueprint.config(), blueprint) + # List output produces better diff in pytest error output. + assert output.split("\n") == [ + " testmodulea:", + " * testmodulea.default_rpc_timeout: float (default: 120.0)", + " * testmodulea.frame_id_prefix: str | None (default: foo)", + " * testmodulea.frame_id: str | None (default: None)", + " * testmodulea.min_interval_sec: float (default: 0.1)", + " * testmodulea.entity_prefix: str (default: world)", + " * testmodulea.viewer_mode: typing.Literal['native', 'web', 'connect', 'none'] (default: web)", + " testmoduleb:", + " * testmoduleb.default_rpc_timeout: float (default: 120.0)", + " * testmoduleb.frame_id_prefix: str | None (default: None)", + " * testmoduleb.frame_id: str | None (default: None)", + " * testmoduleb.memory_limit: str (default: 25%)", + " * testmoduleb.ip: str (default: 1.1.1.1)", + "", + ] + + +def test_blueprint_arg_help_required(): + """Test required arguments.""" + + class Config(ModuleConfig): + foo: int + spam: str = "eggs" + + class TestModule(Module[Config]): + default_config = Config + + blueprint = TestModule.blueprint() + output = arg_help(blueprint.config(), blueprint) + assert output.split("\n") == [ + " testmodule:", + " * testmodule.default_rpc_timeout: float (default: 120.0)", + " * testmodule.frame_id_prefix: str | None (default: None)", + " * testmodule.frame_id: str | None (default: None)", + " * [Required] testmodule.foo: int", + " * testmodule.spam: str (default: eggs)", + "", + ] diff --git a/dimos/utils/logging_config.py b/dimos/utils/logging_config.py index bf7632fa60..bec99b4db4 100644 --- a/dimos/utils/logging_config.py +++ b/dimos/utils/logging_config.py @@ -27,7 +27,7 @@ import structlog from structlog.processors import CallsiteParameter, CallsiteParameterAdder -from dimos.constants import DIMOS_LOG_DIR, DIMOS_PROJECT_ROOT +from dimos.constants import DIMOS_PROJECT_ROOT, LOG_DIR # Suppress noisy loggers logging.getLogger("aiortc.codecs.h264").setLevel(logging.ERROR) @@ -79,16 +79,7 @@ def get_run_log_dir() -> Path | None: def _get_log_directory() -> Path: - # Check if running from a git repository - if (DIMOS_PROJECT_ROOT / ".git").exists(): - log_dir = DIMOS_LOG_DIR - else: - # Running from an installed package - use XDG_STATE_HOME - xdg_state_home = os.getenv("XDG_STATE_HOME") - if xdg_state_home: - log_dir = Path(xdg_state_home) / "dimos" / "logs" - else: - log_dir = Path.home() / ".local" / "state" / "dimos" / "logs" + log_dir = LOG_DIR try: log_dir.mkdir(parents=True, exist_ok=True) diff --git a/docs/usage/blueprints.md b/docs/usage/blueprints.md index 0a8371032a..668c9263b2 100644 --- a/docs/usage/blueprints.md +++ b/docs/usage/blueprints.md @@ -230,6 +230,45 @@ The config is normally taken from .env or from environment variables. But you ca blueprint = ModuleA.blueprint().global_config(n_workers=8) ``` +## Providing blueprint configuration to users + +`Blueprint.config()` can be used to get a `pydantic.BaseModel` that can be used to +inspect or test configuration settings that can be passed to `Blueprint.build()`: + +```python session=blueprint-ex1 +# Validate config input +blueprint_args = { + "module1": {"arg1": 5} +} +config = base_blueprint.config() +config(**blueprint_args) # raises pydantic.ValidationError if args are incorrect +``` + +`dimos.robot.cli.dimos.arghelp()` is a helper function that will return a string +containing all details of these arguments (this is how the output is produced when +running `dimos run unitree-go2 --help`, for example): + +```python session=blueprint-ex1 +from dimos.robot.cli.dimos import arghelp +print(arghelp(base_blueprint.config(), base_blueprint)) +``` + +Another function is `dimos.robot.cli.dimos.load_config_args()` which can create the +argument dict for users from a config file, environment variables and CLI arguments: + + +```python session=blueprint-ex1 +from dimos.robot.cli.dimos import load_config_args + +config_path = Path.home() / "base-blueprint-config.json" +cli_args = ["arg1=5"] +blueprint_args = load_config_args(base_blueprint.config(), cli_args, config_path) +# Test user input is valid +config(**blueprint_args) +# Then we can build the blueprint +base_blueprint.build(blueprint_args) +``` + ## Calling the methods of other modules Imagine you have this code: diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 50a76cf552..9e1cf628cc 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -65,8 +65,11 @@ dimos run [ ...] [--daemon] [--disable ...] | Option | Description | |--------|-------------| +| `--config` `-c` | Path to read JSON config file from (options can be overriden with `-o` | | `--daemon`, `-d` | Run in background (double-fork, health check, writes run registry) | | `--disable` | Module class names to exclude from the blueprint | +| `--option`, `-o` | Provide an configuration option to the blueprint (e.g. `-o voxelgridmapper.voxel_size=1` | +| `--help` | Display the available configuration options that can be changed with `-o` or the config file | ```bash # Foreground (Ctrl-C to stop)