Files
hermes-agent/tests/plugins/test_plugin_automation_blueprints.py
Teknium a928a959e3 feat(plugins): let plugins register Automation Blueprints via ctx
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.
2026-10-05 17:34:36 -07:00

203 lines
8.4 KiB
Python

"""Plugins register Automation Blueprints via ``ctx.register_automation_blueprint``.
Contracts: a registered blueprint is listed next to the built-ins (``/blueprint`` and
``/api/cron/blueprints``) under a ``<plugin>:<key>`` key with a plugin source, instantiates
into a normal job, can never shadow a built-in, and is visible only in the profile whose
plugin registered it.
"""
from __future__ import annotations
import json
import logging
import textwrap
from pathlib import Path
import pytest
import hermes_cli.web_models as _web_models
import hermes_cli.web_routers.cron as _rt_cron
_STANDUP = textwrap.dedent(
"""
def register(ctx):
ctx.register_automation_blueprint(
"standup",
title="Team standup digest",
description="Weekday summary of what changed in a repo.",
category="work",
schedule_template="{minute} {hour} * * 1-5",
prompt_template="Summarize yesterday's commits for {repo}.",
slots=[
{"name": "repo", "type": "text", "label": "Which repo?", "default": "acme/app"},
{"name": "time", "type": "time", "label": "What time?", "default": "09:15"},
{"name": "deliver", "type": "enum", "label": "Where?", "default": "origin",
"options": ["origin", "local"], "strict": False},
],
tags=["work"],
)
"""
)
def _write_plugin(home: Path, name: str, body: str, *, enable: bool = True) -> None:
plugin_dir = home / "plugins" / name
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / "plugin.yaml").write_text(f"name: {name}\nversion: 0.1.0\n")
(plugin_dir / "__init__.py").write_text(body)
# JSON is valid YAML; avoids a yaml import in the test module.
plugins = {"enabled": [name]} if enable else {"enabled": [], "disabled": [name]}
(home / "config.yaml").write_text(json.dumps({"model": "test-model", "plugins": plugins}))
@pytest.fixture()
def homes(tmp_path, monkeypatch):
"""Default profile home (active) plus a second profile home, both isolated."""
from hermes_cli import profiles
default_home = tmp_path / ".hermes"
profiles_root = default_home / "profiles"
other_home = profiles_root / "other"
for home in (default_home, other_home):
(home / "cron").mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("model: test-model\n")
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "other": other_home}
def _plugin_keys(entries):
return {e["key"]: e for e in entries if e.get("source") == "plugin"}
def test_plugin_blueprint_is_listed_by_slash_command_and_matches(homes):
from hermes_cli.blueprint_cmd import handle_blueprint_command, match_blueprint
_write_plugin(homes["default"], "teamtools", _STANDUP)
catalog = handle_blueprint_command("")
assert "teamtools:standup" in catalog.text
assert "Team standup digest" in catalog.text
assert match_blueprint("teamtools:standup")[0].key == "teamtools:standup"
assert match_blueprint("standup")[0].key == "teamtools:standup"
@pytest.mark.asyncio
async def test_plugin_blueprint_in_api_catalog_and_instantiates_real_job(homes):
from cron.jobs import load_jobs
_write_plugin(homes["default"], "teamtools", _STANDUP)
listed = await _rt_cron.list_cron_blueprints(profile="default")
entry = _plugin_keys(listed["blueprints"])["teamtools:standup"]
assert entry["plugin"] == "teamtools"
assert entry["title"] == "Team standup digest"
assert [f["name"] for f in entry["fields"]] == ["repo", "time", "deliver"]
builtin = next(e for e in listed["blueprints"] if e["key"] == "morning-brief")
assert builtin["source"] == "builtin" and builtin["plugin"] is None
created = await _rt_cron.instantiate_blueprint(
_web_models.AutomationBlueprintInstantiate(
blueprint="teamtools:standup",
values={"repo": "acme/widgets", "time": "10:05", "deliver": "local"},
),
profile="default",
)
assert created["name"] == "Team standup digest"
assert created["profile"] == "default"
[job] = [j for j in load_jobs() if j["id"] == created["id"]]
assert job["prompt"] == "Summarize yesterday's commits for acme/widgets."
assert job["schedule"]["expr"] == "5 10 * * 1-5"
assert job["deliver"] == "local"
@pytest.mark.asyncio
async def test_plugin_blueprints_are_profile_scoped(homes):
_write_plugin(homes["default"], "teamtools", _STANDUP)
# Interleave A -> B -> A: no cached plugin catalog may leak across profiles.
a1 = _plugin_keys((await _rt_cron.list_cron_blueprints(profile="default"))["blueprints"])
b = _plugin_keys((await _rt_cron.list_cron_blueprints(profile="other"))["blueprints"])
a2 = _plugin_keys((await _rt_cron.list_cron_blueprints(profile="default"))["blueprints"])
assert set(a1) == set(a2) == {"teamtools:standup"}
assert b == {}
with pytest.raises(_rt_cron.HTTPException) as exc:
await _rt_cron.instantiate_blueprint(
_web_models.AutomationBlueprintInstantiate(blueprint="teamtools:standup", values={}),
profile="other",
)
assert exc.value.status_code == 404
def test_plugin_cannot_shadow_builtin_and_bad_keys_are_rejected(homes, caplog):
from cron.blueprint_catalog import get_blueprint, list_blueprints
body = textwrap.dedent(
"""
def register(ctx):
common = dict(title="Mine", description="d", schedule_template="0 9 * * *",
prompt_template="hi")
ctx.register_automation_blueprint("morning-brief", **common)
ctx.register_automation_blueprint("morning-brief", **common) # duplicate
ctx.register_automation_blueprint("other:spoof", **common) # namespace spoof
"""
)
_write_plugin(homes["default"], "teamtools", body)
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
keys = [bp.key for bp in list_blueprints()]
assert keys.count("morning-brief") == 1
builtin, mine = get_blueprint("morning-brief"), get_blueprint("teamtools:morning-brief")
assert builtin is not None and builtin.plugin == "" # still the built-in
assert mine is not None and mine.title == "Mine"
assert keys.count("teamtools:morning-brief") == 1
assert not any(k.endswith("spoof") for k in keys)
assert "already registered" in caplog.text
assert "other:spoof" in caplog.text
@pytest.mark.parametrize(
"fields, reason",
[
({"prompt_template": "hello {nobody}"}, "nobody"),
({"slots": [{"name": "x", "type": "colour", "label": "X"}]}, "type"),
({"schedule_template": "not a schedule"}, "schedule"),
({"schedule_template": "{minute} {hour} * * *"}, "time"),
({"title": ""}, "title"),
],
)
def test_malformed_plugin_blueprint_is_warned_and_ignored(homes, caplog, fields, reason):
from cron.blueprint_catalog import list_blueprints
base = dict(title="T", description="d", schedule_template="0 9 * * *", prompt_template="hi")
base.update(fields)
_write_plugin(homes["default"], "teamtools", f"def register(ctx):\n ctx.register_automation_blueprint('bad', **{base!r})\n")
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
keys = [bp.key for bp in list_blueprints()]
assert "teamtools:bad" not in keys
assert "teamtools" in caplog.text and reason in caplog.text
@pytest.mark.asyncio
async def test_job_from_plugin_blueprint_survives_plugin_disable(homes):
from cron.jobs import load_jobs
from hermes_cli.plugins import _reset_plugin_managers_for_tests
_write_plugin(homes["default"], "teamtools", _STANDUP)
created = await _rt_cron.instantiate_blueprint(
_web_models.AutomationBlueprintInstantiate(blueprint="teamtools:standup", values={}),
profile="default",
)
_write_plugin(homes["default"], "teamtools", _STANDUP, enable=False)
_reset_plugin_managers_for_tests()
listed = await _rt_cron.list_cron_blueprints(profile="default")
assert _plugin_keys(listed["blueprints"]) == {}
[job] = [j for j in load_jobs() if j["id"] == created["id"]]
assert job["prompt"] == "Summarize yesterday's commits for acme/app."
assert job["schedule"]["expr"] == "15 9 * * 1-5"