This document explains how mas-runtime knows which plugins exist and
which types of plugin are valid — the mechanism behind mas.runtime. registry. For how a plugin's canonical URN relates to human-friendly
short names, see plugin-aliases.md; this document is
about registration, not naming.
Earlier versions of the runtime had a hardcoded Python catalog
(plugin_registry.yaml parsed by literal Python code, plus separate
hardcoded dicts in mas-lab-bench for pipeline steps and artifact codecs).
Every new plugin type — not just every new plugin — required a code
change in the runtime or in bench. That meant:
- Libraries could not introduce a new kind of pluggable thing without a runtime code change.
- There was no single place to answer "what plugins exist, and where do they come from?"
- Bench (the benchmark engine) accumulated built-in implementations of pluggable concepts (steps, codecs) that architecturally belong in a library, not the engine.
The manifest/fixpoint system below fixes this: plugin types are data,
not code. A library can introduce type: my_new_thing in a manifest
with zero runtime code changes, as long as something (a builtin, an
explicit types: declaration, or another plugin's provides_types:)
establishes that the type is legitimate before the fixpoint pass ends.
| Concept | Where | What it is |
|---|---|---|
PluginEntry |
registry/__init__.py |
One registered plugin: URN, description, variants, shortcuts, attributes. |
VariantInfo |
registry/__init__.py |
One implementation of a plugin (module + class_name + version). |
PluginRegistry |
registry/__init__.py |
Process-wide singleton (get_registry()) holding all entries, aliases, and known types. |
| Known type | PluginRegistry._known_types |
A category string ("design_pattern", "step", "codec", ...) the registry currently accepts. |
| Manifest | usually library.yaml, or *.plugins.yaml for libraries that split it up |
A YAML file declaring types:, plugins:, aliases:, defaults:. Parsed by bootstrap._parse_generic_manifest. |
The types:/plugins:/aliases:/defaults: payload below is the same
regardless of which file carries it. In library.yaml it sits alongside
the apiVersion: mas/v1 / kind: Library header and the library's own
metadata (see docs/schemas/library.schema.yaml
for the full, validated shape); a split-out plugins/<name>.plugins.yaml
file has no header at all — register_manifest_file reads the same four
keys directly.
# Optional: types this manifest itself introduces, independent of any
# plugin entry below. Rarely needed — see "How a type becomes known".
types: [my_new_thing]
plugins:
- type: step # required — the plugin's category
name: my_step # short name; becomes the default shortcut
urn: mas.step.my_step # optional — derived from type+name if omitted
module: mas.library.lab.steps.my_step
class: MyStep
shortcuts: [my_step] # optional — defaults to [name]
description: "..." # optional
attributes: {} # optional — merged into PluginEntry.attributes
provides_types: [my_new_thing] # optional — see below
- type: my_new_thing
name: instance_one
module: mas.library.lab.widgets
class: WidgetOne
# Optional: alias table merged into the registry (role/short-name -> URN).
aliases:
my_short_name: mas.step.my_step
# Optional: per-type default plugin id, used when a spec omits an explicit
# binding. Mirrors the runtime-wide mechanism in defaults.yaml/defaults.schema.yaml
# (see agent-defaults.md) but scoped to this manifest's own types.
defaults:
my_new_thing: mas.widget.instance_oneA manifest entry can declare variants: (a dict of named implementations,
e.g. builtin/otel) instead of a single top-level module/class, for
plugins that ship more than one backing implementation behind the same URN.
The registry does not trust a plugin's own type: field just because
it appears in plugins:. A type is only "known" — and therefore
registerable — if at least one of these is true before the fixpoint
resolves that candidate:
- It's a core runtime type (
design_pattern,context_manager,context_plugin,memory,governance,step,codec— seebootstrap._BUILTIN_TYPES). - It's listed in this manifest's own top-level
types:. - It's listed in
provides_types:on some other candidate that has already been resolved (possibly from an earlier-loaded manifest, or builtins).
This is a deliberate design choice, not an oversight: if simply appearing
in plugins: were enough to legitimize a type, provides_types would be
meaningless (nothing would ever actually wait for it), and a typo like
type: sttep would silently create a brand-new, permanently-empty
category instead of failing loudly. Every manifest that introduces plugins
of a genuinely new type must say so explicitly — either via types: or via
another plugin's provides_types:.
bootstrap._register_candidates_fixpoint repeatedly sweeps the list of
not-yet-registered candidates:
- Any candidate whose type is currently known gets registered; its own
provides_typesare added to the known-types set immediately (so a later candidate in the same sweep can already use them). - Repeat until a sweep makes no progress.
- If candidates remain unresolved after a sweep makes no progress, raise
ValueErrornaming every stuck URN and its type — loudly, at bootstrap time, not as a confusingNonedeep inside a run.
This means one plugin can register a type for another plugin to use, regardless of which library either one ships in, and regardless of manifest file boundaries — the exact capability needed for a library to extend the plugin type system without a runtime code change.
# library-a: introduces "widget" as a real category
plugins:
- type: design_pattern
name: widget_provider
urn: mas.dp.widget_provider
module: mas.library.a.provider
class: WidgetProviderPlugin
provides_types: [widget]
---
# library-b: registers a widget instance, loaded before OR after library-a's
# manifest — order across manifests doesn't matter because register_manifest_data
# merges reg._known_types (already includes anything provided by previously
# processed manifests) with what this manifest itself declares/provides.
plugins:
- type: widget
name: sample
urn: mas.widget.sample
module: mas.library.b.widgets
class: SampleWidgetbootstrap.load_registry() runs, in order:
-
Built-ins (
_BUILTIN_PLUGINS,_BUILTIN_TYPES, plus package defaults fromdefaults.yaml— see agent-defaults.md) — the small set of plugins the runtime itself ships (design patterns, context managers, the context assembler, semantic memory, sample governance). This is intentionally minimal; it is not where library-provided pluggable content (steps, codecs, tools, ...) lives. -
Aliases (
aliases.yaml+config.yamloverrides) — see plugin-aliases.md. Validated withPluginRegistry.validate_aliases(): an alias pointing at a URN that isn't registered raises immediately, rather than silently resolving toNonelater. -
Library plugin manifests — every library root returned by
mas.library_roots.discover_library_roots(installed packages via themas.runtime.manifest_librariesentry point,config.yaml'smanifest_libraries:map, and directory-scannedlibrary.yamlfiles) is checked bymas.library_catalog.discover_plugin_manifestsfor plugin manifests.library.yamlis the plugin manifest — the same file carrieskind: Librarymetadata (name/description/version/ module_base, validated againstdocs/schemas/library.schema.yaml) and optionalapps:/datasets:/tools:catalogs; when it also declarestypes:/plugins:directly (a key-presence check —mas.library_catalog._declares_plugins— not a value-shape guess), that content is registered too. A library only reaches for a separate file if it genuinely wants to split plugin declarations across multiple manifests, via either:- an explicit
plugin_manifests:map inlibrary.yaml(name: relative/path.yaml) — a deliberately different key fromplugins:, so the two conventions never need to be told apart by inspecting a value's type, or - a scan fallback: any
plugins/**/*.plugins.yamlunder the library root.
Every discovered manifest (
library.yamlitself, and/or any split-out file) is registered viaregister_manifest_file()/register_manifest_data()— the exact same fixpoint mechanism described above. A malformed manifest or an unresolvable type raises immediately at startup; it is not swallowed. - an explicit
mas-library-lab's pipeline steps and artifact codecs are registered this
way (see library-lab/library.yaml's types:/plugins: block) without
mas-lab-bench containing a single hardcoded step or codec class — bench
ships only the pipeline engine (Pipeline, PipelineExecutor,
PipelineStep base class, caching, dependency resolution); every concrete
step or codec is a library plugin resolved through this registry at run
time.
docs/design/manifest-libraries.md (on feat/folder-libraries, stacked on
top of this work) proposes extending library.yaml further: per-plugin
requires:/extra: for optional dependencies, an availability gate
(PluginUnavailable with an install hint), and a mas plugin CLI. That
design is compatible with — and builds on — the manifest schema and
fixpoint mechanism described here; it does not replace it. Nothing in this
document should be considered final once that lands, but the types: /
plugins: / provides_types: shape is expected to remain the payload
format libraries write, however they end up being discovered.
When adding a new plugin type (not just a new plugin):
- Does an existing type already fit? Prefer reusing
step,codec,design_pattern, etc. over inventing a new one. - If genuinely new: does some already-resolvable plugin's
provides_types:cover it, or does your manifest need its owntypes:entry? - Add a manifest test asserting the new type resolves end-to-end
through
register_manifest_data/register_manifest_file— not just through the low-level_register_candidates_fixpointhelper (seeruntime/tests/test_registry_fixpoint.pyfor both styles and why the distinction matters). - Document the new type and a minimal example in this file.