Skip to content

Commit 89b4098

Browse files
Merge pull request #20 from offendingcommit/feat/capability-registration
Add atomic capability selection and registration preflight
2 parents 68770c0 + 254514e commit 89b4098

5 files changed

Lines changed: 273 additions & 32 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ plugin.
2929
`register_plugin` with an explicit receipt identity. Iterable registration
3030
must retain module registration's duplicate checks, deterministic ordering,
3131
skills, and `RegistrationSummary` contract.
32+
- Use `resolve_capability_selection` when several runtime-gated surfaces form
33+
one authorization unit. Capability membership belongs to the plugin, not
34+
deployment configuration; explicit-name selection remains mutually exclusive
35+
for narrow legacy surfaces. Registration preflight must finish before the
36+
first `ctx.register_*` mutation, and receipts must name selected capabilities.
3237
- Consumer plugins must pin this package to an immutable commit, not a moving
3338
branch. Profiles that install multiple plugins into one Python environment
3439
must keep every consumer on the same kit revision.

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,36 @@ single registration receipt; module registration keeps the existing manifest
261261
and module-derived defaults. Duplicate detection and returned
262262
`RegistrationSummary` inventories are identical for both declaration forms.
263263

264+
When a plugin exposes surfaces that must be enabled together, resolve a named
265+
capability before calling `register_plugin` instead of making every deployment
266+
copy the capability's members:
267+
268+
```python
269+
from hermes_plugin_kit import resolve_capability_selection
270+
271+
selection = resolve_capability_selection(
272+
TOOLS_BY_NAME,
273+
enabled_capabilities=("image", "video"),
274+
capability_groups={
275+
"image": {"image_generate"},
276+
"video": {"video_generate", "video_status", "video_cancel"},
277+
},
278+
)
279+
return register_plugin(
280+
ctx,
281+
(TOOLS_BY_NAME[name] for name in selection.names),
282+
capabilities=selection.capabilities,
283+
plugin_name="media",
284+
)
285+
```
286+
287+
Capability selection and explicit-name selection are mutually exclusive.
288+
Unknown capabilities, unknown explicit names, and capability groups that refer
289+
to undeclared names fail before registration begins. The selected capability
290+
names are included in the lifecycle receipt. `register_plugin` also preflights
291+
the required context registrars and specialized providers before registering
292+
any surface, so an unsupported host cannot leave a partially registered plugin.
293+
264294
`@command` requires a bare lowercase kebab-case name. Slash commands are the
265295
backward-compatible default: the handler receives trailing command text
266296
unchanged and may return `str | None` synchronously or asynchronously. The

hermes_plugin_kit/__init__.py

Lines changed: 152 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ def register(ctx):
9595
"CommandType",
9696
"MiddlewareKind",
9797
"PluginSkill",
98+
"CapabilitySelection",
9899
"RegistrationSummary",
100+
"resolve_capability_selection",
99101
"load_plugin_config",
100102
"configure_stderr_logging",
101103
"register_all",
@@ -322,6 +324,14 @@ class PluginSkill:
322324
optional: bool = False
323325

324326

327+
@dataclass(frozen=True)
328+
class CapabilitySelection:
329+
"""Validated capability names and their atomically expanded surfaces."""
330+
331+
capabilities: tuple[str, ...] = ()
332+
names: tuple[str, ...] = ()
333+
334+
325335
@dataclass(frozen=True)
326336
class RegistrationSummary:
327337
"""Inventory of lifecycle surfaces registered by :func:`register_plugin`."""
@@ -336,6 +346,7 @@ class RegistrationSummary:
336346
image_gen_providers: tuple[str, ...] = ()
337347
video_gen_providers: tuple[str, ...] = ()
338348
cli_commands: tuple[str, ...] = ()
349+
capabilities: tuple[str, ...] = ()
339350

340351

