From fe7a7de5874ffbc4ba290e97aae6a863205dd4f0 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 4 Jun 2026 13:41:32 -0700 Subject: [PATCH 1/7] 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 2/7] 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 cf4c88cb63dc68b0c3a8f61d4fd4f485c0aee313 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 13:00:32 -0700 Subject: [PATCH 3/7] feat(control): add extension sideloading --- dimos/control/extensions.py | 97 ++++++++ dimos/control/tasks/registry.py | 18 +- dimos/control/test_extensions.py | 235 ++++++++++++++++++ ...test_external_control_extension_example.py | 77 ++++++ dimos/hardware/drive_trains/registry.py | 17 +- .../drive_trains/transport/adapter.py | 12 +- dimos/hardware/manipulators/registry.py | 28 ++- dimos/hardware/whole_body/registry.py | 15 +- .../hardware/whole_body/transport/adapter.py | 18 +- docs/capabilities/external_robot_packages.md | 218 ++++++++++++++++ .../manipulation/adding_a_custom_arm.md | 8 +- examples/external_control_extension/README.md | 23 ++ .../demo_external_control.py | 30 +++ .../__init__.py | 23 ++ .../adapters.py | 102 ++++++++ .../blueprints.py | 112 +++++++++ .../dimos_external_control_extension/tasks.py | 98 ++++++++ .../.openspec.yaml | 2 + .../control-extension-sideloading/design.md | 125 ++++++++++ .../control-extension-sideloading/docs.md | 77 ++++++ .../control-extension-sideloading/proposal.md | 38 +++ .../control-extension-sideloading/spec.md | 67 +++++ .../control-extension-sideloading/tasks.md | 44 ++++ 23 files changed, 1455 insertions(+), 29 deletions(-) create mode 100644 dimos/control/extensions.py create mode 100644 dimos/control/test_extensions.py create mode 100644 dimos/control/test_external_control_extension_example.py create mode 100644 docs/capabilities/external_robot_packages.md create mode 100644 examples/external_control_extension/README.md create mode 100644 examples/external_control_extension/demo_external_control.py create mode 100644 examples/external_control_extension/dimos_external_control_extension/__init__.py create mode 100644 examples/external_control_extension/dimos_external_control_extension/adapters.py create mode 100644 examples/external_control_extension/dimos_external_control_extension/blueprints.py create mode 100644 examples/external_control_extension/dimos_external_control_extension/tasks.py create mode 100644 openspec/changes/control-extension-sideloading/.openspec.yaml create mode 100644 openspec/changes/control-extension-sideloading/design.md create mode 100644 openspec/changes/control-extension-sideloading/docs.md create mode 100644 openspec/changes/control-extension-sideloading/proposal.md create mode 100644 openspec/changes/control-extension-sideloading/specs/control-extension-sideloading/spec.md create mode 100644 openspec/changes/control-extension-sideloading/tasks.md diff --git a/dimos/control/extensions.py b/dimos/control/extensions.py new file mode 100644 index 0000000000..da2a646aa0 --- /dev/null +++ b/dimos/control/extensions.py @@ -0,0 +1,97 @@ +# 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. + +"""Public extension surface for ControlCoordinator sideloading.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, cast + +from dimos.control.components import HardwareType +from dimos.control.tasks.registry import control_task_registry + +if TYPE_CHECKING: + from dimos.hardware.drive_trains.spec import TwistBaseAdapter + from dimos.hardware.manipulators.spec import ManipulatorAdapter + from dimos.hardware.whole_body.spec import WholeBodyAdapter + + +def register_hardware_adapter( + hardware_type: HardwareType, + adapter_type: str, + factory: Callable[..., object], +) -> None: + """Register a hardware adapter factory for external packages. + + The adapter is registered into the existing DimOS registry that matches + ``hardware_type``. Re-registering the same adapter name with the same + factory object is idempotent; registering a different factory for an + existing name raises from the target registry. + """ + adapter_name = _normalize_name(adapter_type, label="Hardware adapter type") + if not callable(factory): + raise TypeError("Hardware adapter factory must be callable") + + match hardware_type: + case HardwareType.MANIPULATOR: + from dimos.hardware.manipulators.registry import adapter_registry + + adapter_registry.register( + adapter_name, cast("Callable[..., ManipulatorAdapter]", factory) + ) + case HardwareType.BASE: + from dimos.hardware.drive_trains.registry import twist_base_adapter_registry + + twist_base_adapter_registry.register( + adapter_name, + cast("Callable[..., TwistBaseAdapter]", factory), + ) + case HardwareType.WHOLE_BODY: + from dimos.hardware.whole_body.registry import whole_body_adapter_registry + + whole_body_adapter_registry.register( + adapter_name, + cast("Callable[..., WholeBodyAdapter]", factory), + ) + case _: + raise ValueError(f"Unsupported hardware type: {hardware_type!r}") + + +def register_control_task(task_type: str, factory_path: str) -> None: + """Register a lazy control task factory path for external packages. + + The target factory module is not imported during registration. It is + resolved later by the control task registry when a coordinator creates a + matching ``TaskConfig``. + """ + task_name = _normalize_name(task_type, label="Control task type") + _validate_factory_path(factory_path) + control_task_registry.register_path(task_name, factory_path) + + +def _normalize_name(name: str, *, label: str) -> str: + normalized = name.strip().lower() + if not normalized: + raise ValueError(f"{label} must be non-empty") + return normalized + + +def _validate_factory_path(factory_path: str) -> None: + module_name, separator, attr = factory_path.partition(":") + if not factory_path.strip() or separator != ":" or not module_name or not attr: + raise ValueError(f"Invalid control task factory path: {factory_path!r}") + + +__all__ = ["register_control_task", "register_hardware_adapter"] diff --git a/dimos/control/tasks/registry.py b/dimos/control/tasks/registry.py index aab4d98731..ab7bcf94dc 100644 --- a/dimos/control/tasks/registry.py +++ b/dimos/control/tasks/registry.py @@ -78,9 +78,8 @@ def discover(self) -> None: def register_path(self, name: str, factory_path: str) -> None: """Register a lazy task factory import path.""" - if ":" not in factory_path: - raise ValueError(f"Invalid task factory path: {factory_path!r}") - key = name.lower() + key = _normalize_task_name(name) + _validate_factory_path(factory_path) existing = self._factory_paths.get(key) if existing is not None and existing != factory_path: raise ValueError(f"Duplicate task type {key!r}: {existing!r} vs {factory_path!r}") @@ -130,6 +129,19 @@ def _resolve_factory(self, key: str) -> TaskFactory: return factory +def _normalize_task_name(name: str) -> str: + key = name.strip().lower() + if not key: + raise ValueError("Task type must be non-empty") + return key + + +def _validate_factory_path(factory_path: str) -> None: + module_name, separator, attr = factory_path.partition(":") + if not factory_path.strip() or separator != ":" or not module_name or not attr: + raise ValueError(f"Invalid task factory path: {factory_path!r}") + + control_task_registry = ControlTaskRegistry() __all__ = ["ControlTaskRegistry", "TaskFactory", "control_task_registry"] diff --git a/dimos/control/test_extensions.py b/dimos/control/test_extensions.py new file mode 100644 index 0000000000..f32ab2ad9c --- /dev/null +++ b/dimos/control/test_extensions.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. + +"""Tests for ControlCoordinator extension sideloading.""" + +from __future__ import annotations + +from collections.abc import Generator +import sys +from types import ModuleType + +import pytest + +from dimos.control.components import HardwareComponent, HardwareType, make_twist_base_joints +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.control.extensions import register_control_task, register_hardware_adapter +from dimos.control.task import BaseControlTask, CoordinatorState, JointCommandOutput, ResourceClaim +from dimos.control.tasks.registry import control_task_registry +from dimos.hardware.drive_trains.registry import twist_base_adapter_registry +from dimos.hardware.manipulators.registry import adapter_registry +from dimos.hardware.whole_body.registry import whole_body_adapter_registry + + +class ExternalBaseAdapter: + def __init__(self, dof: int = 3, **kwargs: object) -> None: + self._dof = dof + self._connected = False + self._enabled = False + + def connect(self) -> bool: + self._connected = True + return True + + def disconnect(self) -> None: + self._connected = False + + def is_connected(self) -> bool: + return self._connected + + def get_dof(self) -> int: + return self._dof + + def read_velocities(self) -> list[float]: + return [0.0] * self._dof + + def read_odometry(self) -> list[float] | None: + return [0.0] * self._dof + + def write_velocities(self, velocities: list[float]) -> bool: + return True + + def write_stop(self) -> bool: + return True + + def write_enable(self, enable: bool) -> bool: + self._enabled = enable + return True + + def read_enabled(self) -> bool: + return self._enabled + + +class ExternalControlTask(BaseControlTask): + def __init__(self, cfg: TaskConfig, hardware: object) -> None: + self._name = cfg.name + self.hardware = hardware + + @property + def name(self) -> str: + return self._name + + def claim(self) -> ResourceClaim: + return ResourceClaim(joints=frozenset()) + + def is_active(self) -> bool: + return False + + def compute(self, state: CoordinatorState) -> JointCommandOutput | None: + return None + + +@pytest.fixture(autouse=True) +def restore_registries() -> Generator[None, None, None]: + manipulator_adapters = adapter_registry._adapters.copy() + base_adapters = twist_base_adapter_registry._adapters.copy() + whole_body_adapters = whole_body_adapter_registry._adapters.copy() + task_paths = control_task_registry._factory_paths.copy() + task_factories = control_task_registry._factories.copy() + try: + yield + finally: + adapter_registry._adapters = manipulator_adapters + twist_base_adapter_registry._adapters = base_adapters + whole_body_adapter_registry._adapters = whole_body_adapters + control_task_registry._factory_paths = task_paths + control_task_registry._factories = task_factories + + +def test_register_hardware_adapter_supports_all_hardware_types() -> None: + def manipulator_factory(**kwargs: object) -> object: + return object() + + def base_factory(**kwargs: object) -> object: + return object() + + def whole_body_factory(**kwargs: object) -> object: + return object() + + register_hardware_adapter(HardwareType.MANIPULATOR, "Ext_Manipulator", manipulator_factory) + register_hardware_adapter(HardwareType.BASE, "Ext_Base", base_factory) + register_hardware_adapter(HardwareType.WHOLE_BODY, "Ext_Whole_Body", whole_body_factory) + + assert adapter_registry._adapters["ext_manipulator"] is manipulator_factory + assert twist_base_adapter_registry._adapters["ext_base"] is base_factory + assert whole_body_adapter_registry._adapters["ext_whole_body"] is whole_body_factory + + +@pytest.mark.parametrize( + ("hardware_type", "registry_name"), + [ + (HardwareType.MANIPULATOR, "manipulator"), + (HardwareType.BASE, "base"), + (HardwareType.WHOLE_BODY, "whole_body"), + ], +) +def test_register_hardware_adapter_duplicate_policy_keeps_original( + hardware_type: HardwareType, + registry_name: str, +) -> None: + def original_factory(**kwargs: object) -> object: + return object() + + def different_factory(**kwargs: object) -> object: + return object() + + adapter_name = f"external_duplicate_{hardware_type.value}" + + register_hardware_adapter(hardware_type, adapter_name, original_factory) + register_hardware_adapter(hardware_type, adapter_name, original_factory) + + with pytest.raises(ValueError): + register_hardware_adapter(hardware_type, adapter_name, different_factory) + + registries = { + "manipulator": adapter_registry._adapters, + "base": twist_base_adapter_registry._adapters, + "whole_body": whole_body_adapter_registry._adapters, + } + assert registries[registry_name][adapter_name] is original_factory + + +def test_register_control_task_registers_lazy_path_without_importing_target(mocker) -> None: + import_module = mocker.patch("dimos.control.tasks.registry.importlib.import_module") + + register_control_task("External_Task", "external_pkg.tasks:make_task") + + assert control_task_registry._factory_paths["external_task"] == "external_pkg.tasks:make_task" + import_module.assert_not_called() + + +@pytest.mark.parametrize( + "factory_path", ["", "external_pkg.tasks", ":make_task", "external_pkg.tasks:"] +) +def test_register_control_task_validates_path_format_only(factory_path: str) -> None: + with pytest.raises(ValueError): + register_control_task("external_invalid_path", factory_path) + + +def test_register_control_task_duplicate_policy_keeps_original() -> None: + register_control_task("external_duplicate_task", "external_pkg.tasks:make_task") + register_control_task("external_duplicate_task", "external_pkg.tasks:make_task") + + with pytest.raises(ValueError): + register_control_task("external_duplicate_task", "external_pkg.other:make_task") + + assert ( + control_task_registry._factory_paths["external_duplicate_task"] + == "external_pkg.tasks:make_task" + ) + + +def test_control_coordinator_resolves_external_base_adapter_without_manifest() -> None: + adapter_name = "external_base_no_manifest" + register_hardware_adapter(HardwareType.BASE, adapter_name, ExternalBaseAdapter) + coordinator = ControlCoordinator( + publish_joint_state=False, + hardware=[ + HardwareComponent( + hardware_id="external_base", + hardware_type=HardwareType.BASE, + joints=make_twist_base_joints("external_base"), + adapter_type=adapter_name, + ) + ], + ) + + try: + coordinator.start() + connected = coordinator._hardware["external_base"] + assert isinstance(connected.adapter, ExternalBaseAdapter) + assert "external_base/vx" in coordinator._joint_to_hardware + finally: + coordinator.stop() + + +def test_control_coordinator_resolves_external_task_from_lazy_registry_without_manifest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module_name = "external_control_extension_task_module" + task_type = "external_task_no_manifest" + module = ModuleType(module_name) + module.make_task = ExternalControlTask + monkeypatch.setitem(sys.modules, module_name, module) + register_control_task(task_type, f"{module_name}:make_task") + coordinator = ControlCoordinator( + publish_joint_state=False, + tasks=[TaskConfig(name="external_task", type=task_type)], + ) + + try: + coordinator.start() + assert isinstance(coordinator.get_task("external_task"), ExternalControlTask) + finally: + coordinator.stop() diff --git a/dimos/control/test_external_control_extension_example.py b/dimos/control/test_external_control_extension_example.py new file mode 100644 index 0000000000..e866f0cfe7 --- /dev/null +++ b/dimos/control/test_external_control_extension_example.py @@ -0,0 +1,77 @@ +# 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. + +"""Tests for the logged external ControlCoordinator extension example.""" + +from __future__ import annotations + +from collections.abc import Generator +import importlib +import logging +from pathlib import Path +import sys + +import pytest + +from dimos.control.tasks.registry import control_task_registry +from dimos.hardware.drive_trains.registry import twist_base_adapter_registry + +EXAMPLE_ROOT = Path(__file__).parents[2] / "examples" / "external_control_extension" +EXAMPLE_PACKAGE = "dimos_external_control_extension" + + +@pytest.fixture(autouse=True) +def restore_external_example_state() -> Generator[None, None, None]: + base_adapters = twist_base_adapter_registry._adapters.copy() + task_paths = control_task_registry._factory_paths.copy() + task_factories = control_task_registry._factories.copy() + loaded_modules = { + name: module + for name, module in sys.modules.items() + if name == EXAMPLE_PACKAGE or name.startswith(f"{EXAMPLE_PACKAGE}.") + } + try: + yield + finally: + twist_base_adapter_registry._adapters = base_adapters + control_task_registry._factory_paths = task_paths + control_task_registry._factories = task_factories + for name in list(sys.modules): + if name == EXAMPLE_PACKAGE or name.startswith(f"{EXAMPLE_PACKAGE}."): + del sys.modules[name] + sys.modules.update(loaded_modules) + + +def test_external_control_extension_demo_logs_registration_and_runtime( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.syspath_prepend(str(EXAMPLE_ROOT)) + caplog.set_level(logging.INFO, logger=EXAMPLE_PACKAGE) + blueprints = importlib.import_module(f"{EXAMPLE_PACKAGE}.blueprints") + + blueprints.run_demo(target_writes=2, timeout=2.0) + + messages = "\n".join(caplog.messages) + assert "[external_test_robot] registered hardware adapter: BASE/external_test_base" in messages + assert "[external_test_robot] registered control task: external_test_drive" in messages + assert "[external_test_robot] ExternalTestBaseAdapter.__init__" in messages + assert "[external_test_robot] ExternalTestBaseAdapter.connect()" in messages + assert "[external_test_robot] ExternalTestDriveTask created for task=drive_demo" in messages + assert "[external_test_robot] ExternalTestDriveTask.compute(tick=1" in messages + assert ( + "[external_test_robot] ExternalTestBaseAdapter.write_velocities([0.1, 0.0, 0.0])" + in messages + ) + assert "[external_test_robot] demo observed" in messages diff --git a/dimos/hardware/drive_trains/registry.py b/dimos/hardware/drive_trains/registry.py index a4c49b183c..c249dd7d2c 100644 --- a/dimos/hardware/drive_trains/registry.py +++ b/dimos/hardware/drive_trains/registry.py @@ -50,9 +50,15 @@ def __init__(self) -> None: self._adapters: dict[str, type[TwistBaseAdapter] | Callable[..., TwistBaseAdapter]] = {} def register( - self, name: str, cls: type[TwistBaseAdapter] | Callable[..., TwistBaseAdapter] + self, name: str, factory: type[TwistBaseAdapter] | Callable[..., TwistBaseAdapter] ) -> None: - self._adapters[name.lower()] = cls + key = _normalize_adapter_name(name) + existing = self._adapters.get(key) + if existing is factory: + return + if existing is not None: + raise ValueError(f"Duplicate twist base adapter {key!r}") + self._adapters[key] = factory def create(self, name: str, **kwargs: Any) -> TwistBaseAdapter: """Create an adapter instance by name. @@ -97,6 +103,13 @@ def discover(self) -> None: logger.warning(f"Skipping twist base adapter {entry}: {e}") +def _normalize_adapter_name(name: str) -> str: + key = name.strip().lower() + if not key: + raise ValueError("Adapter name must be non-empty") + return key + + twist_base_adapter_registry = TwistBaseAdapterRegistry() twist_base_adapter_registry.discover() diff --git a/dimos/hardware/drive_trains/transport/adapter.py b/dimos/hardware/drive_trains/transport/adapter.py index 5447b2eb93..6d379f18f6 100644 --- a/dimos/hardware/drive_trains/transport/adapter.py +++ b/dimos/hardware/drive_trains/transport/adapter.py @@ -23,7 +23,7 @@ import threading from typing import TYPE_CHECKING, Any -from dimos.core.transport import LCMTransport +from dimos.core.transport import LCMTransport, ROSTransport from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Twist import Twist from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -147,11 +147,13 @@ def _on_odom(self, msg: PoseStamped) -> None: self._latest_odom = [msg.x, msg.y, msg.yaw] -def register(registry: TwistBaseAdapterRegistry) -> None: - from dimos.core.transport import ROSTransport +_LCM_TRANSPORT_FACTORY = partial(TransportTwistAdapter, transport_cls=LCMTransport) +_ROS_TRANSPORT_FACTORY = partial(TransportTwistAdapter, transport_cls=ROSTransport) + - registry.register("transport_lcm", partial(TransportTwistAdapter, transport_cls=LCMTransport)) - registry.register("transport_ros", partial(TransportTwistAdapter, transport_cls=ROSTransport)) +def register(registry: TwistBaseAdapterRegistry) -> None: + registry.register("transport_lcm", _LCM_TRANSPORT_FACTORY) + registry.register("transport_ros", _ROS_TRANSPORT_FACTORY) __all__ = ["TransportTwistAdapter"] diff --git a/dimos/hardware/manipulators/registry.py b/dimos/hardware/manipulators/registry.py index a8c87c149c..06da3f9097 100644 --- a/dimos/hardware/manipulators/registry.py +++ b/dimos/hardware/manipulators/registry.py @@ -31,6 +31,7 @@ from __future__ import annotations +from collections.abc import Callable import importlib from typing import TYPE_CHECKING, Any @@ -46,11 +47,23 @@ class AdapterRegistry: """Registry for manipulator adapters with auto-discovery.""" def __init__(self) -> None: - self._adapters: dict[str, type[ManipulatorAdapter]] = {} + self._adapters: dict[str, Callable[..., ManipulatorAdapter]] = {} - def register(self, name: str, cls: type[ManipulatorAdapter]) -> None: - """Register an adapter class.""" - self._adapters[name.lower()] = cls + def register(self, name: str, factory: Callable[..., ManipulatorAdapter]) -> None: + """Register an adapter factory. + + Re-registering the same name with the same factory object is + idempotent. Re-registering the same name with a different factory is a + configuration error because it would otherwise silently change which + adapter a blueprint resolves. + """ + key = _normalize_adapter_name(name) + existing = self._adapters.get(key) + if existing is factory: + return + if existing is not None: + raise ValueError(f"Duplicate manipulator adapter {key!r}") + self._adapters[key] = factory def create(self, name: str, **kwargs: Any) -> ManipulatorAdapter: """Create an adapter instance by name. @@ -99,6 +112,13 @@ def discover(self) -> None: logger.debug(f"Skipping adapter {child.name}: {e}") +def _normalize_adapter_name(name: str) -> str: + key = name.strip().lower() + if not key: + raise ValueError("Adapter name must be non-empty") + return key + + adapter_registry = AdapterRegistry() adapter_registry.discover() diff --git a/dimos/hardware/whole_body/registry.py b/dimos/hardware/whole_body/registry.py index 5e6291b946..79c27c8639 100644 --- a/dimos/hardware/whole_body/registry.py +++ b/dimos/hardware/whole_body/registry.py @@ -57,7 +57,13 @@ def __init__(self) -> None: def register(self, name: str, cls: Callable[..., WholeBodyAdapter]) -> None: """Register an adapter factory (class or callable).""" - self._adapters[name.lower()] = cls + key = _normalize_adapter_name(name) + existing = self._adapters.get(key) + if existing is cls: + return + if existing is not None: + raise ValueError(f"Duplicate whole-body adapter {key!r}") + self._adapters[key] = cls def create(self, name: str, **kwargs: Any) -> WholeBodyAdapter: """Create an adapter instance by name.""" @@ -100,6 +106,13 @@ def _discover_in(self, pkg_path: str, dir_path: str, *, max_depth: int) -> None: mod.register(self) +def _normalize_adapter_name(name: str) -> str: + key = name.strip().lower() + if not key: + raise ValueError("Adapter name must be non-empty") + return key + + whole_body_adapter_registry = WholeBodyAdapterRegistry() whole_body_adapter_registry.discover() diff --git a/dimos/hardware/whole_body/transport/adapter.py b/dimos/hardware/whole_body/transport/adapter.py index 8950803bb3..a928810597 100644 --- a/dimos/hardware/whole_body/transport/adapter.py +++ b/dimos/hardware/whole_body/transport/adapter.py @@ -24,7 +24,7 @@ import threading from typing import TYPE_CHECKING, Any -from dimos.core.transport import LCMTransport +from dimos.core.transport import LCMTransport, ROSTransport from dimos.hardware.whole_body.spec import IMUState, MotorCommand, MotorState from dimos.msgs.sensor_msgs.Imu import Imu from dimos.msgs.sensor_msgs.JointState import JointState @@ -182,18 +182,14 @@ def _on_imu(self, msg: Imu) -> None: ) +_LCM_TRANSPORT_FACTORY = partial(TransportWholeBodyAdapter, transport_cls=LCMTransport) +_ROS_TRANSPORT_FACTORY = partial(TransportWholeBodyAdapter, transport_cls=ROSTransport) + + def register(registry: WholeBodyAdapterRegistry) -> None: """Auto-discovered by ``whole_body_adapter_registry.discover()``.""" - from dimos.core.transport import ROSTransport - - registry.register( - "transport_lcm", - partial(TransportWholeBodyAdapter, transport_cls=LCMTransport), - ) - registry.register( - "transport_ros", - partial(TransportWholeBodyAdapter, transport_cls=ROSTransport), - ) + registry.register("transport_lcm", _LCM_TRANSPORT_FACTORY) + registry.register("transport_ros", _ROS_TRANSPORT_FACTORY) __all__ = ["TransportWholeBodyAdapter"] diff --git a/docs/capabilities/external_robot_packages.md b/docs/capabilities/external_robot_packages.md new file mode 100644 index 0000000000..71bd842387 --- /dev/null +++ b/docs/capabilities/external_robot_packages.md @@ -0,0 +1,218 @@ +# External Robot Packages + +This guide shows how an installed package can provide the ControlCoordinator pieces its blueprints reference without putting adapter or task files inside the DimOS source tree. + +Use this path when you are building a robot package outside the DimOS repository, such as `dimos_mydog`, `dimos_myarm`, or `dimos_myhumanoid`. + +## What this supports + +This guide covers explicit registration for: + +- hardware adapters used by `HardwareComponent(adapter_type=...)`; +- control tasks used by `TaskConfig(type=...)`. + +It does not add package auto-discovery, Python entry point discovery, blueprint discovery changes, template generation, or `available_*` diagnostics helpers. Your package still needs to be imported by user code or by a separate blueprint-discovery mechanism before its registered names can be used. + +## Package layout + +A small external robot package usually has this shape: + +```text +dimos_mydog/ +├── adapters.py +├── blueprints.py # calls register_extensions() +├── tasks/ +│ └── gait.py # lazy control task factory +├── skills.py # optional +└── prompts.py # optional +``` + +The important part is that the module defining the blueprint also performs extension registration before the blueprint is built. + +For a runnable no-hardware version of this pattern, see `examples/external_control_extension/`: + +```bash +uv run python examples/external_control_extension/demo_external_control.py +``` + +The demo logs registration, adapter construction, task creation, task ticks, and adapter writes so you can see that the externally registered pieces are actually running. + +## Supported embodiments + +Register hardware adapters for the same hardware categories that `ControlCoordinator` already understands: + +| Embodiment | Hardware type | Example adapter name | +|------------|---------------|----------------------| +| External arm | `HardwareType.MANIPULATOR` | `"myarm"` | +| Robot dog or mobile base | `HardwareType.BASE` | `"mydog"` | +| Humanoid or whole-body robot | `HardwareType.WHOLE_BODY` | `"myhumanoid"` | + +Each registration routes to the matching built-in registry, but external packages should use only the public facade in `dimos.control.extensions`. + +## Full example: robot dog / mobile base + +In `dimos_mydog/adapters.py`, define a base adapter that implements the twist-base adapter protocol used by `HardwareType.BASE`: + +```python skip +class MyDogAdapter: + def __init__( + self, + dof: int, + address: str | None = None, + hardware_id: str = "base", + **_: object, + ) -> None: + self._dof = dof + self._address = address + self._hardware_id = hardware_id + self._connected = False + self._enabled = False + + def connect(self) -> bool: + self._connected = True + return True + + def disconnect(self) -> None: + self._connected = False + + def is_connected(self) -> bool: + return self._connected + + def get_dof(self) -> int: + return self._dof + + def read_velocities(self) -> list[float]: + return [0.0] * self._dof + + def read_odometry(self) -> list[float] | None: + return None + + def write_velocities(self, velocities: list[float]) -> bool: + return True + + def write_stop(self) -> bool: + return True + + def write_enable(self, enable: bool) -> bool: + self._enabled = enable + return True + + def read_enabled(self) -> bool: + return self._enabled +``` + +In `dimos_mydog/tasks/gait.py`, define the task factory. The task module is referenced by import path, so it is imported only when a coordinator actually creates this task: + +```python skip +from dimos.control.task import BaseControlTask, CoordinatorState, JointCommandOutput, ResourceClaim + + +class MyDogGaitTask(BaseControlTask): + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + def claim(self) -> ResourceClaim: + return ResourceClaim(joints=frozenset()) + + def is_active(self) -> bool: + return False + + def compute(self, state: CoordinatorState) -> JointCommandOutput | None: + return None + + def on_preempted(self, by_task: str, joints: frozenset[str]) -> None: + return None + + +def create_task(cfg, hardware): + return MyDogGaitTask(cfg.name) +``` + +In `dimos_mydog/blueprints.py`, group registration in `register_extensions()` and call it at module import time: + +```python skip +from dimos.control.components import HardwareComponent, HardwareType, make_twist_base_joints +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.control.extensions import register_control_task, register_hardware_adapter +from dimos.core.coordination.blueprints import autoconnect + +from dimos_mydog.adapters import MyDogAdapter + + +def register_extensions() -> None: + register_hardware_adapter(HardwareType.BASE, "mydog", MyDogAdapter) + register_control_task("mydog_gait", "dimos_mydog.tasks.gait:create_task") + + +register_extensions() + +mydog = autoconnect( + ControlCoordinator.blueprint( + hardware=[ + HardwareComponent( + hardware_id="base", + hardware_type=HardwareType.BASE, + joints=make_twist_base_joints("base"), + adapter_type="mydog", + ), + ], + tasks=[ + TaskConfig( + name="gait", + type="mydog_gait", + joint_names=[], + auto_start=True, + ), + ], + ), +) +``` + +The blueprint still uses the normal DimOS configuration objects: + +```python skip +HardwareComponent(hardware_type=HardwareType.BASE, adapter_type="mydog") +TaskConfig(type="mydog_gait") +``` + +The only new requirement is that `register_extensions()` runs before the coordinator resolves those names. + +## Other embodiments + +The same pattern works for arms and whole-body robots: + +```python skip +from dimos.control.components import HardwareType +from dimos.control.extensions import register_hardware_adapter + +register_hardware_adapter(HardwareType.MANIPULATOR, "myarm", MyArmAdapter) +register_hardware_adapter(HardwareType.BASE, "mydog", MyDogAdapter) +register_hardware_adapter(HardwareType.WHOLE_BODY, "myhumanoid", MyHumanoidAdapter) +``` + +Choose the hardware type that matches the protocol your adapter implements: + +- `MANIPULATOR`: arm-style joint, Cartesian, and gripper IO. +- `BASE`: velocity-commanded platforms that consume twist-like virtual joints. +- `WHOLE_BODY`: joint-level whole-body motor IO. + +## Duplicate registration policy + +Registration is deterministic: + +- registering the same hardware type, adapter name, and same factory object again is idempotent; +- registering the same hardware type and adapter name with a different factory raises an error; +- registering the same task type and same factory path again is idempotent; +- registering the same task type with a different factory path raises an error. + +Use stable top-level classes or functions for hardware factories. Fresh wrappers, lambdas, or `functools.partial(...)` objects created on each call are different objects and are not treated as the same factory. + +## Boundaries + +This registration API handles only the ControlCoordinator layer. It does not make the external package discoverable by `dimos run` on its own. External blueprint discovery, package auto-discovery, entry points, richer diagnostics, and robot package templates are separate work. + +Low-level registry singletons such as `adapter_registry`, `twist_base_adapter_registry`, and `whole_body_adapter_registry` remain internal implementation details for DimOS itself. External robot packages should import from `dimos.control.extensions`. diff --git a/docs/capabilities/manipulation/adding_a_custom_arm.md b/docs/capabilities/manipulation/adding_a_custom_arm.md index 1e0a14a2a0..485118bac0 100644 --- a/docs/capabilities/manipulation/adding_a_custom_arm.md +++ b/docs/capabilities/manipulation/adding_a_custom_arm.md @@ -2,6 +2,8 @@ This guide walks through integrating a new robot arm with DimOS, from writing the hardware adapter to creating blueprints for planning and control. +If you are packaging an arm outside the DimOS repository, start with [External Robot Packages](/docs/capabilities/external_robot_packages.md). That guide shows how to register adapters and control tasks from an installed package without placing files under `dimos/hardware/` or `dimos/robot/`. + ## Architecture Overview DimOS uses a **Protocol-based adapter pattern** — no base class inheritance required. Your adapter wraps the vendor SDK and exposes a standard interface that the rest of the system consumes: @@ -40,7 +42,7 @@ DimOS uses a **Protocol-based adapter pattern** — no base class inheritance re ## Step 1: Create the Adapter -Create a new directory for your arm under `dimos/hardware/manipulators/`: +For in-repo DimOS integrations, create a new directory for your arm under `dimos/hardware/manipulators/`: ``` dimos/hardware/manipulators/ @@ -397,7 +399,7 @@ The `AdapterRegistry` in `dimos/hardware/manipulators/registry.py` automatically 2. For each subpackage, it tries to import `.adapter` 3. If that module has a `register()` function, it calls it -This means **no manual registration is needed** — just having the `register()` function in your `adapter.py` is sufficient. +This means **no manual registration is needed for in-repo adapters** — just having the `register()` function in your `adapter.py` is sufficient. External packages should instead use `register_hardware_adapter(...)` from [External Robot Packages](/docs/capabilities/external_robot_packages.md). You can verify discovery works: @@ -597,7 +599,7 @@ yourarm_planner = manipulation_module( ## Step 5: Register Blueprints -The blueprint registry in `dimos/robot/all_blueprints.py` is **auto-generated** by scanning the codebase for blueprint declarations. After adding your blueprints: +For in-repo integrations, the blueprint registry in `dimos/robot/all_blueprints.py` is **auto-generated** by scanning the codebase for blueprint declarations. After adding your blueprints: 1. Run the generation test to update the registry: ```bash diff --git a/examples/external_control_extension/README.md b/examples/external_control_extension/README.md new file mode 100644 index 0000000000..f080946cf9 --- /dev/null +++ b/examples/external_control_extension/README.md @@ -0,0 +1,23 @@ +# External Control Extension Example + +This example is shaped like a tiny package outside `dimos/`. It proves that an +external package can register a hardware adapter and a control task, then run +them through the normal `ControlCoordinator` path. + +Run the demo and watch the logs: + +```bash +uv run python examples/external_control_extension/demo_external_control.py +``` + +Expected log events include: + +- registering `BASE/external_test_base` +- registering task type `external_test_drive` +- constructing and connecting `ExternalTestBaseAdapter` +- creating `ExternalTestDriveTask` +- ticking `ExternalTestDriveTask.compute(...)` +- sending commands through `ExternalTestBaseAdapter.write_velocities(...)` + +This example performs no robot I/O. The adapter records commands in memory so +the demo is safe to run locally and in CI. diff --git a/examples/external_control_extension/demo_external_control.py b/examples/external_control_extension/demo_external_control.py new file mode 100644 index 0000000000..1eca27dca4 --- /dev/null +++ b/examples/external_control_extension/demo_external_control.py @@ -0,0 +1,30 @@ +# 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. + +"""Run the external ControlCoordinator extension example.""" + +from __future__ import annotations + +import logging + +from dimos_external_control_extension.blueprints import run_demo + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(message)s") + run_demo(target_writes=3, timeout=2.0) + + +if __name__ == "__main__": + main() diff --git a/examples/external_control_extension/dimos_external_control_extension/__init__.py b/examples/external_control_extension/dimos_external_control_extension/__init__.py new file mode 100644 index 0000000000..a8f52f3050 --- /dev/null +++ b/examples/external_control_extension/dimos_external_control_extension/__init__.py @@ -0,0 +1,23 @@ +# 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. + +"""Sample external package for ControlCoordinator extension sideloading.""" + +from dimos_external_control_extension.blueprints import ( + build_demo_coordinator, + register_extensions, + run_demo, +) + +__all__ = ["build_demo_coordinator", "register_extensions", "run_demo"] diff --git a/examples/external_control_extension/dimos_external_control_extension/adapters.py b/examples/external_control_extension/dimos_external_control_extension/adapters.py new file mode 100644 index 0000000000..8fb24f2200 --- /dev/null +++ b/examples/external_control_extension/dimos_external_control_extension/adapters.py @@ -0,0 +1,102 @@ +# 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. + +"""Hardware adapter implemented by an external package.""" + +from __future__ import annotations + +import logging +from pathlib import Path +import threading + +LOGGER = logging.getLogger("dimos_external_control_extension") + + +class ExternalTestBaseAdapter: + """In-memory twist-base adapter used by the external package demo.""" + + def __init__( + self, + dof: int = 3, + address: str | Path | None = None, + hardware_id: str = "external_test_base", + **adapter_kwargs: object, + ) -> None: + self._dof = dof + self._hardware_id = hardware_id + self._connected = False + self._enabled = False + self._last_velocities = [0.0] * dof + self._write_count = 0 + self.command_event = threading.Event() + LOGGER.info( + "[external_test_robot] ExternalTestBaseAdapter.__init__(dof=%s, hardware_id=%s, address=%s, kwargs=%s)", + dof, + hardware_id, + address, + adapter_kwargs, + ) + + @property + def write_count(self) -> int: + return self._write_count + + @property + def last_velocities(self) -> list[float]: + return list(self._last_velocities) + + def connect(self) -> bool: + self._connected = True + LOGGER.info("[external_test_robot] ExternalTestBaseAdapter.connect()") + return True + + def disconnect(self) -> None: + self._connected = False + LOGGER.info("[external_test_robot] ExternalTestBaseAdapter.disconnect()") + + def is_connected(self) -> bool: + return self._connected + + def get_dof(self) -> int: + return self._dof + + def read_velocities(self) -> list[float]: + return list(self._last_velocities) + + def read_odometry(self) -> list[float] | None: + return [0.0] * self._dof + + def write_velocities(self, velocities: list[float]) -> bool: + self._last_velocities = list(velocities) + self._write_count += 1 + self.command_event.set() + LOGGER.info( + "[external_test_robot] ExternalTestBaseAdapter.write_velocities(%s)", + velocities, + ) + return True + + def write_stop(self) -> bool: + return self.write_velocities([0.0] * self._dof) + + def write_enable(self, enable: bool) -> bool: + self._enabled = enable + LOGGER.info("[external_test_robot] ExternalTestBaseAdapter.write_enable(%s)", enable) + return True + + def read_enabled(self) -> bool: + return self._enabled + + +__all__ = ["ExternalTestBaseAdapter"] diff --git a/examples/external_control_extension/dimos_external_control_extension/blueprints.py b/examples/external_control_extension/dimos_external_control_extension/blueprints.py new file mode 100644 index 0000000000..14b5f06562 --- /dev/null +++ b/examples/external_control_extension/dimos_external_control_extension/blueprints.py @@ -0,0 +1,112 @@ +# 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. + +"""Blueprint-style entry points for the external control extension example.""" + +from __future__ import annotations + +import logging +import time + +from dimos.control.components import HardwareComponent, HardwareType, make_twist_base_joints +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.control.extensions import register_control_task, register_hardware_adapter +from dimos_external_control_extension.adapters import ExternalTestBaseAdapter + +LOGGER = logging.getLogger("dimos_external_control_extension") + +ADAPTER_TYPE = "external_test_base" +TASK_TYPE = "external_test_drive" +TASK_FACTORY_PATH = "dimos_external_control_extension.tasks:create_task" +HARDWARE_ID = "external_test_base" +TASK_NAME = "drive_demo" + + +def register_extensions() -> None: + """Register this package's external ControlCoordinator extensions.""" + register_hardware_adapter(HardwareType.BASE, ADAPTER_TYPE, ExternalTestBaseAdapter) + LOGGER.info( + "[external_test_robot] registered hardware adapter: BASE/%s", + ADAPTER_TYPE, + ) + register_control_task(TASK_TYPE, TASK_FACTORY_PATH) + LOGGER.info("[external_test_robot] registered control task: %s", TASK_TYPE) + + +def build_demo_coordinator() -> ControlCoordinator: + """Build a coordinator using only externally registered names.""" + register_extensions() + return ControlCoordinator( + tick_rate=20.0, + publish_joint_state=False, + log_ticks=True, + hardware=[ + HardwareComponent( + hardware_id=HARDWARE_ID, + hardware_type=HardwareType.BASE, + joints=make_twist_base_joints(HARDWARE_ID), + adapter_type=ADAPTER_TYPE, + ) + ], + tasks=[ + TaskConfig( + name=TASK_NAME, + type=TASK_TYPE, + joint_names=make_twist_base_joints(HARDWARE_ID), + priority=10, + params={"velocity": 0.1}, + ) + ], + ) + + +def run_demo(target_writes: int = 3, timeout: float = 2.0) -> ControlCoordinator: + """Start the demo coordinator until the external adapter receives commands.""" + coordinator = build_demo_coordinator() + coordinator.start() + try: + adapter = coordinator._hardware[HARDWARE_ID].adapter + if not isinstance(adapter, ExternalTestBaseAdapter): + raise TypeError(f"Expected ExternalTestBaseAdapter, got {type(adapter).__name__}") + + deadline = time.monotonic() + timeout + while adapter.write_count < target_writes: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"External adapter received {adapter.write_count} writes; " + f"expected {target_writes}" + ) + adapter.command_event.wait(timeout=remaining) + adapter.command_event.clear() + LOGGER.info( + "[external_test_robot] demo observed %s external adapter writes", + adapter.write_count, + ) + return coordinator + finally: + coordinator.stop() + + +register_extensions() + +__all__ = [ + "ADAPTER_TYPE", + "HARDWARE_ID", + "TASK_NAME", + "TASK_TYPE", + "build_demo_coordinator", + "register_extensions", + "run_demo", +] diff --git a/examples/external_control_extension/dimos_external_control_extension/tasks.py b/examples/external_control_extension/dimos_external_control_extension/tasks.py new file mode 100644 index 0000000000..e5c09f3e52 --- /dev/null +++ b/examples/external_control_extension/dimos_external_control_extension/tasks.py @@ -0,0 +1,98 @@ +# 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. + +"""Control task implemented by an external package.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging + +from dimos.control.coordinator import TaskConfig +from dimos.control.hardware_interface import ConnectedHardware, ConnectedWholeBody +from dimos.control.task import BaseControlTask, CoordinatorState, JointCommandOutput, ResourceClaim +from dimos.hardware.manipulators.spec import ControlMode + +LOGGER = logging.getLogger("dimos_external_control_extension") + + +class ExternalTestDriveTask(BaseControlTask): + """Always-active demo task that commands a tiny base velocity.""" + + def __init__( + self, + cfg: TaskConfig, + hardware: Mapping[str, ConnectedHardware | ConnectedWholeBody], + ) -> None: + self._name = cfg.name + self._joint_names = list(cfg.joint_names) + self._priority = cfg.priority + velocity = cfg.params.get("velocity", 0.1) + self._velocity = float(velocity) + self._tick_count = 0 + self._hardware_ids = sorted(hardware.keys()) + LOGGER.info( + "[external_test_robot] ExternalTestDriveTask created for task=%s hardware=%s", + cfg.name, + self._hardware_ids, + ) + + @property + def name(self) -> str: + return self._name + + @property + def tick_count(self) -> int: + return self._tick_count + + def claim(self) -> ResourceClaim: + return ResourceClaim( + joints=frozenset(self._joint_names), + priority=self._priority, + mode=ControlMode.VELOCITY, + ) + + def is_active(self) -> bool: + return True + + def on_preempted(self, by_task: str, joints: frozenset[str]) -> None: + LOGGER.info( + "[external_test_robot] ExternalTestDriveTask.on_preempted(by_task=%s, joints=%s)", + by_task, + sorted(joints), + ) + + def compute(self, state: CoordinatorState) -> JointCommandOutput | None: + self._tick_count += 1 + velocities = [self._velocity, 0.0, 0.0][: len(self._joint_names)] + LOGGER.info( + "[external_test_robot] ExternalTestDriveTask.compute(tick=%s, dt=%.4f)", + self._tick_count, + state.dt, + ) + return JointCommandOutput( + joint_names=self._joint_names, + velocities=velocities, + mode=ControlMode.VELOCITY, + ) + + +def create_task( + cfg: TaskConfig, + hardware: Mapping[str, ConnectedHardware | ConnectedWholeBody], +) -> ExternalTestDriveTask: + return ExternalTestDriveTask(cfg=cfg, hardware=hardware) + + +__all__ = ["ExternalTestDriveTask", "create_task"] diff --git a/openspec/changes/control-extension-sideloading/.openspec.yaml b/openspec/changes/control-extension-sideloading/.openspec.yaml new file mode 100644 index 0000000000..d57ce332e0 --- /dev/null +++ b/openspec/changes/control-extension-sideloading/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-20 diff --git a/openspec/changes/control-extension-sideloading/design.md b/openspec/changes/control-extension-sideloading/design.md new file mode 100644 index 0000000000..5f8a03949d --- /dev/null +++ b/openspec/changes/control-extension-sideloading/design.md @@ -0,0 +1,125 @@ +## Context + +DimOS blueprints describe hardware through `HardwareComponent` entries and control behavior through `TaskConfig` entries. `ControlCoordinator` resolves those string names through internal registries: manipulators use the manipulator adapter registry, mobile bases use the twist/base adapter registry, whole-body robots use the whole-body adapter registry, and control tasks use the task registry. + +Those registries currently discover built-in adapters and tasks from DimOS package-local files. That works for in-repo robots, but it blocks external robot packages from being complete on their own: an external blueprint can refer to `adapter_type="mydog"` or `TaskConfig(type="mydog_gait")`, but DimOS has no public supported API for that package to register those names before `ControlCoordinator` resolves them. + +This design adds a small public facade for explicit registration. External blueprint discovery and package auto-discovery are intentionally out of scope; this change assumes an external package's blueprint module is imported by the user or by a separate discovery mechanism. + +## Goals / Non-Goals + +**Goals:** + +- Provide a public `dimos.control.extensions` module for external ControlCoordinator registration. +- Support external hardware adapter registration for all current `HardwareType` values: `MANIPULATOR`, `BASE`, and `WHOLE_BODY`. +- Support external control task registration using the existing lazy factory-path style. +- Preserve unchanged blueprint usage through `HardwareComponent.adapter_type` and `TaskConfig.type`. +- Make duplicate registrations safe and debuggable by failing on conflicting mappings while allowing idempotent same-object or same-path re-registration. +- Document an external robot package pattern centered on `register_extensions()` in the package's blueprint module. + +**Non-Goals:** + +- Add Python entry point discovery. +- Add automatic package scanning. +- Change CLI or generated blueprint registry discovery. +- Add `available_*` diagnostics helpers. +- Replace the three hardware registries with a new unified internal registry. +- Add a robot template generator. +- Change stream, transport, skill, or MCP contracts. + +## DimOS Architecture + +The public API should live in a new facade module: + +```python +from dimos.control.extensions import register_hardware_adapter, register_control_task +``` + +`register_hardware_adapter(hardware_type, adapter_type, factory)` is the logical public hardware catalog. Internally it dispatches to the existing registries by `HardwareType`: + +- `MANIPULATOR` delegates to `dimos.hardware.manipulators.registry.adapter_registry`. +- `BASE` delegates to `dimos.hardware.drive_trains.registry.twist_base_adapter_registry`. +- `WHOLE_BODY` delegates to `dimos.hardware.whole_body.registry.whole_body_adapter_registry`. + +The hardware factory is any callable accepted by the target registry. The public contract does not guarantee semantic equivalence for wrappers such as fresh `partial()` objects or lambdas; idempotency is object identity only. + +`register_control_task(task_type, factory_path)` delegates to the existing `control_task_registry.register_path(...)` lazy path mechanism. The extension facade should validate the name and path shape but should not import the task module during registration. The factory is imported later when `ControlCoordinator` creates the configured task. + +Blueprints remain unchanged: + +```python +HardwareComponent(hardware_type=HardwareType.BASE, adapter_type="mydog") +TaskConfig(type="mydog_gait") +``` + +External packages should group registration in a helper and call it from their blueprint module at import time: + +```python +def register_extensions() -> None: + register_hardware_adapter(HardwareType.BASE, "mydog", MyDogAdapter) + register_control_task("mydog_gait", "dimos_mydog.tasks.gait:create_task") + +register_extensions() +``` + +No module stream names or transport schemas change. No DimOS Python `Spec` Protocol is required for this API. Adapter Protocol expectations remain those already consumed by `ConnectedHardware`, `ConnectedTwistBase`, and `ConnectedWholeBody`. Skills and MCP exposure remain dynamic through launched modules and are not part of this registration surface. + +## Decisions + +1. **Use a public facade instead of documenting internal registries.** + External users should import `dimos.control.extensions`, not low-level hardware or task registry singletons. This keeps the stable public API small while allowing internal registry implementation to evolve. + +2. **Use one hardware registration function keyed by `HardwareType`.** + The user-facing model is a logical hardware adapter catalog keyed by `(hardware_type, adapter_type)`. The implementation can still delegate to separate internal registries to avoid a larger refactor. + +3. **Keep hardware factories direct callables.** + Hardware registries already create adapter instances from callable objects. Direct callables keep external adapter registration simple and allow classes or factory functions. + +4. **Use lazy factory paths for control tasks.** + Control tasks may depend on heavier planning, simulation, policy, or IK code. A lazy path matches the existing built-in task registry and avoids importing task modules until the task is actually selected. + +5. **Validate task paths only by shape at registration time.** + The registration API should reject empty or malformed paths, but it should preserve laziness by not importing the target module. + +6. **Enforce duplicate protection in underlying registries.** + Duplicate behavior should be consistent whether registration happens through the facade or through existing internal discovery. Same key with the same object/path is idempotent; same key with a different object/path raises an error. + +7. **Do not support semantic equivalence for wrappers.** + If a caller creates a new `partial()`, lambda, or wrapper each time, it is a different factory object. The public pattern should use stable top-level classes or functions. + +## Safety / Simulation / Replay + +This change does not alter command semantics, robot motion limits, stream contents, or transport behavior. It changes which registered adapter and task names `ControlCoordinator` can resolve. + +The safety-sensitive risk is accidental name collision. Silent overwrite could cause a blueprint to resolve to the wrong hardware adapter or task factory. The new duplicate policy mitigates this by failing on conflicting mappings. + +Simulation adapters are covered when they register through one of the existing hardware registries. Replay behavior is unchanged because no recorded stream format changes. + +Manual QA should use fake adapters and tasks rather than physical robot motion. Hardware-facing manual tests, if any, should be limited to confirming registration and coordinator construction before any movement command is issued. + +## Risks / Trade-offs + +- **Underlying duplicate enforcement may expose existing internal collisions.** Run focused tests and fix any collision explicitly instead of preserving silent overwrite. +- **Import order remains explicit.** Without entry points, registration only happens when the external package module that calls `register_extensions()` is imported. Documentation must make this pattern clear. +- **The facade is intentionally narrow.** External packages still need a way to make their blueprints importable or discoverable. That is handled elsewhere or later. +- **Callable identity is strict.** This avoids fuzzy equality rules, but callers must use stable factory objects for idempotent registration. + +## Migration / Rollout + +Existing built-in DimOS registrations should continue to use their current registry hooks unless maintainers choose to migrate them later. External docs should bless only `dimos.control.extensions`. + +No generated blueprint registry update is required because this change does not add or rename DimOS blueprints. No new dependency is required. + +Rollout steps: + +1. Add the public facade module. +2. Tighten duplicate registration behavior in hardware and task registries. +3. Add tests for direct facade behavior, duplicate policy, invalid input, and real `ControlCoordinator` resolution. +4. Add the external robot package guide. +5. Run focused control/registry tests and documentation validation. + +Rollback is straightforward: remove the facade and restore prior duplicate behavior if needed, with no data migration. + +## Open Questions + +None for phase 1. Future work may add entry point discovery, diagnostics helpers, automatic package scanning, or a robot package template. diff --git a/openspec/changes/control-extension-sideloading/docs.md b/openspec/changes/control-extension-sideloading/docs.md new file mode 100644 index 0000000000..fb6c340526 --- /dev/null +++ b/openspec/changes/control-extension-sideloading/docs.md @@ -0,0 +1,77 @@ +## User-Facing Docs + +Add a new external robot package guide under `docs/capabilities/` or another user-facing location chosen during implementation. The guide should explain the package anatomy and focus on how external packages register ControlCoordinator extension points. + +Recommended guide outline: + +```text +dimos_mydog/ +├── adapters.py +├── blueprints.py # calls register_extensions() +├── tasks/ +│ └── gait.py # lazy control task factory +├── skills.py # optional +└── prompts.py # optional +``` + +The guide should show all supported embodiments briefly: + +- `HardwareType.MANIPULATOR` for an external arm. +- `HardwareType.BASE` for an external robot dog or mobile base. +- `HardwareType.WHOLE_BODY` for an external humanoid or whole-body robot. + +Use a `BASE` / robot-dog style walkthrough as the primary example: + +```python +from dimos.control.components import HardwareType +from dimos.control.extensions import register_hardware_adapter, register_control_task + +from dimos_mydog.adapters import MyDogAdapter + + +def register_extensions() -> None: + register_hardware_adapter(HardwareType.BASE, "mydog", MyDogAdapter) + register_control_task("mydog_gait", "dimos_mydog.tasks.gait:create_task") + + +register_extensions() +``` + +Then show unchanged blueprint usage: + +```python +HardwareComponent(hardware_type=HardwareType.BASE, adapter_type="mydog") +TaskConfig(type="mydog_gait") +``` + +The guide should explicitly state boundaries: + +- This change handles external hardware adapter and control task registration. +- External blueprint discovery is separate. +- Package auto-discovery and entry points are future work. +- Direct imports from low-level registry singletons are internal/advanced and should not be the documented external package path. + +Consider updating existing custom arm documentation to point external-package authors to the new guide instead of requiring files under `dimos/hardware/manipulators/`. + +## Contributor Docs + +No contributor-process documentation is required unless implementation discovers new testing or release steps. If duplicate registry behavior exposes built-in collisions, note the expected duplicate policy near relevant registry tests or developer docs. + +## Coding-Agent Docs + +No `AGENTS.md` or coding-agent documentation update is required for phase 1. The external robot package guide should be sufficient for future coding agents working on robot package examples. + +## Doc Validation + +Run documentation checks appropriate to the files changed during implementation. Expected commands include: + +```bash +doclinks +md-babel-py run +``` + +If the chosen docs do not contain executable snippets, record that no snippet execution is required. + +## No Docs Needed + +Documentation is required because this is a new public extension surface for external robot authors. diff --git a/openspec/changes/control-extension-sideloading/proposal.md b/openspec/changes/control-extension-sideloading/proposal.md new file mode 100644 index 0000000000..4dbd9a5ff1 --- /dev/null +++ b/openspec/changes/control-extension-sideloading/proposal.md @@ -0,0 +1,38 @@ +## Why + +External robot packages need a supported way to provide the ControlCoordinator pieces their blueprints reference without placing files inside the DimOS repository. Today, hardware adapters are discovered by closed-world in-repo registries and control tasks are discovered from in-repo manifests. That makes a complete outside-the-repo robot package impractical even when blueprint discovery is handled elsewhere. + +This change introduces a small public extension surface for external packages to sideload hardware adapters and control tasks before a blueprint builds. The goal is to let an external robot package register its adapter and task names, then keep using the existing `HardwareComponent` and `TaskConfig` configuration path unchanged. + +## What Changes + +- Add a public `dimos.control.extensions` facade for external packages. +- Add `register_hardware_adapter(hardware_type, adapter_type, factory)` for all current `HardwareType` values: manipulator, base, and whole-body. +- Add `register_control_task(task_type, factory_path)` using the existing lazy factory-path model for task factories. +- Tighten duplicate registration behavior in the underlying hardware and control-task registries: same key with the same object/path is idempotent; same key with a different object/path raises an error. +- Document the blessed external package pattern: define `register_extensions()`, call it from the external blueprint module at import time, and continue using `HardwareComponent` and `TaskConfig` normally. +- No entry point discovery, automatic package scanning, diagnostics helpers, or blueprint discovery changes are included in this phase. + +## Affected DimOS Surfaces + +- Modules/streams: no stream protocol changes; external modules may use the new registration facade before blueprint build. +- Blueprints/CLI: blueprints can reference externally registered `HardwareComponent.adapter_type` and `TaskConfig.type` values; CLI discovery behavior is unchanged. +- Skills/MCP: no direct changes; external skills remain exposed through launched modules as today. +- Hardware/simulation/replay: hardware adapter registration changes affect manipulator, base, and whole-body adapter registries, including simulation adapters that use those registries. +- Docs/generated registries: add external robot package documentation; no generated blueprint registry behavior changes. + +## Capabilities + +### New Capabilities + +- `control-extension-sideloading`: Public behavior for registering external ControlCoordinator hardware adapters and control tasks. + +### Modified Capabilities + +- None. + +## Impact + +External robot authors gain a stable public API for registering ControlCoordinator extension points from installed packages. Existing DimOS blueprints and built-in registries keep their current usage pattern, but duplicate name collisions now fail instead of silently overwriting. This may expose accidental internal duplicate registrations during testing, which should be fixed explicitly. + +Documentation should present this as one layer of the external robot package story: this change handles ControlCoordinator sideloading, while external blueprint discovery and automatic package discovery remain separate or future work. Tests should cover real ControlCoordinator resolution through externally registered hardware adapter and task names, plus duplicate and validation behavior. diff --git a/openspec/changes/control-extension-sideloading/specs/control-extension-sideloading/spec.md b/openspec/changes/control-extension-sideloading/specs/control-extension-sideloading/spec.md new file mode 100644 index 0000000000..79e1983e6b --- /dev/null +++ b/openspec/changes/control-extension-sideloading/specs/control-extension-sideloading/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: External hardware adapter registration +DimOS SHALL provide a public API that lets external packages register a hardware adapter name for each supported hardware type before a blueprint using that adapter is built. + +#### Scenario: External base adapter resolves through coordinator configuration +- **GIVEN** an external package has registered a base hardware adapter with adapter type `mydog` +- **WHEN** a blueprint configures a hardware component with hardware type `BASE` and adapter type `mydog` +- **THEN** DimOS MUST resolve that adapter through the normal ControlCoordinator hardware path +- **AND** the blueprint MUST NOT require the adapter implementation to live under the DimOS source tree. + +#### Scenario: All current hardware types are supported +- **GIVEN** an external package registers adapters for manipulator, base, and whole-body hardware types +- **WHEN** blueprints configure matching hardware components for each registered adapter type +- **THEN** DimOS MUST route each adapter registration to the matching hardware category +- **AND** each registered adapter type MUST be resolvable by the normal ControlCoordinator path for that category. + +### Requirement: External control task registration +DimOS SHALL provide a public API that lets external packages register a control task type using a lazy factory path before a blueprint using that task is built. + +#### Scenario: External task resolves lazily +- **GIVEN** an external package has registered control task type `mydog_gait` with a factory path +- **WHEN** a coordinator configuration requests task type `mydog_gait` +- **THEN** DimOS MUST resolve the task through the normal control task registry path +- **AND** DimOS MUST NOT import the task factory module at registration time. + +#### Scenario: Invalid task registration is rejected early +- **GIVEN** an external package attempts to register a control task with an empty name or malformed factory path +- **WHEN** registration is attempted +- **THEN** DimOS MUST reject the registration before coordinator construction +- **AND** the error MUST identify the invalid registration input. + +### Requirement: Duplicate extension registration is deterministic +DimOS SHALL make duplicate hardware adapter and control task registrations deterministic by allowing idempotent same-mapping registration and rejecting conflicting mappings. + +#### Scenario: Same hardware registration is idempotent +- **GIVEN** a hardware adapter type is registered for a hardware category with a stable factory object +- **WHEN** the same hardware category, adapter type, and factory object are registered again +- **THEN** DimOS MUST treat the second registration as idempotent +- **AND** the registered adapter mapping MUST remain unchanged. + +#### Scenario: Conflicting hardware registration is rejected +- **GIVEN** a hardware adapter type is already registered for a hardware category +- **WHEN** a different factory object is registered for the same hardware category and adapter type +- **THEN** DimOS MUST reject the registration +- **AND** the original adapter mapping MUST remain unchanged. + +#### Scenario: Same task path registration is idempotent +- **GIVEN** a control task type is registered with a factory path +- **WHEN** the same control task type and same factory path are registered again +- **THEN** DimOS MUST treat the second registration as idempotent +- **AND** the registered task mapping MUST remain unchanged. + +#### Scenario: Conflicting task path registration is rejected +- **GIVEN** a control task type is already registered with a factory path +- **WHEN** a different factory path is registered for the same control task type +- **THEN** DimOS MUST reject the registration +- **AND** the original task mapping MUST remain unchanged. + +### Requirement: External package registration pattern +DimOS SHALL document a supported external package pattern where extension registration is grouped in a package helper and run before blueprint construction. + +#### Scenario: External blueprint module performs registration before use +- **GIVEN** an external robot package defines a blueprint module with a `register_extensions()` helper +- **WHEN** that blueprint module is imported +- **THEN** the helper MUST be able to register required hardware adapters and control tasks before the blueprint is built +- **AND** the blueprint MUST be able to reference those registered names through unchanged `HardwareComponent` and `TaskConfig` usage. diff --git a/openspec/changes/control-extension-sideloading/tasks.md b/openspec/changes/control-extension-sideloading/tasks.md new file mode 100644 index 0000000000..d7b89f7191 --- /dev/null +++ b/openspec/changes/control-extension-sideloading/tasks.md @@ -0,0 +1,44 @@ +## 1. Implementation + +- [x] 1.1 Add `dimos/control/extensions.py` with public `register_hardware_adapter(...)` and `register_control_task(...)` functions. +- [x] 1.2 Implement `register_hardware_adapter(...)` dispatch for `HardwareType.MANIPULATOR`, `HardwareType.BASE`, and `HardwareType.WHOLE_BODY` through the existing hardware adapter registries. +- [x] 1.3 Implement `register_control_task(...)` as a lazy factory-path wrapper around the existing control task registry without importing the target factory module at registration time. +- [x] 1.4 Add input validation for extension registration: non-empty hardware adapter type, non-empty control task type, callable hardware factory, and format-valid control task factory path. +- [x] 1.5 Tighten duplicate registration behavior in the manipulator adapter registry so same-name/same-factory registration is idempotent and same-name/different-factory registration raises an error. +- [x] 1.6 Tighten duplicate registration behavior in the twist/base adapter registry so same-name/same-factory registration is idempotent and same-name/different-factory registration raises an error. +- [x] 1.7 Tighten duplicate registration behavior in the whole-body adapter registry so same-name/same-factory registration is idempotent and same-name/different-factory registration raises an error. +- [x] 1.8 Verify or update control task registry duplicate behavior so same-task/same-path registration is idempotent and same-task/different-path registration raises an error. +- [x] 1.9 Preserve existing built-in discovery hooks for hardware adapters and control tasks while routing external packages through the new facade. + +## 2. Tests + +- [x] 2.1 Add unit tests for `register_hardware_adapter(...)` covering all current `HardwareType` values. +- [x] 2.2 Add unit tests for hardware duplicate policy: same object is idempotent; different object for the same adapter name and hardware type raises. +- [x] 2.3 Add unit tests for `register_control_task(...)` covering lazy path registration and format-only validation. +- [x] 2.4 Add unit tests for control task duplicate policy: same path is idempotent; different path for the same task type raises. +- [x] 2.5 Add a coordinator-level test proving a registered external `BASE` adapter type resolves through `HardwareComponent(hardware_type=BASE, adapter_type="...")` using the normal `ControlCoordinator` path. +- [x] 2.6 Add a coordinator-level test proving a registered external task type resolves from `TaskConfig(type="...")` through the normal lazy control task registry path. +- [x] 2.7 Add regression coverage showing no DimOS package-local adapter or task manifest file is required for the external registration test case. + +## 3. Documentation + +- [x] 3.1 Add an external robot package guide under the appropriate user-facing docs location, centered on the `register_extensions()` pattern. +- [x] 3.2 In the guide, show all supported embodiments: `MANIPULATOR`, `BASE`, and `WHOLE_BODY`. +- [x] 3.3 Use a `BASE` / robot-dog example as the full walkthrough, including hardware adapter registration, lazy control task registration, and unchanged `HardwareComponent` / `TaskConfig` usage. +- [x] 3.4 Explicitly document phase boundaries: no entry points, no automatic package scanning, no blueprint discovery changes, and no diagnostics helpers in this change. +- [x] 3.5 Update existing custom arm documentation to point external-package authors toward the new external robot package guide where appropriate. + +## 4. Verification + +- [x] 4.1 Run `openspec validate control-extension-sideloading`. +- [x] 4.2 Run focused tests for control extensions, hardware registries, control task registry, and ControlCoordinator resolution. +- [x] 4.3 Run docs validation for changed docs, including `doclinks` and `md-babel-py run ` when applicable. +- [x] 4.4 Confirm no blueprint or module registry generation is required; if implementation adds or renames blueprint registry inputs, run `pytest dimos/robot/test_all_blueprints_generation.py`. +- [x] 4.5 Manually QA by importing an external-package-style module that calls `register_extensions()`, then constructing a coordinator configuration with the registered adapter and task names without physical robot motion. + +## 5. Runnable example + +- [x] 5.1 Add a no-hardware sample external package with a custom `BASE` adapter and custom control task. +- [x] 5.2 Add visible logging for extension registration, adapter construction/connection, task creation/ticks, and adapter command writes. +- [x] 5.3 Add pytest coverage that imports the sample as an external package and captures the runtime log proof. +- [x] 5.4 Document the demo command from the external robot package guide. From be6bfd1224109ce346ac234734410f10ca1d00e2 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 13:12:45 -0700 Subject: [PATCH 4/7] chore(control): drop incidental transport adapter edits --- .../hardware/drive_trains/transport/adapter.py | 12 +++++------- dimos/hardware/whole_body/transport/adapter.py | 18 +++++++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/dimos/hardware/drive_trains/transport/adapter.py b/dimos/hardware/drive_trains/transport/adapter.py index 6d379f18f6..5447b2eb93 100644 --- a/dimos/hardware/drive_trains/transport/adapter.py +++ b/dimos/hardware/drive_trains/transport/adapter.py @@ -23,7 +23,7 @@ import threading from typing import TYPE_CHECKING, Any -from dimos.core.transport import LCMTransport, ROSTransport +from dimos.core.transport import LCMTransport from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Twist import Twist from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -147,13 +147,11 @@ def _on_odom(self, msg: PoseStamped) -> None: self._latest_odom = [msg.x, msg.y, msg.yaw] -_LCM_TRANSPORT_FACTORY = partial(TransportTwistAdapter, transport_cls=LCMTransport) -_ROS_TRANSPORT_FACTORY = partial(TransportTwistAdapter, transport_cls=ROSTransport) - - def register(registry: TwistBaseAdapterRegistry) -> None: - registry.register("transport_lcm", _LCM_TRANSPORT_FACTORY) - registry.register("transport_ros", _ROS_TRANSPORT_FACTORY) + from dimos.core.transport import ROSTransport + + registry.register("transport_lcm", partial(TransportTwistAdapter, transport_cls=LCMTransport)) + registry.register("transport_ros", partial(TransportTwistAdapter, transport_cls=ROSTransport)) __all__ = ["TransportTwistAdapter"] diff --git a/dimos/hardware/whole_body/transport/adapter.py b/dimos/hardware/whole_body/transport/adapter.py index a928810597..8950803bb3 100644 --- a/dimos/hardware/whole_body/transport/adapter.py +++ b/dimos/hardware/whole_body/transport/adapter.py @@ -24,7 +24,7 @@ import threading from typing import TYPE_CHECKING, Any -from dimos.core.transport import LCMTransport, ROSTransport +from dimos.core.transport import LCMTransport from dimos.hardware.whole_body.spec import IMUState, MotorCommand, MotorState from dimos.msgs.sensor_msgs.Imu import Imu from dimos.msgs.sensor_msgs.JointState import JointState @@ -182,14 +182,18 @@ def _on_imu(self, msg: Imu) -> None: ) -_LCM_TRANSPORT_FACTORY = partial(TransportWholeBodyAdapter, transport_cls=LCMTransport) -_ROS_TRANSPORT_FACTORY = partial(TransportWholeBodyAdapter, transport_cls=ROSTransport) - - def register(registry: WholeBodyAdapterRegistry) -> None: """Auto-discovered by ``whole_body_adapter_registry.discover()``.""" - registry.register("transport_lcm", _LCM_TRANSPORT_FACTORY) - registry.register("transport_ros", _ROS_TRANSPORT_FACTORY) + from dimos.core.transport import ROSTransport + + registry.register( + "transport_lcm", + partial(TransportWholeBodyAdapter, transport_cls=LCMTransport), + ) + registry.register( + "transport_ros", + partial(TransportWholeBodyAdapter, transport_cls=ROSTransport), + ) __all__ = ["TransportWholeBodyAdapter"] From 79e5b405862e424f2471009444fad034eb1480e4 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 13:19:57 -0700 Subject: [PATCH 5/7] openspec remove --- .../.openspec.yaml | 2 - .../control-extension-sideloading/design.md | 125 ----------------- .../control-extension-sideloading/docs.md | 77 ----------- .../control-extension-sideloading/proposal.md | 38 ------ .../control-extension-sideloading/spec.md | 67 --------- .../control-extension-sideloading/tasks.md | 44 ------ 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 -- 13 files changed, 643 deletions(-) delete mode 100644 openspec/changes/control-extension-sideloading/.openspec.yaml delete mode 100644 openspec/changes/control-extension-sideloading/design.md delete mode 100644 openspec/changes/control-extension-sideloading/docs.md delete mode 100644 openspec/changes/control-extension-sideloading/proposal.md delete mode 100644 openspec/changes/control-extension-sideloading/specs/control-extension-sideloading/spec.md delete mode 100644 openspec/changes/control-extension-sideloading/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/openspec/changes/control-extension-sideloading/.openspec.yaml b/openspec/changes/control-extension-sideloading/.openspec.yaml deleted file mode 100644 index d57ce332e0..0000000000 --- a/openspec/changes/control-extension-sideloading/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-20 diff --git a/openspec/changes/control-extension-sideloading/design.md b/openspec/changes/control-extension-sideloading/design.md deleted file mode 100644 index 5f8a03949d..0000000000 --- a/openspec/changes/control-extension-sideloading/design.md +++ /dev/null @@ -1,125 +0,0 @@ -## Context - -DimOS blueprints describe hardware through `HardwareComponent` entries and control behavior through `TaskConfig` entries. `ControlCoordinator` resolves those string names through internal registries: manipulators use the manipulator adapter registry, mobile bases use the twist/base adapter registry, whole-body robots use the whole-body adapter registry, and control tasks use the task registry. - -Those registries currently discover built-in adapters and tasks from DimOS package-local files. That works for in-repo robots, but it blocks external robot packages from being complete on their own: an external blueprint can refer to `adapter_type="mydog"` or `TaskConfig(type="mydog_gait")`, but DimOS has no public supported API for that package to register those names before `ControlCoordinator` resolves them. - -This design adds a small public facade for explicit registration. External blueprint discovery and package auto-discovery are intentionally out of scope; this change assumes an external package's blueprint module is imported by the user or by a separate discovery mechanism. - -## Goals / Non-Goals - -**Goals:** - -- Provide a public `dimos.control.extensions` module for external ControlCoordinator registration. -- Support external hardware adapter registration for all current `HardwareType` values: `MANIPULATOR`, `BASE`, and `WHOLE_BODY`. -- Support external control task registration using the existing lazy factory-path style. -- Preserve unchanged blueprint usage through `HardwareComponent.adapter_type` and `TaskConfig.type`. -- Make duplicate registrations safe and debuggable by failing on conflicting mappings while allowing idempotent same-object or same-path re-registration. -- Document an external robot package pattern centered on `register_extensions()` in the package's blueprint module. - -**Non-Goals:** - -- Add Python entry point discovery. -- Add automatic package scanning. -- Change CLI or generated blueprint registry discovery. -- Add `available_*` diagnostics helpers. -- Replace the three hardware registries with a new unified internal registry. -- Add a robot template generator. -- Change stream, transport, skill, or MCP contracts. - -## DimOS Architecture - -The public API should live in a new facade module: - -```python -from dimos.control.extensions import register_hardware_adapter, register_control_task -``` - -`register_hardware_adapter(hardware_type, adapter_type, factory)` is the logical public hardware catalog. Internally it dispatches to the existing registries by `HardwareType`: - -- `MANIPULATOR` delegates to `dimos.hardware.manipulators.registry.adapter_registry`. -- `BASE` delegates to `dimos.hardware.drive_trains.registry.twist_base_adapter_registry`. -- `WHOLE_BODY` delegates to `dimos.hardware.whole_body.registry.whole_body_adapter_registry`. - -The hardware factory is any callable accepted by the target registry. The public contract does not guarantee semantic equivalence for wrappers such as fresh `partial()` objects or lambdas; idempotency is object identity only. - -`register_control_task(task_type, factory_path)` delegates to the existing `control_task_registry.register_path(...)` lazy path mechanism. The extension facade should validate the name and path shape but should not import the task module during registration. The factory is imported later when `ControlCoordinator` creates the configured task. - -Blueprints remain unchanged: - -```python -HardwareComponent(hardware_type=HardwareType.BASE, adapter_type="mydog") -TaskConfig(type="mydog_gait") -``` - -External packages should group registration in a helper and call it from their blueprint module at import time: - -```python -def register_extensions() -> None: - register_hardware_adapter(HardwareType.BASE, "mydog", MyDogAdapter) - register_control_task("mydog_gait", "dimos_mydog.tasks.gait:create_task") - -register_extensions() -``` - -No module stream names or transport schemas change. No DimOS Python `Spec` Protocol is required for this API. Adapter Protocol expectations remain those already consumed by `ConnectedHardware`, `ConnectedTwistBase`, and `ConnectedWholeBody`. Skills and MCP exposure remain dynamic through launched modules and are not part of this registration surface. - -## Decisions - -1. **Use a public facade instead of documenting internal registries.** - External users should import `dimos.control.extensions`, not low-level hardware or task registry singletons. This keeps the stable public API small while allowing internal registry implementation to evolve. - -2. **Use one hardware registration function keyed by `HardwareType`.** - The user-facing model is a logical hardware adapter catalog keyed by `(hardware_type, adapter_type)`. The implementation can still delegate to separate internal registries to avoid a larger refactor. - -3. **Keep hardware factories direct callables.** - Hardware registries already create adapter instances from callable objects. Direct callables keep external adapter registration simple and allow classes or factory functions. - -4. **Use lazy factory paths for control tasks.** - Control tasks may depend on heavier planning, simulation, policy, or IK code. A lazy path matches the existing built-in task registry and avoids importing task modules until the task is actually selected. - -5. **Validate task paths only by shape at registration time.** - The registration API should reject empty or malformed paths, but it should preserve laziness by not importing the target module. - -6. **Enforce duplicate protection in underlying registries.** - Duplicate behavior should be consistent whether registration happens through the facade or through existing internal discovery. Same key with the same object/path is idempotent; same key with a different object/path raises an error. - -7. **Do not support semantic equivalence for wrappers.** - If a caller creates a new `partial()`, lambda, or wrapper each time, it is a different factory object. The public pattern should use stable top-level classes or functions. - -## Safety / Simulation / Replay - -This change does not alter command semantics, robot motion limits, stream contents, or transport behavior. It changes which registered adapter and task names `ControlCoordinator` can resolve. - -The safety-sensitive risk is accidental name collision. Silent overwrite could cause a blueprint to resolve to the wrong hardware adapter or task factory. The new duplicate policy mitigates this by failing on conflicting mappings. - -Simulation adapters are covered when they register through one of the existing hardware registries. Replay behavior is unchanged because no recorded stream format changes. - -Manual QA should use fake adapters and tasks rather than physical robot motion. Hardware-facing manual tests, if any, should be limited to confirming registration and coordinator construction before any movement command is issued. - -## Risks / Trade-offs - -- **Underlying duplicate enforcement may expose existing internal collisions.** Run focused tests and fix any collision explicitly instead of preserving silent overwrite. -- **Import order remains explicit.** Without entry points, registration only happens when the external package module that calls `register_extensions()` is imported. Documentation must make this pattern clear. -- **The facade is intentionally narrow.** External packages still need a way to make their blueprints importable or discoverable. That is handled elsewhere or later. -- **Callable identity is strict.** This avoids fuzzy equality rules, but callers must use stable factory objects for idempotent registration. - -## Migration / Rollout - -Existing built-in DimOS registrations should continue to use their current registry hooks unless maintainers choose to migrate them later. External docs should bless only `dimos.control.extensions`. - -No generated blueprint registry update is required because this change does not add or rename DimOS blueprints. No new dependency is required. - -Rollout steps: - -1. Add the public facade module. -2. Tighten duplicate registration behavior in hardware and task registries. -3. Add tests for direct facade behavior, duplicate policy, invalid input, and real `ControlCoordinator` resolution. -4. Add the external robot package guide. -5. Run focused control/registry tests and documentation validation. - -Rollback is straightforward: remove the facade and restore prior duplicate behavior if needed, with no data migration. - -## Open Questions - -None for phase 1. Future work may add entry point discovery, diagnostics helpers, automatic package scanning, or a robot package template. diff --git a/openspec/changes/control-extension-sideloading/docs.md b/openspec/changes/control-extension-sideloading/docs.md deleted file mode 100644 index fb6c340526..0000000000 --- a/openspec/changes/control-extension-sideloading/docs.md +++ /dev/null @@ -1,77 +0,0 @@ -## User-Facing Docs - -Add a new external robot package guide under `docs/capabilities/` or another user-facing location chosen during implementation. The guide should explain the package anatomy and focus on how external packages register ControlCoordinator extension points. - -Recommended guide outline: - -```text -dimos_mydog/ -├── adapters.py -├── blueprints.py # calls register_extensions() -├── tasks/ -│ └── gait.py # lazy control task factory -├── skills.py # optional -└── prompts.py # optional -``` - -The guide should show all supported embodiments briefly: - -- `HardwareType.MANIPULATOR` for an external arm. -- `HardwareType.BASE` for an external robot dog or mobile base. -- `HardwareType.WHOLE_BODY` for an external humanoid or whole-body robot. - -Use a `BASE` / robot-dog style walkthrough as the primary example: - -```python -from dimos.control.components import HardwareType -from dimos.control.extensions import register_hardware_adapter, register_control_task - -from dimos_mydog.adapters import MyDogAdapter - - -def register_extensions() -> None: - register_hardware_adapter(HardwareType.BASE, "mydog", MyDogAdapter) - register_control_task("mydog_gait", "dimos_mydog.tasks.gait:create_task") - - -register_extensions() -``` - -Then show unchanged blueprint usage: - -```python -HardwareComponent(hardware_type=HardwareType.BASE, adapter_type="mydog") -TaskConfig(type="mydog_gait") -``` - -The guide should explicitly state boundaries: - -- This change handles external hardware adapter and control task registration. -- External blueprint discovery is separate. -- Package auto-discovery and entry points are future work. -- Direct imports from low-level registry singletons are internal/advanced and should not be the documented external package path. - -Consider updating existing custom arm documentation to point external-package authors to the new guide instead of requiring files under `dimos/hardware/manipulators/`. - -## Contributor Docs - -No contributor-process documentation is required unless implementation discovers new testing or release steps. If duplicate registry behavior exposes built-in collisions, note the expected duplicate policy near relevant registry tests or developer docs. - -## Coding-Agent Docs - -No `AGENTS.md` or coding-agent documentation update is required for phase 1. The external robot package guide should be sufficient for future coding agents working on robot package examples. - -## Doc Validation - -Run documentation checks appropriate to the files changed during implementation. Expected commands include: - -```bash -doclinks -md-babel-py run -``` - -If the chosen docs do not contain executable snippets, record that no snippet execution is required. - -## No Docs Needed - -Documentation is required because this is a new public extension surface for external robot authors. diff --git a/openspec/changes/control-extension-sideloading/proposal.md b/openspec/changes/control-extension-sideloading/proposal.md deleted file mode 100644 index 4dbd9a5ff1..0000000000 --- a/openspec/changes/control-extension-sideloading/proposal.md +++ /dev/null @@ -1,38 +0,0 @@ -## Why - -External robot packages need a supported way to provide the ControlCoordinator pieces their blueprints reference without placing files inside the DimOS repository. Today, hardware adapters are discovered by closed-world in-repo registries and control tasks are discovered from in-repo manifests. That makes a complete outside-the-repo robot package impractical even when blueprint discovery is handled elsewhere. - -This change introduces a small public extension surface for external packages to sideload hardware adapters and control tasks before a blueprint builds. The goal is to let an external robot package register its adapter and task names, then keep using the existing `HardwareComponent` and `TaskConfig` configuration path unchanged. - -## What Changes - -- Add a public `dimos.control.extensions` facade for external packages. -- Add `register_hardware_adapter(hardware_type, adapter_type, factory)` for all current `HardwareType` values: manipulator, base, and whole-body. -- Add `register_control_task(task_type, factory_path)` using the existing lazy factory-path model for task factories. -- Tighten duplicate registration behavior in the underlying hardware and control-task registries: same key with the same object/path is idempotent; same key with a different object/path raises an error. -- Document the blessed external package pattern: define `register_extensions()`, call it from the external blueprint module at import time, and continue using `HardwareComponent` and `TaskConfig` normally. -- No entry point discovery, automatic package scanning, diagnostics helpers, or blueprint discovery changes are included in this phase. - -## Affected DimOS Surfaces - -- Modules/streams: no stream protocol changes; external modules may use the new registration facade before blueprint build. -- Blueprints/CLI: blueprints can reference externally registered `HardwareComponent.adapter_type` and `TaskConfig.type` values; CLI discovery behavior is unchanged. -- Skills/MCP: no direct changes; external skills remain exposed through launched modules as today. -- Hardware/simulation/replay: hardware adapter registration changes affect manipulator, base, and whole-body adapter registries, including simulation adapters that use those registries. -- Docs/generated registries: add external robot package documentation; no generated blueprint registry behavior changes. - -## Capabilities - -### New Capabilities - -- `control-extension-sideloading`: Public behavior for registering external ControlCoordinator hardware adapters and control tasks. - -### Modified Capabilities - -- None. - -## Impact - -External robot authors gain a stable public API for registering ControlCoordinator extension points from installed packages. Existing DimOS blueprints and built-in registries keep their current usage pattern, but duplicate name collisions now fail instead of silently overwriting. This may expose accidental internal duplicate registrations during testing, which should be fixed explicitly. - -Documentation should present this as one layer of the external robot package story: this change handles ControlCoordinator sideloading, while external blueprint discovery and automatic package discovery remain separate or future work. Tests should cover real ControlCoordinator resolution through externally registered hardware adapter and task names, plus duplicate and validation behavior. diff --git a/openspec/changes/control-extension-sideloading/specs/control-extension-sideloading/spec.md b/openspec/changes/control-extension-sideloading/specs/control-extension-sideloading/spec.md deleted file mode 100644 index 79e1983e6b..0000000000 --- a/openspec/changes/control-extension-sideloading/specs/control-extension-sideloading/spec.md +++ /dev/null @@ -1,67 +0,0 @@ -## ADDED Requirements - -### Requirement: External hardware adapter registration -DimOS SHALL provide a public API that lets external packages register a hardware adapter name for each supported hardware type before a blueprint using that adapter is built. - -#### Scenario: External base adapter resolves through coordinator configuration -- **GIVEN** an external package has registered a base hardware adapter with adapter type `mydog` -- **WHEN** a blueprint configures a hardware component with hardware type `BASE` and adapter type `mydog` -- **THEN** DimOS MUST resolve that adapter through the normal ControlCoordinator hardware path -- **AND** the blueprint MUST NOT require the adapter implementation to live under the DimOS source tree. - -#### Scenario: All current hardware types are supported -- **GIVEN** an external package registers adapters for manipulator, base, and whole-body hardware types -- **WHEN** blueprints configure matching hardware components for each registered adapter type -- **THEN** DimOS MUST route each adapter registration to the matching hardware category -- **AND** each registered adapter type MUST be resolvable by the normal ControlCoordinator path for that category. - -### Requirement: External control task registration -DimOS SHALL provide a public API that lets external packages register a control task type using a lazy factory path before a blueprint using that task is built. - -#### Scenario: External task resolves lazily -- **GIVEN** an external package has registered control task type `mydog_gait` with a factory path -- **WHEN** a coordinator configuration requests task type `mydog_gait` -- **THEN** DimOS MUST resolve the task through the normal control task registry path -- **AND** DimOS MUST NOT import the task factory module at registration time. - -#### Scenario: Invalid task registration is rejected early -- **GIVEN** an external package attempts to register a control task with an empty name or malformed factory path -- **WHEN** registration is attempted -- **THEN** DimOS MUST reject the registration before coordinator construction -- **AND** the error MUST identify the invalid registration input. - -### Requirement: Duplicate extension registration is deterministic -DimOS SHALL make duplicate hardware adapter and control task registrations deterministic by allowing idempotent same-mapping registration and rejecting conflicting mappings. - -#### Scenario: Same hardware registration is idempotent -- **GIVEN** a hardware adapter type is registered for a hardware category with a stable factory object -- **WHEN** the same hardware category, adapter type, and factory object are registered again -- **THEN** DimOS MUST treat the second registration as idempotent -- **AND** the registered adapter mapping MUST remain unchanged. - -#### Scenario: Conflicting hardware registration is rejected -- **GIVEN** a hardware adapter type is already registered for a hardware category -- **WHEN** a different factory object is registered for the same hardware category and adapter type -- **THEN** DimOS MUST reject the registration -- **AND** the original adapter mapping MUST remain unchanged. - -#### Scenario: Same task path registration is idempotent -- **GIVEN** a control task type is registered with a factory path -- **WHEN** the same control task type and same factory path are registered again -- **THEN** DimOS MUST treat the second registration as idempotent -- **AND** the registered task mapping MUST remain unchanged. - -#### Scenario: Conflicting task path registration is rejected -- **GIVEN** a control task type is already registered with a factory path -- **WHEN** a different factory path is registered for the same control task type -- **THEN** DimOS MUST reject the registration -- **AND** the original task mapping MUST remain unchanged. - -### Requirement: External package registration pattern -DimOS SHALL document a supported external package pattern where extension registration is grouped in a package helper and run before blueprint construction. - -#### Scenario: External blueprint module performs registration before use -- **GIVEN** an external robot package defines a blueprint module with a `register_extensions()` helper -- **WHEN** that blueprint module is imported -- **THEN** the helper MUST be able to register required hardware adapters and control tasks before the blueprint is built -- **AND** the blueprint MUST be able to reference those registered names through unchanged `HardwareComponent` and `TaskConfig` usage. diff --git a/openspec/changes/control-extension-sideloading/tasks.md b/openspec/changes/control-extension-sideloading/tasks.md deleted file mode 100644 index d7b89f7191..0000000000 --- a/openspec/changes/control-extension-sideloading/tasks.md +++ /dev/null @@ -1,44 +0,0 @@ -## 1. Implementation - -- [x] 1.1 Add `dimos/control/extensions.py` with public `register_hardware_adapter(...)` and `register_control_task(...)` functions. -- [x] 1.2 Implement `register_hardware_adapter(...)` dispatch for `HardwareType.MANIPULATOR`, `HardwareType.BASE`, and `HardwareType.WHOLE_BODY` through the existing hardware adapter registries. -- [x] 1.3 Implement `register_control_task(...)` as a lazy factory-path wrapper around the existing control task registry without importing the target factory module at registration time. -- [x] 1.4 Add input validation for extension registration: non-empty hardware adapter type, non-empty control task type, callable hardware factory, and format-valid control task factory path. -- [x] 1.5 Tighten duplicate registration behavior in the manipulator adapter registry so same-name/same-factory registration is idempotent and same-name/different-factory registration raises an error. -- [x] 1.6 Tighten duplicate registration behavior in the twist/base adapter registry so same-name/same-factory registration is idempotent and same-name/different-factory registration raises an error. -- [x] 1.7 Tighten duplicate registration behavior in the whole-body adapter registry so same-name/same-factory registration is idempotent and same-name/different-factory registration raises an error. -- [x] 1.8 Verify or update control task registry duplicate behavior so same-task/same-path registration is idempotent and same-task/different-path registration raises an error. -- [x] 1.9 Preserve existing built-in discovery hooks for hardware adapters and control tasks while routing external packages through the new facade. - -## 2. Tests - -- [x] 2.1 Add unit tests for `register_hardware_adapter(...)` covering all current `HardwareType` values. -- [x] 2.2 Add unit tests for hardware duplicate policy: same object is idempotent; different object for the same adapter name and hardware type raises. -- [x] 2.3 Add unit tests for `register_control_task(...)` covering lazy path registration and format-only validation. -- [x] 2.4 Add unit tests for control task duplicate policy: same path is idempotent; different path for the same task type raises. -- [x] 2.5 Add a coordinator-level test proving a registered external `BASE` adapter type resolves through `HardwareComponent(hardware_type=BASE, adapter_type="...")` using the normal `ControlCoordinator` path. -- [x] 2.6 Add a coordinator-level test proving a registered external task type resolves from `TaskConfig(type="...")` through the normal lazy control task registry path. -- [x] 2.7 Add regression coverage showing no DimOS package-local adapter or task manifest file is required for the external registration test case. - -## 3. Documentation - -- [x] 3.1 Add an external robot package guide under the appropriate user-facing docs location, centered on the `register_extensions()` pattern. -- [x] 3.2 In the guide, show all supported embodiments: `MANIPULATOR`, `BASE`, and `WHOLE_BODY`. -- [x] 3.3 Use a `BASE` / robot-dog example as the full walkthrough, including hardware adapter registration, lazy control task registration, and unchanged `HardwareComponent` / `TaskConfig` usage. -- [x] 3.4 Explicitly document phase boundaries: no entry points, no automatic package scanning, no blueprint discovery changes, and no diagnostics helpers in this change. -- [x] 3.5 Update existing custom arm documentation to point external-package authors toward the new external robot package guide where appropriate. - -## 4. Verification - -- [x] 4.1 Run `openspec validate control-extension-sideloading`. -- [x] 4.2 Run focused tests for control extensions, hardware registries, control task registry, and ControlCoordinator resolution. -- [x] 4.3 Run docs validation for changed docs, including `doclinks` and `md-babel-py run ` when applicable. -- [x] 4.4 Confirm no blueprint or module registry generation is required; if implementation adds or renames blueprint registry inputs, run `pytest dimos/robot/test_all_blueprints_generation.py`. -- [x] 4.5 Manually QA by importing an external-package-style module that calls `register_extensions()`, then constructing a coordinator configuration with the registered adapter and task names without physical robot motion. - -## 5. Runnable example - -- [x] 5.1 Add a no-hardware sample external package with a custom `BASE` adapter and custom control task. -- [x] 5.2 Add visible logging for extension registration, adapter construction/connection, task creation/ticks, and adapter command writes. -- [x] 5.3 Add pytest coverage that imports the sample as an external package and captures the runtime log proof. -- [x] 5.4 Document the demo command from the external robot package guide. 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 a2b61ba4adf4f60fa16f1690bcff22daad31e52e Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 13:31:45 -0700 Subject: [PATCH 6/7] refactor(control): deduplicate registry validation helpers --- dimos/control/extensions.py | 21 ++++--------- dimos/control/tasks/registry.py | 21 ++++--------- dimos/control/tasks/registry_utils.py | 39 +++++++++++++++++++++++++ dimos/control/test_extensions.py | 33 ++++++++++++++++++++- dimos/hardware/drive_trains/registry.py | 12 ++------ dimos/hardware/manipulators/registry.py | 12 ++------ dimos/hardware/registry_utils.py | 28 ++++++++++++++++++ dimos/hardware/whole_body/registry.py | 12 ++------ 8 files changed, 118 insertions(+), 60 deletions(-) create mode 100644 dimos/control/tasks/registry_utils.py create mode 100644 dimos/hardware/registry_utils.py diff --git a/dimos/control/extensions.py b/dimos/control/extensions.py index da2a646aa0..43a320fe16 100644 --- a/dimos/control/extensions.py +++ b/dimos/control/extensions.py @@ -21,6 +21,8 @@ from dimos.control.components import HardwareType from dimos.control.tasks.registry import control_task_registry +from dimos.control.tasks.registry_utils import normalize_task_name, validate_task_factory_path +from dimos.hardware.registry_utils import normalize_adapter_name if TYPE_CHECKING: from dimos.hardware.drive_trains.spec import TwistBaseAdapter @@ -40,7 +42,7 @@ def register_hardware_adapter( factory object is idempotent; registering a different factory for an existing name raises from the target registry. """ - adapter_name = _normalize_name(adapter_type, label="Hardware adapter type") + adapter_name = normalize_adapter_name(adapter_type) if not callable(factory): raise TypeError("Hardware adapter factory must be callable") @@ -76,22 +78,9 @@ def register_control_task(task_type: str, factory_path: str) -> None: resolved later by the control task registry when a coordinator creates a matching ``TaskConfig``. """ - task_name = _normalize_name(task_type, label="Control task type") - _validate_factory_path(factory_path) + task_name = normalize_task_name(task_type) + validate_task_factory_path(factory_path, label="control task factory path") control_task_registry.register_path(task_name, factory_path) -def _normalize_name(name: str, *, label: str) -> str: - normalized = name.strip().lower() - if not normalized: - raise ValueError(f"{label} must be non-empty") - return normalized - - -def _validate_factory_path(factory_path: str) -> None: - module_name, separator, attr = factory_path.partition(":") - if not factory_path.strip() or separator != ":" or not module_name or not attr: - raise ValueError(f"Invalid control task factory path: {factory_path!r}") - - __all__ = ["register_control_task", "register_hardware_adapter"] diff --git a/dimos/control/tasks/registry.py b/dimos/control/tasks/registry.py index ab7bcf94dc..590022db0e 100644 --- a/dimos/control/tasks/registry.py +++ b/dimos/control/tasks/registry.py @@ -33,6 +33,8 @@ import os from typing import TYPE_CHECKING, cast +from dimos.control.tasks.registry_utils import normalize_task_name, validate_task_factory_path + if TYPE_CHECKING: from dimos.control.coordinator import TaskConfig from dimos.control.hardware_interface import ConnectedHardware, ConnectedWholeBody @@ -78,8 +80,8 @@ def discover(self) -> None: def register_path(self, name: str, factory_path: str) -> None: """Register a lazy task factory import path.""" - key = _normalize_task_name(name) - _validate_factory_path(factory_path) + key = normalize_task_name(name) + validate_task_factory_path(factory_path) existing = self._factory_paths.get(key) if existing is not None and existing != factory_path: raise ValueError(f"Duplicate task type {key!r}: {existing!r} vs {factory_path!r}") @@ -102,7 +104,7 @@ def create( adapter resolve it from their typed params; pass ``None`` only if no task in this registry needs hardware. """ - key = name.lower() + key = normalize_task_name(name) factory = self._resolve_factory(key) return factory(cfg=cfg, hardware=hardware or {}) @@ -129,19 +131,6 @@ def _resolve_factory(self, key: str) -> TaskFactory: return factory -def _normalize_task_name(name: str) -> str: - key = name.strip().lower() - if not key: - raise ValueError("Task type must be non-empty") - return key - - -def _validate_factory_path(factory_path: str) -> None: - module_name, separator, attr = factory_path.partition(":") - if not factory_path.strip() or separator != ":" or not module_name or not attr: - raise ValueError(f"Invalid task factory path: {factory_path!r}") - - control_task_registry = ControlTaskRegistry() __all__ = ["ControlTaskRegistry", "TaskFactory", "control_task_registry"] diff --git a/dimos/control/tasks/registry_utils.py b/dimos/control/tasks/registry_utils.py new file mode 100644 index 0000000000..84142ad637 --- /dev/null +++ b/dimos/control/tasks/registry_utils.py @@ -0,0 +1,39 @@ +# 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. + +"""Shared helpers for control task registries and extension registration.""" + +from __future__ import annotations + + +def normalize_task_name(name: str) -> str: + """Normalize task type names consistently for registration and lookup.""" + key = name.strip().lower() + if not key: + raise ValueError("Task type must be non-empty") + return key + + +def validate_task_factory_path( + factory_path: str, + *, + label: str = "task factory path", +) -> None: + """Validate a lazy factory path of the form ``module:function``.""" + module_name, separator, attr = factory_path.partition(":") + if not factory_path.strip() or separator != ":" or not module_name or not attr: + raise ValueError(f"Invalid {label}: {factory_path!r}") + + +__all__ = ["normalize_task_name", "validate_task_factory_path"] diff --git a/dimos/control/test_extensions.py b/dimos/control/test_extensions.py index f32ab2ad9c..674b6b4100 100644 --- a/dimos/control/test_extensions.py +++ b/dimos/control/test_extensions.py @@ -126,6 +126,19 @@ def whole_body_factory(**kwargs: object) -> object: assert whole_body_adapter_registry._adapters["ext_whole_body"] is whole_body_factory +def test_hardware_adapter_create_uses_same_normalization_as_register() -> None: + class PaddedAdapter: + pass + + register_hardware_adapter(HardwareType.MANIPULATOR, " Padded_Manipulator ", PaddedAdapter) + register_hardware_adapter(HardwareType.BASE, " Padded_Base ", PaddedAdapter) + register_hardware_adapter(HardwareType.WHOLE_BODY, " Padded_Whole_Body ", PaddedAdapter) + + assert isinstance(adapter_registry.create(" padded_manipulator "), PaddedAdapter) + assert isinstance(twist_base_adapter_registry.create(" padded_base "), PaddedAdapter) + assert isinstance(whole_body_adapter_registry.create(" padded_whole_body "), PaddedAdapter) + + @pytest.mark.parametrize( ("hardware_type", "registry_name"), [ @@ -169,6 +182,24 @@ def test_register_control_task_registers_lazy_path_without_importing_target(mock import_module.assert_not_called() +def test_control_task_create_uses_same_normalization_as_register( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module_name = "external_padded_task_module" + module = ModuleType(module_name) + module.__dict__["make_task"] = ExternalControlTask + monkeypatch.setitem(sys.modules, module_name, module) + + control_task_registry.register_path(" External_Padded_Task ", f"{module_name}:make_task") + + task = control_task_registry.create( + " external_padded_task ", + TaskConfig(name="external_padded_task", type="external_padded_task"), + ) + + assert isinstance(task, ExternalControlTask) + + @pytest.mark.parametrize( "factory_path", ["", "external_pkg.tasks", ":make_task", "external_pkg.tasks:"] ) @@ -220,7 +251,7 @@ def test_control_coordinator_resolves_external_task_from_lazy_registry_without_m module_name = "external_control_extension_task_module" task_type = "external_task_no_manifest" module = ModuleType(module_name) - module.make_task = ExternalControlTask + module.__dict__["make_task"] = ExternalControlTask monkeypatch.setitem(sys.modules, module_name, module) register_control_task(task_type, f"{module_name}:make_task") coordinator = ControlCoordinator( diff --git a/dimos/hardware/drive_trains/registry.py b/dimos/hardware/drive_trains/registry.py index c249dd7d2c..190f9d8581 100644 --- a/dimos/hardware/drive_trains/registry.py +++ b/dimos/hardware/drive_trains/registry.py @@ -35,6 +35,7 @@ import os from typing import TYPE_CHECKING, Any +from dimos.hardware.registry_utils import normalize_adapter_name from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: @@ -52,7 +53,7 @@ def __init__(self) -> None: def register( self, name: str, factory: type[TwistBaseAdapter] | Callable[..., TwistBaseAdapter] ) -> None: - key = _normalize_adapter_name(name) + key = normalize_adapter_name(name) existing = self._adapters.get(key) if existing is factory: return @@ -73,7 +74,7 @@ def create(self, name: str, **kwargs: Any) -> TwistBaseAdapter: Raises: KeyError: If adapter name is not found """ - key = name.lower() + key = normalize_adapter_name(name) if key not in self._adapters: raise KeyError(f"Unknown twist base adapter: {name}. Available: {self.available()}") @@ -103,13 +104,6 @@ def discover(self) -> None: logger.warning(f"Skipping twist base adapter {entry}: {e}") -def _normalize_adapter_name(name: str) -> str: - key = name.strip().lower() - if not key: - raise ValueError("Adapter name must be non-empty") - return key - - twist_base_adapter_registry = TwistBaseAdapterRegistry() twist_base_adapter_registry.discover() diff --git a/dimos/hardware/manipulators/registry.py b/dimos/hardware/manipulators/registry.py index 06da3f9097..770ba90543 100644 --- a/dimos/hardware/manipulators/registry.py +++ b/dimos/hardware/manipulators/registry.py @@ -35,6 +35,7 @@ import importlib from typing import TYPE_CHECKING, Any +from dimos.hardware.registry_utils import normalize_adapter_name from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: @@ -57,7 +58,7 @@ def register(self, name: str, factory: Callable[..., ManipulatorAdapter]) -> Non configuration error because it would otherwise silently change which adapter a blueprint resolves. """ - key = _normalize_adapter_name(name) + key = normalize_adapter_name(name) existing = self._adapters.get(key) if existing is factory: return @@ -78,7 +79,7 @@ def create(self, name: str, **kwargs: Any) -> ManipulatorAdapter: Raises: KeyError: If adapter name is not found """ - key = name.lower() + key = normalize_adapter_name(name) if key not in self._adapters: raise KeyError(f"Unknown adapter: {name}. Available: {self.available()}") @@ -112,13 +113,6 @@ def discover(self) -> None: logger.debug(f"Skipping adapter {child.name}: {e}") -def _normalize_adapter_name(name: str) -> str: - key = name.strip().lower() - if not key: - raise ValueError("Adapter name must be non-empty") - return key - - adapter_registry = AdapterRegistry() adapter_registry.discover() diff --git a/dimos/hardware/registry_utils.py b/dimos/hardware/registry_utils.py new file mode 100644 index 0000000000..e50c167900 --- /dev/null +++ b/dimos/hardware/registry_utils.py @@ -0,0 +1,28 @@ +# 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. + +"""Shared helpers for hardware registries.""" + +from __future__ import annotations + + +def normalize_adapter_name(name: str) -> str: + """Normalize adapter names consistently for registration and lookup.""" + key = name.strip().lower() + if not key: + raise ValueError("Adapter name must be non-empty") + return key + + +__all__ = ["normalize_adapter_name"] diff --git a/dimos/hardware/whole_body/registry.py b/dimos/hardware/whole_body/registry.py index 79c27c8639..847d8b84fa 100644 --- a/dimos/hardware/whole_body/registry.py +++ b/dimos/hardware/whole_body/registry.py @@ -38,6 +38,7 @@ import os from typing import TYPE_CHECKING, Any +from dimos.hardware.registry_utils import normalize_adapter_name from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: @@ -57,7 +58,7 @@ def __init__(self) -> None: def register(self, name: str, cls: Callable[..., WholeBodyAdapter]) -> None: """Register an adapter factory (class or callable).""" - key = _normalize_adapter_name(name) + key = normalize_adapter_name(name) existing = self._adapters.get(key) if existing is cls: return @@ -67,7 +68,7 @@ def register(self, name: str, cls: Callable[..., WholeBodyAdapter]) -> None: def create(self, name: str, **kwargs: Any) -> WholeBodyAdapter: """Create an adapter instance by name.""" - key = name.lower() + key = normalize_adapter_name(name) if key not in self._adapters: raise KeyError(f"Unknown whole-body adapter: {name}. Available: {self.available()}") return self._adapters[key](**kwargs) @@ -106,13 +107,6 @@ def _discover_in(self, pkg_path: str, dir_path: str, *, max_depth: int) -> None: mod.register(self) -def _normalize_adapter_name(name: str) -> str: - key = name.strip().lower() - if not key: - raise ValueError("Adapter name must be non-empty") - return key - - whole_body_adapter_registry = WholeBodyAdapterRegistry() whole_body_adapter_registry.discover() From 7bf8e969dfafe4a2ad1fb349a6cf09109c9f1f89 Mon Sep 17 00:00:00 2001 From: cc <55869557+TomCC7@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:38:55 -0700 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: cc <55869557+TomCC7@users.noreply.github.com> --- docs/capabilities/external_robot_packages.md | 16 ---------------- .../manipulation/adding_a_custom_arm.md | 6 +++--- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/docs/capabilities/external_robot_packages.md b/docs/capabilities/external_robot_packages.md index 71bd842387..e0fa486424 100644 --- a/docs/capabilities/external_robot_packages.md +++ b/docs/capabilities/external_robot_packages.md @@ -200,19 +200,3 @@ Choose the hardware type that matches the protocol your adapter implements: - `BASE`: velocity-commanded platforms that consume twist-like virtual joints. - `WHOLE_BODY`: joint-level whole-body motor IO. -## Duplicate registration policy - -Registration is deterministic: - -- registering the same hardware type, adapter name, and same factory object again is idempotent; -- registering the same hardware type and adapter name with a different factory raises an error; -- registering the same task type and same factory path again is idempotent; -- registering the same task type with a different factory path raises an error. - -Use stable top-level classes or functions for hardware factories. Fresh wrappers, lambdas, or `functools.partial(...)` objects created on each call are different objects and are not treated as the same factory. - -## Boundaries - -This registration API handles only the ControlCoordinator layer. It does not make the external package discoverable by `dimos run` on its own. External blueprint discovery, package auto-discovery, entry points, richer diagnostics, and robot package templates are separate work. - -Low-level registry singletons such as `adapter_registry`, `twist_base_adapter_registry`, and `whole_body_adapter_registry` remain internal implementation details for DimOS itself. External robot packages should import from `dimos.control.extensions`. diff --git a/docs/capabilities/manipulation/adding_a_custom_arm.md b/docs/capabilities/manipulation/adding_a_custom_arm.md index 0e2cc97ffe..a663367df2 100644 --- a/docs/capabilities/manipulation/adding_a_custom_arm.md +++ b/docs/capabilities/manipulation/adding_a_custom_arm.md @@ -42,7 +42,7 @@ DimOS uses a **Protocol-based adapter pattern** — no base class inheritance re ## Step 1: Create the Adapter -For in-repo DimOS integrations, create a new directory for your arm under `dimos/hardware/manipulators/`: +Create a new directory for your arm under `dimos/hardware/manipulators/`: ``` dimos/hardware/manipulators/ @@ -399,7 +399,7 @@ The `AdapterRegistry` in `dimos/hardware/manipulators/registry.py` automatically 2. For each subpackage, it tries to import `.adapter` 3. If that module has a `register()` function, it calls it -This means **no manual registration is needed for in-repo adapters** — just having the `register()` function in your `adapter.py` is sufficient. External packages should instead use `register_hardware_adapter(...)` from [External Robot Packages](/docs/capabilities/external_robot_packages.md). +This means **no manual registration is needed** — just having the `register()` function in your `adapter.py` is sufficient. You can verify discovery works: @@ -592,7 +592,7 @@ yourarm_planner = manipulation_module( ## Step 5: Register Blueprints -For in-repo integrations, the blueprint registry in `dimos/robot/all_blueprints.py` is **auto-generated** by scanning the codebase for blueprint declarations. After adding your blueprints: +The blueprint registry in `dimos/robot/all_blueprints.py` is **auto-generated** by scanning the codebase for blueprint declarations. After adding your blueprints: 1. Run the generation test to update the registry: ```bash