Plugins can now call ctx.register_automation_blueprint(key, *, title, description, schedule_template, prompt_template, category, slots, deliver_default, skills, tags). The fields mirror the built-in AutomationBlueprint, and slots are BlueprintSlot field dicts, so the call also crosses the plugin-host boundary. - One lookup, cron.blueprint_catalog.list_blueprints() / get_blueprint(), merges the built-ins with the active profile's plugin blueprints at call time. /blueprint (CLI/TUI/messengers), /api/cron/blueprints and the instantiate route all use it. Each dashboard route resolves inside the ?profile= scope, off the event loop. - Keys are namespaced <plugin>:<key>, so a plugin cannot shadow a built-in. A ':' in the key, a duplicate key, an unknown placeholder, a bad slot type or a schedule that does not parse is warned and ignored at registration. - Catalog entries carry source/plugin. Desktop fetches the catalog for the profile it creates jobs in, keyed per profile, and shows the plugin name on the blueprint row and in the "Start from" select. The dashboard shows a plugin badge. - A created job is an ordinary cron job with the prompt and schedule already filled in, so it keeps running if the plugin is later disabled. - The ctx content registrars (register_skill, now joined by register_automation_blueprint) move to hermes_cli/plugins_content.py. The dashboard's blueprint types move to web/src/lib/automation-blueprints.ts. Closes #132373. Requested by @enckequity.
149 lines
6.6 KiB
Python
149 lines
6.6 KiB
Python
"""Plugin-contributed Automation Blueprints (``ctx.register_automation_blueprint``).
|
|
|
|
A plugin hands over the same fields an in-repo :class:`~cron.blueprint_catalog.AutomationBlueprint`
|
|
carries; :func:`build_plugin_blueprint` validates them (bad input raises ``ValueError``, which the
|
|
registrar logs and ignores) and namespaces the key as ``<plugin>:<key>``, so a plugin can never
|
|
shadow a built-in. :func:`plugin_blueprints` reads the ACTIVE profile's plugin manager at call time;
|
|
plugins load per profile, so nothing here is cached across profiles.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import string
|
|
from dataclasses import fields
|
|
from typing import Any, Iterable, List, Mapping
|
|
|
|
from cron.blueprint_catalog import AutomationBlueprint, BlueprintSlot, fill_blueprint
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = ["build_plugin_blueprint", "plugin_blueprints"]
|
|
|
|
_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
_NAMESPACE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
_SLOT_NAME_RE = re.compile(r"^[A-Za-z_]\w{0,63}$")
|
|
_SLOT_FIELDS = frozenset(f.name for f in fields(BlueprintSlot))
|
|
# Placeholders ``_resolve_schedule`` derives itself: minute/hour from the ``time`` slot, dow from a
|
|
# ``recurrence``/``day`` slot (or ``*`` when neither exists).
|
|
_DERIVED_SCHEDULE_FIELDS = {"minute": "time", "hour": "time", "dow": None}
|
|
|
|
|
|
def _text(name: str, value: Any) -> str:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(f"{name} must be a non-empty string")
|
|
return value.strip()
|
|
|
|
|
|
def _strings(name: str, value: Any) -> tuple:
|
|
if isinstance(value, str) or not isinstance(value, Iterable):
|
|
raise ValueError(f"{name} must be a list of strings")
|
|
items = tuple(value)
|
|
if not all(isinstance(v, str) and v.strip() for v in items):
|
|
raise ValueError(f"{name} must be a list of non-empty strings")
|
|
return items
|
|
|
|
|
|
def _slot(raw: Any) -> BlueprintSlot:
|
|
"""Accept a ``BlueprintSlot`` (in-process) or its field mapping (the plugin-host wire form)."""
|
|
if isinstance(raw, BlueprintSlot):
|
|
slot = raw
|
|
elif isinstance(raw, Mapping):
|
|
unknown = sorted(set(raw) - _SLOT_FIELDS)
|
|
if unknown:
|
|
raise ValueError(f"unknown slot field(s): {', '.join(unknown)}")
|
|
data = dict(raw)
|
|
if "options" in data:
|
|
data["options"] = tuple(data["options"] or ())
|
|
try:
|
|
slot = BlueprintSlot(**data)
|
|
except TypeError as exc: # missing name/type/label
|
|
raise ValueError(f"slot {data.get('name')!r}: {exc}") from exc
|
|
else:
|
|
raise ValueError(f"slot must be a mapping, got {type(raw).__name__}")
|
|
if not isinstance(slot.name, str) or not _SLOT_NAME_RE.match(slot.name):
|
|
raise ValueError(f"invalid slot name {slot.name!r}")
|
|
_text(f"slot {slot.name} label", slot.label)
|
|
return slot
|
|
|
|
|
|
def _placeholders(template: str, what: str) -> List[str]:
|
|
names = []
|
|
for _literal, name, _spec, _conv in string.Formatter().parse(template):
|
|
if name is None:
|
|
continue
|
|
if not _SLOT_NAME_RE.match(name):
|
|
raise ValueError(f"{what} placeholder {{{name}}} must be a plain slot name")
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
def _validate(blueprint: AutomationBlueprint) -> None:
|
|
by_name = {s.name: s for s in blueprint.slots}
|
|
if len(by_name) != len(blueprint.slots):
|
|
raise ValueError("slot names must be unique")
|
|
for name in _placeholders(blueprint.prompt_template, "prompt_template"):
|
|
slot = by_name.get(name)
|
|
if slot is None:
|
|
raise ValueError(f"prompt_template uses {{{name}}} but there is no slot named {name!r}")
|
|
if slot.optional and slot.default in (None, ""):
|
|
raise ValueError(f"prompt_template uses optional slot {name!r} that has no default")
|
|
for name in _placeholders(blueprint.schedule_template, "schedule_template"):
|
|
if name in _DERIVED_SCHEDULE_FIELDS:
|
|
needed = _DERIVED_SCHEDULE_FIELDS[name]
|
|
if needed and needed not in by_name:
|
|
raise ValueError(f"schedule_template uses {{{name}}}, which needs a slot named {needed!r}")
|
|
elif name not in by_name:
|
|
raise ValueError(f"schedule_template uses {{{name}}} but there is no slot named {name!r}")
|
|
# When every required slot has a default, fill it now so a broken template or schedule fails at
|
|
# registration instead of on the user's first "Schedule it".
|
|
if all(s.optional or s.default not in (None, "") for s in blueprint.slots):
|
|
from cron.jobs import parse_schedule
|
|
|
|
parse_schedule(fill_blueprint(blueprint, {})["schedule"])
|
|
|
|
|
|
def build_plugin_blueprint(
|
|
plugin: str, key: str, *, title: str, description: str, schedule_template: str,
|
|
prompt_template: str, category: str = "general", slots: Iterable[Any] = (),
|
|
deliver_default: str = "origin", skills: Iterable[str] = (), tags: Iterable[str] = (),
|
|
) -> AutomationBlueprint:
|
|
"""Validated blueprint keyed ``<plugin>:<key>``; raises ``ValueError`` on malformed input."""
|
|
if not isinstance(plugin, str) or not _NAMESPACE_RE.match(plugin):
|
|
raise ValueError(f"plugin name {plugin!r} cannot namespace a blueprint key")
|
|
if not isinstance(key, str) or not _KEY_RE.match(key):
|
|
raise ValueError(
|
|
f"invalid key {key!r}: use lowercase letters, digits, '-' or '_' (the "
|
|
f"'{plugin}:' namespace is added automatically)"
|
|
)
|
|
if isinstance(slots, (str, Mapping)) or not isinstance(slots, Iterable):
|
|
raise ValueError("slots must be a list")
|
|
blueprint = AutomationBlueprint(
|
|
key=f"{plugin}:{key}",
|
|
title=_text("title", title),
|
|
description=_text("description", description),
|
|
category=_text("category", category),
|
|
schedule_template=_text("schedule_template", schedule_template),
|
|
prompt_template=_text("prompt_template", prompt_template),
|
|
slots=[_slot(s) for s in slots],
|
|
deliver_default=_text("deliver_default", deliver_default),
|
|
skills=_strings("skills", skills),
|
|
tags=_strings("tags", tags),
|
|
plugin=plugin,
|
|
)
|
|
_validate(blueprint)
|
|
return blueprint
|
|
|
|
|
|
def plugin_blueprints() -> List[AutomationBlueprint]:
|
|
"""Blueprints the active profile's enabled plugins registered (resolved per call)."""
|
|
try:
|
|
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
|
|
|
discover_plugins() # idempotent; servers that never import model_tools still see plugins
|
|
return get_plugin_manager().list_automation_blueprints()
|
|
except Exception:
|
|
logger.warning("plugin automation blueprints unavailable", exc_info=True)
|
|
return []
|