The Discovery System¶
Jarvis automatically finds and loads plugins at runtime using Python's reflection capabilities. Every discovery service follows the same core algorithm: scan a package directory, find classes that implement a target interface, instantiate them, and register them.
This page explains how that works in detail, including the differences between each discovery service.
The Universal Discovery Algorithm¶
All discovery services use a variation of this pattern:
import pkgutil
import importlib
import inspect
def discover(package, base_interface):
"""Scan a package for classes implementing base_interface."""
plugins = {}
for importer, module_name, is_pkg in pkgutil.iter_modules(package.__path__):
try:
module = importlib.import_module(f"{package.__name__}.{module_name}")
except ImportError as e:
logger.warning(f"Skipping {module_name}: {e}")
continue
for attr_name in dir(module):
attr = getattr(module, attr_name)
if not inspect.isclass(attr):
continue
if not issubclass(attr, base_interface):
continue
if attr is base_interface:
continue # skip the interface itself
try:
instance = attr()
# Validate secrets if applicable: validate_secrets()
# returns the keys that are missing (empty when all set)
if hasattr(instance, 'validate_secrets'):
missing = instance.validate_secrets()
if missing:
logger.warning(f"Skipping {attr_name}: missing secrets {missing}")
continue
# keyed by the plugin's name property: command_name for
# commands, protocol_name for protocols, name otherwise
plugins[plugin_name(instance)] = instance
except Exception as e:
logger.error(f"Failed to instantiate {attr_name}: {e}")
return plugins
The key ingredients from the Python standard library:
pkgutil.iter_modules()--- iterates over all modules in a package directory without importing themimportlib.import_module()--- dynamically imports a module by nameissubclass()--- checks if a class implements the target interfaceinspect.isclass()--- filters out non-class attributes
This combination means you never need to register a plugin: a class that subclasses the right interface, in a module the service scans, is found. Built-in plugins are single modules in the package directory (commands/, agents/ and so on). Installed packages are scanned only in their own layout, below.
Where Installed Packages Land¶
Pantry (through the mobile app) and jdt deploy install each component of a package into a custom_* directory on the node, one directory per component, and the discovery service looks only for the entry file there:
| Component type | Installed to | Entry file the node loads |
|---|---|---|
command |
commands/custom_commands/<name>/ |
command.py |
agent |
agents/custom_agents/<name>/ |
agent.py |
device_protocol |
device_families/custom_families/<name>/ |
protocol.py |
device_manager |
device_managers/custom_managers/<name>/ |
any .py file not starting with _ |
routine |
routines/custom_routines/<name>/ |
routine.json (read by RoutineCommand) |
There is no install directory for providers or prompt providers: they cannot be installed as packages.
Nothing watches these directories. A package goes live when the node scans again:
- Mobile app (Pantry) install: the node restarts its own service after a successful install, and the new components load at boot.
jdt deploy: the install runs in a separate process and does not touch the running node. Restart the node afterwards (sudo systemctl restart jarvis-nodeon a Pi; stop and start it if you run it from a checkout).- A file copied in by hand: the same; restart the node.
Discovery Services¶
Each extension point has its own discovery service. They all follow the universal algorithm above, but differ in when they scan, how they validate, and whether they refresh.
| Service | Scans | Interface | Secret Validation | Background Refresh |
|---|---|---|---|---|
CommandDiscoveryService |
commands/ |
IJarvisCommand |
At execution time | No — refreshed on demand (uninstall, test installs and config pushes call refresh_now()) |
AgentDiscoveryService |
agents/ |
IJarvisAgent |
At discovery (skip if missing) | No (once at startup) |
DeviceManagerDiscoveryService |
device_managers/ |
IJarvisDeviceManager |
At discovery (skip if missing) | No (once at startup) |
DeviceFamilyDiscoveryService |
device_families/ |
IJarvisDeviceProtocol |
At discovery (skip if missing) | No (once at startup) |
CommandDiscoveryService¶
CommandDiscoveryService is the most full-featured discovery service. It handles the core command plugins that define what Jarvis can do.
On-Demand Refresh¶
CommandDiscoveryService does not poll in the background. Discovery runs on demand: the command execution service forces an initial scan at startup, and the in-process paths that change what's on disk or in settings — uninstall, test installs, config pushes, settings snapshots — call refresh_now() explicitly. A Pantry install restarts the node instead, and jdt deploy needs a manual restart (see Where installed packages land). (An earlier 10-minute polling thread was removed: it re-imported every custom command module each cycle and leaked memory, and it was redundant with the install-driven refresh path.)
class CommandDiscoveryService:
def __init__(self):
self._commands_cache: dict[str, IJarvisCommand] = {}
self._failed_modules: dict[str, str] = {}
self._lock = threading.Lock()
def refresh_now(self):
"""Force an immediate re-scan of the commands directory."""
self._discover_commands()
CommandRegistry Filtering¶
CommandDiscoveryService integrates with the CommandRegistry database table. Even if a command plugin is discovered on disk, it is only active if it is enabled in the registry. This allows administrators to disable commands without deleting files:
Disk scan → Found: [get_weather, calculate, get_sports_scores]
Registry → Enabled: [get_weather, calculate]
Result → Active: [get_weather, calculate]
Deferred Secret Validation¶
Commands do not have their secrets validated at discovery time. Instead, validation happens at execution time. This means a command with missing secrets still appears in the registry (as disabled/unavailable) rather than being silently dropped. Users can see what commands exist and what secrets they need to configure.
AgentDiscoveryService¶
AgentDiscoveryService scans the agents/ package for classes implementing IJarvisAgent. Agents are higher-level orchestrators that can coordinate multiple commands and maintain conversational context.
Discovery happens once at startup. Secret validation occurs immediately --- agents with missing secrets are skipped and logged:
# Agent discovery validates secrets eagerly
instance = agent_class()
missing = instance.validate_secrets() # list of missing keys
if missing:
logger.warning(
f"Agent '{instance.name}' skipped: missing secrets {missing}"
)
continue
There is no background refresh for agents. If you add a new agent file, restart the node to pick it up.
DeviceManagerDiscoveryService¶
DeviceManagerDiscoveryService scans device_managers/ for classes implementing IJarvisDeviceManager. Device managers are inventory backends: they decide where the list of devices comes from, such as Home Assistant (home_assistant) or Jarvis's own protocol adapters (jarvis_direct). Integrations for particular brands (Govee, Nest, LIFX) are device protocols, not managers. See Devices.
Like agents, discovery runs once at startup with eager secret validation:
instance = manager_class()
missing = instance.validate_secrets() # list of missing keys
if missing:
logger.warning(
f"Device manager '{instance.name}' skipped: missing secrets {missing}"
)
continue
DeviceFamilyDiscoveryService¶
DeviceFamilyDiscoveryService scans device_families/ for classes implementing the IJarvisDeviceProtocol. Device families are the protocol adapters that talk to devices of one kind, over the local network (LIFX, Kasa), a cloud API, or both (Govee). The jarvis_direct device manager aggregates them into one inventory.
The pattern is identical to DeviceManagerDiscoveryService --- single scan at startup with eager secret validation and no background refresh.
Prompt Providers (legacy)¶
Not supported on jarvisd
The old Docker install's Command Center discovered Python prompt providers with a PromptProviderFactory. Jarvis Automation (jarvisd) loads no Python prompt providers: its prompt formats are built in, and an admin picks one with the llm.prompt_provider setting. The old factory is described under Legacy.
Graceful Failure¶
A critical design principle across all discovery services: a broken plugin never crashes the system.
Every discovery service wraps module imports in try/except blocks. The most common failure mode is ImportError from missing pip packages:
try:
module = importlib.import_module(f"commands.{module_name}")
except ImportError as e:
# Plugin requires a package not installed --- skip it
logger.warning(f"Could not load {module_name}: {e}")
continue
except Exception as e:
# Unexpected error --- still skip, don't crash
logger.error(f"Error loading {module_name}: {e}")
continue
This enables optional dependencies. For example, the get_sports_scores command might require the espn-api package. On nodes where that package is not installed, the command is silently skipped while all other commands work normally.
Instantiation failures are also caught. If a plugin's __init__ raises an exception, that plugin is skipped and logged.
Thread Safety¶
Discovery services use a threading.Lock to protect the plugin registry:
class CommandDiscoveryService:
def __init__(self):
self._lock = threading.Lock()
self._commands_cache: dict[str, IJarvisCommand] = {}
def get_command(self, name: str) -> IJarvisCommand | None:
with self._lock:
return self._commands_cache.get(name)
A refresh builds the entire new registry in a local variable, then swaps it in under the lock. This minimizes the time the lock is held and ensures readers never see a partially-built registry.
Singleton Pattern¶
Each discovery service is accessed through a module-level singleton accessor:
_discovery_service: CommandDiscoveryService | None = None
_service_lock = threading.Lock()
def get_command_discovery_service() -> CommandDiscoveryService:
global _discovery_service
if _discovery_service is None:
with _service_lock:
if _discovery_service is None: # double-checked locking
_discovery_service = CommandDiscoveryService()
return _discovery_service
This ensures:
- Only one instance exists per process
- Initialization happens lazily (on first access)
- Thread-safe via double-checked locking
The first consumer (the command execution service) triggers the initial scan by calling refresh_now().
The same pattern applies to get_agent_discovery_service(), get_device_manager_discovery_service(), and get_device_family_discovery_service().
Forcing a Refresh¶
Inside the node's process, code can trigger a re-scan:
from utils.command_discovery_service import get_command_discovery_service
service = get_command_discovery_service()
service.refresh_now() # synchronous re-scan of this process's registry
The registry is per process, so running this in a separate Python shell refreshes that shell's copy and does nothing to the running node. During development, restart the node to pick up changes to a plugin.
Discovery Lifecycle Summary¶
sequenceDiagram
participant App as Application Startup
participant DS as Discovery Service
participant FS as Filesystem
participant Reg as Plugin Registry
App->>DS: get_*_discovery_service()
DS->>DS: Initialize (singleton)
DS->>FS: pkgutil.iter_modules(package_path)
FS-->>DS: [module_a, module_b, module_c]
loop For each module
DS->>FS: importlib.import_module(module)
alt ImportError
DS->>DS: Log warning, skip
else Success
DS->>DS: Find subclasses of interface
DS->>DS: Instantiate plugin
alt Secrets valid (or deferred)
DS->>Reg: Register plugin
else Secrets missing
DS->>DS: Log warning, skip
end
end
end
Adding a New Discovery Service¶
If you are creating a new extension point, follow this checklist:
- Define the ABC --- Create an abstract base class with the required interface methods
- Create the package --- Add a directory with an
__init__.pythat imports the ABC - Write the discovery service --- Follow the universal algorithm pattern above
- Add thread safety --- Use a lock (
RLockif discovery re-enters it) to protect the registry - Add a singleton accessor ---
get_*_discovery_service()with double-checked locking - Handle failures gracefully --- Catch
ImportErrorand instantiation errors - Decide on refresh strategy --- Scan once at startup, and give the paths that change the disk inside the node's process a
refresh()to call