From fe7a7de5874ffbc4ba290e97aae6a863205dd4f0 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 4 Jun 2026 13:41:32 -0700 Subject: [PATCH 01/12] spec: openspec init --- .gitignore | 2 + docs/coding-agents/index.md | 1 + docs/development/openspec.md | 102 ++++++++++++++ docs/docs.json | 1 + openspec/config.yaml | 45 ++++++ openspec/schemas/dimos-capability/schema.yaml | 128 ++++++++++++++++++ .../dimos-capability/templates/design.md | 35 +++++ .../dimos-capability/templates/docs.md | 19 +++ .../dimos-capability/templates/proposal.md | 32 +++++ .../dimos-capability/templates/spec.md | 16 +++ .../dimos-capability/templates/tasks.md | 15 ++ 11 files changed, 396 insertions(+) create mode 100644 docs/development/openspec.md create mode 100644 openspec/config.yaml create mode 100644 openspec/schemas/dimos-capability/schema.yaml create mode 100644 openspec/schemas/dimos-capability/templates/design.md create mode 100644 openspec/schemas/dimos-capability/templates/docs.md create mode 100644 openspec/schemas/dimos-capability/templates/proposal.md create mode 100644 openspec/schemas/dimos-capability/templates/spec.md create mode 100644 openspec/schemas/dimos-capability/templates/tasks.md diff --git a/.gitignore b/.gitignore index 42bdddfa45..787163e787 100644 --- a/.gitignore +++ b/.gitignore @@ -63,8 +63,10 @@ yolo11n.pt # symlink one of .envrc.* if you'd like to use .envrc .claude +.opencode/ **/CLAUDE.md .direnv/ +.omo/ /logs diff --git a/docs/coding-agents/index.md b/docs/coding-agents/index.md index ff778ac5cf..5ac7c854a7 100644 --- a/docs/coding-agents/index.md +++ b/docs/coding-agents/index.md @@ -3,6 +3,7 @@ ├── worktrees.md (creating provisioned worktrees with `bin/worktree`) ├── style.md (code style guidelines for dimos) ├── testing.md (docs about writing tests) +├── ../development/openspec.md (OpenSpec behavior-spec workflow) ├── docs (these are docs about writing docs) │   ├── codeblocks.md │   ├── doclinks.md diff --git a/docs/development/openspec.md b/docs/development/openspec.md new file mode 100644 index 0000000000..280eb0f57e --- /dev/null +++ b/docs/development/openspec.md @@ -0,0 +1,102 @@ +# OpenSpec Workflow + +DimOS uses OpenSpec as the checked-in planning layer for behavior changes. OpenSpec artifacts live under `openspec/` and should describe what the system is supposed to do, why it is changing, and how contributors or agents should validate the work. + +## Terminology + +Keep these two meanings separate: + +- **OpenSpec capability spec**: Markdown requirements under `openspec/specs//spec.md`. These describe observable behavior and acceptance scenarios. +- **DimOS Spec**: Python Protocol/RPC contracts in files like `dimos/navigation/navigation_spec.py` or `dimos/manipulation/control/arm_driver_spec.py`. These describe module interfaces for code wiring. + +Use "OpenSpec capability spec" in prose when there is any chance of confusion. + +## Schema + +The project uses the `dimos-capability` schema configured in `openspec/config.yaml`. + +The artifact flow is: + +```text +proposal + ├── specs + ├── design + └── docs + └── tasks +``` + +| Artifact | Purpose | +|---|---| +| `proposal.md` | Intent, scope, affected DimOS surfaces, and capability impact. | +| `specs//spec.md` | Behavior-first requirements and scenarios. | +| `design.md` | Module, stream, blueprint, skill/MCP, safety, and rollout decisions. | +| `docs.md` | Documentation impact and doc validation plan. | +| `tasks.md` | Implementation, docs, verification, and manual QA checklist. | + +## When to create a change + +Create an OpenSpec change when work changes observable behavior, public CLI/API/MCP behavior, robot behavior, hardware/simulation/replay workflows, docs that users rely on, or cross-module architecture. + +Do not create a change for a purely mechanical refactor, typo fix, or internal cleanup unless it changes behavior or needs cross-session planning context. + +## Writing specs + +OpenSpec capability specs are behavior contracts, not implementation plans. + +Good spec content: + +- User- or developer-visible behavior. +- Public CLI/API/MCP tool behavior. +- Stream or message behavior that downstream modules rely on. +- Robot safety constraints and hardware/simulation/replay expectations. +- Scenarios that can be tested or manually verified. + +Avoid in specs: + +- Private class/function names. +- Generated-file mechanics. +- Library choices and wiring details. +- Step-by-step implementation tasks. + +Put those details in `design.md` or `tasks.md`. + +## Capability names + +Prefer behavior-domain names over code names. Useful starting points: + +- `module-system` +- `blueprint-composition` +- `cli-lifecycle` +- `agent-skills-mcp` +- `configuration` +- `navigation-stack` +- `manipulation-stack` +- `hardware-adapters` +- `simulation-replay` +- `documentation-system` + +Add specs progressively as changes need them. Do not try to backfill the whole project at once. + +## Validation + +Use OpenSpec validation before implementation and before archiving: + +```bash skip +openspec schema validate dimos-capability +openspec validate +openspec templates --json +``` + +For documentation changes, also run the relevant doc checks from [Writing Docs](/docs/development/writing_docs.md): + +```bash skip +md-babel-py run +``` + +When a change touches blueprint names, module-level blueprint variables, or module registry inputs, run: + +```bash skip +pytest dimos/robot/test_all_blueprints_generation.py +``` + +Then run focused tests for the changed code and manually QA through the actual surface: CLI command, MCP tool, HTTP API, simulation/replay blueprint, hardware procedure, or library driver. diff --git a/docs/docs.json b/docs/docs.json index 58da2ff6a1..f0064c9ab9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,6 +144,7 @@ "group": "Development", "pages": [ "development/conventions", + "development/openspec", "development/testing", "development/docker", "development/grid_testing", diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000000..62a72bba63 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,45 @@ +schema: dimos-capability + +context: | + DimOS is a robotics operating system for generalist robots. Modules communicate + through typed streams (`In[T]`, `Out[T]`) over LCM, SHM, ROS, DDS, or other + transports. Blueprints compose modules into runnable robot stacks. Skills are + `@skill`-annotated RPC methods exposed to agents and MCP clients. + + Terminology boundary: + - "OpenSpec spec" means a behavior specification under `openspec/specs/`. + - "DimOS Spec" means a Python Protocol/RPC contract in `*_spec.py` files, + usually inheriting `dimos.spec.utils.Spec` and `typing.Protocol`. + Keep these separate. OpenSpec specs describe observable behavior; DimOS Specs + describe code-level module interfaces. + + OpenSpec specs should capture current behavior, user/developer-visible + outcomes, public CLI/API/tool surfaces, robot safety constraints, and testable + scenarios. Put implementation choices, class names, module wiring, generated + registry updates, and rollout details in `design.md` or `tasks.md`. + + Documentation lives in: + - `docs/usage/` for user-facing concepts and APIs. + - `docs/capabilities/` for capability and platform guides. + - `docs/development/` for contributor process. + - `docs/coding-agents/` and `AGENTS.md` for coding-agent guidance. + +rules: + proposal: + - "Identify affected DimOS surfaces: modules, streams, blueprints, CLI, skills/MCP, docs, hardware, simulation, replay, or generated registries." + - Use capability names that match behavior domains, not Python class names. + - Mark hardware safety or public API/CLI changes explicitly. + specs: + - Write behavior-first requirements; avoid implementation detail unless it is externally observable. + - Every requirement must include at least one `#### Scenario:` block with concrete observable outcomes. + - Use "OpenSpec capability spec" when prose might otherwise be confused with DimOS Python `Spec` Protocols. + design: + - Call out DimOS `Spec` Protocols, adapter Protocols, blueprint composition, stream names/types, and skill/MCP exposure when relevant. + - Mention generated files and required regeneration commands, especially `pytest dimos/robot/test_all_blueprints_generation.py` for blueprint registry changes. + - Include hardware/simulation/replay assumptions and safety constraints for robot-facing work. + docs: + - List user-facing docs, contributor docs, coding-agent docs, and AGENTS.md updates required by the change. + - Include documentation validation commands for changed docs, such as `doclinks` and `md-babel-py run ` where applicable. + tasks: + - Include verification tasks for OpenSpec validation, relevant pytest targets, type checks when needed, and manual QA through the user-facing surface. + - Add registry generation tasks when blueprint names, module classes, or generated registry inputs change. diff --git a/openspec/schemas/dimos-capability/schema.yaml b/openspec/schemas/dimos-capability/schema.yaml new file mode 100644 index 0000000000..fedb7964ee --- /dev/null +++ b/openspec/schemas/dimos-capability/schema.yaml @@ -0,0 +1,128 @@ +name: dimos-capability +version: 1 +description: DimOS capability workflow - proposal → specs/design/docs → tasks +artifacts: + - id: proposal + generates: proposal.md + description: DimOS change proposal covering intent, scope, capability impact, and affected robot/software surfaces + template: proposal.md + instruction: | + Create the proposal document that establishes WHY this change is needed and what DimOS behavior it affects. + + Sections: + - **Why**: 1-2 concise paragraphs on the problem or opportunity. Explain why the change matters now. + - **What Changes**: Bullet list of added, modified, or removed behavior. Mark public API/CLI or hardware-safety breaking changes with **BREAKING**. + - **Affected DimOS Surfaces**: Identify modules, streams, blueprints, CLI commands, skills/MCP tools, docs, hardware, simulation, replay, generated registries, or external protocols touched by the change. + - **Capabilities**: Identify which OpenSpec capability specs will be created or modified: + - **New Capabilities**: List behavior domains introduced by the change. Each becomes `specs//spec.md`. Use kebab-case names (for example, `agent-skills-mcp`, `blueprint-composition`, `manipulation-stack`). + - **Modified Capabilities**: List existing `openspec/specs//` entries whose requirements change. Only include spec-level behavior changes, not implementation-only refactors. + - **Impact**: Summarize user/developer impact, compatibility risks, dependency changes, documentation updates, and test/QA scope. + + Keep proposals concise. Do not include line-by-line implementation details; put architecture and rollout decisions in `design.md`. + requires: [] + - id: specs + generates: specs/**/*.md + description: Behavior-first OpenSpec capability delta specifications + template: spec.md + instruction: | + Create OpenSpec capability specs that define WHAT DimOS should do, not how it is implemented. + + Create one delta spec file per capability listed in proposal.md: + - New capabilities: use `specs//spec.md` with the exact kebab-case name from the proposal. + - Modified capabilities: use the existing folder from `openspec/specs//`. + + Use these delta sections as `##` headers: + - **ADDED Requirements**: New externally observable behavior. + - **MODIFIED Requirements**: Changed behavior. Include the full updated requirement block, not a partial patch. + - **REMOVED Requirements**: Deprecated behavior. Include **Reason** and **Migration**. + - **RENAMED Requirements**: Name-only changes. Use FROM:/TO: format. + + Requirement format: + - Use `### Requirement: `. + - Use SHALL/MUST for normative requirements. + - Include at least one `#### Scenario: ` per requirement. Scenario headings MUST use exactly four `#` characters. + - Prefer `- **GIVEN**`, `- **WHEN**`, `- **THEN**`, and `- **AND**` bullets. + - Cover happy path plus meaningful edge/error/safety cases. + + DimOS-specific guidance: + - Specify user/developer-visible behavior, robot outcomes, CLI behavior, skill/MCP tool behavior, stream contracts, safety constraints, and compatibility expectations. + - Avoid Python class names, private module internals, transport implementation choices, and generated-file details unless those details are observable API contracts. + - Use "OpenSpec capability spec" in prose when needed to avoid confusion with DimOS Python `Spec` Protocols. + - If the behavior only changes implementation and not observable requirements, do not create a spec delta. + requires: + - proposal + - id: design + generates: design.md + description: DimOS technical design and architecture decisions + template: design.md + instruction: | + Create the design document that explains HOW the change should be implemented in DimOS. + + Include design.md for cross-module changes, new robot/hardware integration, new public interfaces, new dependencies, safety-sensitive behavior, generated registry changes, or unclear architecture. + + Sections: + - **Context**: Current state, relevant modules/blueprints/docs, and constraints. + - **Goals / Non-Goals**: What the design achieves and explicitly excludes. + - **DimOS Architecture**: Modules, streams, transports, blueprints, RPC/module refs, DimOS `Spec` Protocols, adapter Protocols, skills/MCP exposure, CLI entry points, and generated registries involved. + - **Decisions**: Key choices with rationale and alternatives considered. + - **Safety / Simulation / Replay**: Hardware assumptions, sim/replay behavior, safety constraints, and manual QA surface. + - **Risks / Trade-offs**: Known risks and mitigations. + - **Migration / Rollout**: Compatibility, generated files, docs, and deployment steps. + - **Open Questions**: Outstanding decisions or unknowns. + + Reference proposal.md for intent and specs for behavior. Keep line-by-line work in tasks.md. + requires: + - proposal + - id: docs + generates: docs.md + description: Documentation impact plan for user, contributor, and coding-agent docs + template: docs.md + instruction: | + Create the documentation impact plan for the change. + + Sections: + - **User-Facing Docs**: Updates under `docs/usage/`, `docs/capabilities/`, `docs/platforms/`, or README files. + - **Contributor Docs**: Updates under `docs/development/`. + - **Coding-Agent Docs**: Updates under `docs/coding-agents/` or `AGENTS.md`. + - **Doc Validation**: Commands needed for changed docs, such as `doclinks`, `md-babel-py run `, and `bin/gen-diagrams`. + - **No Docs Needed**: If no docs are needed, explain why. + + Match `docs/development/writing_docs.md`: contributor-only docs belong in `docs/development`; user-facing behavior belongs in `docs/usage` or `docs/capabilities`. + requires: + - proposal + - id: tasks + generates: tasks.md + description: Implementation, validation, docs, and manual-QA checklist + template: tasks.md + instruction: | + Create the implementation checklist. The apply phase parses checkbox format, so every actionable task MUST use `- [ ]`. + + Guidelines: + - Group tasks under numbered `##` headings. + - Each task must be `- [ ] X.Y Task description`. + - Keep tasks small enough to complete in one focused session. + - Order tasks by dependency. + - Include docs and validation tasks from docs.md. + - Include generated registry tasks when blueprints or module registry inputs change. + - Include manual QA through the actual user surface: CLI, TUI, HTTP API, MCP tool, simulation/replay blueprint, hardware procedure, or library driver. + + Typical DimOS validation tasks: + - Run `openspec validate `. + - Run focused pytest targets for changed modules. + - Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint registry output may change. + - Run docs validation commands for changed docs. + - Run lints/types when the touched area requires them. + + Reference specs for WHAT, design for HOW, and docs.md for documentation work. + requires: + - specs + - design + - docs +apply: + requires: + - tasks + tracks: tasks.md + instruction: | + Read proposal.md, specs, design.md, docs.md, and tasks.md before editing code. + Work through pending tasks, mark checkboxes complete as they finish, and keep artifacts current when implementation changes the plan. + Verify with OpenSpec validation, focused tests, docs checks, and manual QA through the relevant DimOS surface. diff --git a/openspec/schemas/dimos-capability/templates/design.md b/openspec/schemas/dimos-capability/templates/design.md new file mode 100644 index 0000000000..25031ceb8b --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/design.md @@ -0,0 +1,35 @@ +## Context + + + +## Goals / Non-Goals + +**Goals:** + + +**Non-Goals:** + + +## DimOS Architecture + + + +## Decisions + + + +## Safety / Simulation / Replay + + + +## Risks / Trade-offs + + + +## Migration / Rollout + + + +## Open Questions + + diff --git a/openspec/schemas/dimos-capability/templates/docs.md b/openspec/schemas/dimos-capability/templates/docs.md new file mode 100644 index 0000000000..d274aed653 --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/docs.md @@ -0,0 +1,19 @@ +## User-Facing Docs + + + +## Contributor Docs + + + +## Coding-Agent Docs + + + +## Doc Validation + + + +## No Docs Needed + + diff --git a/openspec/schemas/dimos-capability/templates/proposal.md b/openspec/schemas/dimos-capability/templates/proposal.md new file mode 100644 index 0000000000..98d409e8de --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/proposal.md @@ -0,0 +1,32 @@ +## Why + + + +## What Changes + + + +## Affected DimOS Surfaces + + +- Modules/streams: +- Blueprints/CLI: +- Skills/MCP: +- Hardware/simulation/replay: +- Docs/generated registries: + +## Capabilities + +### New Capabilities + +- ``: + +### Modified Capabilities + +- ``: + +## Impact + + diff --git a/openspec/schemas/dimos-capability/templates/spec.md b/openspec/schemas/dimos-capability/templates/spec.md new file mode 100644 index 0000000000..afc0c1ff58 --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **GIVEN** +- **WHEN** +- **THEN** +- **AND** + + diff --git a/openspec/schemas/dimos-capability/templates/tasks.md b/openspec/schemas/dimos-capability/templates/tasks.md new file mode 100644 index 0000000000..b38fcdfabb --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [ ] 1.1 +- [ ] 1.2 + +## 2. Documentation + +- [ ] 2.1 + +## 3. Verification + +- [ ] 3.1 Run `openspec validate ` +- [ ] 3.2 Run focused tests for changed code +- [ ] 3.3 Run docs validation commands for changed docs +- [ ] 3.4 Manually QA through the relevant DimOS surface (CLI, MCP, simulation/replay, hardware procedure, HTTP API, or library driver) From 76158b261a9a4c3f0509bc7a218db7cfe1010e44 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 8 Jun 2026 16:20:39 -0700 Subject: [PATCH 02/12] chore: revert change to doc folder --- docs/coding-agents/index.md | 1 - docs/development/openspec.md | 102 ----------------------------------- docs/docs.json | 1 - 3 files changed, 104 deletions(-) delete mode 100644 docs/development/openspec.md diff --git a/docs/coding-agents/index.md b/docs/coding-agents/index.md index 5ac7c854a7..ff778ac5cf 100644 --- a/docs/coding-agents/index.md +++ b/docs/coding-agents/index.md @@ -3,7 +3,6 @@ ├── worktrees.md (creating provisioned worktrees with `bin/worktree`) ├── style.md (code style guidelines for dimos) ├── testing.md (docs about writing tests) -├── ../development/openspec.md (OpenSpec behavior-spec workflow) ├── docs (these are docs about writing docs) │   ├── codeblocks.md │   ├── doclinks.md diff --git a/docs/development/openspec.md b/docs/development/openspec.md deleted file mode 100644 index 280eb0f57e..0000000000 --- a/docs/development/openspec.md +++ /dev/null @@ -1,102 +0,0 @@ -# OpenSpec Workflow - -DimOS uses OpenSpec as the checked-in planning layer for behavior changes. OpenSpec artifacts live under `openspec/` and should describe what the system is supposed to do, why it is changing, and how contributors or agents should validate the work. - -## Terminology - -Keep these two meanings separate: - -- **OpenSpec capability spec**: Markdown requirements under `openspec/specs//spec.md`. These describe observable behavior and acceptance scenarios. -- **DimOS Spec**: Python Protocol/RPC contracts in files like `dimos/navigation/navigation_spec.py` or `dimos/manipulation/control/arm_driver_spec.py`. These describe module interfaces for code wiring. - -Use "OpenSpec capability spec" in prose when there is any chance of confusion. - -## Schema - -The project uses the `dimos-capability` schema configured in `openspec/config.yaml`. - -The artifact flow is: - -```text -proposal - ├── specs - ├── design - └── docs - └── tasks -``` - -| Artifact | Purpose | -|---|---| -| `proposal.md` | Intent, scope, affected DimOS surfaces, and capability impact. | -| `specs//spec.md` | Behavior-first requirements and scenarios. | -| `design.md` | Module, stream, blueprint, skill/MCP, safety, and rollout decisions. | -| `docs.md` | Documentation impact and doc validation plan. | -| `tasks.md` | Implementation, docs, verification, and manual QA checklist. | - -## When to create a change - -Create an OpenSpec change when work changes observable behavior, public CLI/API/MCP behavior, robot behavior, hardware/simulation/replay workflows, docs that users rely on, or cross-module architecture. - -Do not create a change for a purely mechanical refactor, typo fix, or internal cleanup unless it changes behavior or needs cross-session planning context. - -## Writing specs - -OpenSpec capability specs are behavior contracts, not implementation plans. - -Good spec content: - -- User- or developer-visible behavior. -- Public CLI/API/MCP tool behavior. -- Stream or message behavior that downstream modules rely on. -- Robot safety constraints and hardware/simulation/replay expectations. -- Scenarios that can be tested or manually verified. - -Avoid in specs: - -- Private class/function names. -- Generated-file mechanics. -- Library choices and wiring details. -- Step-by-step implementation tasks. - -Put those details in `design.md` or `tasks.md`. - -## Capability names - -Prefer behavior-domain names over code names. Useful starting points: - -- `module-system` -- `blueprint-composition` -- `cli-lifecycle` -- `agent-skills-mcp` -- `configuration` -- `navigation-stack` -- `manipulation-stack` -- `hardware-adapters` -- `simulation-replay` -- `documentation-system` - -Add specs progressively as changes need them. Do not try to backfill the whole project at once. - -## Validation - -Use OpenSpec validation before implementation and before archiving: - -```bash skip -openspec schema validate dimos-capability -openspec validate -openspec templates --json -``` - -For documentation changes, also run the relevant doc checks from [Writing Docs](/docs/development/writing_docs.md): - -```bash skip -md-babel-py run -``` - -When a change touches blueprint names, module-level blueprint variables, or module registry inputs, run: - -```bash skip -pytest dimos/robot/test_all_blueprints_generation.py -``` - -Then run focused tests for the changed code and manually QA through the actual surface: CLI command, MCP tool, HTTP API, simulation/replay blueprint, hardware procedure, or library driver. diff --git a/docs/docs.json b/docs/docs.json index f0064c9ab9..58da2ff6a1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,7 +144,6 @@ "group": "Development", "pages": [ "development/conventions", - "development/openspec", "development/testing", "development/docker", "development/grid_testing", From 99d6f266d5f24034dc5a6e938efe5c24b67ccc62 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 17:10:42 -0700 Subject: [PATCH 03/12] feat: discover external blueprints --- AGENTS.md | 6 +- CONTEXT.md | 49 ++++ .../coordination/test_module_coordinator.py | 25 +- dimos/porcelain/test_dimos.py | 12 + dimos/robot/cli/dimos.py | 19 +- dimos/robot/cli/test_dimos.py | 66 ++++- dimos/robot/external_blueprints.py | 235 ++++++++++++++++ dimos/robot/get_all_blueprints.py | 17 ++ dimos/robot/test_external_blueprints.py | 251 ++++++++++++++++++ docs/coding-agents/code-quality-rules.md | 2 +- docs/development/conventions.md | 3 +- docs/usage/blueprints.md | 48 ++++ docs/usage/cli.md | 40 ++- .../.openspec.yaml | 2 + .../design.md | 196 ++++++++++++++ .../add-external-blueprint-discovery/docs.md | 37 +++ .../proposal.md | 37 +++ .../external-blueprint-discovery/spec.md | 142 ++++++++++ .../add-external-blueprint-discovery/tasks.md | 46 ++++ 19 files changed, 1221 insertions(+), 12 deletions(-) create mode 100644 CONTEXT.md create mode 100644 dimos/robot/external_blueprints.py create mode 100644 dimos/robot/test_external_blueprints.py create mode 100644 openspec/changes/add-external-blueprint-discovery/.openspec.yaml create mode 100644 openspec/changes/add-external-blueprint-discovery/design.md create mode 100644 openspec/changes/add-external-blueprint-discovery/docs.md create mode 100644 openspec/changes/add-external-blueprint-discovery/proposal.md create mode 100644 openspec/changes/add-external-blueprint-discovery/specs/external-blueprint-discovery/spec.md create mode 100644 openspec/changes/add-external-blueprint-discovery/tasks.md diff --git a/AGENTS.md b/AGENTS.md index 4da2b37404..00b0851643 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -190,7 +190,7 @@ To run a blueprint directly from Python: autoconnect(module_a(), module_b(), module_c()).build().loop() ``` -Expose as a module-level variable for `dimos run` to find it. Add to the registry by running `pytest dimos/robot/test_all_blueprints_generation.py`. +For in-repo DimOS blueprints, expose a module-level variable for `dimos run` to find it and add it to the built-in registry by running `pytest dimos/robot/test_all_blueprints_generation.py`. External packages should not edit `all_blueprints.py`; expose runnable blueprints through installed Python package entry points in the `dimos.blueprints` group. ### GlobalConfig @@ -366,13 +366,13 @@ Code style rules: ## `all_blueprints.py` is auto-generated -`dimos/robot/all_blueprints.py` is generated by `test_all_blueprints_generation.py`. After adding or renaming blueprints: +`dimos/robot/all_blueprints.py` is generated by `test_all_blueprints_generation.py` for in-repo built-in blueprints and modules. After adding or renaming built-in blueprints: ```bash pytest dimos/robot/test_all_blueprints_generation.py ``` -CI asserts the file is current — if it's stale, CI fails. +CI asserts the file is current — if it's stale, CI fails. Externally packaged blueprints are discovered from installed `dimos.blueprints` entry points and do not require regenerating this file. --- diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..9b99d5ae85 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,49 @@ +# DimOS + +DimOS composes robot software into named runnable stacks that users can launch from the command line or Python APIs. + +## Language + +**Blueprint**: +A composition of modules that represents a runnable robot stack. +_Avoid_: stack definition, launch file + +**DimOS Module**: +An autonomous subsystem that can participate in a blueprint and communicate with other modules through typed streams or RPC. +_Avoid_: Python module, component, node + +**Runnable Blueprint Name**: +The public name a user invokes to select a blueprint for execution. +_Avoid_: registry key, CLI alias, package name + +**Namespaced Blueprint Name**: +A runnable blueprint name formed from an external blueprint namespace, a dot separator, and a local entry name. +_Avoid_: fully qualified import path, plugin route + +**External Blueprint Namespace**: +A required prefix, derived by default from the installed distribution name, that identifies the source of runnable blueprint names provided outside DimOS itself. +_Avoid_: plugin prefix, discovery trigger, package alias + +**Canonical Distribution Namespace**: +The normalized form of an installed distribution name used as an external blueprint namespace. +_Avoid_: raw package name, import package name + +**External Blueprint Discovery**: +The recognition of externally provided runnable blueprint names from installed package metadata without loading the blueprint itself. +_Avoid_: validation, import scan, registry generation + +**External Blueprint Entry Point**: +An installed package metadata declaration that provides a runnable blueprint name by pointing to either a blueprint object or a DimOS Module class. +_Avoid_: factory hook, plugin loader, filesystem scan + +**External Local Blueprint Name**: +The package-defined suffix of a namespaced blueprint name, using DimOS-style lowercase kebab-case. +_Avoid_: Python symbol name, import name, nested path, runnable entry + +**Bare Blueprint Name**: +A runnable blueprint name without an external namespace; bare names refer only to DimOS-provided blueprints. +_Avoid_: unqualified plugin name, default external name + +**Blueprint Composition Command**: +A `dimos run` invocation that names one or more blueprints or modules, each resolved independently and composed into one runnable stack. +_Avoid_: launch command, multi-blueprint mode diff --git a/dimos/core/coordination/test_module_coordinator.py b/dimos/core/coordination/test_module_coordinator.py index c1baad17b2..3168c9189c 100644 --- a/dimos/core/coordination/test_module_coordinator.py +++ b/dimos/core/coordination/test_module_coordinator.py @@ -13,7 +13,7 @@ # limitations under the License. from types import MappingProxyType -from typing import Protocol +from typing import Any, Protocol import pytest @@ -94,6 +94,10 @@ class TargetModule(Module): remapped_data: In[Data1] +class ExternalNameLoadModule(Module): + pass + + # ModuleRef / RPC tests class CalculatorSpec(Spec, Protocol): @rpc @@ -790,3 +794,22 @@ def test_list_module_names(dynamic_coordinator) -> None: dynamic_coordinator.load_module(ModuleA) dynamic_coordinator.load_module(ModuleC) assert set(dynamic_coordinator.list_module_names()) == {"ModuleA", "ModuleC"} + + +def test_load_blueprint_by_name_uses_shared_resolver(monkeypatch: pytest.MonkeyPatch) -> None: + import dimos.robot.get_all_blueprints as resolver + + expected_blueprint = ExternalNameLoadModule.blueprint() + loaded_blueprints: list[Any] = [] + + def fake_get_by_name(name: str): # type: ignore[no-untyped-def] + assert name == "my-test-stack.demo" + return expected_blueprint + + coordinator = object.__new__(ModuleCoordinator) + coordinator.load_blueprint = loaded_blueprints.append # type: ignore[method-assign] + monkeypatch.setattr(resolver, "get_by_name", fake_get_by_name) + + coordinator.load_blueprint_by_name("my-test-stack.demo") + + assert loaded_blueprints == [expected_blueprint] diff --git a/dimos/porcelain/test_dimos.py b/dimos/porcelain/test_dimos.py index b9ab867195..27e157f3fa 100644 --- a/dimos/porcelain/test_dimos.py +++ b/dimos/porcelain/test_dimos.py @@ -61,6 +61,18 @@ def test_resolve_string_name(): assert bp is not None +def test_resolve_external_string_name_uses_shared_resolver(monkeypatch): + expected = StressTestModule.blueprint() + + def fake_get_by_name(name: str): + assert name == "my-test-stack.demo" + return expected + + monkeypatch.setattr("dimos.porcelain.dimos.get_by_name", fake_get_by_name) + + assert _resolve_target("my-test-stack.demo") is expected + + def test_resolve_unknown_string(): with pytest.raises(ValueError, match="Unknown"): _resolve_target("nonexistent-blueprint-xyz") diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 3f94a6be4e..31d8d28a3c 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -609,10 +609,27 @@ def show_config(ctx: typer.Context) -> None: def list_blueprints() -> None: """List all available blueprints.""" from dimos.robot.all_blueprints import all_blueprints + from dimos.robot.external_blueprints import ( + ExternalBlueprintError, + list_external_blueprint_names, + ) blueprints = [name for name in all_blueprints.keys() if not name.startswith("demo-")] + typer.echo("Built-in blueprints:") for blueprint_name in sorted(blueprints): - typer.echo(blueprint_name) + typer.echo(f" {blueprint_name}") + + try: + external_blueprints = list_external_blueprint_names() + except ExternalBlueprintError as exc: + typer.echo(typer.style(str(exc), fg=typer.colors.RED), err=True) + raise typer.Exit(1) from exc + + if external_blueprints: + typer.echo("") + typer.echo("External blueprints:") + for blueprint_name in external_blueprints: + typer.echo(f" {blueprint_name}") @main.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) diff --git a/dimos/robot/cli/test_dimos.py b/dimos/robot/cli/test_dimos.py index b925d4ebd9..f008b36538 100644 --- a/dimos/robot/cli/test_dimos.py +++ b/dimos/robot/cli/test_dimos.py @@ -15,10 +15,12 @@ from typing import Literal import pytest +from typer.testing import CliRunner from dimos.core.coordination.blueprints import autoconnect from dimos.core.module import Module, ModuleConfig -from dimos.robot.cli.dimos import _normalize_simulation_argv, arg_help +from dimos.robot import external_blueprints as external +from dimos.robot.cli.dimos import _normalize_simulation_argv, arg_help, main @pytest.mark.parametrize( @@ -144,3 +146,65 @@ class TestModule(Module): " * testmodule.spam: str (default: eggs)", "", ] + + +def test_list_blueprints_groups_builtin_and_external(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + external, + "list_external_blueprint_names", + lambda: ["my-test-stack.demo", "my-test-stack.keyboard-teleop"], + ) + + result = CliRunner().invoke(main, ["list"]) + + assert result.exit_code == 0 + assert "Built-in blueprints:" in result.output + assert " unitree-go2" in result.output + assert "demo-agent" not in result.output + assert "External blueprints:" in result.output + assert " my-test-stack.demo" in result.output + assert " my-test-stack.keyboard-teleop" in result.output + + +def test_list_blueprints_without_external_names(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(external, "list_external_blueprint_names", lambda: []) + + result = CliRunner().invoke(main, ["list"]) + + assert result.exit_code == 0 + assert "Built-in blueprints:" in result.output + assert "External blueprints:" not in result.output + + +def test_list_blueprints_reports_external_discovery_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_error() -> list[str]: + raise external.ExternalBlueprintError("external metadata is invalid") + + monkeypatch.setattr(external, "list_external_blueprint_names", raise_error) + + result = CliRunner().invoke(main, ["list"]) + + assert result.exit_code == 1 + assert "external metadata is invalid" in result.output + + +def test_run_reports_external_resolution_errors(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_error(name: str): + raise external.ExternalBlueprintLoadError( + name, + "my_test_stack.missing:demo_blueprint", + ModuleNotFoundError("No module named 'my_test_stack.missing'"), + ) + + monkeypatch.setattr( + "dimos.robot.get_all_blueprints.resolve_external_blueprint_by_name", + raise_error, + ) + + result = CliRunner().invoke(main, ["run", "my-test-stack.demo"]) + + assert result.exit_code == 1 + assert "Failed to load external blueprint 'my-test-stack.demo'" in result.output + assert "my_test_stack.missing:demo_blueprint" in result.output diff --git a/dimos/robot/external_blueprints.py b/dimos/robot/external_blueprints.py new file mode 100644 index 0000000000..58618925ea --- /dev/null +++ b/dimos/robot/external_blueprints.py @@ -0,0 +1,235 @@ +# 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 __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +import importlib.metadata as importlib_metadata +import re +from typing import Any + +from dimos.core.coordination.blueprints import Blueprint +from dimos.core.module import is_module_type + +ENTRY_POINT_GROUP = "dimos.blueprints" +LOCAL_BLUEPRINT_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +_DISTRIBUTION_SEPARATOR_PATTERN = re.compile(r"[-_.]+") + + +class ExternalBlueprintError(ValueError): + """Base class for external blueprint discovery and resolution errors.""" + + +class InvalidExternalBlueprintNameError(ExternalBlueprintError): + def __init__(self, local_name: str, distribution_name: str) -> None: + super().__init__( + "Invalid external blueprint entry point name " + f"{local_name!r} in distribution {distribution_name!r}. " + "External local blueprint names must be lowercase kebab-case " + "and match ^[a-z0-9]+(-[a-z0-9]+)*$." + ) + + +class AmbiguousExternalBlueprintNamespaceError(ExternalBlueprintError): + def __init__(self, namespace: str, distribution_names: Iterable[str]) -> None: + names = sorted(set(distribution_names)) + super().__init__( + f"Ambiguous external blueprint namespace {namespace!r}; " + f"multiple installed distributions normalize to it: {', '.join(names)}." + ) + + +class AmbiguousExternalBlueprintNameError(ExternalBlueprintError): + def __init__(self, name: str) -> None: + super().__init__( + f"Ambiguous external blueprint name {name!r}; multiple entry points match it." + ) + + +class ExternalBlueprintNamespaceNotFoundError(ExternalBlueprintError): + def __init__(self, namespace: str, available_namespaces: Iterable[str]) -> None: + msg = f"External blueprint namespace {namespace!r} was not discovered." + available = sorted(set(available_namespaces)) + if available: + msg += f" Available external namespaces: {', '.join(available)}." + super().__init__(msg) + + +class ExternalBlueprintLocalNameNotFoundError(ExternalBlueprintError): + def __init__( + self, namespace: str, local_name: str, available_local_names: Iterable[str] + ) -> None: + msg = f"External blueprint namespace {namespace!r} has no local blueprint {local_name!r}." + available = sorted(set(available_local_names)) + if available: + msg += f" Available local blueprints: {', '.join(available)}." + super().__init__(msg) + + +class ExternalBlueprintLoadError(ExternalBlueprintError): + def __init__(self, name: str, target: str, cause: Exception) -> None: + super().__init__( + f"Failed to load external blueprint {name!r} from entry point {target!r}: " + f"{type(cause).__name__}: {cause}" + ) + + +class InvalidExternalBlueprintTargetError(ExternalBlueprintError): + def __init__(self, name: str, target: Any) -> None: + super().__init__( + f"External blueprint {name!r} loaded unsupported target {target!r}. " + "Entry point targets must be a Blueprint object or a DimOS Module class. " + "Factory functions are not supported." + ) + + +@dataclass(frozen=True) +class ExternalBlueprintEntry: + namespace: str + local_name: str + distribution_name: str + entry_point: Any + + @property + def qualified_name(self) -> str: + return f"{self.namespace}.{self.local_name}" + + @property + def target(self) -> str: + return str(getattr(self.entry_point, "value", "")) + + +def canonicalize_distribution_namespace(distribution_name: str) -> str: + """Normalize a Python distribution name for use as an external blueprint namespace.""" + + return _DISTRIBUTION_SEPARATOR_PATTERN.sub("-", distribution_name).lower() + + +def is_valid_external_local_blueprint_name(name: str) -> bool: + """Return whether a local external blueprint name uses DimOS-style kebab-case.""" + + return LOCAL_BLUEPRINT_NAME_PATTERN.fullmatch(name) is not None + + +def is_namespaced_blueprint_name(name: str) -> bool: + """Return whether a runnable blueprint name has an external namespace separator.""" + + return "." in name + + +def list_external_blueprint_names() -> list[str]: + """List namespaced external blueprint names from installed package metadata.""" + + return sorted(entry.qualified_name for entry in list_external_blueprints()) + + +def list_external_blueprints() -> list[ExternalBlueprintEntry]: + """List external blueprint entry point metadata without loading targets.""" + + namespace_entries = _collect_external_blueprints() + return sorted( + (entry for entries in namespace_entries.values() for entry in entries), + key=lambda entry: entry.qualified_name, + ) + + +def resolve_external_blueprint_by_name(name: str) -> Blueprint: + """Resolve a fully-qualified external blueprint name to a Blueprint.""" + + namespace, sep, local_name = name.partition(".") + if not sep: + raise ExternalBlueprintNamespaceNotFoundError(name, []) + if not is_valid_external_local_blueprint_name(local_name): + raise InvalidExternalBlueprintNameError(local_name, namespace) + + namespace_entries = _collect_external_blueprints() + if namespace not in namespace_entries: + raise ExternalBlueprintNamespaceNotFoundError(namespace, namespace_entries.keys()) + + entries = namespace_entries[namespace] + matches = [entry for entry in entries if entry.local_name == local_name] + if not matches: + raise ExternalBlueprintLocalNameNotFoundError( + namespace, local_name, (entry.local_name for entry in entries) + ) + if len(matches) > 1: + raise AmbiguousExternalBlueprintNameError(name) + + entry = matches[0] + try: + target = entry.entry_point.load() + except Exception as exc: + raise ExternalBlueprintLoadError(entry.qualified_name, entry.target, exc) from exc + + return _target_to_blueprint(entry.qualified_name, target) + + +def _target_to_blueprint(name: str, target: Any) -> Blueprint: + if isinstance(target, Blueprint): + return target + if is_module_type(target): + return target.blueprint() # type: ignore[no-any-return] + raise InvalidExternalBlueprintTargetError(name, target) + + +def _collect_external_blueprints() -> dict[str, list[ExternalBlueprintEntry]]: + entries_by_namespace: dict[str, list[ExternalBlueprintEntry]] = {} + distribution_names_by_namespace: dict[str, set[str]] = {} + + for distribution in importlib_metadata.distributions(): + distribution_name = _distribution_name(distribution) + if distribution_name is None: + continue + + namespace = canonicalize_distribution_namespace(distribution_name) + external_entry_points = [ + entry_point + for entry_point in getattr(distribution, "entry_points", ()) + if getattr(entry_point, "group", None) == ENTRY_POINT_GROUP + ] + if not external_entry_points: + continue + + distribution_names_by_namespace.setdefault(namespace, set()).add(distribution_name) + for entry_point in external_entry_points: + local_name = str(getattr(entry_point, "name", "")) + if not is_valid_external_local_blueprint_name(local_name): + raise InvalidExternalBlueprintNameError(local_name, distribution_name) + entries_by_namespace.setdefault(namespace, []).append( + ExternalBlueprintEntry( + namespace=namespace, + local_name=local_name, + distribution_name=distribution_name, + entry_point=entry_point, + ) + ) + + for namespace, distribution_names in distribution_names_by_namespace.items(): + if len(distribution_names) > 1 and namespace in entries_by_namespace: + raise AmbiguousExternalBlueprintNamespaceError(namespace, distribution_names) + + return entries_by_namespace + + +def _distribution_name(distribution: Any) -> str | None: + metadata = getattr(distribution, "metadata", None) + if metadata is not None: + name = metadata.get("Name") + if name: + return str(name) + name = getattr(distribution, "name", None) + if name: + return str(name) + return None diff --git a/dimos/robot/get_all_blueprints.py b/dimos/robot/get_all_blueprints.py index 98d5e2d98f..273fe17a4b 100644 --- a/dimos/robot/get_all_blueprints.py +++ b/dimos/robot/get_all_blueprints.py @@ -21,6 +21,11 @@ from dimos.core.coordination.blueprints import Blueprint from dimos.robot.all_blueprints import all_blueprints, all_modules +from dimos.robot.external_blueprints import ( + ExternalBlueprintError, + is_namespaced_blueprint_name, + resolve_external_blueprint_by_name, +) all_names = sorted(set(all_blueprints.keys()) | set(all_modules.keys())) @@ -61,6 +66,8 @@ def get_by_name(name: str) -> Blueprint: return get_blueprint_by_name(name) elif name in all_modules: return get_module_by_name(name) + elif is_namespaced_blueprint_name(name): + return resolve_external_blueprint_by_name(name) else: _raise_unknown(name, all_names) @@ -75,11 +82,21 @@ def _fail_or_exit(name: str, candidates: list[str]) -> NoReturn: sys.exit(1) +def _exit_with_error(message: str) -> NoReturn: + typer.echo(typer.style(message, fg=typer.colors.RED), err=True) + sys.exit(1) + + def get_by_name_or_exit(name: str) -> Blueprint: if name in all_blueprints: return get_blueprint_by_name(name) elif name in all_modules: return get_module_by_name(name) + elif is_namespaced_blueprint_name(name): + try: + return resolve_external_blueprint_by_name(name) + except ExternalBlueprintError as exc: + _exit_with_error(str(exc)) else: _fail_or_exit(name, all_names) diff --git a/dimos/robot/test_external_blueprints.py b/dimos/robot/test_external_blueprints.py new file mode 100644 index 0000000000..5f161dd591 --- /dev/null +++ b/dimos/robot/test_external_blueprints.py @@ -0,0 +1,251 @@ +# 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 __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from dimos.core.coordination.blueprints import Blueprint, autoconnect +from dimos.core.module import Module +from dimos.robot import external_blueprints as external +from dimos.robot.get_all_blueprints import get_by_name + + +class ExternalTestModule(Module): + pass + + +@dataclass(frozen=True) +class FakeEntryPoint: + name: str + value: str + target: Any = None + error: Exception | None = None + group: str = external.ENTRY_POINT_GROUP + + def load(self) -> Any: + if self.error is not None: + raise self.error + return self.target + + +@dataclass(frozen=True) +class FakeDistribution: + name: str + entry_points: tuple[FakeEntryPoint, ...] + + @property + def metadata(self) -> dict[str, str]: + return {"Name": self.name} + + +def patch_distributions(monkeypatch: pytest.MonkeyPatch, *distributions: FakeDistribution) -> None: + monkeypatch.setattr( + external.importlib_metadata, + "distributions", + lambda: list(distributions), + ) + + +@pytest.mark.parametrize( + ("distribution_name", "expected"), + [ + ("My_Robot.Stack", "my-robot-stack"), + ("my---robot___stack", "my-robot-stack"), + ("my.robot_stack", "my-robot-stack"), + ("my-robot-stack", "my-robot-stack"), + ], +) +def test_canonicalize_distribution_namespace(distribution_name: str, expected: str) -> None: + assert external.canonicalize_distribution_namespace(distribution_name) == expected + + +@pytest.mark.parametrize("name", ["go2", "keyboard-teleop", "g1-sim2"]) +def test_valid_external_local_blueprint_names(name: str) -> None: + assert external.is_valid_external_local_blueprint_name(name) + + +@pytest.mark.parametrize("name", ["", "Go2", "go2_sim", "go2.sim", "go2/real", "go2--sim"]) +def test_invalid_external_local_blueprint_names(name: str) -> None: + assert not external.is_valid_external_local_blueprint_name(name) + + +def test_list_external_blueprint_names_without_loading_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entry_point = FakeEntryPoint( + name="demo", + value="external_stack.demo:demo_blueprint", + error=AssertionError("list must not load targets"), + ) + patch_distributions(monkeypatch, FakeDistribution("My_Test.Stack", (entry_point,))) + + assert external.list_external_blueprint_names() == ["my-test-stack.demo"] + + +def test_resolve_external_blueprint_object(monkeypatch: pytest.MonkeyPatch) -> None: + blueprint = ExternalTestModule.blueprint() + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + (FakeEntryPoint("demo", "external_stack.demo:demo_blueprint", blueprint),), + ), + ) + + assert external.resolve_external_blueprint_by_name("my-test-stack.demo") is blueprint + + +def test_resolve_external_module_class(monkeypatch: pytest.MonkeyPatch) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + ( + FakeEntryPoint( + "module-demo", "external_stack.demo:ExternalTestModule", ExternalTestModule + ), + ), + ), + ) + + blueprint = external.resolve_external_blueprint_by_name("my-test-stack.module-demo") + + assert isinstance(blueprint, Blueprint) + assert blueprint.blueprints[0].module is ExternalTestModule + + +@pytest.mark.parametrize("target", [lambda: ExternalTestModule.blueprint(), 42, object()]) +def test_rejects_unsupported_external_targets(monkeypatch: pytest.MonkeyPatch, target: Any) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", (FakeEntryPoint("demo", "external_stack.demo:target", target),) + ), + ) + + with pytest.raises(external.InvalidExternalBlueprintTargetError): + external.resolve_external_blueprint_by_name("my-test-stack.demo") + + +def test_unknown_external_namespace(monkeypatch: pytest.MonkeyPatch) -> None: + patch_distributions(monkeypatch, FakeDistribution("My-Test-Stack", ())) + + with pytest.raises(external.ExternalBlueprintNamespaceNotFoundError): + external.resolve_external_blueprint_by_name("missing-stack.demo") + + +def test_namespace_exists_but_local_name_missing(monkeypatch: pytest.MonkeyPatch) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + ( + FakeEntryPoint( + "demo", "external_stack.demo:demo_blueprint", ExternalTestModule.blueprint() + ), + ), + ), + ) + + with pytest.raises(external.ExternalBlueprintLocalNameNotFoundError): + external.resolve_external_blueprint_by_name("my-test-stack.arm") + + +def test_entry_point_load_failure(monkeypatch: pytest.MonkeyPatch) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + ( + FakeEntryPoint( + "demo", "external_stack.demo:demo_blueprint", error=ImportError("boom") + ), + ), + ), + ) + + with pytest.raises(external.ExternalBlueprintLoadError, match="ImportError: boom"): + external.resolve_external_blueprint_by_name("my-test-stack.demo") + + +def test_invalid_external_metadata_name(monkeypatch: pytest.MonkeyPatch) -> None: + patch_distributions( + monkeypatch, + FakeDistribution("My-Test-Stack", (FakeEntryPoint("Go2", "external_stack.demo:go2"),)), + ) + + with pytest.raises(external.InvalidExternalBlueprintNameError): + external.list_external_blueprints() + + +def test_ambiguous_canonical_namespace(monkeypatch: pytest.MonkeyPatch) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", (FakeEntryPoint("demo", "a:b", ExternalTestModule.blueprint()),) + ), + FakeDistribution( + "my_test.stack", (FakeEntryPoint("other", "c:d", ExternalTestModule.blueprint()),) + ), + ) + + with pytest.raises(external.AmbiguousExternalBlueprintNamespaceError): + external.resolve_external_blueprint_by_name("my-test-stack.demo") + + +def test_bare_names_never_search_external_entry_points(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + external.importlib_metadata, + "distributions", + lambda: (_ for _ in ()).throw(AssertionError("bare lookup searched external metadata")), + ) + + with pytest.raises(ValueError, match="Unknown blueprint or module"): + get_by_name("missing-bare-blueprint") + + +def test_get_by_name_resolves_external_names(monkeypatch: pytest.MonkeyPatch) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + (FakeEntryPoint("demo", "external_stack.demo:ExternalTestModule", ExternalTestModule),), + ), + ) + + blueprint = get_by_name("my-test-stack.demo") + + assert blueprint.blueprints[0].module is ExternalTestModule + + +def test_mixed_builtin_and_external_names_resolve_before_composition( + monkeypatch: pytest.MonkeyPatch, +) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + (FakeEntryPoint("demo", "external_stack.demo:ExternalTestModule", ExternalTestModule),), + ), + ) + + mixed_blueprint = autoconnect( + get_by_name("demo-mcp-stress-test"), get_by_name("my-test-stack.demo") + ) + + assert any(atom.module is ExternalTestModule for atom in mixed_blueprint.blueprints) diff --git a/docs/coding-agents/code-quality-rules.md b/docs/coding-agents/code-quality-rules.md index 5b252fc968..ad87831e43 100644 --- a/docs/coding-agents/code-quality-rules.md +++ b/docs/coding-agents/code-quality-rules.md @@ -18,7 +18,7 @@ Rules dimos code is expected to follow. They address recurring issues found in c * No lambdas -- they can't be pickled to worker processes. Use named functions. * Do no work at import time: no subprocesses, viewers, model parsing, or network. In particular don't call `get_data(...)` (it blocks import until the download finishes) -- use `LfsPath` (resolved at access time) or build the config in `start`/`build`. Any process you start must be managed (shut down when not needed). * Blueprint files define blueprints, not modules/classes. -* Helper blueprints not meant to run alone must start with `_` (the `all_blueprints.py` generator skips them); demo/non-shared ones get a `demo_` prefix (hidden from `dimos list`). +* Helper blueprints not meant to run alone must start with `_` (the in-repo `all_blueprints.py` generator skips them); demo/non-shared built-in ones get a `demo_` prefix (hidden from `dimos list`). Externally packaged blueprints are not added to `all_blueprints.py`; expose them with `dimos.blueprints` Python package entry points. ## Concurrency and thread safety diff --git a/docs/development/conventions.md b/docs/development/conventions.md index 53259c74da..5484bb4125 100644 --- a/docs/development/conventions.md +++ b/docs/development/conventions.md @@ -9,4 +9,5 @@ This mostly to track when conventions change (with regard to codebase updates) b - To customize the way rerun renders something, right now we use a `rerun_config` dict. This will (hopefully) change very soon to be a per-module config instead of a per-blueprint config - Similar to the `rerun_config` the `rrb` (rerun blueprint) is defined at a blueprint level right now, but ideally would be a per-module contribution with only a per-blueprint override of the layout. - No `__init__.py` files -- Helper blueprints (like `_with_vis`) that should not be used on their own need to start with an underscore to avoid being picked up by the all_blueprints.py code generation step +- Helper blueprints (like `_with_vis`) that should not be used on their own need to start with an underscore to avoid being picked up by the in-repo `all_blueprints.py` code generation step +- Built-in runnable blueprints are registered through the generated in-repo `all_blueprints.py`; externally packaged blueprints are discovered through installed Python package entry points in the `dimos.blueprints` group instead. diff --git a/docs/usage/blueprints.md b/docs/usage/blueprints.md index bceb356cd7..85b54f0080 100644 --- a/docs/usage/blueprints.md +++ b/docs/usage/blueprints.md @@ -84,6 +84,54 @@ expanded_blueprint = autoconnect( Blueprints are frozen data classes, and `autoconnect()` always constructs an expanded blueprint so you never have to worry about changes in one affecting the other. +## Publishing external blueprints + +DimOS can discover runnable blueprints from installed Python packages. External +packages declare entry points in the `dimos.blueprints` group: + +```toml +[project] +name = "my-robot-stack" + +[project.entry-points."dimos.blueprints"] +go2 = "my_robot_stack.go2:go2_blueprint" +keyboard-teleop = "my_robot_stack.teleop:KeyboardTeleop" +``` + +After the package is installed in the same Python environment as DimOS, users can run +those blueprints by fully qualified name: + +```bash +dimos run my-robot-stack.go2 +dimos run unitree-go2 my-robot-stack.keyboard-teleop +``` + +External names are always `.`: + +- The namespace comes from the installed distribution name. DimOS lowercases it and + collapses runs of `-`, `_`, and `.` into `-`, so `My_Robot.Stack` becomes + `my-robot-stack`. +- The local blueprint name is the entry point name. It must be lowercase kebab-case + matching `^[a-z0-9]+(-[a-z0-9]+)*$`, such as `go2` or `keyboard-teleop`. + +Entry point targets may be either: + +- a `Blueprint` object, such as a module-level `go2_blueprint`; or +- a DimOS `Module` class, such as `KeyboardTeleop`, which DimOS converts with + `.blueprint()`. + +Factory functions are not supported in v1. If an entry point target is a function or +another unsupported object, `dimos run` fails with an invalid external blueprint target +error. + +`dimos list` includes external names from package metadata without importing the target +modules. `dimos run my-robot-stack.go2` imports only the requested entry point target. + +Remote coordinator resolution happens in the coordinator environment. If a client asks +a coordinator to load `my-robot-stack.go2`, the `my-robot-stack` package must be +installed where the coordinator performs name resolution; installing it only in the +client environment is not enough. + ### Duplicate module handling If the same module appears multiple times in `autoconnect`, the **later blueprint wins** and overrides earlier ones: diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7abd1fa7bb..894c6a0f5e 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -59,7 +59,9 @@ Environment variables and `.env` values must be prefixed with `DIMOS_`. ### `dimos run` -Start a robot blueprint. +Start one or more robot blueprints. Built-in DimOS blueprints use bare names such as +`unitree-go2`; external blueprints installed from Python packages use namespaced names +such as `my-robot-stack.go2`. ```bash dimos run [ ...] [--daemon] [--disable ...] @@ -89,10 +91,22 @@ dimos run unitree-go2-agentic --robot-ip 192.168.123.161 # Compose modules dynamically dimos run unitree-go2 keyboard-teleop +# Run an externally packaged blueprint +dimos run my-robot-stack.go2 + +# Compose built-in and external blueprints +dimos run unitree-go2 my-robot-stack.keyboard-teleop + # Disable specific modules dimos run unitree-go2-agentic --disable OsmSkill WebInput ``` +External blueprint names are always fully qualified as +`.`. The namespace is +derived from the installed Python distribution name by lowercasing it and collapsing +runs of `-`, `_`, and `.` into `-`. The local blueprint name is the entry point name +and must be lowercase kebab-case, for example `keyboard-teleop`. + When `--daemon` is used, the process: 1. Builds and starts all modules (foreground — you see errors) 2. Runs a health check (polls worker PIDs) @@ -101,13 +115,17 @@ When `--daemon` is used, the process: #### Adding a New Blueprint -Define a module-level `Blueprint` variable and register it in `all_blueprints.py`: +For an in-repository DimOS blueprint, define a module-level `Blueprint` variable and +regenerate the built-in registry: ```bash pytest dimos/robot/test_all_blueprints_generation.py ``` -This auto-generates the registry. See [blueprints](/docs/usage/blueprints.md) for composition details. +This auto-generates `dimos/robot/all_blueprints.py` for built-in blueprints. External +packages do not edit that file; they expose blueprints through Python package entry +points. See [blueprints](/docs/usage/blueprints.md) for composition and external +publishing details. ### `dimos status` @@ -179,12 +197,26 @@ dimos log --json | jq 'select(.logger | contains("RerunBridge"))' ### `dimos list` -List all available blueprints. +List all available blueprints. Built-in and external blueprints are grouped separately; +external names are read from installed package metadata without importing their target +modules. ```bash dimos list ``` +Example output: + +```text +Built-in blueprints: + unitree-go2 + unitree-go2-agentic + +External blueprints: + my-robot-stack.go2 + my-robot-stack.keyboard-teleop +``` + ### `dimos show-config` Print resolved GlobalConfig values and their sources. diff --git a/openspec/changes/add-external-blueprint-discovery/.openspec.yaml b/openspec/changes/add-external-blueprint-discovery/.openspec.yaml new file mode 100644 index 0000000000..40f4fff67f --- /dev/null +++ b/openspec/changes/add-external-blueprint-discovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-16 diff --git a/openspec/changes/add-external-blueprint-discovery/design.md b/openspec/changes/add-external-blueprint-discovery/design.md new file mode 100644 index 0000000000..7bf3b1f229 --- /dev/null +++ b/openspec/changes/add-external-blueprint-discovery/design.md @@ -0,0 +1,196 @@ +## Context + +`dimos run` currently resolves runnable blueprint names through the generated in-repository registries in `dimos/robot/all_blueprints.py`. Runtime lookup flows through `dimos/robot/get_all_blueprints.py`, which maps built-in blueprint names to import paths and built-in DimOS Module names to module classes converted with `.blueprint()`. The CLI, Python API, and coordinator RPC path all rely on this shared lookup behavior. + +This works for DimOS-shipped blueprints but forces external robot stacks to edit DimOS source and regenerate the static registry before users can run them by name. The new design adds dynamic external blueprint discovery from installed Python package metadata while preserving the generated registry as the authoritative source for built-in bare names. + +Relevant existing surfaces: +- `dimos/robot/all_blueprints.py`: generated built-in `all_blueprints` and `all_modules` maps. +- `dimos/robot/get_all_blueprints.py`: shared resolver used by CLI, Python API, and coordinator loading. +- `dimos/robot/cli/dimos.py`: `dimos run` composes one or more names with `autoconnect(...)`; `dimos list` lists built-in names today. +- `dimos/porcelain/dimos.py`: `Dimos.run(...)` resolves string targets locally or asks a remote coordinator to resolve by name. +- `dimos/core/coordination/module_coordinator.py`: coordinator-side `load_blueprint_by_name(...)` uses the shared resolver. +- `dimos/core/module.py`: `ModuleBase.blueprint` provides class-level conversion from DimOS Module classes to blueprints. + +## Goals / Non-Goals + +**Goals:** +- Let installed external Python distributions expose runnable blueprint names without editing DimOS source. +- Keep public terminology blueprint-centric and consistent with existing `dimos run` and `dimos list` behavior. +- Preserve bare-name behavior for built-in DimOS blueprints and modules. +- Require explicit namespacing for external blueprints. +- Support entry point targets that are either Blueprint objects or DimOS Module classes. +- Keep discovery metadata-only until a user actually resolves/runs a specific external blueprint. +- Provide namespace-aware errors that make failed external resolution diagnosable. +- Apply resolver behavior consistently across CLI, Python API, and coordinator-side loading. + +**Non-Goals:** +- No filesystem scanning for external blueprints. +- No local config fallback in v1. +- No zero-argument factory functions or other callable target conventions. +- No explicit namespace override in v1; namespace is derived from the installed distribution name. +- No cache layer in v1; installed package metadata is read on demand. +- No validation/import of all external entry points during `dimos list`. +- No changes to stream contracts, worker lifecycle, hardware commands, simulation, replay, skills, or MCP tool exposure. + +## DimOS Architecture + +### Discovery model + +External packages register blueprint metadata with the Python packaging entry point group `dimos.blueprints`: + +```toml +[project] +name = "my-robot-stack" + +[project.entry-points."dimos.blueprints"] +go2 = "my_robot_stack.go2:go2_blueprint" +keyboard-teleop = "my_robot_stack.teleop:KeyboardTeleop" +``` + +DimOS exposes these as namespaced blueprint names: + +```text +my-robot-stack.go2 +my-robot-stack.keyboard-teleop +``` + +The external namespace is the canonicalized installed distribution name. Canonicalization follows Python packaging distribution-name normalization: lowercase and collapse runs of `-`, `_`, and `.` into `-`. This can be implemented directly with a small helper equivalent to PEP 503 normalization, avoiding a new runtime dependency unless the project already chooses to depend on `packaging`. + +The external local blueprint name is the entry point name and must use lowercase DimOS-style kebab-case: + +```text +^[a-z0-9]+(-[a-z0-9]+)*$ +``` + +Dots are reserved for separating external namespace from local blueprint name. Underscores, uppercase characters, slashes, and nested dotted local names are invalid. + +### Resolver integration + +Dynamic discovery should be added to the shared name resolver, not only the CLI. A small resolver module can live near the current registry resolver, for example under `dimos/robot/`, and expose helpers for: +- listing external blueprint metadata; +- parsing and validating namespaced blueprint names; +- resolving one external blueprint entry point by fully qualified name; +- converting a loaded target object to a `Blueprint`. + +The existing lookup order remains: + +1. If the requested name is bare, resolve only against built-in `all_blueprints` and `all_modules`. +2. If the requested name is namespaced with a dot, first preserve existing built-in behavior where applicable, then resolve external metadata by namespace and local name. +3. If the external target loads to a Blueprint object, return it. +4. If the external target loads to a DimOS Module class, return `Target.blueprint()`. +5. Otherwise raise an invalid external blueprint target error. + +Built-in names keep their current generated-registry path. External names do not modify `all_blueprints.py`; that file remains generated only from in-repo blueprints and modules. + +### CLI behavior + +`dimos run` already accepts multiple blueprint/module arguments and composes resolved targets with `autoconnect(...)`. No new CLI shape is needed. Each argument is resolved independently, so mixed built-in and external composition works naturally: + +```bash +dimos run unitree-go2 my-robot-stack.keyboard-teleop +``` + +`dimos list` should show names only, grouped by source: + +```text +Built-in blueprints: + unitree-go2 + unitree-go2-agentic + +External blueprints: + my-robot-stack.go2 + my-robot-stack.keyboard-teleop +``` + +Listing external blueprints reads entry point metadata and does not import target modules or validate target object types. A future `--verbose` flag may show entry point target strings, but v1 does not require it. + +### Python API and coordinator behavior + +`Dimos.run("my-robot-stack.go2")` should work when the external distribution is installed in the environment that resolves the name. + +Remote mode is intentionally coordinator-side: if the client sends a string name through coordinator RPC, the package must be installed where the coordinator performs `load_blueprint_by_name(...)`. Installing the package only in the client environment is not sufficient for remote resolution. + +### Errors + +External errors should be namespace-aware. At minimum, distinguish: +- namespaced name has an external namespace that is not installed/discovered; +- namespace exists but the requested external local blueprint name is not declared; +- the external entry point exists but fails to load/import; +- the loaded object is neither a Blueprint object nor a DimOS Module class; +- an external local blueprint name in metadata is invalid. + +CLI exit wrappers can continue to convert resolver exceptions into user-facing messages, but should preserve these distinctions and include useful suggestions where safe. + +### DimOS Specs, streams, skills, and MCP + +No DimOS Python `Spec` Protocol changes are required. This change does not add module RPC contracts, stream names/types, transport behavior, skill decorators, or MCP exposure. It only expands how blueprint names are discovered and resolved. + +## Decisions + +1. **Use installed Python entry points for external discovery.** + - Rationale: packaging metadata is explicit, environment-scoped, compatible with installed distributions, and avoids scanning arbitrary files. + - Alternatives considered: filesystem scanning and local config. Filesystem scanning is too implicit and environment-dependent; local config may be a future escape hatch but is not v1. + +2. **Keep external names always namespaced.** + - Rationale: prevents external packages from shadowing or competing with built-in bare names and makes dynamic discovery intentional. + - Alternative considered: allow bare external names. Rejected because it creates ambiguity and surprising name resolution. + +3. **Derive namespace from the installed distribution name.** + - Rationale: reduces ceremony and gives each package a natural namespace. + - Alternative considered: require explicit namespace declaration in another entry point group or config key. Rejected for v1 as extra surface area. + +4. **Use `.` between namespace and local blueprint name.** + - Rationale: readable fully qualified names such as `my-robot-stack.go2`, while preserving kebab-case local names. + - Constraint: local external blueprint names cannot contain dots. + +5. **Enforce lowercase kebab-case external local blueprint names.** + - Rationale: matches existing DimOS-style runnable names and avoids hidden normalization surprises. + - Invalid metadata should be reported as invalid rather than silently normalized. + +6. **Use the existing `dimos.blueprints` entry point group.** + - Rationale: keep terms blueprint-centric, even though Module classes may be accepted as a convenience and converted to blueprints. + - Alternative considered: `dimos.runnables`. Rejected to avoid introducing a new public term. + +7. **Support Blueprint objects and DimOS Module classes, but not factories.** + - Rationale: mirrors internal registry behavior and avoids adding a new callable convention. + - Factories can be reconsidered later if there is a concrete need. + +8. **List external blueprints by metadata only.** + - Rationale: `dimos list` should be fast, safe, and not fail because an optional robot dependency cannot import in the current environment. + - Validation happens when a specific external blueprint is resolved. + +9. **Do not cache discovery in v1.** + - Rationale: simple on-demand metadata reads mean install/uninstall changes are visible immediately. + - A cache can be added later if performance measurements justify it. + +10. **Implement dynamic resolution centrally.** + - Rationale: CLI, Python API, and coordinator RPC should share behavior and error semantics. + +## Safety / Simulation / Replay + +This change does not alter robot motion, stream payloads, hardware drivers, simulation, or replay behavior. It changes how runnable blueprint names are discovered before a stack is built. Hardware risk is limited to the fact that external packages can expose blueprints that run real robot modules; existing DimOS safety expectations still apply once a blueprint is selected. + +Manual QA should include non-hardware examples and, when practical, a simulated or replay-only external blueprint package. Real hardware validation is not required to validate the discovery mechanism itself. + +## Risks / Trade-offs + +- **Metadata collisions:** Two installed distributions may canonicalize to the same namespace. Mitigate by treating this as an ambiguous external namespace error or by surfacing all matching distributions in diagnostics. +- **Invalid third-party metadata:** Bad entry point names or invalid target objects can appear in installed packages. Mitigate with clear errors on resolution and cautious behavior in `dimos list`. +- **Import side effects on run:** Loading an external entry point imports package code. This is expected at run time but intentionally avoided during list. +- **Remote environment confusion:** Users may install an external package locally but run against a coordinator environment that lacks it. Mitigate with docs and coordinator-side error wording. +- **Entry point API compatibility:** `importlib.metadata.entry_points()` has changed return shapes across Python versions. Implement a small compatibility helper and test it under supported Python versions. +- **Terminology precision:** Accepting DimOS Module classes through `dimos.blueprints` is internally broader than the group name. Mitigate by documenting that Module classes are converted to blueprints and keeping public language blueprint-centric. + +## Migration / Rollout + +- Existing built-in blueprint and module registries remain unchanged. +- No regeneration of `all_blueprints.py` is required for external packages. The existing registry generation test remains required only when in-repo blueprints/modules are added or renamed. +- Add resolver/listing tests with synthetic entry point metadata rather than depending on globally installed packages. +- Add docs showing `pyproject.toml` entry point examples, namespaced `dimos run`, `dimos list`, Module class conversion, and remote coordinator installation expectations. +- Rollback is straightforward: remove external discovery from the resolver/list command while preserving the generated built-in registry. + +## Open Questions + +- Should `dimos list` display invalid external metadata with a warning, or omit invalid entries and reserve the error for direct resolution? The current design prefers reporting invalid metadata as invalid when encountered, while keeping list metadata-only. +- If two installed distributions canonicalize to the same namespace, should DimOS fail the whole namespace as ambiguous or choose a deterministic but potentially surprising order? The safer default is to fail as ambiguous. diff --git a/openspec/changes/add-external-blueprint-discovery/docs.md b/openspec/changes/add-external-blueprint-discovery/docs.md new file mode 100644 index 0000000000..4ce19f1406 --- /dev/null +++ b/openspec/changes/add-external-blueprint-discovery/docs.md @@ -0,0 +1,37 @@ +## User-Facing Docs + +- Update `docs/usage/cli.md`: + - Explain that `dimos run` accepts both built-in bare blueprint names and external namespaced blueprint names. + - Add examples such as `dimos run my-robot-stack.go2` and `dimos run unitree-go2 my-robot-stack.keyboard-teleop`. + - Document that external names must be fully qualified as `.`. + - Document that `dimos list` shows built-in and external blueprints in separate sections. +- Update `docs/usage/blueprints.md`: + - Add a section for publishing external blueprints from installed Python packages. + - Include a `pyproject.toml` example using `[project.entry-points."dimos.blueprints"]`. + - Explain that entry point targets may be Blueprint objects or DimOS Module classes. + - State that factories are not supported in v1. + - State that external local blueprint names must be lowercase kebab-case. +- Update any README or quick-start page that describes the built-in-only registry flow if it would otherwise imply external packages must edit DimOS source. + +## Contributor Docs + +- Update `docs/development/dimos_run.md` or the nearest contributor guide for runnable blueprint registration: + - Clarify the split between generated built-in registry registration and external entry point discovery. + - Preserve the existing `pytest dimos/robot/test_all_blueprints_generation.py` guidance for in-repo blueprints. + - Add guidance for testing external discovery with synthetic entry point metadata. +- Update `docs/development/testing.md` if new test helpers or monkeypatch patterns are introduced for entry point metadata. + +## Coding-Agent Docs + +- Update `AGENTS.md` if the blueprint quick-reference or “Adding a blueprint” guidance says all runnable blueprints must be added through the generated registry. +- Update `docs/coding-agents/` only if those docs contain registry-generation instructions that should distinguish built-in versus external blueprint registration. + +## Doc Validation + +- Run the project’s standard documentation link validation if available, for example `doclinks`. +- If changed docs contain executable Python snippets, run `md-babel-py run ` for each affected markdown file. +- Run targeted tests that exercise CLI help/list behavior if docs rely on command output examples. + +## No Docs Needed + +Documentation changes are needed because this change introduces a new public packaging and CLI workflow for external DimOS users. diff --git a/openspec/changes/add-external-blueprint-discovery/proposal.md b/openspec/changes/add-external-blueprint-discovery/proposal.md new file mode 100644 index 0000000000..f287296096 --- /dev/null +++ b/openspec/changes/add-external-blueprint-discovery/proposal.md @@ -0,0 +1,37 @@ +## Why + +DimOS blueprint registration is currently limited to the in-repository, generated `all_blueprints.py` and `all_modules` registries. External robot stacks cannot become runnable through `dimos run` without modifying DimOS source code and regenerating the built-in registry, which makes third-party and downstream package workflows unnecessarily coupled to the DimOS repository. + +This change lets installed Python packages expose DimOS-provided runnable blueprint names through packaging metadata, while preserving the existing built-in registry and bare-name behavior. The goal is ROS2-like package-level discoverability for external blueprints without filesystem scanning, eager imports, or extra factory conventions. + +## What Changes + +- Add dynamic external blueprint discovery from installed Python package entry points in the `dimos.blueprints` entry point group. +- External runnable blueprint names are always namespaced as `.`. +- Bare blueprint names continue to resolve only through DimOS built-in registries; `dimos run ` never searches external packages. +- External entry point targets may be either a `Blueprint` object or a DimOS Module class, which is converted with `.blueprint()`. +- Factory functions are not supported for v1. +- `dimos list` includes external blueprints by default, grouped separately from built-in blueprints, using metadata only and without importing or validating target objects. +- Name resolution errors distinguish unknown external namespaces, missing local blueprint names within a namespace, load failures, and invalid target object types. +- Shared resolver behavior applies consistently to CLI, Python API, and coordinator-side name loading. +- No hardware-safety behavior changes are introduced. + +## Affected DimOS Surfaces + +- Modules/streams: DimOS Module classes can be exposed through external blueprint entry points and converted to blueprints; no stream semantics change. +- Blueprints/CLI: `dimos run`, `dimos list`, static built-in blueprint/module lookup, blueprint composition command behavior, and resolver error messages. +- Skills/MCP: No direct skill or MCP tool changes. +- Hardware/simulation/replay: No hardware, simulation, or replay semantics change; external packages may provide robot-specific stacks using the same launch path. +- Docs/generated registries: User/developer docs for exposing external blueprints; generated built-in registries remain authoritative for bare names and in-repo blueprints. + +## Capabilities + +### New Capabilities +- `external-blueprint-discovery`: Discovery, listing, and resolution of externally provided runnable blueprint names from installed package metadata. + +### Modified Capabilities +- None. + +## Impact + +External DimOS users can publish robot stacks as normal Python packages and run them through `dimos run` without editing DimOS source. Existing built-in blueprint names, generated registry tests, and multi-argument blueprint composition remain compatible. The change introduces a runtime dependency on Python entry point metadata APIs and packaging-name normalization, plus new tests for discovery, listing, resolver behavior, error handling, and documentation examples. Remote coordinator loading requires the external package to be installed in the coordinator environment that performs name resolution. diff --git a/openspec/changes/add-external-blueprint-discovery/specs/external-blueprint-discovery/spec.md b/openspec/changes/add-external-blueprint-discovery/specs/external-blueprint-discovery/spec.md new file mode 100644 index 0000000000..60348078fb --- /dev/null +++ b/openspec/changes/add-external-blueprint-discovery/specs/external-blueprint-discovery/spec.md @@ -0,0 +1,142 @@ +## ADDED Requirements + +### Requirement: External blueprint names SHALL be discovered from installed package metadata +DimOS SHALL discover external blueprint names from installed Python distribution entry points in the `dimos.blueprints` group. + +#### Scenario: Installed package exposes external blueprints +- **GIVEN** an installed distribution named `my-robot-stack` +- **AND** the distribution declares `dimos.blueprints` entry points named `go2` and `keyboard-teleop` +- **WHEN** DimOS discovers external blueprints +- **THEN** the discovered names SHALL include `my-robot-stack.go2` and `my-robot-stack.keyboard-teleop` +- **AND** discovery SHALL use package metadata without importing the entry point target modules. + +#### Scenario: Distribution name is canonicalized +- **GIVEN** an installed distribution whose name contains uppercase letters, underscores, dots, or repeated separators +- **WHEN** DimOS derives the external blueprint namespace +- **THEN** it SHALL lowercase the name and collapse runs of `-`, `_`, and `.` into a single `-` +- **AND** the canonical distribution namespace SHALL be used as the prefix of every external blueprint name from that distribution. + +### Requirement: External blueprint names SHALL be explicitly namespaced +DimOS SHALL require external blueprints to be referenced as `.`. + +#### Scenario: Bare built-in name remains built-in only +- **GIVEN** a user requests `dimos run unitree-go2` +- **WHEN** DimOS resolves the bare blueprint name +- **THEN** it SHALL resolve only against DimOS built-in blueprint and module registries +- **AND** it SHALL NOT search external packages for a matching bare name. + +#### Scenario: Namespaced external name is requested +- **GIVEN** an installed distribution with canonical namespace `my-robot-stack` +- **AND** it declares an external local blueprint name `go2` +- **WHEN** a user requests `dimos run my-robot-stack.go2` +- **THEN** DimOS SHALL resolve the external blueprint identified by namespace `my-robot-stack` and local name `go2`. + +### Requirement: External local blueprint names SHALL use DimOS-style kebab-case +DimOS SHALL accept only lowercase kebab-case external local blueprint names matching `^[a-z0-9]+(-[a-z0-9]+)*$`. + +#### Scenario: Valid local blueprint name +- **GIVEN** an external entry point named `keyboard-teleop` +- **WHEN** DimOS lists or resolves external blueprint metadata +- **THEN** the local blueprint name SHALL be considered valid. + +#### Scenario: Invalid local blueprint name +- **GIVEN** an external entry point named `Go2`, `go2_sim`, `go2.sim`, or `go2/real` +- **WHEN** DimOS encounters that entry point as an external blueprint +- **THEN** DimOS SHALL treat the entry point name as invalid external blueprint metadata +- **AND** it SHALL NOT silently normalize the invalid local blueprint name into a different runnable name. + +### Requirement: External blueprint resolution SHALL load only the requested entry point +DimOS SHALL defer importing external target modules until a specific external blueprint name is resolved for execution or API use. + +#### Scenario: Listing external blueprints does not import targets +- **GIVEN** an installed external package with `dimos.blueprints` metadata +- **WHEN** a user runs `dimos list` +- **THEN** DimOS SHALL display the external blueprint names from metadata +- **AND** it SHALL NOT import the entry point target modules merely to list them. + +#### Scenario: Running external blueprint loads target +- **GIVEN** an installed external package exposing `my-robot-stack.go2` +- **WHEN** a user runs `dimos run my-robot-stack.go2` +- **THEN** DimOS SHALL load the target for that specific entry point +- **AND** it SHALL resolve it to a runnable blueprint before building the stack. + +### Requirement: External targets SHALL resolve to blueprints or DimOS Module classes +DimOS SHALL accept external entry point targets that are Blueprint objects or DimOS Module classes, and SHALL reject unsupported target types. + +#### Scenario: Entry point targets a Blueprint object +- **GIVEN** an external entry point target resolves to a Blueprint object +- **WHEN** DimOS resolves the namespaced blueprint name +- **THEN** DimOS SHALL return that Blueprint for normal composition and execution. + +#### Scenario: Entry point targets a DimOS Module class +- **GIVEN** an external entry point target resolves to a DimOS Module class +- **WHEN** DimOS resolves the namespaced blueprint name +- **THEN** DimOS SHALL convert the class using `.blueprint()` +- **AND** it SHALL return the resulting Blueprint for normal composition and execution. + +#### Scenario: Entry point targets an unsupported object +- **GIVEN** an external entry point target resolves to a factory function, non-DimOS class, primitive value, or other unsupported object +- **WHEN** DimOS resolves the namespaced blueprint name +- **THEN** DimOS SHALL fail with an error that identifies the target as an invalid external blueprint target. + +### Requirement: Blueprint composition commands SHALL support mixed built-in and external names +DimOS SHALL resolve each argument to `dimos run` independently so built-in and external blueprint names can be composed in one command. + +#### Scenario: Mixed composition command +- **GIVEN** a built-in blueprint named `unitree-go2` +- **AND** an installed external blueprint named `my-robot-stack.keyboard-teleop` +- **WHEN** a user runs `dimos run unitree-go2 my-robot-stack.keyboard-teleop` +- **THEN** DimOS SHALL resolve both names independently +- **AND** it SHALL compose the resulting blueprints into one runnable stack using the existing composition behavior. + +### Requirement: List command SHALL group built-in and external blueprints +`dimos list` SHALL include external blueprints by default and SHALL separate built-in blueprints from external blueprints. + +#### Scenario: User lists blueprints +- **GIVEN** DimOS has built-in blueprints +- **AND** an installed distribution exposes external blueprints +- **WHEN** a user runs `dimos list` +- **THEN** the output SHALL include a built-in blueprints section +- **AND** the output SHALL include an external blueprints section +- **AND** external names SHALL be displayed fully qualified with namespace and local blueprint name. + +#### Scenario: No external blueprints are installed +- **GIVEN** no installed distributions expose `dimos.blueprints` entry points +- **WHEN** a user runs `dimos list` +- **THEN** DimOS SHALL still list built-in blueprints normally +- **AND** it SHALL NOT fail because no external blueprints are present. + +### Requirement: External resolution errors SHALL identify namespace-aware failure modes +DimOS SHALL provide distinct errors for unknown namespaces, missing local names, entry point load failures, invalid local names, and invalid target object types. + +#### Scenario: Namespace is not discovered +- **GIVEN** no installed distribution has canonical namespace `missing-stack` +- **WHEN** a user requests `dimos run missing-stack.go2` +- **THEN** DimOS SHALL fail with an error indicating that the external blueprint namespace was not discovered. + +#### Scenario: Local blueprint name is not declared +- **GIVEN** an installed distribution has canonical namespace `my-robot-stack` +- **AND** it does not declare an external local blueprint name `arm` +- **WHEN** a user requests `dimos run my-robot-stack.arm` +- **THEN** DimOS SHALL fail with an error indicating that the namespace exists but the local blueprint name is not declared. + +#### Scenario: Entry point target fails to load +- **GIVEN** an installed distribution declares `my-robot-stack.go2` +- **AND** loading the entry point target raises an import or loading error +- **WHEN** DimOS resolves `my-robot-stack.go2` +- **THEN** DimOS SHALL fail with an error indicating that the external blueprint entry point failed to load +- **AND** the error SHALL preserve enough context to diagnose the underlying load failure. + +### Requirement: Shared resolver behavior SHALL apply to CLI, Python API, and coordinator loading +DimOS SHALL use consistent external blueprint name resolution across command-line, local Python API, and coordinator-side loading paths. + +#### Scenario: Python API resolves external name +- **GIVEN** an external package exposing `my-robot-stack.go2` is installed in the local Python environment +- **WHEN** user code calls `Dimos.run("my-robot-stack.go2")` +- **THEN** DimOS SHALL resolve the external blueprint using the same rules as `dimos run my-robot-stack.go2`. + +#### Scenario: Coordinator resolves external name in its own environment +- **GIVEN** a client asks a coordinator to load `my-robot-stack.go2` by name +- **WHEN** the coordinator performs name resolution +- **THEN** the coordinator SHALL discover external blueprints from packages installed in the coordinator environment +- **AND** packages installed only in the client environment SHALL NOT be assumed available to the coordinator. diff --git a/openspec/changes/add-external-blueprint-discovery/tasks.md b/openspec/changes/add-external-blueprint-discovery/tasks.md new file mode 100644 index 0000000000..47971e3356 --- /dev/null +++ b/openspec/changes/add-external-blueprint-discovery/tasks.md @@ -0,0 +1,46 @@ +## 1. Implementation + +- [x] 1.1 Add an external blueprint discovery/resolution helper near `dimos/robot/get_all_blueprints.py` that reads `importlib.metadata` entry points from the `dimos.blueprints` group. +- [x] 1.2 Implement canonical distribution namespace normalization by lowercasing distribution names and collapsing runs of `-`, `_`, and `.` into `-`. +- [x] 1.3 Implement external local blueprint name validation for lowercase kebab-case names matching `^[a-z0-9]+(-[a-z0-9]+)*$`. +- [x] 1.4 Implement metadata-only listing of external blueprint names as `.` without importing entry point targets. +- [x] 1.5 Implement fully qualified external blueprint resolution that locates the matching distribution namespace and local blueprint name, then loads only the requested entry point. +- [x] 1.6 Implement target conversion so loaded Blueprint objects are returned directly and loaded DimOS Module classes are converted with `.blueprint()`. +- [x] 1.7 Implement resolver error types/messages for unknown namespace, missing local blueprint name, invalid local blueprint metadata, entry point load failure, ambiguous namespace, and invalid target object type. +- [x] 1.8 Integrate external resolution into the shared `get_by_name` path while preserving bare-name lookup against built-in `all_blueprints` and `all_modules` only. +- [x] 1.9 Update CLI exit wrappers so external resolver errors produce clear user-facing messages and built-in typo suggestions continue to work for bare names. +- [x] 1.10 Update `dimos list` to print grouped built-in and external blueprint sections, with external names included by default and no target import/validation. +- [x] 1.11 Confirm `Dimos.run(...)` and coordinator-side `load_blueprint_by_name(...)` use the shared resolver path without separate external-discovery logic. +- [x] 1.12 Do not modify generated `dimos/robot/all_blueprints.py` for external packages; keep registry generation scoped to in-repo built-in blueprints and modules. + +## 2. Tests + +- [x] 2.1 Add unit tests for canonical distribution namespace normalization, including uppercase, underscores, dots, repeated separators, and already-normalized names. +- [x] 2.2 Add unit tests for external local blueprint name validation, including valid kebab-case and invalid uppercase, underscore, dotted, slash, empty, and repeated-separator cases. +- [x] 2.3 Add resolver tests with monkeypatched/synthetic entry point metadata for successful Blueprint object resolution. +- [x] 2.4 Add resolver tests with monkeypatched/synthetic entry point metadata for successful DimOS Module class conversion through `.blueprint()`. +- [x] 2.5 Add resolver tests proving factory functions and unsupported objects are rejected. +- [x] 2.6 Add resolver tests for unknown namespace, namespace exists but local name missing, target load failure, invalid local name metadata, and ambiguous canonical namespace. +- [x] 2.7 Add tests proving bare names never search external entry points and continue to use built-in registries only. +- [x] 2.8 Add tests proving mixed built-in/external arguments to the run resolution path are resolved independently before composition. +- [x] 2.9 Add CLI/list tests proving `dimos list` groups built-in and external names and does not import external targets. +- [x] 2.10 Add Python API/coordinator-path tests or targeted coverage proving external names are resolved through the shared resolver in those paths. + +## 3. Documentation + +- [x] 3.1 Update `docs/usage/cli.md` with namespaced external blueprint examples, mixed composition examples, and grouped `dimos list` behavior. +- [x] 3.2 Update `docs/usage/blueprints.md` with external package publishing guidance and a `pyproject.toml` example for `[project.entry-points."dimos.blueprints"]`. +- [x] 3.3 Document that external targets may be Blueprint objects or DimOS Module classes, while factories are not supported in v1. +- [x] 3.4 Document external local blueprint naming rules and canonical distribution namespace behavior. +- [x] 3.5 Document remote coordinator expectations: external packages must be installed where coordinator-side name resolution runs. +- [x] 3.6 Update contributor or coding-agent docs only where existing text implies all runnable blueprints must be registered through the generated in-repo registry. + +## 4. Verification + +- [x] 4.1 Run `openspec validate add-external-blueprint-discovery`. +- [x] 4.2 Run focused pytest targets for the external discovery resolver and CLI/list behavior. +- [x] 4.3 Run existing blueprint registry tests, including `pytest dimos/robot/test_all_blueprints_generation.py`, to confirm generated built-in registry behavior remains unchanged. +- [x] 4.4 Run relevant Python API/coordinator tests that cover `Dimos.run(...)` and `load_blueprint_by_name(...)` string resolution paths. +- [x] 4.5 Run docs validation commands for changed docs, such as `doclinks` and `md-babel-py run ` for docs with executable snippets. +- [x] 4.6 Manually QA with a temporary installed test package exposing `dimos.blueprints` entry points: verify `dimos list`, `dimos run my-test-stack.demo`, and `dimos run my-test-stack.demo`. +- [x] 4.7 Manually QA an external entry point whose target import fails and confirm the CLI reports an entry point load failure rather than a generic unknown blueprint. From d1e4fa9ac0aed4b7e64cb4dd94a140f42809e8c9 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 17:17:03 -0700 Subject: [PATCH 04/12] spec: remove --- CONTEXT.md | 49 ----- .../.openspec.yaml | 2 - .../design.md | 196 ------------------ .../add-external-blueprint-discovery/docs.md | 37 ---- .../proposal.md | 37 ---- .../external-blueprint-discovery/spec.md | 142 ------------- .../add-external-blueprint-discovery/tasks.md | 46 ---- openspec/config.yaml | 45 ---- openspec/schemas/dimos-capability/schema.yaml | 128 ------------ .../dimos-capability/templates/design.md | 35 ---- .../dimos-capability/templates/docs.md | 19 -- .../dimos-capability/templates/proposal.md | 32 --- .../dimos-capability/templates/spec.md | 16 -- .../dimos-capability/templates/tasks.md | 15 -- 14 files changed, 799 deletions(-) delete mode 100644 CONTEXT.md delete mode 100644 openspec/changes/add-external-blueprint-discovery/.openspec.yaml delete mode 100644 openspec/changes/add-external-blueprint-discovery/design.md delete mode 100644 openspec/changes/add-external-blueprint-discovery/docs.md delete mode 100644 openspec/changes/add-external-blueprint-discovery/proposal.md delete mode 100644 openspec/changes/add-external-blueprint-discovery/specs/external-blueprint-discovery/spec.md delete mode 100644 openspec/changes/add-external-blueprint-discovery/tasks.md delete mode 100644 openspec/config.yaml delete mode 100644 openspec/schemas/dimos-capability/schema.yaml delete mode 100644 openspec/schemas/dimos-capability/templates/design.md delete mode 100644 openspec/schemas/dimos-capability/templates/docs.md delete mode 100644 openspec/schemas/dimos-capability/templates/proposal.md delete mode 100644 openspec/schemas/dimos-capability/templates/spec.md delete mode 100644 openspec/schemas/dimos-capability/templates/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 9b99d5ae85..0000000000 --- a/CONTEXT.md +++ /dev/null @@ -1,49 +0,0 @@ -# DimOS - -DimOS composes robot software into named runnable stacks that users can launch from the command line or Python APIs. - -## Language - -**Blueprint**: -A composition of modules that represents a runnable robot stack. -_Avoid_: stack definition, launch file - -**DimOS Module**: -An autonomous subsystem that can participate in a blueprint and communicate with other modules through typed streams or RPC. -_Avoid_: Python module, component, node - -**Runnable Blueprint Name**: -The public name a user invokes to select a blueprint for execution. -_Avoid_: registry key, CLI alias, package name - -**Namespaced Blueprint Name**: -A runnable blueprint name formed from an external blueprint namespace, a dot separator, and a local entry name. -_Avoid_: fully qualified import path, plugin route - -**External Blueprint Namespace**: -A required prefix, derived by default from the installed distribution name, that identifies the source of runnable blueprint names provided outside DimOS itself. -_Avoid_: plugin prefix, discovery trigger, package alias - -**Canonical Distribution Namespace**: -The normalized form of an installed distribution name used as an external blueprint namespace. -_Avoid_: raw package name, import package name - -**External Blueprint Discovery**: -The recognition of externally provided runnable blueprint names from installed package metadata without loading the blueprint itself. -_Avoid_: validation, import scan, registry generation - -**External Blueprint Entry Point**: -An installed package metadata declaration that provides a runnable blueprint name by pointing to either a blueprint object or a DimOS Module class. -_Avoid_: factory hook, plugin loader, filesystem scan - -**External Local Blueprint Name**: -The package-defined suffix of a namespaced blueprint name, using DimOS-style lowercase kebab-case. -_Avoid_: Python symbol name, import name, nested path, runnable entry - -**Bare Blueprint Name**: -A runnable blueprint name without an external namespace; bare names refer only to DimOS-provided blueprints. -_Avoid_: unqualified plugin name, default external name - -**Blueprint Composition Command**: -A `dimos run` invocation that names one or more blueprints or modules, each resolved independently and composed into one runnable stack. -_Avoid_: launch command, multi-blueprint mode diff --git a/openspec/changes/add-external-blueprint-discovery/.openspec.yaml b/openspec/changes/add-external-blueprint-discovery/.openspec.yaml deleted file mode 100644 index 40f4fff67f..0000000000 --- a/openspec/changes/add-external-blueprint-discovery/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-16 diff --git a/openspec/changes/add-external-blueprint-discovery/design.md b/openspec/changes/add-external-blueprint-discovery/design.md deleted file mode 100644 index 7bf3b1f229..0000000000 --- a/openspec/changes/add-external-blueprint-discovery/design.md +++ /dev/null @@ -1,196 +0,0 @@ -## Context - -`dimos run` currently resolves runnable blueprint names through the generated in-repository registries in `dimos/robot/all_blueprints.py`. Runtime lookup flows through `dimos/robot/get_all_blueprints.py`, which maps built-in blueprint names to import paths and built-in DimOS Module names to module classes converted with `.blueprint()`. The CLI, Python API, and coordinator RPC path all rely on this shared lookup behavior. - -This works for DimOS-shipped blueprints but forces external robot stacks to edit DimOS source and regenerate the static registry before users can run them by name. The new design adds dynamic external blueprint discovery from installed Python package metadata while preserving the generated registry as the authoritative source for built-in bare names. - -Relevant existing surfaces: -- `dimos/robot/all_blueprints.py`: generated built-in `all_blueprints` and `all_modules` maps. -- `dimos/robot/get_all_blueprints.py`: shared resolver used by CLI, Python API, and coordinator loading. -- `dimos/robot/cli/dimos.py`: `dimos run` composes one or more names with `autoconnect(...)`; `dimos list` lists built-in names today. -- `dimos/porcelain/dimos.py`: `Dimos.run(...)` resolves string targets locally or asks a remote coordinator to resolve by name. -- `dimos/core/coordination/module_coordinator.py`: coordinator-side `load_blueprint_by_name(...)` uses the shared resolver. -- `dimos/core/module.py`: `ModuleBase.blueprint` provides class-level conversion from DimOS Module classes to blueprints. - -## Goals / Non-Goals - -**Goals:** -- Let installed external Python distributions expose runnable blueprint names without editing DimOS source. -- Keep public terminology blueprint-centric and consistent with existing `dimos run` and `dimos list` behavior. -- Preserve bare-name behavior for built-in DimOS blueprints and modules. -- Require explicit namespacing for external blueprints. -- Support entry point targets that are either Blueprint objects or DimOS Module classes. -- Keep discovery metadata-only until a user actually resolves/runs a specific external blueprint. -- Provide namespace-aware errors that make failed external resolution diagnosable. -- Apply resolver behavior consistently across CLI, Python API, and coordinator-side loading. - -**Non-Goals:** -- No filesystem scanning for external blueprints. -- No local config fallback in v1. -- No zero-argument factory functions or other callable target conventions. -- No explicit namespace override in v1; namespace is derived from the installed distribution name. -- No cache layer in v1; installed package metadata is read on demand. -- No validation/import of all external entry points during `dimos list`. -- No changes to stream contracts, worker lifecycle, hardware commands, simulation, replay, skills, or MCP tool exposure. - -## DimOS Architecture - -### Discovery model - -External packages register blueprint metadata with the Python packaging entry point group `dimos.blueprints`: - -```toml -[project] -name = "my-robot-stack" - -[project.entry-points."dimos.blueprints"] -go2 = "my_robot_stack.go2:go2_blueprint" -keyboard-teleop = "my_robot_stack.teleop:KeyboardTeleop" -``` - -DimOS exposes these as namespaced blueprint names: - -```text -my-robot-stack.go2 -my-robot-stack.keyboard-teleop -``` - -The external namespace is the canonicalized installed distribution name. Canonicalization follows Python packaging distribution-name normalization: lowercase and collapse runs of `-`, `_`, and `.` into `-`. This can be implemented directly with a small helper equivalent to PEP 503 normalization, avoiding a new runtime dependency unless the project already chooses to depend on `packaging`. - -The external local blueprint name is the entry point name and must use lowercase DimOS-style kebab-case: - -```text -^[a-z0-9]+(-[a-z0-9]+)*$ -``` - -Dots are reserved for separating external namespace from local blueprint name. Underscores, uppercase characters, slashes, and nested dotted local names are invalid. - -### Resolver integration - -Dynamic discovery should be added to the shared name resolver, not only the CLI. A small resolver module can live near the current registry resolver, for example under `dimos/robot/`, and expose helpers for: -- listing external blueprint metadata; -- parsing and validating namespaced blueprint names; -- resolving one external blueprint entry point by fully qualified name; -- converting a loaded target object to a `Blueprint`. - -The existing lookup order remains: - -1. If the requested name is bare, resolve only against built-in `all_blueprints` and `all_modules`. -2. If the requested name is namespaced with a dot, first preserve existing built-in behavior where applicable, then resolve external metadata by namespace and local name. -3. If the external target loads to a Blueprint object, return it. -4. If the external target loads to a DimOS Module class, return `Target.blueprint()`. -5. Otherwise raise an invalid external blueprint target error. - -Built-in names keep their current generated-registry path. External names do not modify `all_blueprints.py`; that file remains generated only from in-repo blueprints and modules. - -### CLI behavior - -`dimos run` already accepts multiple blueprint/module arguments and composes resolved targets with `autoconnect(...)`. No new CLI shape is needed. Each argument is resolved independently, so mixed built-in and external composition works naturally: - -```bash -dimos run unitree-go2 my-robot-stack.keyboard-teleop -``` - -`dimos list` should show names only, grouped by source: - -```text -Built-in blueprints: - unitree-go2 - unitree-go2-agentic - -External blueprints: - my-robot-stack.go2 - my-robot-stack.keyboard-teleop -``` - -Listing external blueprints reads entry point metadata and does not import target modules or validate target object types. A future `--verbose` flag may show entry point target strings, but v1 does not require it. - -### Python API and coordinator behavior - -`Dimos.run("my-robot-stack.go2")` should work when the external distribution is installed in the environment that resolves the name. - -Remote mode is intentionally coordinator-side: if the client sends a string name through coordinator RPC, the package must be installed where the coordinator performs `load_blueprint_by_name(...)`. Installing the package only in the client environment is not sufficient for remote resolution. - -### Errors - -External errors should be namespace-aware. At minimum, distinguish: -- namespaced name has an external namespace that is not installed/discovered; -- namespace exists but the requested external local blueprint name is not declared; -- the external entry point exists but fails to load/import; -- the loaded object is neither a Blueprint object nor a DimOS Module class; -- an external local blueprint name in metadata is invalid. - -CLI exit wrappers can continue to convert resolver exceptions into user-facing messages, but should preserve these distinctions and include useful suggestions where safe. - -### DimOS Specs, streams, skills, and MCP - -No DimOS Python `Spec` Protocol changes are required. This change does not add module RPC contracts, stream names/types, transport behavior, skill decorators, or MCP exposure. It only expands how blueprint names are discovered and resolved. - -## Decisions - -1. **Use installed Python entry points for external discovery.** - - Rationale: packaging metadata is explicit, environment-scoped, compatible with installed distributions, and avoids scanning arbitrary files. - - Alternatives considered: filesystem scanning and local config. Filesystem scanning is too implicit and environment-dependent; local config may be a future escape hatch but is not v1. - -2. **Keep external names always namespaced.** - - Rationale: prevents external packages from shadowing or competing with built-in bare names and makes dynamic discovery intentional. - - Alternative considered: allow bare external names. Rejected because it creates ambiguity and surprising name resolution. - -3. **Derive namespace from the installed distribution name.** - - Rationale: reduces ceremony and gives each package a natural namespace. - - Alternative considered: require explicit namespace declaration in another entry point group or config key. Rejected for v1 as extra surface area. - -4. **Use `.` between namespace and local blueprint name.** - - Rationale: readable fully qualified names such as `my-robot-stack.go2`, while preserving kebab-case local names. - - Constraint: local external blueprint names cannot contain dots. - -5. **Enforce lowercase kebab-case external local blueprint names.** - - Rationale: matches existing DimOS-style runnable names and avoids hidden normalization surprises. - - Invalid metadata should be reported as invalid rather than silently normalized. - -6. **Use the existing `dimos.blueprints` entry point group.** - - Rationale: keep terms blueprint-centric, even though Module classes may be accepted as a convenience and converted to blueprints. - - Alternative considered: `dimos.runnables`. Rejected to avoid introducing a new public term. - -7. **Support Blueprint objects and DimOS Module classes, but not factories.** - - Rationale: mirrors internal registry behavior and avoids adding a new callable convention. - - Factories can be reconsidered later if there is a concrete need. - -8. **List external blueprints by metadata only.** - - Rationale: `dimos list` should be fast, safe, and not fail because an optional robot dependency cannot import in the current environment. - - Validation happens when a specific external blueprint is resolved. - -9. **Do not cache discovery in v1.** - - Rationale: simple on-demand metadata reads mean install/uninstall changes are visible immediately. - - A cache can be added later if performance measurements justify it. - -10. **Implement dynamic resolution centrally.** - - Rationale: CLI, Python API, and coordinator RPC should share behavior and error semantics. - -## Safety / Simulation / Replay - -This change does not alter robot motion, stream payloads, hardware drivers, simulation, or replay behavior. It changes how runnable blueprint names are discovered before a stack is built. Hardware risk is limited to the fact that external packages can expose blueprints that run real robot modules; existing DimOS safety expectations still apply once a blueprint is selected. - -Manual QA should include non-hardware examples and, when practical, a simulated or replay-only external blueprint package. Real hardware validation is not required to validate the discovery mechanism itself. - -## Risks / Trade-offs - -- **Metadata collisions:** Two installed distributions may canonicalize to the same namespace. Mitigate by treating this as an ambiguous external namespace error or by surfacing all matching distributions in diagnostics. -- **Invalid third-party metadata:** Bad entry point names or invalid target objects can appear in installed packages. Mitigate with clear errors on resolution and cautious behavior in `dimos list`. -- **Import side effects on run:** Loading an external entry point imports package code. This is expected at run time but intentionally avoided during list. -- **Remote environment confusion:** Users may install an external package locally but run against a coordinator environment that lacks it. Mitigate with docs and coordinator-side error wording. -- **Entry point API compatibility:** `importlib.metadata.entry_points()` has changed return shapes across Python versions. Implement a small compatibility helper and test it under supported Python versions. -- **Terminology precision:** Accepting DimOS Module classes through `dimos.blueprints` is internally broader than the group name. Mitigate by documenting that Module classes are converted to blueprints and keeping public language blueprint-centric. - -## Migration / Rollout - -- Existing built-in blueprint and module registries remain unchanged. -- No regeneration of `all_blueprints.py` is required for external packages. The existing registry generation test remains required only when in-repo blueprints/modules are added or renamed. -- Add resolver/listing tests with synthetic entry point metadata rather than depending on globally installed packages. -- Add docs showing `pyproject.toml` entry point examples, namespaced `dimos run`, `dimos list`, Module class conversion, and remote coordinator installation expectations. -- Rollback is straightforward: remove external discovery from the resolver/list command while preserving the generated built-in registry. - -## Open Questions - -- Should `dimos list` display invalid external metadata with a warning, or omit invalid entries and reserve the error for direct resolution? The current design prefers reporting invalid metadata as invalid when encountered, while keeping list metadata-only. -- If two installed distributions canonicalize to the same namespace, should DimOS fail the whole namespace as ambiguous or choose a deterministic but potentially surprising order? The safer default is to fail as ambiguous. diff --git a/openspec/changes/add-external-blueprint-discovery/docs.md b/openspec/changes/add-external-blueprint-discovery/docs.md deleted file mode 100644 index 4ce19f1406..0000000000 --- a/openspec/changes/add-external-blueprint-discovery/docs.md +++ /dev/null @@ -1,37 +0,0 @@ -## User-Facing Docs - -- Update `docs/usage/cli.md`: - - Explain that `dimos run` accepts both built-in bare blueprint names and external namespaced blueprint names. - - Add examples such as `dimos run my-robot-stack.go2` and `dimos run unitree-go2 my-robot-stack.keyboard-teleop`. - - Document that external names must be fully qualified as `.`. - - Document that `dimos list` shows built-in and external blueprints in separate sections. -- Update `docs/usage/blueprints.md`: - - Add a section for publishing external blueprints from installed Python packages. - - Include a `pyproject.toml` example using `[project.entry-points."dimos.blueprints"]`. - - Explain that entry point targets may be Blueprint objects or DimOS Module classes. - - State that factories are not supported in v1. - - State that external local blueprint names must be lowercase kebab-case. -- Update any README or quick-start page that describes the built-in-only registry flow if it would otherwise imply external packages must edit DimOS source. - -## Contributor Docs - -- Update `docs/development/dimos_run.md` or the nearest contributor guide for runnable blueprint registration: - - Clarify the split between generated built-in registry registration and external entry point discovery. - - Preserve the existing `pytest dimos/robot/test_all_blueprints_generation.py` guidance for in-repo blueprints. - - Add guidance for testing external discovery with synthetic entry point metadata. -- Update `docs/development/testing.md` if new test helpers or monkeypatch patterns are introduced for entry point metadata. - -## Coding-Agent Docs - -- Update `AGENTS.md` if the blueprint quick-reference or “Adding a blueprint” guidance says all runnable blueprints must be added through the generated registry. -- Update `docs/coding-agents/` only if those docs contain registry-generation instructions that should distinguish built-in versus external blueprint registration. - -## Doc Validation - -- Run the project’s standard documentation link validation if available, for example `doclinks`. -- If changed docs contain executable Python snippets, run `md-babel-py run ` for each affected markdown file. -- Run targeted tests that exercise CLI help/list behavior if docs rely on command output examples. - -## No Docs Needed - -Documentation changes are needed because this change introduces a new public packaging and CLI workflow for external DimOS users. diff --git a/openspec/changes/add-external-blueprint-discovery/proposal.md b/openspec/changes/add-external-blueprint-discovery/proposal.md deleted file mode 100644 index f287296096..0000000000 --- a/openspec/changes/add-external-blueprint-discovery/proposal.md +++ /dev/null @@ -1,37 +0,0 @@ -## Why - -DimOS blueprint registration is currently limited to the in-repository, generated `all_blueprints.py` and `all_modules` registries. External robot stacks cannot become runnable through `dimos run` without modifying DimOS source code and regenerating the built-in registry, which makes third-party and downstream package workflows unnecessarily coupled to the DimOS repository. - -This change lets installed Python packages expose DimOS-provided runnable blueprint names through packaging metadata, while preserving the existing built-in registry and bare-name behavior. The goal is ROS2-like package-level discoverability for external blueprints without filesystem scanning, eager imports, or extra factory conventions. - -## What Changes - -- Add dynamic external blueprint discovery from installed Python package entry points in the `dimos.blueprints` entry point group. -- External runnable blueprint names are always namespaced as `.`. -- Bare blueprint names continue to resolve only through DimOS built-in registries; `dimos run ` never searches external packages. -- External entry point targets may be either a `Blueprint` object or a DimOS Module class, which is converted with `.blueprint()`. -- Factory functions are not supported for v1. -- `dimos list` includes external blueprints by default, grouped separately from built-in blueprints, using metadata only and without importing or validating target objects. -- Name resolution errors distinguish unknown external namespaces, missing local blueprint names within a namespace, load failures, and invalid target object types. -- Shared resolver behavior applies consistently to CLI, Python API, and coordinator-side name loading. -- No hardware-safety behavior changes are introduced. - -## Affected DimOS Surfaces - -- Modules/streams: DimOS Module classes can be exposed through external blueprint entry points and converted to blueprints; no stream semantics change. -- Blueprints/CLI: `dimos run`, `dimos list`, static built-in blueprint/module lookup, blueprint composition command behavior, and resolver error messages. -- Skills/MCP: No direct skill or MCP tool changes. -- Hardware/simulation/replay: No hardware, simulation, or replay semantics change; external packages may provide robot-specific stacks using the same launch path. -- Docs/generated registries: User/developer docs for exposing external blueprints; generated built-in registries remain authoritative for bare names and in-repo blueprints. - -## Capabilities - -### New Capabilities -- `external-blueprint-discovery`: Discovery, listing, and resolution of externally provided runnable blueprint names from installed package metadata. - -### Modified Capabilities -- None. - -## Impact - -External DimOS users can publish robot stacks as normal Python packages and run them through `dimos run` without editing DimOS source. Existing built-in blueprint names, generated registry tests, and multi-argument blueprint composition remain compatible. The change introduces a runtime dependency on Python entry point metadata APIs and packaging-name normalization, plus new tests for discovery, listing, resolver behavior, error handling, and documentation examples. Remote coordinator loading requires the external package to be installed in the coordinator environment that performs name resolution. diff --git a/openspec/changes/add-external-blueprint-discovery/specs/external-blueprint-discovery/spec.md b/openspec/changes/add-external-blueprint-discovery/specs/external-blueprint-discovery/spec.md deleted file mode 100644 index 60348078fb..0000000000 --- a/openspec/changes/add-external-blueprint-discovery/specs/external-blueprint-discovery/spec.md +++ /dev/null @@ -1,142 +0,0 @@ -## ADDED Requirements - -### Requirement: External blueprint names SHALL be discovered from installed package metadata -DimOS SHALL discover external blueprint names from installed Python distribution entry points in the `dimos.blueprints` group. - -#### Scenario: Installed package exposes external blueprints -- **GIVEN** an installed distribution named `my-robot-stack` -- **AND** the distribution declares `dimos.blueprints` entry points named `go2` and `keyboard-teleop` -- **WHEN** DimOS discovers external blueprints -- **THEN** the discovered names SHALL include `my-robot-stack.go2` and `my-robot-stack.keyboard-teleop` -- **AND** discovery SHALL use package metadata without importing the entry point target modules. - -#### Scenario: Distribution name is canonicalized -- **GIVEN** an installed distribution whose name contains uppercase letters, underscores, dots, or repeated separators -- **WHEN** DimOS derives the external blueprint namespace -- **THEN** it SHALL lowercase the name and collapse runs of `-`, `_`, and `.` into a single `-` -- **AND** the canonical distribution namespace SHALL be used as the prefix of every external blueprint name from that distribution. - -### Requirement: External blueprint names SHALL be explicitly namespaced -DimOS SHALL require external blueprints to be referenced as `.`. - -#### Scenario: Bare built-in name remains built-in only -- **GIVEN** a user requests `dimos run unitree-go2` -- **WHEN** DimOS resolves the bare blueprint name -- **THEN** it SHALL resolve only against DimOS built-in blueprint and module registries -- **AND** it SHALL NOT search external packages for a matching bare name. - -#### Scenario: Namespaced external name is requested -- **GIVEN** an installed distribution with canonical namespace `my-robot-stack` -- **AND** it declares an external local blueprint name `go2` -- **WHEN** a user requests `dimos run my-robot-stack.go2` -- **THEN** DimOS SHALL resolve the external blueprint identified by namespace `my-robot-stack` and local name `go2`. - -### Requirement: External local blueprint names SHALL use DimOS-style kebab-case -DimOS SHALL accept only lowercase kebab-case external local blueprint names matching `^[a-z0-9]+(-[a-z0-9]+)*$`. - -#### Scenario: Valid local blueprint name -- **GIVEN** an external entry point named `keyboard-teleop` -- **WHEN** DimOS lists or resolves external blueprint metadata -- **THEN** the local blueprint name SHALL be considered valid. - -#### Scenario: Invalid local blueprint name -- **GIVEN** an external entry point named `Go2`, `go2_sim`, `go2.sim`, or `go2/real` -- **WHEN** DimOS encounters that entry point as an external blueprint -- **THEN** DimOS SHALL treat the entry point name as invalid external blueprint metadata -- **AND** it SHALL NOT silently normalize the invalid local blueprint name into a different runnable name. - -### Requirement: External blueprint resolution SHALL load only the requested entry point -DimOS SHALL defer importing external target modules until a specific external blueprint name is resolved for execution or API use. - -#### Scenario: Listing external blueprints does not import targets -- **GIVEN** an installed external package with `dimos.blueprints` metadata -- **WHEN** a user runs `dimos list` -- **THEN** DimOS SHALL display the external blueprint names from metadata -- **AND** it SHALL NOT import the entry point target modules merely to list them. - -#### Scenario: Running external blueprint loads target -- **GIVEN** an installed external package exposing `my-robot-stack.go2` -- **WHEN** a user runs `dimos run my-robot-stack.go2` -- **THEN** DimOS SHALL load the target for that specific entry point -- **AND** it SHALL resolve it to a runnable blueprint before building the stack. - -### Requirement: External targets SHALL resolve to blueprints or DimOS Module classes -DimOS SHALL accept external entry point targets that are Blueprint objects or DimOS Module classes, and SHALL reject unsupported target types. - -#### Scenario: Entry point targets a Blueprint object -- **GIVEN** an external entry point target resolves to a Blueprint object -- **WHEN** DimOS resolves the namespaced blueprint name -- **THEN** DimOS SHALL return that Blueprint for normal composition and execution. - -#### Scenario: Entry point targets a DimOS Module class -- **GIVEN** an external entry point target resolves to a DimOS Module class -- **WHEN** DimOS resolves the namespaced blueprint name -- **THEN** DimOS SHALL convert the class using `.blueprint()` -- **AND** it SHALL return the resulting Blueprint for normal composition and execution. - -#### Scenario: Entry point targets an unsupported object -- **GIVEN** an external entry point target resolves to a factory function, non-DimOS class, primitive value, or other unsupported object -- **WHEN** DimOS resolves the namespaced blueprint name -- **THEN** DimOS SHALL fail with an error that identifies the target as an invalid external blueprint target. - -### Requirement: Blueprint composition commands SHALL support mixed built-in and external names -DimOS SHALL resolve each argument to `dimos run` independently so built-in and external blueprint names can be composed in one command. - -#### Scenario: Mixed composition command -- **GIVEN** a built-in blueprint named `unitree-go2` -- **AND** an installed external blueprint named `my-robot-stack.keyboard-teleop` -- **WHEN** a user runs `dimos run unitree-go2 my-robot-stack.keyboard-teleop` -- **THEN** DimOS SHALL resolve both names independently -- **AND** it SHALL compose the resulting blueprints into one runnable stack using the existing composition behavior. - -### Requirement: List command SHALL group built-in and external blueprints -`dimos list` SHALL include external blueprints by default and SHALL separate built-in blueprints from external blueprints. - -#### Scenario: User lists blueprints -- **GIVEN** DimOS has built-in blueprints -- **AND** an installed distribution exposes external blueprints -- **WHEN** a user runs `dimos list` -- **THEN** the output SHALL include a built-in blueprints section -- **AND** the output SHALL include an external blueprints section -- **AND** external names SHALL be displayed fully qualified with namespace and local blueprint name. - -#### Scenario: No external blueprints are installed -- **GIVEN** no installed distributions expose `dimos.blueprints` entry points -- **WHEN** a user runs `dimos list` -- **THEN** DimOS SHALL still list built-in blueprints normally -- **AND** it SHALL NOT fail because no external blueprints are present. - -### Requirement: External resolution errors SHALL identify namespace-aware failure modes -DimOS SHALL provide distinct errors for unknown namespaces, missing local names, entry point load failures, invalid local names, and invalid target object types. - -#### Scenario: Namespace is not discovered -- **GIVEN** no installed distribution has canonical namespace `missing-stack` -- **WHEN** a user requests `dimos run missing-stack.go2` -- **THEN** DimOS SHALL fail with an error indicating that the external blueprint namespace was not discovered. - -#### Scenario: Local blueprint name is not declared -- **GIVEN** an installed distribution has canonical namespace `my-robot-stack` -- **AND** it does not declare an external local blueprint name `arm` -- **WHEN** a user requests `dimos run my-robot-stack.arm` -- **THEN** DimOS SHALL fail with an error indicating that the namespace exists but the local blueprint name is not declared. - -#### Scenario: Entry point target fails to load -- **GIVEN** an installed distribution declares `my-robot-stack.go2` -- **AND** loading the entry point target raises an import or loading error -- **WHEN** DimOS resolves `my-robot-stack.go2` -- **THEN** DimOS SHALL fail with an error indicating that the external blueprint entry point failed to load -- **AND** the error SHALL preserve enough context to diagnose the underlying load failure. - -### Requirement: Shared resolver behavior SHALL apply to CLI, Python API, and coordinator loading -DimOS SHALL use consistent external blueprint name resolution across command-line, local Python API, and coordinator-side loading paths. - -#### Scenario: Python API resolves external name -- **GIVEN** an external package exposing `my-robot-stack.go2` is installed in the local Python environment -- **WHEN** user code calls `Dimos.run("my-robot-stack.go2")` -- **THEN** DimOS SHALL resolve the external blueprint using the same rules as `dimos run my-robot-stack.go2`. - -#### Scenario: Coordinator resolves external name in its own environment -- **GIVEN** a client asks a coordinator to load `my-robot-stack.go2` by name -- **WHEN** the coordinator performs name resolution -- **THEN** the coordinator SHALL discover external blueprints from packages installed in the coordinator environment -- **AND** packages installed only in the client environment SHALL NOT be assumed available to the coordinator. diff --git a/openspec/changes/add-external-blueprint-discovery/tasks.md b/openspec/changes/add-external-blueprint-discovery/tasks.md deleted file mode 100644 index 47971e3356..0000000000 --- a/openspec/changes/add-external-blueprint-discovery/tasks.md +++ /dev/null @@ -1,46 +0,0 @@ -## 1. Implementation - -- [x] 1.1 Add an external blueprint discovery/resolution helper near `dimos/robot/get_all_blueprints.py` that reads `importlib.metadata` entry points from the `dimos.blueprints` group. -- [x] 1.2 Implement canonical distribution namespace normalization by lowercasing distribution names and collapsing runs of `-`, `_`, and `.` into `-`. -- [x] 1.3 Implement external local blueprint name validation for lowercase kebab-case names matching `^[a-z0-9]+(-[a-z0-9]+)*$`. -- [x] 1.4 Implement metadata-only listing of external blueprint names as `.` without importing entry point targets. -- [x] 1.5 Implement fully qualified external blueprint resolution that locates the matching distribution namespace and local blueprint name, then loads only the requested entry point. -- [x] 1.6 Implement target conversion so loaded Blueprint objects are returned directly and loaded DimOS Module classes are converted with `.blueprint()`. -- [x] 1.7 Implement resolver error types/messages for unknown namespace, missing local blueprint name, invalid local blueprint metadata, entry point load failure, ambiguous namespace, and invalid target object type. -- [x] 1.8 Integrate external resolution into the shared `get_by_name` path while preserving bare-name lookup against built-in `all_blueprints` and `all_modules` only. -- [x] 1.9 Update CLI exit wrappers so external resolver errors produce clear user-facing messages and built-in typo suggestions continue to work for bare names. -- [x] 1.10 Update `dimos list` to print grouped built-in and external blueprint sections, with external names included by default and no target import/validation. -- [x] 1.11 Confirm `Dimos.run(...)` and coordinator-side `load_blueprint_by_name(...)` use the shared resolver path without separate external-discovery logic. -- [x] 1.12 Do not modify generated `dimos/robot/all_blueprints.py` for external packages; keep registry generation scoped to in-repo built-in blueprints and modules. - -## 2. Tests - -- [x] 2.1 Add unit tests for canonical distribution namespace normalization, including uppercase, underscores, dots, repeated separators, and already-normalized names. -- [x] 2.2 Add unit tests for external local blueprint name validation, including valid kebab-case and invalid uppercase, underscore, dotted, slash, empty, and repeated-separator cases. -- [x] 2.3 Add resolver tests with monkeypatched/synthetic entry point metadata for successful Blueprint object resolution. -- [x] 2.4 Add resolver tests with monkeypatched/synthetic entry point metadata for successful DimOS Module class conversion through `.blueprint()`. -- [x] 2.5 Add resolver tests proving factory functions and unsupported objects are rejected. -- [x] 2.6 Add resolver tests for unknown namespace, namespace exists but local name missing, target load failure, invalid local name metadata, and ambiguous canonical namespace. -- [x] 2.7 Add tests proving bare names never search external entry points and continue to use built-in registries only. -- [x] 2.8 Add tests proving mixed built-in/external arguments to the run resolution path are resolved independently before composition. -- [x] 2.9 Add CLI/list tests proving `dimos list` groups built-in and external names and does not import external targets. -- [x] 2.10 Add Python API/coordinator-path tests or targeted coverage proving external names are resolved through the shared resolver in those paths. - -## 3. Documentation - -- [x] 3.1 Update `docs/usage/cli.md` with namespaced external blueprint examples, mixed composition examples, and grouped `dimos list` behavior. -- [x] 3.2 Update `docs/usage/blueprints.md` with external package publishing guidance and a `pyproject.toml` example for `[project.entry-points."dimos.blueprints"]`. -- [x] 3.3 Document that external targets may be Blueprint objects or DimOS Module classes, while factories are not supported in v1. -- [x] 3.4 Document external local blueprint naming rules and canonical distribution namespace behavior. -- [x] 3.5 Document remote coordinator expectations: external packages must be installed where coordinator-side name resolution runs. -- [x] 3.6 Update contributor or coding-agent docs only where existing text implies all runnable blueprints must be registered through the generated in-repo registry. - -## 4. Verification - -- [x] 4.1 Run `openspec validate add-external-blueprint-discovery`. -- [x] 4.2 Run focused pytest targets for the external discovery resolver and CLI/list behavior. -- [x] 4.3 Run existing blueprint registry tests, including `pytest dimos/robot/test_all_blueprints_generation.py`, to confirm generated built-in registry behavior remains unchanged. -- [x] 4.4 Run relevant Python API/coordinator tests that cover `Dimos.run(...)` and `load_blueprint_by_name(...)` string resolution paths. -- [x] 4.5 Run docs validation commands for changed docs, such as `doclinks` and `md-babel-py run ` for docs with executable snippets. -- [x] 4.6 Manually QA with a temporary installed test package exposing `dimos.blueprints` entry points: verify `dimos list`, `dimos run my-test-stack.demo`, and `dimos run my-test-stack.demo`. -- [x] 4.7 Manually QA an external entry point whose target import fails and confirm the CLI reports an entry point load failure rather than a generic unknown blueprint. diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 62a72bba63..0000000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,45 +0,0 @@ -schema: dimos-capability - -context: | - DimOS is a robotics operating system for generalist robots. Modules communicate - through typed streams (`In[T]`, `Out[T]`) over LCM, SHM, ROS, DDS, or other - transports. Blueprints compose modules into runnable robot stacks. Skills are - `@skill`-annotated RPC methods exposed to agents and MCP clients. - - Terminology boundary: - - "OpenSpec spec" means a behavior specification under `openspec/specs/`. - - "DimOS Spec" means a Python Protocol/RPC contract in `*_spec.py` files, - usually inheriting `dimos.spec.utils.Spec` and `typing.Protocol`. - Keep these separate. OpenSpec specs describe observable behavior; DimOS Specs - describe code-level module interfaces. - - OpenSpec specs should capture current behavior, user/developer-visible - outcomes, public CLI/API/tool surfaces, robot safety constraints, and testable - scenarios. Put implementation choices, class names, module wiring, generated - registry updates, and rollout details in `design.md` or `tasks.md`. - - Documentation lives in: - - `docs/usage/` for user-facing concepts and APIs. - - `docs/capabilities/` for capability and platform guides. - - `docs/development/` for contributor process. - - `docs/coding-agents/` and `AGENTS.md` for coding-agent guidance. - -rules: - proposal: - - "Identify affected DimOS surfaces: modules, streams, blueprints, CLI, skills/MCP, docs, hardware, simulation, replay, or generated registries." - - Use capability names that match behavior domains, not Python class names. - - Mark hardware safety or public API/CLI changes explicitly. - specs: - - Write behavior-first requirements; avoid implementation detail unless it is externally observable. - - Every requirement must include at least one `#### Scenario:` block with concrete observable outcomes. - - Use "OpenSpec capability spec" when prose might otherwise be confused with DimOS Python `Spec` Protocols. - design: - - Call out DimOS `Spec` Protocols, adapter Protocols, blueprint composition, stream names/types, and skill/MCP exposure when relevant. - - Mention generated files and required regeneration commands, especially `pytest dimos/robot/test_all_blueprints_generation.py` for blueprint registry changes. - - Include hardware/simulation/replay assumptions and safety constraints for robot-facing work. - docs: - - List user-facing docs, contributor docs, coding-agent docs, and AGENTS.md updates required by the change. - - Include documentation validation commands for changed docs, such as `doclinks` and `md-babel-py run ` where applicable. - tasks: - - Include verification tasks for OpenSpec validation, relevant pytest targets, type checks when needed, and manual QA through the user-facing surface. - - Add registry generation tasks when blueprint names, module classes, or generated registry inputs change. diff --git a/openspec/schemas/dimos-capability/schema.yaml b/openspec/schemas/dimos-capability/schema.yaml deleted file mode 100644 index fedb7964ee..0000000000 --- a/openspec/schemas/dimos-capability/schema.yaml +++ /dev/null @@ -1,128 +0,0 @@ -name: dimos-capability -version: 1 -description: DimOS capability workflow - proposal → specs/design/docs → tasks -artifacts: - - id: proposal - generates: proposal.md - description: DimOS change proposal covering intent, scope, capability impact, and affected robot/software surfaces - template: proposal.md - instruction: | - Create the proposal document that establishes WHY this change is needed and what DimOS behavior it affects. - - Sections: - - **Why**: 1-2 concise paragraphs on the problem or opportunity. Explain why the change matters now. - - **What Changes**: Bullet list of added, modified, or removed behavior. Mark public API/CLI or hardware-safety breaking changes with **BREAKING**. - - **Affected DimOS Surfaces**: Identify modules, streams, blueprints, CLI commands, skills/MCP tools, docs, hardware, simulation, replay, generated registries, or external protocols touched by the change. - - **Capabilities**: Identify which OpenSpec capability specs will be created or modified: - - **New Capabilities**: List behavior domains introduced by the change. Each becomes `specs//spec.md`. Use kebab-case names (for example, `agent-skills-mcp`, `blueprint-composition`, `manipulation-stack`). - - **Modified Capabilities**: List existing `openspec/specs//` entries whose requirements change. Only include spec-level behavior changes, not implementation-only refactors. - - **Impact**: Summarize user/developer impact, compatibility risks, dependency changes, documentation updates, and test/QA scope. - - Keep proposals concise. Do not include line-by-line implementation details; put architecture and rollout decisions in `design.md`. - requires: [] - - id: specs - generates: specs/**/*.md - description: Behavior-first OpenSpec capability delta specifications - template: spec.md - instruction: | - Create OpenSpec capability specs that define WHAT DimOS should do, not how it is implemented. - - Create one delta spec file per capability listed in proposal.md: - - New capabilities: use `specs//spec.md` with the exact kebab-case name from the proposal. - - Modified capabilities: use the existing folder from `openspec/specs//`. - - Use these delta sections as `##` headers: - - **ADDED Requirements**: New externally observable behavior. - - **MODIFIED Requirements**: Changed behavior. Include the full updated requirement block, not a partial patch. - - **REMOVED Requirements**: Deprecated behavior. Include **Reason** and **Migration**. - - **RENAMED Requirements**: Name-only changes. Use FROM:/TO: format. - - Requirement format: - - Use `### Requirement: `. - - Use SHALL/MUST for normative requirements. - - Include at least one `#### Scenario: ` per requirement. Scenario headings MUST use exactly four `#` characters. - - Prefer `- **GIVEN**`, `- **WHEN**`, `- **THEN**`, and `- **AND**` bullets. - - Cover happy path plus meaningful edge/error/safety cases. - - DimOS-specific guidance: - - Specify user/developer-visible behavior, robot outcomes, CLI behavior, skill/MCP tool behavior, stream contracts, safety constraints, and compatibility expectations. - - Avoid Python class names, private module internals, transport implementation choices, and generated-file details unless those details are observable API contracts. - - Use "OpenSpec capability spec" in prose when needed to avoid confusion with DimOS Python `Spec` Protocols. - - If the behavior only changes implementation and not observable requirements, do not create a spec delta. - requires: - - proposal - - id: design - generates: design.md - description: DimOS technical design and architecture decisions - template: design.md - instruction: | - Create the design document that explains HOW the change should be implemented in DimOS. - - Include design.md for cross-module changes, new robot/hardware integration, new public interfaces, new dependencies, safety-sensitive behavior, generated registry changes, or unclear architecture. - - Sections: - - **Context**: Current state, relevant modules/blueprints/docs, and constraints. - - **Goals / Non-Goals**: What the design achieves and explicitly excludes. - - **DimOS Architecture**: Modules, streams, transports, blueprints, RPC/module refs, DimOS `Spec` Protocols, adapter Protocols, skills/MCP exposure, CLI entry points, and generated registries involved. - - **Decisions**: Key choices with rationale and alternatives considered. - - **Safety / Simulation / Replay**: Hardware assumptions, sim/replay behavior, safety constraints, and manual QA surface. - - **Risks / Trade-offs**: Known risks and mitigations. - - **Migration / Rollout**: Compatibility, generated files, docs, and deployment steps. - - **Open Questions**: Outstanding decisions or unknowns. - - Reference proposal.md for intent and specs for behavior. Keep line-by-line work in tasks.md. - requires: - - proposal - - id: docs - generates: docs.md - description: Documentation impact plan for user, contributor, and coding-agent docs - template: docs.md - instruction: | - Create the documentation impact plan for the change. - - Sections: - - **User-Facing Docs**: Updates under `docs/usage/`, `docs/capabilities/`, `docs/platforms/`, or README files. - - **Contributor Docs**: Updates under `docs/development/`. - - **Coding-Agent Docs**: Updates under `docs/coding-agents/` or `AGENTS.md`. - - **Doc Validation**: Commands needed for changed docs, such as `doclinks`, `md-babel-py run `, and `bin/gen-diagrams`. - - **No Docs Needed**: If no docs are needed, explain why. - - Match `docs/development/writing_docs.md`: contributor-only docs belong in `docs/development`; user-facing behavior belongs in `docs/usage` or `docs/capabilities`. - requires: - - proposal - - id: tasks - generates: tasks.md - description: Implementation, validation, docs, and manual-QA checklist - template: tasks.md - instruction: | - Create the implementation checklist. The apply phase parses checkbox format, so every actionable task MUST use `- [ ]`. - - Guidelines: - - Group tasks under numbered `##` headings. - - Each task must be `- [ ] X.Y Task description`. - - Keep tasks small enough to complete in one focused session. - - Order tasks by dependency. - - Include docs and validation tasks from docs.md. - - Include generated registry tasks when blueprints or module registry inputs change. - - Include manual QA through the actual user surface: CLI, TUI, HTTP API, MCP tool, simulation/replay blueprint, hardware procedure, or library driver. - - Typical DimOS validation tasks: - - Run `openspec validate `. - - Run focused pytest targets for changed modules. - - Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint registry output may change. - - Run docs validation commands for changed docs. - - Run lints/types when the touched area requires them. - - Reference specs for WHAT, design for HOW, and docs.md for documentation work. - requires: - - specs - - design - - docs -apply: - requires: - - tasks - tracks: tasks.md - instruction: | - Read proposal.md, specs, design.md, docs.md, and tasks.md before editing code. - Work through pending tasks, mark checkboxes complete as they finish, and keep artifacts current when implementation changes the plan. - Verify with OpenSpec validation, focused tests, docs checks, and manual QA through the relevant DimOS surface. diff --git a/openspec/schemas/dimos-capability/templates/design.md b/openspec/schemas/dimos-capability/templates/design.md deleted file mode 100644 index 25031ceb8b..0000000000 --- a/openspec/schemas/dimos-capability/templates/design.md +++ /dev/null @@ -1,35 +0,0 @@ -## Context - - - -## Goals / Non-Goals - -**Goals:** - - -**Non-Goals:** - - -## DimOS Architecture - - - -## Decisions - - - -## Safety / Simulation / Replay - - - -## Risks / Trade-offs - - - -## Migration / Rollout - - - -## Open Questions - - diff --git a/openspec/schemas/dimos-capability/templates/docs.md b/openspec/schemas/dimos-capability/templates/docs.md deleted file mode 100644 index d274aed653..0000000000 --- a/openspec/schemas/dimos-capability/templates/docs.md +++ /dev/null @@ -1,19 +0,0 @@ -## User-Facing Docs - - - -## Contributor Docs - - - -## Coding-Agent Docs - - - -## Doc Validation - - - -## No Docs Needed - - diff --git a/openspec/schemas/dimos-capability/templates/proposal.md b/openspec/schemas/dimos-capability/templates/proposal.md deleted file mode 100644 index 98d409e8de..0000000000 --- a/openspec/schemas/dimos-capability/templates/proposal.md +++ /dev/null @@ -1,32 +0,0 @@ -## Why - - - -## What Changes - - - -## Affected DimOS Surfaces - - -- Modules/streams: -- Blueprints/CLI: -- Skills/MCP: -- Hardware/simulation/replay: -- Docs/generated registries: - -## Capabilities - -### New Capabilities - -- ``: - -### Modified Capabilities - -- ``: - -## Impact - - diff --git a/openspec/schemas/dimos-capability/templates/spec.md b/openspec/schemas/dimos-capability/templates/spec.md deleted file mode 100644 index afc0c1ff58..0000000000 --- a/openspec/schemas/dimos-capability/templates/spec.md +++ /dev/null @@ -1,16 +0,0 @@ -## ADDED Requirements - -### Requirement: - - -#### Scenario: -- **GIVEN** -- **WHEN** -- **THEN** -- **AND** - - diff --git a/openspec/schemas/dimos-capability/templates/tasks.md b/openspec/schemas/dimos-capability/templates/tasks.md deleted file mode 100644 index b38fcdfabb..0000000000 --- a/openspec/schemas/dimos-capability/templates/tasks.md +++ /dev/null @@ -1,15 +0,0 @@ -## 1. Implementation - -- [ ] 1.1 -- [ ] 1.2 - -## 2. Documentation - -- [ ] 2.1 - -## 3. Verification - -- [ ] 3.1 Run `openspec validate ` -- [ ] 3.2 Run focused tests for changed code -- [ ] 3.3 Run docs validation commands for changed docs -- [ ] 3.4 Manually QA through the relevant DimOS surface (CLI, MCP, simulation/replay, hardware procedure, HTTP API, or library driver) From 6f9c73c09cd272c858117f855c915e350ad50367 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 17:30:16 -0700 Subject: [PATCH 05/12] fix: refine external blueprint errors --- dimos/robot/external_blueprints.py | 62 ++++++++++++++++++++++--- dimos/robot/get_all_blueprints.py | 5 +- dimos/robot/test_external_blueprints.py | 39 +++++++++++++++- 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/dimos/robot/external_blueprints.py b/dimos/robot/external_blueprints.py index 58618925ea..f61f1fafde 100644 --- a/dimos/robot/external_blueprints.py +++ b/dimos/robot/external_blueprints.py @@ -42,6 +42,15 @@ def __init__(self, local_name: str, distribution_name: str) -> None: ) +class InvalidExternalBlueprintRequestNameError(ExternalBlueprintError): + def __init__(self, local_name: str) -> None: + super().__init__( + f"Invalid external blueprint local name {local_name!r}. " + "External local blueprint names must be lowercase kebab-case " + "and match ^[a-z0-9]+(-[a-z0-9]+)*$." + ) + + class AmbiguousExternalBlueprintNamespaceError(ExternalBlueprintError): def __init__(self, namespace: str, distribution_names: Iterable[str]) -> None: names = sorted(set(distribution_names)) @@ -111,6 +120,20 @@ def target(self) -> str: return str(getattr(self.entry_point, "value", "")) +@dataclass(frozen=True) +class InvalidExternalBlueprintEntry: + namespace: str + local_name: str + distribution_name: str + + +@dataclass(frozen=True) +class _ExternalBlueprintCollection: + entries_by_namespace: dict[str, list[ExternalBlueprintEntry]] + invalid_entries_by_namespace: dict[str, list[InvalidExternalBlueprintEntry]] + ambiguous_distribution_names_by_namespace: dict[str, set[str]] + + def canonicalize_distribution_namespace(distribution_name: str) -> str: """Normalize a Python distribution name for use as an external blueprint namespace.""" @@ -138,7 +161,7 @@ def list_external_blueprint_names() -> list[str]: def list_external_blueprints() -> list[ExternalBlueprintEntry]: """List external blueprint entry point metadata without loading targets.""" - namespace_entries = _collect_external_blueprints() + namespace_entries = _collect_external_blueprints().entries_by_namespace return sorted( (entry for entries in namespace_entries.values() for entry in entries), key=lambda entry: entry.qualified_name, @@ -152,10 +175,21 @@ def resolve_external_blueprint_by_name(name: str) -> Blueprint: if not sep: raise ExternalBlueprintNamespaceNotFoundError(name, []) if not is_valid_external_local_blueprint_name(local_name): - raise InvalidExternalBlueprintNameError(local_name, namespace) + raise InvalidExternalBlueprintRequestNameError(local_name) - namespace_entries = _collect_external_blueprints() + collection = _collect_external_blueprints() + namespace_entries = collection.entries_by_namespace + if namespace in collection.ambiguous_distribution_names_by_namespace: + raise AmbiguousExternalBlueprintNamespaceError( + namespace, collection.ambiguous_distribution_names_by_namespace[namespace] + ) if namespace not in namespace_entries: + invalid_entries = collection.invalid_entries_by_namespace.get(namespace) + if invalid_entries: + invalid_entry = invalid_entries[0] + raise InvalidExternalBlueprintNameError( + invalid_entry.local_name, invalid_entry.distribution_name + ) raise ExternalBlueprintNamespaceNotFoundError(namespace, namespace_entries.keys()) entries = namespace_entries[namespace] @@ -184,8 +218,9 @@ def _target_to_blueprint(name: str, target: Any) -> Blueprint: raise InvalidExternalBlueprintTargetError(name, target) -def _collect_external_blueprints() -> dict[str, list[ExternalBlueprintEntry]]: +def _collect_external_blueprints() -> _ExternalBlueprintCollection: entries_by_namespace: dict[str, list[ExternalBlueprintEntry]] = {} + invalid_entries_by_namespace: dict[str, list[InvalidExternalBlueprintEntry]] = {} distribution_names_by_namespace: dict[str, set[str]] = {} for distribution in importlib_metadata.distributions(): @@ -206,7 +241,14 @@ def _collect_external_blueprints() -> dict[str, list[ExternalBlueprintEntry]]: for entry_point in external_entry_points: local_name = str(getattr(entry_point, "name", "")) if not is_valid_external_local_blueprint_name(local_name): - raise InvalidExternalBlueprintNameError(local_name, distribution_name) + invalid_entries_by_namespace.setdefault(namespace, []).append( + InvalidExternalBlueprintEntry( + namespace=namespace, + local_name=local_name, + distribution_name=distribution_name, + ) + ) + continue entries_by_namespace.setdefault(namespace, []).append( ExternalBlueprintEntry( namespace=namespace, @@ -216,11 +258,17 @@ def _collect_external_blueprints() -> dict[str, list[ExternalBlueprintEntry]]: ) ) + ambiguous_distribution_names_by_namespace: dict[str, set[str]] = {} for namespace, distribution_names in distribution_names_by_namespace.items(): if len(distribution_names) > 1 and namespace in entries_by_namespace: - raise AmbiguousExternalBlueprintNamespaceError(namespace, distribution_names) + ambiguous_distribution_names_by_namespace[namespace] = distribution_names + entries_by_namespace.pop(namespace, None) - return entries_by_namespace + return _ExternalBlueprintCollection( + entries_by_namespace=entries_by_namespace, + invalid_entries_by_namespace=invalid_entries_by_namespace, + ambiguous_distribution_names_by_namespace=ambiguous_distribution_names_by_namespace, + ) def _distribution_name(distribution: Any) -> str | None: diff --git a/dimos/robot/get_all_blueprints.py b/dimos/robot/get_all_blueprints.py index 273fe17a4b..3e816c1413 100644 --- a/dimos/robot/get_all_blueprints.py +++ b/dimos/robot/get_all_blueprints.py @@ -14,7 +14,6 @@ import difflib import re -import sys from typing import NoReturn import typer @@ -79,12 +78,12 @@ def _fail_or_exit(name: str, candidates: list[str]) -> NoReturn: typer.echo("Did you mean one of these?", err=True) for s in suggestions: typer.echo(f" {s}", err=True) - sys.exit(1) + raise typer.Exit(1) def _exit_with_error(message: str) -> NoReturn: typer.echo(typer.style(message, fg=typer.colors.RED), err=True) - sys.exit(1) + raise typer.Exit(1) def get_by_name_or_exit(name: str) -> Blueprint: diff --git a/dimos/robot/test_external_blueprints.py b/dimos/robot/test_external_blueprints.py index 5f161dd591..fce7adfc04 100644 --- a/dimos/robot/test_external_blueprints.py +++ b/dimos/robot/test_external_blueprints.py @@ -189,8 +189,45 @@ def test_invalid_external_metadata_name(monkeypatch: pytest.MonkeyPatch) -> None FakeDistribution("My-Test-Stack", (FakeEntryPoint("Go2", "external_stack.demo:go2"),)), ) + assert external.list_external_blueprint_names() == [] with pytest.raises(external.InvalidExternalBlueprintNameError): - external.list_external_blueprints() + external.resolve_external_blueprint_by_name("my-test-stack.demo") + + +def test_invalid_requested_external_local_name(monkeypatch: pytest.MonkeyPatch) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + (FakeEntryPoint("demo", "external_stack.demo:ExternalTestModule", ExternalTestModule),), + ), + ) + + with pytest.raises(external.InvalidExternalBlueprintRequestNameError) as exc_info: + external.resolve_external_blueprint_by_name("my-test-stack.Go2") + + message = str(exc_info.value) + assert "Invalid external blueprint local name 'Go2'" in message + assert "entry point name" not in message + assert "distribution" not in message + + +def test_invalid_external_metadata_does_not_block_unrelated_valid_package( + monkeypatch: pytest.MonkeyPatch, +) -> None: + patch_distributions( + monkeypatch, + FakeDistribution("Broken-Stack", (FakeEntryPoint("BadName", "broken_stack.demo:demo"),)), + FakeDistribution( + "My-Test-Stack", + (FakeEntryPoint("demo", "external_stack.demo:ExternalTestModule", ExternalTestModule),), + ), + ) + + assert external.list_external_blueprint_names() == ["my-test-stack.demo"] + blueprint = external.resolve_external_blueprint_by_name("my-test-stack.demo") + + assert blueprint.blueprints[0].module is ExternalTestModule def test_ambiguous_canonical_namespace(monkeypatch: pytest.MonkeyPatch) -> None: From 84117a4c41eaa01bec799feb28ea1da632a6834c Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 17:56:38 -0700 Subject: [PATCH 06/12] fix: ignore invalid entries in namespace ambiguity --- dimos/robot/external_blueprints.py | 8 +++++--- dimos/robot/test_external_blueprints.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/dimos/robot/external_blueprints.py b/dimos/robot/external_blueprints.py index f61f1fafde..26ba332707 100644 --- a/dimos/robot/external_blueprints.py +++ b/dimos/robot/external_blueprints.py @@ -221,7 +221,7 @@ def _target_to_blueprint(name: str, target: Any) -> Blueprint: def _collect_external_blueprints() -> _ExternalBlueprintCollection: entries_by_namespace: dict[str, list[ExternalBlueprintEntry]] = {} invalid_entries_by_namespace: dict[str, list[InvalidExternalBlueprintEntry]] = {} - distribution_names_by_namespace: dict[str, set[str]] = {} + valid_distribution_names_by_namespace: dict[str, set[str]] = {} for distribution in importlib_metadata.distributions(): distribution_name = _distribution_name(distribution) @@ -237,7 +237,6 @@ def _collect_external_blueprints() -> _ExternalBlueprintCollection: if not external_entry_points: continue - distribution_names_by_namespace.setdefault(namespace, set()).add(distribution_name) for entry_point in external_entry_points: local_name = str(getattr(entry_point, "name", "")) if not is_valid_external_local_blueprint_name(local_name): @@ -249,6 +248,9 @@ def _collect_external_blueprints() -> _ExternalBlueprintCollection: ) ) continue + valid_distribution_names_by_namespace.setdefault(namespace, set()).add( + distribution_name + ) entries_by_namespace.setdefault(namespace, []).append( ExternalBlueprintEntry( namespace=namespace, @@ -259,7 +261,7 @@ def _collect_external_blueprints() -> _ExternalBlueprintCollection: ) ambiguous_distribution_names_by_namespace: dict[str, set[str]] = {} - for namespace, distribution_names in distribution_names_by_namespace.items(): + for namespace, distribution_names in valid_distribution_names_by_namespace.items(): if len(distribution_names) > 1 and namespace in entries_by_namespace: ambiguous_distribution_names_by_namespace[namespace] = distribution_names entries_by_namespace.pop(namespace, None) diff --git a/dimos/robot/test_external_blueprints.py b/dimos/robot/test_external_blueprints.py index fce7adfc04..4981abf6a8 100644 --- a/dimos/robot/test_external_blueprints.py +++ b/dimos/robot/test_external_blueprints.py @@ -230,6 +230,24 @@ def test_invalid_external_metadata_does_not_block_unrelated_valid_package( assert blueprint.blueprints[0].module is ExternalTestModule +def test_all_invalid_distribution_does_not_create_namespace_ambiguity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + patch_distributions( + monkeypatch, + FakeDistribution("my_robot.stack", (FakeEntryPoint("Go2", "invalid_stack.demo:go2"),)), + FakeDistribution( + "My-Test-Stack", + (FakeEntryPoint("demo", "external_stack.demo:ExternalTestModule", ExternalTestModule),), + ), + ) + + assert external.list_external_blueprint_names() == ["my-test-stack.demo"] + blueprint = external.resolve_external_blueprint_by_name("my-test-stack.demo") + + assert blueprint.blueprints[0].module is ExternalTestModule + + def test_ambiguous_canonical_namespace(monkeypatch: pytest.MonkeyPatch) -> None: patch_distributions( monkeypatch, From 4d63d731f9831264dced4da832ad8e5e56db17a1 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 18:09:13 -0700 Subject: [PATCH 07/12] fix: simplify external entry point discovery --- dimos/robot/external_blueprints.py | 77 ++++++------------------- dimos/robot/test_external_blueprints.py | 30 +++++++--- pyproject.toml | 1 + uv.lock | 2 + 4 files changed, 43 insertions(+), 67 deletions(-) diff --git a/dimos/robot/external_blueprints.py b/dimos/robot/external_blueprints.py index 26ba332707..64583bddb4 100644 --- a/dimos/robot/external_blueprints.py +++ b/dimos/robot/external_blueprints.py @@ -20,12 +20,13 @@ import re from typing import Any +from packaging.utils import canonicalize_name + from dimos.core.coordination.blueprints import Blueprint from dimos.core.module import is_module_type ENTRY_POINT_GROUP = "dimos.blueprints" LOCAL_BLUEPRINT_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") -_DISTRIBUTION_SEPARATOR_PATTERN = re.compile(r"[-_.]+") class ExternalBlueprintError(ValueError): @@ -51,22 +52,6 @@ def __init__(self, local_name: str) -> None: ) -class AmbiguousExternalBlueprintNamespaceError(ExternalBlueprintError): - def __init__(self, namespace: str, distribution_names: Iterable[str]) -> None: - names = sorted(set(distribution_names)) - super().__init__( - f"Ambiguous external blueprint namespace {namespace!r}; " - f"multiple installed distributions normalize to it: {', '.join(names)}." - ) - - -class AmbiguousExternalBlueprintNameError(ExternalBlueprintError): - def __init__(self, name: str) -> None: - super().__init__( - f"Ambiguous external blueprint name {name!r}; multiple entry points match it." - ) - - class ExternalBlueprintNamespaceNotFoundError(ExternalBlueprintError): def __init__(self, namespace: str, available_namespaces: Iterable[str]) -> None: msg = f"External blueprint namespace {namespace!r} was not discovered." @@ -131,13 +116,12 @@ class InvalidExternalBlueprintEntry: class _ExternalBlueprintCollection: entries_by_namespace: dict[str, list[ExternalBlueprintEntry]] invalid_entries_by_namespace: dict[str, list[InvalidExternalBlueprintEntry]] - ambiguous_distribution_names_by_namespace: dict[str, set[str]] def canonicalize_distribution_namespace(distribution_name: str) -> str: """Normalize a Python distribution name for use as an external blueprint namespace.""" - return _DISTRIBUTION_SEPARATOR_PATTERN.sub("-", distribution_name).lower() + return str(canonicalize_name(distribution_name)) def is_valid_external_local_blueprint_name(name: str) -> bool: @@ -179,10 +163,6 @@ def resolve_external_blueprint_by_name(name: str) -> Blueprint: collection = _collect_external_blueprints() namespace_entries = collection.entries_by_namespace - if namespace in collection.ambiguous_distribution_names_by_namespace: - raise AmbiguousExternalBlueprintNamespaceError( - namespace, collection.ambiguous_distribution_names_by_namespace[namespace] - ) if namespace not in namespace_entries: invalid_entries = collection.invalid_entries_by_namespace.get(namespace) if invalid_entries: @@ -198,8 +178,6 @@ def resolve_external_blueprint_by_name(name: str) -> Blueprint: raise ExternalBlueprintLocalNameNotFoundError( namespace, local_name, (entry.local_name for entry in entries) ) - if len(matches) > 1: - raise AmbiguousExternalBlueprintNameError(name) entry = matches[0] try: @@ -221,55 +199,36 @@ def _target_to_blueprint(name: str, target: Any) -> Blueprint: def _collect_external_blueprints() -> _ExternalBlueprintCollection: entries_by_namespace: dict[str, list[ExternalBlueprintEntry]] = {} invalid_entries_by_namespace: dict[str, list[InvalidExternalBlueprintEntry]] = {} - valid_distribution_names_by_namespace: dict[str, set[str]] = {} - for distribution in importlib_metadata.distributions(): + for entry_point in importlib_metadata.entry_points(group=ENTRY_POINT_GROUP): + distribution = getattr(entry_point, "dist", None) distribution_name = _distribution_name(distribution) if distribution_name is None: continue namespace = canonicalize_distribution_namespace(distribution_name) - external_entry_points = [ - entry_point - for entry_point in getattr(distribution, "entry_points", ()) - if getattr(entry_point, "group", None) == ENTRY_POINT_GROUP - ] - if not external_entry_points: - continue - - for entry_point in external_entry_points: - local_name = str(getattr(entry_point, "name", "")) - if not is_valid_external_local_blueprint_name(local_name): - invalid_entries_by_namespace.setdefault(namespace, []).append( - InvalidExternalBlueprintEntry( - namespace=namespace, - local_name=local_name, - distribution_name=distribution_name, - ) - ) - continue - valid_distribution_names_by_namespace.setdefault(namespace, set()).add( - distribution_name - ) - entries_by_namespace.setdefault(namespace, []).append( - ExternalBlueprintEntry( + local_name = str(getattr(entry_point, "name", "")) + if not is_valid_external_local_blueprint_name(local_name): + invalid_entries_by_namespace.setdefault(namespace, []).append( + InvalidExternalBlueprintEntry( namespace=namespace, local_name=local_name, distribution_name=distribution_name, - entry_point=entry_point, ) ) - - ambiguous_distribution_names_by_namespace: dict[str, set[str]] = {} - for namespace, distribution_names in valid_distribution_names_by_namespace.items(): - if len(distribution_names) > 1 and namespace in entries_by_namespace: - ambiguous_distribution_names_by_namespace[namespace] = distribution_names - entries_by_namespace.pop(namespace, None) + continue + entries_by_namespace.setdefault(namespace, []).append( + ExternalBlueprintEntry( + namespace=namespace, + local_name=local_name, + distribution_name=distribution_name, + entry_point=entry_point, + ) + ) return _ExternalBlueprintCollection( entries_by_namespace=entries_by_namespace, invalid_entries_by_namespace=invalid_entries_by_namespace, - ambiguous_distribution_names_by_namespace=ambiguous_distribution_names_by_namespace, ) diff --git a/dimos/robot/test_external_blueprints.py b/dimos/robot/test_external_blueprints.py index 4981abf6a8..e430adb3e2 100644 --- a/dimos/robot/test_external_blueprints.py +++ b/dimos/robot/test_external_blueprints.py @@ -14,7 +14,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any import pytest @@ -36,6 +36,7 @@ class FakeEntryPoint: target: Any = None error: Exception | None = None group: str = external.ENTRY_POINT_GROUP + dist: Any = None def load(self) -> Any: if self.error is not None: @@ -54,10 +55,20 @@ def metadata(self) -> dict[str, str]: def patch_distributions(monkeypatch: pytest.MonkeyPatch, *distributions: FakeDistribution) -> None: + entry_points = [ + replace(entry_point, dist=distribution) + for distribution in distributions + for entry_point in distribution.entry_points + ] + monkeypatch.setattr( external.importlib_metadata, - "distributions", - lambda: list(distributions), + "entry_points", + lambda *, group=None: [ + entry_point + for entry_point in entry_points + if group is None or entry_point.group == group + ], ) @@ -230,7 +241,7 @@ def test_invalid_external_metadata_does_not_block_unrelated_valid_package( assert blueprint.blueprints[0].module is ExternalTestModule -def test_all_invalid_distribution_does_not_create_namespace_ambiguity( +def test_all_invalid_colliding_distribution_does_not_block_valid_package( monkeypatch: pytest.MonkeyPatch, ) -> None: patch_distributions( @@ -248,7 +259,9 @@ def test_all_invalid_distribution_does_not_create_namespace_ambiguity( assert blueprint.blueprints[0].module is ExternalTestModule -def test_ambiguous_canonical_namespace(monkeypatch: pytest.MonkeyPatch) -> None: +def test_colliding_external_namespace_uses_matching_valid_entry( + monkeypatch: pytest.MonkeyPatch, +) -> None: patch_distributions( monkeypatch, FakeDistribution( @@ -259,14 +272,15 @@ def test_ambiguous_canonical_namespace(monkeypatch: pytest.MonkeyPatch) -> None: ), ) - with pytest.raises(external.AmbiguousExternalBlueprintNamespaceError): - external.resolve_external_blueprint_by_name("my-test-stack.demo") + blueprint = external.resolve_external_blueprint_by_name("my-test-stack.demo") + + assert isinstance(blueprint, Blueprint) def test_bare_names_never_search_external_entry_points(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( external.importlib_metadata, - "distributions", + "entry_points", lambda: (_ for _ in ()).throw(AssertionError("bare lookup searched external metadata")), ) diff --git a/pyproject.toml b/pyproject.toml index 55e7ffcb73..18fae8ed25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ dependencies = [ "sortedcontainers==2.4.0", "pydantic", "python-dotenv", + "packaging>=24.0", "typing_extensions>=4.0; python_version < '3.11'", "annotation-protocol>=1.4.0", "lazy_loader", diff --git a/uv.lock b/uv.lock index 0736ec2961..91c674145d 100644 --- a/uv.lock +++ b/uv.lock @@ -1867,6 +1867,7 @@ dependencies = [ { name = "open3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, { name = "open3d-unofficial-arm", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, { name = "opencv-python" }, + { name = "packaging" }, { name = "pin" }, { name = "plotext" }, { name = "plum-dispatch" }, @@ -2402,6 +2403,7 @@ requires-dist = [ { name = "openai", marker = "extra == 'agents'" }, { name = "opencv-contrib-python", marker = "extra == 'apriltag'", specifier = "==4.10.0.84" }, { name = "opencv-python" }, + { name = "packaging", specifier = ">=24.0" }, { name = "pillow", marker = "extra == 'perception'" }, { name = "pin", specifier = ">=3.3.0" }, { name = "pin-pink", marker = "extra == 'manipulation'", specifier = ">=4.2.0" }, From 2ac31b14bbd373a9331e2c65458d10d21bd7270a Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 18:14:32 -0700 Subject: [PATCH 08/12] fix: type external entry point metadata --- dimos/robot/external_blueprints.py | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/dimos/robot/external_blueprints.py b/dimos/robot/external_blueprints.py index 64583bddb4..6d26841d5f 100644 --- a/dimos/robot/external_blueprints.py +++ b/dimos/robot/external_blueprints.py @@ -18,7 +18,7 @@ from dataclasses import dataclass import importlib.metadata as importlib_metadata import re -from typing import Any +from typing import Any, cast from packaging.utils import canonicalize_name @@ -94,7 +94,7 @@ class ExternalBlueprintEntry: namespace: str local_name: str distribution_name: str - entry_point: Any + entry_point: importlib_metadata.EntryPoint @property def qualified_name(self) -> str: @@ -102,7 +102,7 @@ def qualified_name(self) -> str: @property def target(self) -> str: - return str(getattr(self.entry_point, "value", "")) + return self.entry_point.value @dataclass(frozen=True) @@ -201,13 +201,10 @@ def _collect_external_blueprints() -> _ExternalBlueprintCollection: invalid_entries_by_namespace: dict[str, list[InvalidExternalBlueprintEntry]] = {} for entry_point in importlib_metadata.entry_points(group=ENTRY_POINT_GROUP): - distribution = getattr(entry_point, "dist", None) - distribution_name = _distribution_name(distribution) - if distribution_name is None: - continue - + distribution = cast("importlib_metadata.Distribution", entry_point.dist) + distribution_name = distribution.metadata["Name"] namespace = canonicalize_distribution_namespace(distribution_name) - local_name = str(getattr(entry_point, "name", "")) + local_name = entry_point.name if not is_valid_external_local_blueprint_name(local_name): invalid_entries_by_namespace.setdefault(namespace, []).append( InvalidExternalBlueprintEntry( @@ -230,15 +227,3 @@ def _collect_external_blueprints() -> _ExternalBlueprintCollection: entries_by_namespace=entries_by_namespace, invalid_entries_by_namespace=invalid_entries_by_namespace, ) - - -def _distribution_name(distribution: Any) -> str | None: - metadata = getattr(distribution, "metadata", None) - if metadata is not None: - name = metadata.get("Name") - if name: - return str(name) - name = getattr(distribution, "name", None) - if name: - return str(name) - return None From 7d1645b651abd8023cb61807907296b7e4a6eaef Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 18:26:51 -0700 Subject: [PATCH 09/12] fix: guard missing entry point distribution --- dimos/robot/external_blueprints.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dimos/robot/external_blueprints.py b/dimos/robot/external_blueprints.py index 6d26841d5f..567a2642fa 100644 --- a/dimos/robot/external_blueprints.py +++ b/dimos/robot/external_blueprints.py @@ -18,7 +18,7 @@ from dataclasses import dataclass import importlib.metadata as importlib_metadata import re -from typing import Any, cast +from typing import Any from packaging.utils import canonicalize_name @@ -201,7 +201,9 @@ def _collect_external_blueprints() -> _ExternalBlueprintCollection: invalid_entries_by_namespace: dict[str, list[InvalidExternalBlueprintEntry]] = {} for entry_point in importlib_metadata.entry_points(group=ENTRY_POINT_GROUP): - distribution = cast("importlib_metadata.Distribution", entry_point.dist) + distribution = entry_point.dist + if distribution is None: + continue distribution_name = distribution.metadata["Name"] namespace = canonicalize_distribution_namespace(distribution_name) local_name = entry_point.name From eec3e9dceb2a8af0e2fe4543a27d5a5796bf0919 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 18:31:17 -0700 Subject: [PATCH 10/12] test: cover external blueprint error paths --- dimos/robot/cli/test_dimos.py | 7 ++++ dimos/robot/test_external_blueprints.py | 44 +++++++++++++++++++++++++ dimos/robot/test_get_all_blueprints.py | 12 ++++++- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/dimos/robot/cli/test_dimos.py b/dimos/robot/cli/test_dimos.py index f008b36538..4f2c949bda 100644 --- a/dimos/robot/cli/test_dimos.py +++ b/dimos/robot/cli/test_dimos.py @@ -208,3 +208,10 @@ def raise_error(name: str): assert result.exit_code == 1 assert "Failed to load external blueprint 'my-test-stack.demo'" in result.output assert "my_test_stack.missing:demo_blueprint" in result.output + + +def test_run_reports_unknown_bare_blueprint() -> None: + result = CliRunner().invoke(main, ["run", "missing-bare-blueprint"]) + + assert result.exit_code == 1 + assert "Unknown blueprint or module: missing-bare-blueprint" in result.output diff --git a/dimos/robot/test_external_blueprints.py b/dimos/robot/test_external_blueprints.py index e430adb3e2..2500a55c0a 100644 --- a/dimos/robot/test_external_blueprints.py +++ b/dimos/robot/test_external_blueprints.py @@ -160,6 +160,32 @@ def test_unknown_external_namespace(monkeypatch: pytest.MonkeyPatch) -> None: external.resolve_external_blueprint_by_name("missing-stack.demo") +def test_unknown_external_namespace_lists_available_namespaces( + monkeypatch: pytest.MonkeyPatch, +) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + (FakeEntryPoint("demo", "external_stack.demo:ExternalTestModule", ExternalTestModule),), + ), + ) + + with pytest.raises(external.ExternalBlueprintNamespaceNotFoundError) as exc_info: + external.resolve_external_blueprint_by_name("missing-stack.demo") + + assert "Available external namespaces: my-test-stack" in str(exc_info.value) + + +def test_resolve_external_name_requires_namespace_separator( + monkeypatch: pytest.MonkeyPatch, +) -> None: + patch_distributions(monkeypatch) + + with pytest.raises(external.ExternalBlueprintNamespaceNotFoundError): + external.resolve_external_blueprint_by_name("my-test-stack") + + def test_namespace_exists_but_local_name_missing(monkeypatch: pytest.MonkeyPatch) -> None: patch_distributions( monkeypatch, @@ -177,6 +203,12 @@ def test_namespace_exists_but_local_name_missing(monkeypatch: pytest.MonkeyPatch external.resolve_external_blueprint_by_name("my-test-stack.arm") +def test_local_name_missing_without_available_names() -> None: + error = external.ExternalBlueprintLocalNameNotFoundError("my-test-stack", "demo", []) + + assert "Available local blueprints" not in str(error) + + def test_entry_point_load_failure(monkeypatch: pytest.MonkeyPatch) -> None: patch_distributions( monkeypatch, @@ -241,6 +273,18 @@ def test_invalid_external_metadata_does_not_block_unrelated_valid_package( assert blueprint.blueprints[0].module is ExternalTestModule +def test_entry_points_without_distribution_are_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + external.importlib_metadata, + "entry_points", + lambda *, group=None: [ + FakeEntryPoint("demo", "external_stack.demo:ExternalTestModule", ExternalTestModule) + ], + ) + + assert external.list_external_blueprint_names() == [] + + def test_all_invalid_colliding_distribution_does_not_block_valid_package( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/dimos/robot/test_get_all_blueprints.py b/dimos/robot/test_get_all_blueprints.py index 4982c2ac64..2b06bdc9a1 100644 --- a/dimos/robot/test_get_all_blueprints.py +++ b/dimos/robot/test_get_all_blueprints.py @@ -16,7 +16,7 @@ import pytest -from dimos.robot.get_all_blueprints import get_by_name +from dimos.robot.get_all_blueprints import get_by_name, get_by_name_or_exit def test_resolve_string_blueprint(): @@ -29,6 +29,16 @@ def test_resolve_string_module(): assert bp is not None +def test_resolve_string_blueprint_or_exit(): + bp = get_by_name_or_exit("demo-mcp-stress-test") + assert bp is not None + + +def test_resolve_string_module_or_exit(): + bp = get_by_name_or_exit("camera-module") + assert bp is not None + + def test_resolve_unknown_name(): with pytest.raises(ValueError, match="Unknown blueprint or module"): get_by_name("nonexistent-blueprint-xyz") From ec2e3b559babda569ff1e67641a76454f1dff2b0 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 18:46:28 -0700 Subject: [PATCH 11/12] ci: allow small project coverage drift --- .codecov.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.codecov.yml b/.codecov.yml index 84caca8b2d..2f7e97ea14 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -5,7 +5,11 @@ codecov: coverage: status: - project: true + project: + default: + # Project coverage includes indirect and carried-forward coverage. Allow + # small global drift while keeping patch coverage strict below. + threshold: 1% patch: true # Carry forward self-hosted, as these are not run on external PRs. From 7171de858854778d095142f50540287be6fbbedb Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 16 Jun 2026 19:42:49 -0700 Subject: [PATCH 12/12] fix: address external blueprint review comments --- .codecov.yml | 6 +- .../coordination/test_module_coordinator.py | 18 +-- dimos/core/module.py | 3 +- dimos/robot/cli/test_dimos.py | 8 +- dimos/robot/external_blueprints.py | 118 +++++++++--------- dimos/robot/test_external_blueprints.py | 42 +++++-- docs/usage/blueprints.md | 4 - 7 files changed, 106 insertions(+), 93 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 2f7e97ea14..84caca8b2d 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -5,11 +5,7 @@ codecov: coverage: status: - project: - default: - # Project coverage includes indirect and carried-forward coverage. Allow - # small global drift while keeping patch coverage strict below. - threshold: 1% + project: true patch: true # Carry forward self-hosted, as these are not run on external PRs. diff --git a/dimos/core/coordination/test_module_coordinator.py b/dimos/core/coordination/test_module_coordinator.py index 3168c9189c..8a14d5448c 100644 --- a/dimos/core/coordination/test_module_coordinator.py +++ b/dimos/core/coordination/test_module_coordinator.py @@ -13,7 +13,7 @@ # limitations under the License. from types import MappingProxyType -from typing import Any, Protocol +from typing import Protocol import pytest @@ -39,6 +39,7 @@ from dimos.core.module import Module from dimos.core.stream import In, Out from dimos.msgs.sensor_msgs.Image import Image +import dimos.robot.get_all_blueprints as resolver from dimos.spec.utils import Spec # Disable Rerun for tests (prevents viewer spawn and gRPC flush errors) @@ -796,20 +797,19 @@ def test_list_module_names(dynamic_coordinator) -> None: assert set(dynamic_coordinator.list_module_names()) == {"ModuleA", "ModuleC"} -def test_load_blueprint_by_name_uses_shared_resolver(monkeypatch: pytest.MonkeyPatch) -> None: - import dimos.robot.get_all_blueprints as resolver - +def test_load_blueprint_by_name_uses_shared_resolver( + monkeypatch: pytest.MonkeyPatch, mocker +) -> None: expected_blueprint = ExternalNameLoadModule.blueprint() - loaded_blueprints: list[Any] = [] - def fake_get_by_name(name: str): # type: ignore[no-untyped-def] + def fake_get_by_name(name: str): assert name == "my-test-stack.demo" return expected_blueprint - coordinator = object.__new__(ModuleCoordinator) - coordinator.load_blueprint = loaded_blueprints.append # type: ignore[method-assign] + coordinator = ModuleCoordinator() + load_blueprint = mocker.patch.object(ModuleCoordinator, "load_blueprint") monkeypatch.setattr(resolver, "get_by_name", fake_get_by_name) coordinator.load_blueprint_by_name("my-test-stack.demo") - assert loaded_blueprints == [expected_blueprint] + load_blueprint.assert_called_once_with(expected_blueprint) diff --git a/dimos/core/module.py b/dimos/core/module.py index 26a2b6f893..09d71f24a3 100644 --- a/dimos/core/module.py +++ b/dimos/core/module.py @@ -25,6 +25,7 @@ ClassVar, Literal, Protocol, + TypeGuard, get_args, get_origin, get_type_hints, @@ -813,7 +814,7 @@ def connect_stream(self, input_name: str, remote_stream: RemoteOut[T]): # type: ModuleSpec = tuple[type[ModuleBase], GlobalConfig, dict[str, Any]] -def is_module_type(value: Any) -> bool: +def is_module_type(value: object) -> TypeGuard[type[Module]]: try: return inspect.isclass(value) and issubclass(value, Module) except Exception: diff --git a/dimos/robot/cli/test_dimos.py b/dimos/robot/cli/test_dimos.py index 4f2c949bda..c6abf16a9a 100644 --- a/dimos/robot/cli/test_dimos.py +++ b/dimos/robot/cli/test_dimos.py @@ -192,10 +192,10 @@ def raise_error() -> list[str]: def test_run_reports_external_resolution_errors(monkeypatch: pytest.MonkeyPatch) -> None: def raise_error(name: str): - raise external.ExternalBlueprintLoadError( - name, - "my_test_stack.missing:demo_blueprint", - ModuleNotFoundError("No module named 'my_test_stack.missing'"), + raise external.ExternalBlueprintError( + "Failed to load external blueprint " + f"{name!r} from entry point 'my_test_stack.missing:demo_blueprint': " + "ModuleNotFoundError: No module named 'my_test_stack.missing'" ) monkeypatch.setattr( diff --git a/dimos/robot/external_blueprints.py b/dimos/robot/external_blueprints.py index 567a2642fa..ddb94825d9 100644 --- a/dimos/robot/external_blueprints.py +++ b/dimos/robot/external_blueprints.py @@ -18,7 +18,6 @@ from dataclasses import dataclass import importlib.metadata as importlib_metadata import re -from typing import Any from packaging.utils import canonicalize_name @@ -33,60 +32,59 @@ class ExternalBlueprintError(ValueError): """Base class for external blueprint discovery and resolution errors.""" -class InvalidExternalBlueprintNameError(ExternalBlueprintError): - def __init__(self, local_name: str, distribution_name: str) -> None: - super().__init__( - "Invalid external blueprint entry point name " - f"{local_name!r} in distribution {distribution_name!r}. " - "External local blueprint names must be lowercase kebab-case " - "and match ^[a-z0-9]+(-[a-z0-9]+)*$." - ) - - -class InvalidExternalBlueprintRequestNameError(ExternalBlueprintError): - def __init__(self, local_name: str) -> None: - super().__init__( - f"Invalid external blueprint local name {local_name!r}. " - "External local blueprint names must be lowercase kebab-case " - "and match ^[a-z0-9]+(-[a-z0-9]+)*$." - ) - - -class ExternalBlueprintNamespaceNotFoundError(ExternalBlueprintError): - def __init__(self, namespace: str, available_namespaces: Iterable[str]) -> None: - msg = f"External blueprint namespace {namespace!r} was not discovered." - available = sorted(set(available_namespaces)) - if available: - msg += f" Available external namespaces: {', '.join(available)}." - super().__init__(msg) +def _invalid_external_blueprint_name_error( + local_name: str, distribution_name: str +) -> ExternalBlueprintError: + return ExternalBlueprintError( + "Invalid external blueprint entry point name " + f"{local_name!r} in distribution {distribution_name!r}. " + "External local blueprint names must be lowercase kebab-case " + "and match ^[a-z0-9]+(-[a-z0-9]+)*$." + ) -class ExternalBlueprintLocalNameNotFoundError(ExternalBlueprintError): - def __init__( - self, namespace: str, local_name: str, available_local_names: Iterable[str] - ) -> None: - msg = f"External blueprint namespace {namespace!r} has no local blueprint {local_name!r}." - available = sorted(set(available_local_names)) - if available: - msg += f" Available local blueprints: {', '.join(available)}." - super().__init__(msg) +def _invalid_external_blueprint_request_name_error(local_name: str) -> ExternalBlueprintError: + return ExternalBlueprintError( + f"Invalid external blueprint local name {local_name!r}. " + "External local blueprint names must be lowercase kebab-case " + "and match ^[a-z0-9]+(-[a-z0-9]+)*$." + ) -class ExternalBlueprintLoadError(ExternalBlueprintError): - def __init__(self, name: str, target: str, cause: Exception) -> None: - super().__init__( - f"Failed to load external blueprint {name!r} from entry point {target!r}: " - f"{type(cause).__name__}: {cause}" - ) +def _external_blueprint_namespace_not_found_error( + namespace: str, available_namespaces: Iterable[str] +) -> ExternalBlueprintError: + msg = f"External blueprint namespace {namespace!r} was not discovered." + available = sorted(set(available_namespaces)) + if available: + msg += f" Available external namespaces: {', '.join(available)}." + return ExternalBlueprintError(msg) + + +def _external_blueprint_local_name_not_found_error( + namespace: str, local_name: str, available_local_names: Iterable[str] +) -> ExternalBlueprintError: + msg = f"External blueprint namespace {namespace!r} has no local blueprint {local_name!r}." + available = sorted(set(available_local_names)) + if available: + msg += f" Available local blueprints: {', '.join(available)}." + return ExternalBlueprintError(msg) + + +def _external_blueprint_load_error( + name: str, target: str, cause: Exception +) -> ExternalBlueprintError: + return ExternalBlueprintError( + f"Failed to load external blueprint {name!r} from entry point {target!r}: " + f"{type(cause).__name__}: {cause}" + ) -class InvalidExternalBlueprintTargetError(ExternalBlueprintError): - def __init__(self, name: str, target: Any) -> None: - super().__init__( - f"External blueprint {name!r} loaded unsupported target {target!r}. " - "Entry point targets must be a Blueprint object or a DimOS Module class. " - "Factory functions are not supported." - ) +def _invalid_external_blueprint_target_error(name: str, target: object) -> ExternalBlueprintError: + return ExternalBlueprintError( + f"External blueprint {name!r} loaded unsupported target {target!r}. " + "Entry point targets must be a Blueprint object or a DimOS Module class." + ) @dataclass(frozen=True) @@ -157,9 +155,9 @@ def resolve_external_blueprint_by_name(name: str) -> Blueprint: namespace, sep, local_name = name.partition(".") if not sep: - raise ExternalBlueprintNamespaceNotFoundError(name, []) + raise _external_blueprint_namespace_not_found_error(name, []) if not is_valid_external_local_blueprint_name(local_name): - raise InvalidExternalBlueprintRequestNameError(local_name) + raise _invalid_external_blueprint_request_name_error(local_name) collection = _collect_external_blueprints() namespace_entries = collection.entries_by_namespace @@ -167,15 +165,15 @@ def resolve_external_blueprint_by_name(name: str) -> Blueprint: invalid_entries = collection.invalid_entries_by_namespace.get(namespace) if invalid_entries: invalid_entry = invalid_entries[0] - raise InvalidExternalBlueprintNameError( + raise _invalid_external_blueprint_name_error( invalid_entry.local_name, invalid_entry.distribution_name ) - raise ExternalBlueprintNamespaceNotFoundError(namespace, namespace_entries.keys()) + raise _external_blueprint_namespace_not_found_error(namespace, namespace_entries.keys()) entries = namespace_entries[namespace] matches = [entry for entry in entries if entry.local_name == local_name] if not matches: - raise ExternalBlueprintLocalNameNotFoundError( + raise _external_blueprint_local_name_not_found_error( namespace, local_name, (entry.local_name for entry in entries) ) @@ -183,17 +181,17 @@ def resolve_external_blueprint_by_name(name: str) -> Blueprint: try: target = entry.entry_point.load() except Exception as exc: - raise ExternalBlueprintLoadError(entry.qualified_name, entry.target, exc) from exc + raise _external_blueprint_load_error(entry.qualified_name, entry.target, exc) from exc return _target_to_blueprint(entry.qualified_name, target) -def _target_to_blueprint(name: str, target: Any) -> Blueprint: +def _target_to_blueprint(name: str, target: object) -> Blueprint: if isinstance(target, Blueprint): return target if is_module_type(target): - return target.blueprint() # type: ignore[no-any-return] - raise InvalidExternalBlueprintTargetError(name, target) + return target.blueprint() + raise _invalid_external_blueprint_target_error(name, target) def _collect_external_blueprints() -> _ExternalBlueprintCollection: @@ -204,7 +202,9 @@ def _collect_external_blueprints() -> _ExternalBlueprintCollection: distribution = entry_point.dist if distribution is None: continue - distribution_name = distribution.metadata["Name"] + distribution_name = distribution.metadata.get("Name") + if not distribution_name: + continue namespace = canonicalize_distribution_namespace(distribution_name) local_name = entry_point.name if not is_valid_external_local_blueprint_name(local_name): diff --git a/dimos/robot/test_external_blueprints.py b/dimos/robot/test_external_blueprints.py index 2500a55c0a..ac559a7f15 100644 --- a/dimos/robot/test_external_blueprints.py +++ b/dimos/robot/test_external_blueprints.py @@ -48,10 +48,15 @@ def load(self) -> Any: class FakeDistribution: name: str entry_points: tuple[FakeEntryPoint, ...] + metadata_name: str | None = None @property def metadata(self) -> dict[str, str]: - return {"Name": self.name} + if self.metadata_name is None: + return {"Name": self.name} + if self.metadata_name == "": + return {} + return {"Name": self.metadata_name} def patch_distributions(monkeypatch: pytest.MonkeyPatch, *distributions: FakeDistribution) -> None: @@ -149,14 +154,14 @@ def test_rejects_unsupported_external_targets(monkeypatch: pytest.MonkeyPatch, t ), ) - with pytest.raises(external.InvalidExternalBlueprintTargetError): + with pytest.raises(external.ExternalBlueprintError): external.resolve_external_blueprint_by_name("my-test-stack.demo") def test_unknown_external_namespace(monkeypatch: pytest.MonkeyPatch) -> None: patch_distributions(monkeypatch, FakeDistribution("My-Test-Stack", ())) - with pytest.raises(external.ExternalBlueprintNamespaceNotFoundError): + with pytest.raises(external.ExternalBlueprintError): external.resolve_external_blueprint_by_name("missing-stack.demo") @@ -171,7 +176,7 @@ def test_unknown_external_namespace_lists_available_namespaces( ), ) - with pytest.raises(external.ExternalBlueprintNamespaceNotFoundError) as exc_info: + with pytest.raises(external.ExternalBlueprintError) as exc_info: external.resolve_external_blueprint_by_name("missing-stack.demo") assert "Available external namespaces: my-test-stack" in str(exc_info.value) @@ -182,7 +187,7 @@ def test_resolve_external_name_requires_namespace_separator( ) -> None: patch_distributions(monkeypatch) - with pytest.raises(external.ExternalBlueprintNamespaceNotFoundError): + with pytest.raises(external.ExternalBlueprintError): external.resolve_external_blueprint_by_name("my-test-stack") @@ -199,12 +204,12 @@ def test_namespace_exists_but_local_name_missing(monkeypatch: pytest.MonkeyPatch ), ) - with pytest.raises(external.ExternalBlueprintLocalNameNotFoundError): + with pytest.raises(external.ExternalBlueprintError): external.resolve_external_blueprint_by_name("my-test-stack.arm") -def test_local_name_missing_without_available_names() -> None: - error = external.ExternalBlueprintLocalNameNotFoundError("my-test-stack", "demo", []) +def test_local_name_missing_message_omits_empty_available_names() -> None: + error = external._external_blueprint_local_name_not_found_error("my-test-stack", "arm", []) assert "Available local blueprints" not in str(error) @@ -222,7 +227,7 @@ def test_entry_point_load_failure(monkeypatch: pytest.MonkeyPatch) -> None: ), ) - with pytest.raises(external.ExternalBlueprintLoadError, match="ImportError: boom"): + with pytest.raises(external.ExternalBlueprintError, match="ImportError: boom"): external.resolve_external_blueprint_by_name("my-test-stack.demo") @@ -233,7 +238,7 @@ def test_invalid_external_metadata_name(monkeypatch: pytest.MonkeyPatch) -> None ) assert external.list_external_blueprint_names() == [] - with pytest.raises(external.InvalidExternalBlueprintNameError): + with pytest.raises(external.ExternalBlueprintError): external.resolve_external_blueprint_by_name("my-test-stack.demo") @@ -246,7 +251,7 @@ def test_invalid_requested_external_local_name(monkeypatch: pytest.MonkeyPatch) ), ) - with pytest.raises(external.InvalidExternalBlueprintRequestNameError) as exc_info: + with pytest.raises(external.ExternalBlueprintError) as exc_info: external.resolve_external_blueprint_by_name("my-test-stack.Go2") message = str(exc_info.value) @@ -285,6 +290,21 @@ def test_entry_points_without_distribution_are_ignored(monkeypatch: pytest.Monke assert external.list_external_blueprint_names() == [] +def test_entry_points_without_distribution_name_are_ignored( + monkeypatch: pytest.MonkeyPatch, +) -> None: + patch_distributions( + monkeypatch, + FakeDistribution( + "My-Test-Stack", + (FakeEntryPoint("demo", "external_stack.demo:ExternalTestModule", ExternalTestModule),), + metadata_name="", + ), + ) + + assert external.list_external_blueprint_names() == [] + + def test_all_invalid_colliding_distribution_does_not_block_valid_package( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/docs/usage/blueprints.md b/docs/usage/blueprints.md index 85b54f0080..919f446c02 100644 --- a/docs/usage/blueprints.md +++ b/docs/usage/blueprints.md @@ -120,10 +120,6 @@ Entry point targets may be either: - a DimOS `Module` class, such as `KeyboardTeleop`, which DimOS converts with `.blueprint()`. -Factory functions are not supported in v1. If an entry point target is a function or -another unsupported object, `dimos run` fails with an invalid external blueprint target -error. - `dimos list` includes external names from package metadata without importing the target modules. `dimos run my-robot-stack.go2` imports only the requested entry point target.