341352
class CommandType(str, Enum):
@@ -361,7 +372,7 @@ def log_registration_summary(
361372
"commands=%s; cli_commands=%s; tools=%s; middlewares=%s; hooks=%s; "
362373
"skills=%s; skipped_optional_skills=%s; memory_providers=%s; "
363374
"image_gen_providers=%s; "
364-
"video_gen_providers=%s",
375+
"video_gen_providers=%s; capabilities=%s",
365376
clean_plugin_name,
366377
",".join(summary.commands) or "<none>",
367378
",".join(summary.cli_commands) or "<none>",
@@ -373,6 +384,7 @@ def log_registration_summary(
373384
",".join(summary.memory_providers) or "<none>",
374385
",".join(summary.image_gen_providers) or "<none>",
375386
",".join(summary.video_gen_providers) or "<none>",
387+
",".join(summary.capabilities) or "<none>",
376388
)
377389

378390

@@ -1858,6 +1870,77 @@ def wrapper(args: dict, **kwargs: Any) -> str:
18581870
# Registration
18591871
# ---------------------------------------------------------------------------
18601872

1873+
def _normalized_names(values: Iterable[str], *, label: str) -> frozenset[str]:
1874+
names: set[str] = set()
1875+
for value in values:
1876+
if not isinstance(value, str) or not value.strip():
1877+
raise ValueError(f"{label} must contain only non-empty strings")
1878+
names.add(value.strip())
1879+
return frozenset(names)
1880+
1881+
1882+
def resolve_capability_selection(
1883+
available_names: Iterable[str],
1884+
*,
1885+
capability_groups: Mapping[str, Iterable[str]],
1886+
enabled_capabilities: Iterable[str] | None = None,
1887+
enabled_names: Iterable[str] | None = None,
1888+
) -> CapabilitySelection:
1889+
"""Validate and atomically expand capabilities into registered names.
1890+
1891+
Capability and explicit-name selection are mutually exclusive so a
1892+
deployment cannot accidentally mix an atomic contract with a partial
1893+
override. When neither is supplied, all available names are selected.
1894+
"""
1895+
available = _normalized_names(available_names, label="available_names")
1896+
if enabled_capabilities is not None and enabled_names is not None:
1897+
raise ValueError(
1898+
"enabled_capabilities cannot be combined with enabled_names"
1899+
)
1900+
1901+
normalized_groups: dict[str, frozenset[str]] = {}
1902+
for raw_capability, raw_members in capability_groups.items():
1903+
if not isinstance(raw_capability, str) or not raw_capability.strip():
1904+
raise ValueError("capability names must be non-empty strings")
1905+
capability = raw_capability.strip()
1906+
members = _normalized_names(
1907+
raw_members, label=f"capability {capability!r} members"
1908+
)
1909+
unknown_members = sorted(members - available)
1910+
if unknown_members:
1911+
raise ValueError(
1912+
f"capability {capability!r} references unknown names: "
1913+
+ ", ".join(unknown_members)
1914+
)
1915+
normalized_groups[capability] = members
1916+
1917+
if enabled_capabilities is not None:
1918+
capabilities = _normalized_names(
1919+
enabled_capabilities, label="enabled_capabilities"
1920+
)
1921+
unknown_capabilities = sorted(capabilities - normalized_groups.keys())
1922+
if unknown_capabilities:
1923+
raise ValueError(
1924+
"unknown capabilities: " + ", ".join(unknown_capabilities)
1925+
)
1926+
selected = frozenset().union(
1927+
*(normalized_groups[name] for name in capabilities)
1928+
)
1929+
return CapabilitySelection(
1930+
capabilities=tuple(sorted(capabilities)),
1931+
names=tuple(sorted(selected)),
1932+
)
1933+
1934+
if enabled_names is not None:
1935+
selected = _normalized_names(enabled_names, label="enabled_names")
1936+
unknown_names = sorted(selected - available)
1937+
if unknown_names:
1938+
raise ValueError("unknown names: " + ", ".join(unknown_names))
1939+
return CapabilitySelection(names=tuple(sorted(selected)))
1940+
1941+
return CapabilitySelection(names=tuple(sorted(available)))
1942+
1943+
18611944
def register_all(ctx: Any, module: Any) -> int:
18621945
"""Register every ``@tool`` defined in *module* with *ctx*.
18631946
@@ -1897,26 +1980,25 @@ def _register_tool(ctx: Any, handler: Callable, spec: dict[str, Any]) -> None:
18971980
)
18981981

18991982

1900-
def _register_generation_providers(
1983+
def _validate_generation_providers(
19011984
ctx: Any,
19021985
providers: Iterable[Any],
19031986
*,
19041987
kind: str,
19051988
registrar_name: str,
1906-
) -> list[str]:
1989+
) -> tuple[Callable, tuple[tuple[str, Any], ...]]:
19071990
registrar = getattr(ctx, registrar_name, None)
19081991
if not callable(registrar):
19091992
raise RuntimeError(f"this Hermes plugin context does not support {kind} providers")
1910-
registered: list[str] = []
1993+
validated: list[tuple[str, Any]] = []
19111994
for provider in providers:
19121995
name = getattr(provider, "name", None)
19131996
if not isinstance(name, str) or not name.strip():
19141997
raise ValueError(f"{kind} providers require a non-empty name")
19151998
if not callable(getattr(provider, "generate", None)):
19161999
raise ValueError(f"{kind} provider {name!r} requires generate()")
1917-
registrar(provider)
1918-
registered.append(name)
1919-
return registered
2000+
validated.append((name, provider))
2001+
return registrar, tuple(validated)
19202002

19212003

19222004
def register_plugin(
@@ -1927,6 +2009,7 @@ def register_plugin(
19272009
memory_providers: tuple[Any, ...] | list[Any] = (),
19282010
image_gen_providers: tuple[Any, ...] | list[Any] = (),
19292011
video_gen_providers: tuple[Any, ...] | list[Any] = (),
2012+
capabilities: tuple[str, ...] | list[str] = (),
19302013
plugin_name: str | None = None,
19312014
logger: logging.Logger | None = None,
19322015
) -> RegistrationSummary:
@@ -1989,6 +2072,9 @@ def register_plugin(
19892072
if not isinstance(resolved_plugin_name, str) or not resolved_plugin_name.strip():
19902073
raise ValueError("plugin_name must be a non-empty string")
19912074
resolved_plugin_name = resolved_plugin_name.strip()
2075+
resolved_capabilities = tuple(
2076+
sorted(_normalized_names(capabilities, label="capabilities"))
2077+
)
19922078

19932079
slash_commands: dict[str, Callable] = {}
19942080
cli_commands: dict[str, Callable] = {}
@@ -2067,6 +2153,54 @@ def register_plugin(
20672153
)
20682154
skipped_skills.append(name)
20692155

2156+
required_registrars = {
2157+
"register_command": slash_commands,
2158+
"register_cli_command": cli_commands,
2159+
"register_tool": tools,
2160+
"register_middleware": middlewares,
2161+
"register_hook": hooks,
2162+
"register_skill": available_skills,
2163+
}
2164+
for registrar_name, surfaces in required_registrars.items():
2165+
if surfaces and not callable(getattr(ctx, registrar_name, None)):
2166+
raise RuntimeError(
2167+
f"this Hermes plugin context does not support {registrar_name}()"
2168+
)
2169+
2170+
memory_registrar = getattr(ctx, "register_memory_provider", None)
2171+
validated_memory_providers: list[tuple[str, Any]] = []
2172+
if memory_providers and not callable(memory_registrar):
2173+
raise RuntimeError(
2174+
"this Hermes plugin context does not support memory providers; "
2175+
"use the memory-provider discovery path"
2176+
)
2177+
for provider in memory_providers:
2178+
name = getattr(provider, "name", None)
2179+
if not isinstance(name, str) or not name.strip():
2180+
raise ValueError("memory providers require a non-empty name")
2181+
validated_memory_providers.append((name, provider))
2182+
2183+
image_registrar, validated_image_providers = (
2184+
_validate_generation_providers(
2185+
ctx,
2186+
image_gen_providers,
2187+
kind="image generation",
2188+
registrar_name="register_image_gen_provider",
2189+
)
2190+
if image_gen_providers
2191+
else (None, ())
2192+
)
2193+
video_registrar, validated_video_providers = (
2194+
_validate_generation_providers(
2195+
ctx,
2196+
video_gen_providers,
2197+
kind="video generation",
2198+
registrar_name="register_video_gen_provider",
2199+
)
2200+
if video_gen_providers
2201+
else (None, ())
2202+
)
2203+
20702204
registered_slash_commands: list[str] = []
20712205
for name in sorted(slash_commands):
20722206
obj = slash_commands[name]
@@ -2119,31 +2253,18 @@ def register_plugin(
21192253
registered_skills.append(skill.name)
21202254

21212255
registered_memory_providers: list[str] = []
2122-
register_memory_provider = getattr(ctx, "register_memory_provider", None)
2123-
if memory_providers and not callable(register_memory_provider):
2124-
raise RuntimeError(
2125-
"this Hermes plugin context does not support memory providers; "
2126-
"use the memory-provider discovery path"
2127-
)
2128-
for provider in memory_providers:
2129-
name = getattr(provider, "name", None)
2130-
if not isinstance(name, str) or not name.strip():
2131-
raise ValueError("memory providers require a non-empty name")
2132-
register_memory_provider(provider)
2256+
for name, provider in validated_memory_providers:
2257+
memory_registrar(provider)
21332258
registered_memory_providers.append(name)
21342259

2135-
registered_image_gen_providers = _register_generation_providers(
2136-
ctx,
2137-
image_gen_providers,
2138-
kind="image generation",
2139-
registrar_name="register_image_gen_provider",
2140-
) if image_gen_providers else []
2141-
registered_video_gen_providers = _register_generation_providers(
2142-
ctx,
2143-
video_gen_providers,
2144-
kind="video generation",
2145-
registrar_name="register_video_gen_provider",
2146-
) if video_gen_providers else []
2260+
registered_image_gen_providers: list[str] = []
2261+
for name, provider in validated_image_providers:
2262+
image_registrar(provider)
2263+
registered_image_gen_providers.append(name)
2264+
registered_video_gen_providers: list[str] = []
2265+
for name, provider in validated_video_providers:
2266+
video_registrar(provider)
2267+
registered_video_gen_providers.append(name)
21472268

21482269
summary = RegistrationSummary(
21492270
commands=tuple(registered_slash_commands),
@@ -2156,6 +2277,7 @@ def register_plugin(
21562277
memory_providers=tuple(registered_memory_providers),
21572278
image_gen_providers=tuple(registered_image_gen_providers),
21582279
video_gen_providers=tuple(registered_video_gen_providers),
2280+
capabilities=resolved_capabilities,
21592281
)
21602282
log_registration_summary(log, resolved_plugin_name, summary)
21612283
return summary

tests/test_hermes_contract.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,8 @@ def contract_hook(**kwargs):
415415
"hooks=pre_llm_call; skills=probe; "
416416
"skipped_optional_skills=<none>; "
417417
"memory_providers=<none>; "
418-
"image_gen_providers=<none>; video_gen_providers=<none>",
418+
"image_gen_providers=<none>; video_gen_providers=<none>; "
419+
"capabilities=<none>",
419420
cap.records[0].getMessage(),
420421
)
421422
self.assertEqual(

0 commit comments

Comments
 (0)