mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 06:39:33 +08:00
Compare commits
1 Commits
fix/window
...
feat/recip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c88c33081a |
@@ -672,6 +672,36 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
|
||||
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
|
||||
|
||||
# Recipe detection: if the installed skill declares a
|
||||
# metadata.hermes.recipe block, it is a runnable automation. Surface how to
|
||||
# schedule it. We do NOT auto-create the cron job — scheduling a recurring
|
||||
# task is a side effect the user should opt into, and do_install runs on
|
||||
# non-interactive surfaces (slash commands / gateway) where we must not
|
||||
# block on input(). Printing the exact command keeps consent explicit.
|
||||
try:
|
||||
from tools.recipes import RecipeError, recipe_spec_for_installed
|
||||
|
||||
try:
|
||||
spec = recipe_spec_for_installed(bundle.name)
|
||||
except RecipeError as _rec_err:
|
||||
c.print(f"[yellow]Recipe block present but invalid:[/] {_rec_err}\n")
|
||||
spec = None
|
||||
if spec is not None:
|
||||
c.print(
|
||||
f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation "
|
||||
f"(schedule [bold]{spec.schedule}[/])."
|
||||
)
|
||||
c.print(
|
||||
"[dim]Schedule it with:[/] "
|
||||
f"[bold]hermes cron create --skill {bundle.name} "
|
||||
f'--schedule "{spec.schedule}"[/]'
|
||||
)
|
||||
if spec.deliver and spec.deliver != "origin":
|
||||
c.print(f"[dim]Recipe delivery target:[/] {spec.deliver}")
|
||||
c.print()
|
||||
except Exception: # pragma: no cover - recipe detection is best-effort
|
||||
pass
|
||||
|
||||
if invalidate_cache:
|
||||
# Invalidate the skills prompt cache so the new skill appears immediately
|
||||
try:
|
||||
|
||||
169
tests/tools/test_recipes.py
Normal file
169
tests/tools/test_recipes.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Tests for the recipes layer (skill frontmatter <-> cron automation bridge).
|
||||
|
||||
A recipe is a skill with a metadata.hermes.recipe block. These verify parsing,
|
||||
the create-job bridge, and the export round-trip without touching the real
|
||||
cron store.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.recipes import (
|
||||
RecipeError,
|
||||
RecipeSpec,
|
||||
create_recipe_job,
|
||||
export_recipe,
|
||||
parse_recipe,
|
||||
recipe_spec_for_installed,
|
||||
)
|
||||
|
||||
|
||||
RECIPE_SKILL = """---
|
||||
name: morning-brief
|
||||
description: Summarize unread email and calendar every morning.
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [recipe, email]
|
||||
recipe:
|
||||
schedule: "0 8 * * *"
|
||||
deliver: telegram
|
||||
prompt: "Summarize my unread email and today's calendar."
|
||||
---
|
||||
|
||||
# Morning Brief
|
||||
|
||||
Every morning, gather unread email and the day's calendar and send a digest.
|
||||
"""
|
||||
|
||||
PLAIN_SKILL = """---
|
||||
name: not-a-recipe
|
||||
description: Just a regular skill.
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [misc]
|
||||
---
|
||||
|
||||
# Not a recipe
|
||||
"""
|
||||
|
||||
MALFORMED_RECIPE = """---
|
||||
name: broken
|
||||
description: Recipe with no schedule.
|
||||
metadata:
|
||||
hermes:
|
||||
recipe:
|
||||
deliver: origin
|
||||
---
|
||||
|
||||
# Broken
|
||||
"""
|
||||
|
||||
|
||||
class TestParseRecipe:
|
||||
def test_parses_full_recipe(self):
|
||||
spec = parse_recipe(RECIPE_SKILL)
|
||||
assert spec is not None
|
||||
assert spec.skill_name == "morning-brief"
|
||||
assert spec.schedule == "0 8 * * *"
|
||||
assert spec.deliver == "telegram"
|
||||
assert spec.prompt is not None and spec.prompt.startswith("Summarize")
|
||||
|
||||
def test_plain_skill_is_not_a_recipe(self):
|
||||
assert parse_recipe(PLAIN_SKILL) is None
|
||||
|
||||
def test_no_frontmatter_is_not_a_recipe(self):
|
||||
assert parse_recipe("just some text, no frontmatter") is None
|
||||
|
||||
def test_missing_schedule_raises(self):
|
||||
with pytest.raises(RecipeError):
|
||||
parse_recipe(MALFORMED_RECIPE)
|
||||
|
||||
def test_recipe_not_mapping_raises(self):
|
||||
bad = "---\nname: x\nmetadata:\n hermes:\n recipe: not-a-dict\n---\n\nbody"
|
||||
with pytest.raises(RecipeError):
|
||||
parse_recipe(bad)
|
||||
|
||||
def test_deliver_defaults_to_origin(self):
|
||||
skill = (
|
||||
"---\nname: r\ndescription: d\nmetadata:\n hermes:\n"
|
||||
' recipe:\n schedule: "every 1h"\n---\n\nbody'
|
||||
)
|
||||
spec = parse_recipe(skill)
|
||||
assert spec is not None
|
||||
assert spec.deliver == "origin"
|
||||
|
||||
|
||||
class TestRecipeSpecForInstalled:
|
||||
def test_finds_and_parses_installed_recipe(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
rec_dir = skills_dir / "productivity" / "morning-brief"
|
||||
rec_dir.mkdir(parents=True)
|
||||
(rec_dir / "SKILL.md").write_text(RECIPE_SKILL, encoding="utf-8")
|
||||
|
||||
with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
|
||||
spec = recipe_spec_for_installed("morning-brief")
|
||||
assert spec is not None
|
||||
assert spec.schedule == "0 8 * * *"
|
||||
|
||||
def test_missing_skill_returns_none(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
|
||||
assert recipe_spec_for_installed("nope") is None
|
||||
|
||||
def test_plain_skill_returns_none(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
d = skills_dir / "misc" / "not-a-recipe"
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(PLAIN_SKILL, encoding="utf-8")
|
||||
with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
|
||||
assert recipe_spec_for_installed("not-a-recipe") is None
|
||||
|
||||
|
||||
class TestCreateRecipeJob:
|
||||
def test_bridges_to_create_job(self):
|
||||
spec = parse_recipe(RECIPE_SKILL)
|
||||
assert spec is not None
|
||||
captured = {}
|
||||
|
||||
def fake_create_job(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"id": "abc123", **kwargs}
|
||||
|
||||
with patch("cron.jobs.create_job", fake_create_job):
|
||||
job = create_recipe_job(spec, origin={"platform": "telegram"})
|
||||
|
||||
assert captured["schedule"] == "0 8 * * *"
|
||||
assert captured["skills"] == ["morning-brief"]
|
||||
assert captured["deliver"] == "telegram"
|
||||
assert captured["prompt"].startswith("Summarize")
|
||||
assert job["id"] == "abc123"
|
||||
|
||||
|
||||
class TestExportRecipe:
|
||||
def test_round_trips_job_to_skill_md(self):
|
||||
job = {
|
||||
"name": "My Morning Brief",
|
||||
"schedule_display": "0 8 * * *",
|
||||
"skills": ["morning-brief"],
|
||||
"deliver": "telegram",
|
||||
"prompt": "Summarize my unread email.",
|
||||
}
|
||||
md = export_recipe(job, "# Morning Brief\n\nDoes the morning digest.")
|
||||
# The exported SKILL.md must itself parse back as a recipe.
|
||||
spec = parse_recipe(md)
|
||||
assert spec is not None
|
||||
assert spec.schedule == "0 8 * * *"
|
||||
assert spec.deliver == "telegram"
|
||||
# Name is sanitized to a valid skill identifier.
|
||||
assert spec.skill_name == "my-morning-brief"
|
||||
|
||||
def test_export_has_recipe_tag(self):
|
||||
job = {"name": "x", "schedule_display": "every 2h", "skills": ["x"]}
|
||||
md = export_recipe(job, "body")
|
||||
assert "recipe" in md
|
||||
assert "automation" in md
|
||||
270
tools/recipes.py
Normal file
270
tools/recipes.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Recipes: shareable plain-language automations layered on skills + cron.
|
||||
|
||||
A "recipe" is NOT a new object type. It is an ordinary skill (a SKILL.md the
|
||||
agent loads) that additionally declares an automation schedule in its
|
||||
frontmatter:
|
||||
|
||||
metadata:
|
||||
hermes:
|
||||
recipe:
|
||||
schedule: "0 9 * * *" # presence of `recipe:` marks it runnable
|
||||
deliver: origin # optional (default "origin")
|
||||
prompt: "..." # optional task instruction for the run
|
||||
no_agent: false # optional
|
||||
|
||||
Because a recipe is just a skill, it flows through the ENTIRE existing
|
||||
skills-hub pipeline for free — search, inspect, quarantine, security scan,
|
||||
install, lock-file provenance, audit log, taps, the centralized index, and
|
||||
`hermes skills publish` for sharing. No new source type, no new store, no new
|
||||
transport. This module is the thin bridge between that skill metadata and the
|
||||
existing cron `create_job()` API:
|
||||
|
||||
* ``parse_recipe(skill_md_text)`` -> RecipeSpec | None
|
||||
* ``recipe_spec_for_installed(name)`` -> RecipeSpec | None
|
||||
* ``create_recipe_job(spec, ...)`` -> the created cron job dict
|
||||
* ``export_recipe(job, body)`` -> a shareable SKILL.md string
|
||||
|
||||
The dev guide's "Extend, Don't Duplicate" rule is the whole design: the recipe
|
||||
is a skill, the schedule is a cron job, sharing is the existing publish/tap/
|
||||
index path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"RecipeSpec",
|
||||
"parse_recipe",
|
||||
"recipe_spec_for_installed",
|
||||
"create_recipe_job",
|
||||
"export_recipe",
|
||||
"RecipeError",
|
||||
]
|
||||
|
||||
|
||||
class RecipeError(ValueError):
|
||||
"""Raised when a recipe block is present but malformed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeSpec:
|
||||
"""Parsed ``metadata.hermes.recipe`` automation spec for a skill."""
|
||||
|
||||
skill_name: str
|
||||
schedule: str
|
||||
deliver: str = "origin"
|
||||
prompt: Optional[str] = None
|
||||
no_agent: bool = False
|
||||
model: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
enabled_toolsets: Optional[List[str]] = None
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the parsed YAML frontmatter mapping, or None if absent/invalid."""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
stripped = text.lstrip()
|
||||
if not stripped.startswith("---"):
|
||||
return None
|
||||
# Find the closing fence after the opening one.
|
||||
after_open = stripped[3:]
|
||||
end = after_open.find("\n---")
|
||||
if end == -1:
|
||||
return None
|
||||
fm_text = after_open[:end]
|
||||
try:
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(fm_text)
|
||||
except Exception as e: # pragma: no cover - malformed YAML
|
||||
logger.debug("recipe: frontmatter YAML parse failed: %s", e)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]:
|
||||
"""Extract a RecipeSpec from a SKILL.md string, or None if not a recipe.
|
||||
|
||||
A skill is a recipe iff ``metadata.hermes.recipe`` is a mapping containing
|
||||
a non-empty ``schedule``. Raises RecipeError if the block exists but is
|
||||
structurally invalid (so a typo surfaces instead of silently no-op'ing).
|
||||
"""
|
||||
fm = _split_frontmatter(skill_md_text)
|
||||
if not fm:
|
||||
return None
|
||||
|
||||
name = str(fm.get("name", "")).strip()
|
||||
|
||||
meta = fm.get("metadata")
|
||||
hermes = meta.get("hermes") if isinstance(meta, dict) else None
|
||||
recipe = hermes.get("recipe") if isinstance(hermes, dict) else None
|
||||
if recipe is None:
|
||||
return None
|
||||
if not isinstance(recipe, dict):
|
||||
raise RecipeError("metadata.hermes.recipe must be a mapping")
|
||||
|
||||
schedule = str(recipe.get("schedule", "")).strip()
|
||||
if not schedule:
|
||||
raise RecipeError("recipe.schedule is required and must be non-empty")
|
||||
|
||||
deliver = str(recipe.get("deliver", "origin")).strip() or "origin"
|
||||
prompt = recipe.get("prompt")
|
||||
if prompt is not None:
|
||||
prompt = str(prompt)
|
||||
no_agent = bool(recipe.get("no_agent", False))
|
||||
model = recipe.get("model")
|
||||
provider = recipe.get("provider")
|
||||
toolsets = recipe.get("enabled_toolsets")
|
||||
if toolsets is not None and not isinstance(toolsets, list):
|
||||
raise RecipeError("recipe.enabled_toolsets must be a list when present")
|
||||
|
||||
return RecipeSpec(
|
||||
skill_name=name,
|
||||
schedule=schedule,
|
||||
deliver=deliver,
|
||||
prompt=prompt,
|
||||
no_agent=no_agent,
|
||||
model=str(model).strip() if model else None,
|
||||
provider=str(provider).strip() if provider else None,
|
||||
enabled_toolsets=[str(t) for t in toolsets] if toolsets else None,
|
||||
raw=recipe,
|
||||
)
|
||||
|
||||
|
||||
def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]:
|
||||
"""Locate an installed skill's SKILL.md and parse its recipe block.
|
||||
|
||||
Searches the standard skills tree for ``<skill_name>/SKILL.md``. Returns
|
||||
None if the skill isn't found or isn't a recipe.
|
||||
"""
|
||||
try:
|
||||
from tools.skills_hub import SKILLS_DIR
|
||||
except Exception: # pragma: no cover - import guard
|
||||
return None
|
||||
|
||||
base = Path(SKILLS_DIR)
|
||||
# Skills live at skills/<category>/<name>/SKILL.md or skills/<name>/SKILL.md.
|
||||
candidates = list(base.glob(f"**/{skill_name}/SKILL.md"))
|
||||
for path in candidates:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
spec = parse_recipe(text)
|
||||
if spec is not None:
|
||||
# Prefer the frontmatter name, fall back to the directory name.
|
||||
if not spec.skill_name:
|
||||
spec.skill_name = skill_name
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
def create_recipe_job(
|
||||
spec: RecipeSpec,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create the cron job described by a RecipeSpec via the existing cron API.
|
||||
|
||||
The recipe's skill is loaded before the run (cron ``skills=[name]``); the
|
||||
optional ``prompt`` becomes the task instruction. Delivery, model, and
|
||||
toolsets carry through. Returns the created job dict.
|
||||
"""
|
||||
from cron.jobs import create_job
|
||||
|
||||
job_name = name or f"recipe:{spec.skill_name}"
|
||||
return create_job(
|
||||
prompt=spec.prompt,
|
||||
schedule=spec.schedule,
|
||||
name=job_name,
|
||||
deliver=spec.deliver,
|
||||
origin=origin,
|
||||
skills=[spec.skill_name] if spec.skill_name else None,
|
||||
model=spec.model,
|
||||
provider=spec.provider,
|
||||
enabled_toolsets=spec.enabled_toolsets,
|
||||
no_agent=spec.no_agent,
|
||||
)
|
||||
|
||||
|
||||
def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str] = None) -> str:
|
||||
"""Render a shareable recipe SKILL.md from an existing cron job dict.
|
||||
|
||||
The inverse of ``create_recipe_job``: take a cron job a user already built
|
||||
and emit a SKILL.md (with a ``metadata.hermes.recipe`` block) they can hand
|
||||
to ``hermes skills publish`` to share. ``body`` is the plain-language
|
||||
description / instructions that become the SKILL.md body.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
name = recipe_name or job.get("name") or "shared-recipe"
|
||||
# Sanitize to a valid skill identifier.
|
||||
name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower())
|
||||
name = name.strip("-_") or "shared-recipe"
|
||||
|
||||
schedule = job.get("schedule_display") or _schedule_to_string(job.get("schedule"))
|
||||
skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
|
||||
|
||||
recipe_block: Dict[str, Any] = {"schedule": schedule}
|
||||
deliver = job.get("deliver")
|
||||
if deliver and deliver != "origin":
|
||||
recipe_block["deliver"] = deliver
|
||||
if job.get("prompt"):
|
||||
recipe_block["prompt"] = job["prompt"]
|
||||
if job.get("no_agent"):
|
||||
recipe_block["no_agent"] = True
|
||||
if job.get("model"):
|
||||
recipe_block["model"] = job["model"]
|
||||
if job.get("provider"):
|
||||
recipe_block["provider"] = job["provider"]
|
||||
if job.get("enabled_toolsets"):
|
||||
recipe_block["enabled_toolsets"] = job["enabled_toolsets"]
|
||||
|
||||
description = (
|
||||
(body.strip().splitlines() or ["Shared automation recipe."])[0][:200]
|
||||
if body.strip()
|
||||
else "Shared automation recipe."
|
||||
)
|
||||
|
||||
frontmatter = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"metadata": {
|
||||
"hermes": {
|
||||
"tags": ["recipe", "automation"],
|
||||
"recipe": recipe_block,
|
||||
}
|
||||
},
|
||||
}
|
||||
fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip()
|
||||
body_text = body.strip() or f"# {name}\n\nShared automation recipe."
|
||||
return f"---\n{fm_yaml}\n---\n\n{body_text}\n"
|
||||
|
||||
|
||||
def _schedule_to_string(schedule: Any) -> str:
|
||||
"""Best-effort render of a parsed schedule dict back to a string."""
|
||||
if isinstance(schedule, str):
|
||||
return schedule
|
||||
if isinstance(schedule, dict):
|
||||
kind = schedule.get("kind")
|
||||
if kind == "cron" and schedule.get("expr"):
|
||||
return str(schedule["expr"])
|
||||
if kind == "interval" and schedule.get("seconds"):
|
||||
secs = int(schedule["seconds"])
|
||||
if secs % 3600 == 0:
|
||||
return f"every {secs // 3600}h"
|
||||
if secs % 60 == 0:
|
||||
return f"every {secs // 60}m"
|
||||
return f"every {secs}s"
|
||||
return "0 9 * * *" # safe daily fallback
|
||||
@@ -66,6 +66,11 @@ metadata:
|
||||
description: "What this setting controls"
|
||||
default: "sensible-default"
|
||||
prompt: "Display prompt for setup"
|
||||
recipe: # Optional — marks this skill a runnable automation
|
||||
schedule: "0 9 * * *" # cron expr / "every 2h" / ISO timestamp
|
||||
deliver: origin # optional (default origin)
|
||||
prompt: "Task instruction for each run" # optional
|
||||
no_agent: false # optional
|
||||
required_environment_variables: # Optional — env vars the skill needs
|
||||
- name: MY_API_KEY
|
||||
prompt: "Enter your API key"
|
||||
@@ -334,6 +339,35 @@ If your skill is official and useful but not universally needed (e.g., a paid se
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a registry and share it via `hermes skills install`.
|
||||
|
||||
## Recipes: skills that are also automations
|
||||
|
||||
A **recipe** is an ordinary skill that additionally declares a schedule in its frontmatter. Add a `metadata.hermes.recipe` block and the skill becomes a shareable, runnable automation:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [recipe, email]
|
||||
recipe:
|
||||
schedule: "0 8 * * *" # presence of `recipe:` marks it runnable
|
||||
deliver: telegram # optional (default: origin)
|
||||
prompt: "Summarize my unread email and today's calendar." # optional
|
||||
no_agent: false # optional
|
||||
```
|
||||
|
||||
Because a recipe **is** a skill, it flows through the entire skills pipeline unchanged — search, inspect, install, security scan, provenance, taps, the centralized index, and `hermes skills publish` for sharing. Nothing new to learn.
|
||||
|
||||
**Installing a recipe.** When you install a skill that carries a `recipe:` block, Hermes tells you it's an automation and prints the exact command to schedule it. Scheduling is **opt-in** — installing never silently creates a recurring job:
|
||||
|
||||
```bash
|
||||
hermes skills install owner/morning-brief
|
||||
# → Recipe: 'morning-brief' is an automation (schedule 0 8 * * *).
|
||||
# Schedule it with: hermes cron create --skill morning-brief --schedule "0 8 * * *"
|
||||
```
|
||||
|
||||
**Sharing an automation you built.** A recipe loaded by a cron job (`hermes cron create --skill <name> ...`) can be exported back to a SKILL.md and published like any other skill, so an automation you tuned for yourself becomes a one-command install for someone else.
|
||||
|
||||
The recipe layer adds no new object type, store, or transport — the recipe is a skill, the schedule is a cron job, and sharing is the existing publish/tap/index path.
|
||||
|
||||
## Publishing Skills
|
||||
|
||||
### To the Skills Hub
|
||||
|
||||
Reference in New Issue
Block a user