mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
feat(cron-recipes): /cron-recipe <name> seeds a conversational fill
Reworks the chat-line UX: pick a recipe by name and the agent asks you for
what it needs, one question at a time, instead of forcing you to hand-type a
slot=val command line.
- /cron-recipe -> lists the catalog
- /cron-recipe <name> -> forgiving name match (exact/prefix/substring/
fuzzy; ambiguous lists candidates), then seeds
the agent with a natural-language fill request
built from the recipe's typed slots + schedule
and prompt templates. The agent asks for each
value one at a time and calls the EXISTING
cronjob tool. No new tool.
- /cron-recipe <name> slot=val -> unchanged deterministic path (fill_recipe ->
create_job) for the dashboard/docs/power user.
Mechanism (no new plumbing, invariant-safe — the seed enters as a normal user
turn, never a synthetic injection):
- shared handler returns RecipeCommandResult{text, agent_seed}; match_recipe()
and build_recipe_seed() are the new shared pieces.
- gateway: dispatch rewrites event.text to the seed and falls through to the
agent (the same pattern /steer uses).
- CLI: handler sets a one-shot self._pending_agent_seed; the interactive loop
consumes it right after process_command() and runs it as the next turn.
The typed-slot schema stays the single source of truth (still validates the
form/inline path via fill_recipe); the agent path just renders those slots into
the questions to ask. Docs updated to lead with the name-then-ask flow.
This commit is contained in:
parent
1593ca5406
commit
e976faac7a
7 changed files with 299 additions and 75 deletions
16
cli.py
16
cli.py
|
|
@ -3504,6 +3504,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
# the next submitted input, whether it's the selection or anything
|
||||
# else). See #34584.
|
||||
self._pending_resume_sessions = None
|
||||
# One-shot agent seed set by a slash handler (e.g. /cron-recipe <name>)
|
||||
# that wants its output run as the next agent turn. Consumed and cleared
|
||||
# by the interactive loop immediately after process_command() returns.
|
||||
self._pending_agent_seed = None
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
|
|
@ -12831,7 +12835,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
# session. Without this guard a KeyboardInterrupt unwinds
|
||||
# to the outer prompt_toolkit loop and the session dies.
|
||||
_cprint("\n[dim]Command interrupted.[/dim]")
|
||||
continue
|
||||
continue
|
||||
# A slash handler may set a one-shot pending seed (e.g.
|
||||
# /cron-recipe <name>) to be run as the next agent turn.
|
||||
# If present, fall through to the chat path with the seed
|
||||
# as the user message instead of looping back to idle.
|
||||
_seed = getattr(self, "_pending_agent_seed", None)
|
||||
if _seed:
|
||||
self._pending_agent_seed = None
|
||||
user_input = _seed
|
||||
else:
|
||||
continue
|
||||
|
||||
# Expand paste references back to full content
|
||||
_paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a
|
|||
feed) produces a list of candidate items; this script scores each with a cheap
|
||||
LLM and prints ONLY the items at or above a threshold. Below-threshold runs
|
||||
print nothing, so a cron job wrapping this stays silent unless something
|
||||
actually matters -- mirroring Poke's email monitor (fetch -> classify urgency
|
||||
-> surface only what's above the bar).
|
||||
actually matters -- the classic urgency-monitor pattern (fetch -> classify
|
||||
urgency -> surface only what's above the bar).
|
||||
|
||||
Design choices:
|
||||
* Uses Hermes' auxiliary client with task="monitor", so the classifier model
|
||||
|
|
|
|||
|
|
@ -7175,7 +7175,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
return await self._handle_suggestions_command(event)
|
||||
|
||||
if canonical == "cron-recipe":
|
||||
return await self._handle_cron_recipe_command(event)
|
||||
_recipe_result = await self._handle_cron_recipe_command(event)
|
||||
_recipe_seed = getattr(_recipe_result, "agent_seed", None)
|
||||
if _recipe_seed:
|
||||
# Recipe matched — rewrite the turn to the seed and fall
|
||||
# through to _handle_message_with_agent so the agent asks the
|
||||
# user for each slot value conversationally and then calls the
|
||||
# cronjob tool (the /steer fall-through pattern). The seed
|
||||
# enters as a normal user turn, preserving role alternation.
|
||||
try:
|
||||
event.text = _recipe_seed
|
||||
except Exception:
|
||||
return getattr(_recipe_result, "text", "") or None
|
||||
else:
|
||||
return getattr(_recipe_result, "text", "") or None
|
||||
|
||||
if canonical == "retry":
|
||||
return await self._handle_retry_command(event)
|
||||
|
|
@ -9273,12 +9286,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
logger.debug("suggestions command failed: %s", e)
|
||||
return f"Suggestions command failed: {e}"
|
||||
|
||||
async def _handle_cron_recipe_command(self, event: MessageEvent) -> str:
|
||||
async def _handle_cron_recipe_command(self, event: MessageEvent):
|
||||
"""Handle /cron-recipe in the gateway.
|
||||
|
||||
Delegates to the shared handler so CLI, TUI, and gateway never drift.
|
||||
Origin is built from the event source so a created recipe job delivers
|
||||
back to this chat/thread.
|
||||
Returns a RecipeCommandResult: ``text`` is shown to the user, and if
|
||||
``agent_seed`` is set the dispatch site rewrites ``event.text`` to the
|
||||
seed and falls through to the agent (the ``/steer`` pattern) so the
|
||||
agent gathers the slot values conversationally. Origin is built from the
|
||||
event source so a directly created recipe job delivers back to this chat.
|
||||
"""
|
||||
args = (event.get_command_args() or "").strip()
|
||||
source = event.source
|
||||
|
|
@ -9301,7 +9317,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
return handle_cron_recipe_command(args, origin=origin)
|
||||
except Exception as e:
|
||||
logger.debug("cron-recipe command failed: %s", e)
|
||||
return f"Cron recipe command failed: {e}"
|
||||
from hermes_cli.cron_recipe_cmd import RecipeCommandResult
|
||||
|
||||
return RecipeCommandResult(f"Cron recipe command failed: {e}")
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# /goal — persistent cross-turn goals (Ralph-style loop)
|
||||
|
|
|
|||
|
|
@ -1279,10 +1279,12 @@ class CLICommandsMixin:
|
|||
def _handle_cron_recipe_command(self, cmd: str):
|
||||
"""Handle /cron-recipe — set up an automation from a recipe template.
|
||||
|
||||
Delegates to the shared handler so CLI, TUI, and gateway never drift.
|
||||
The user pastes a pre-filled command (from the docs/dashboard or a bare
|
||||
``/cron-recipe`` listing), edits the slot values, and sends; the handler
|
||||
validates and creates the cron job, or names the slot that's missing.
|
||||
Delegates to the shared handler. A bare ``/cron-recipe`` lists the
|
||||
catalog; ``/cron-recipe <name>`` name-matches a recipe and seeds the
|
||||
agent to ask the user for each value conversationally (the result's
|
||||
``agent_seed``); ``/cron-recipe <name> slot=val …`` creates the job
|
||||
directly. When a seed is returned it is stashed as a one-shot pending
|
||||
message the interactive loop runs as the next agent turn.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
|
|
@ -1293,10 +1295,16 @@ class CLICommandsMixin:
|
|||
args = " ".join(shlex.quote(t) for t in tokens)
|
||||
try:
|
||||
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
|
||||
output = handle_cron_recipe_command(args)
|
||||
result = handle_cron_recipe_command(args)
|
||||
except Exception as e:
|
||||
output = f"Cron recipe command failed: {e}"
|
||||
self._console_print(output)
|
||||
self._console_print(f"Cron recipe command failed: {e}")
|
||||
return
|
||||
self._console_print(result.text)
|
||||
seed = getattr(result, "agent_seed", None)
|
||||
if seed:
|
||||
# One-shot: the interactive loop picks this up right after the
|
||||
# slash command returns and runs it as a normal agent turn.
|
||||
self._pending_agent_seed = seed
|
||||
|
||||
def _handle_curator_command(self, cmd: str):
|
||||
"""Handle /curator slash command.
|
||||
|
|
|
|||
|
|
@ -3,28 +3,59 @@
|
|||
The conversational counterpart to the dashboard's Cron Recipes form. Where a
|
||||
surface has a screen, the user fills a form (dashboard / GUI app) and the API
|
||||
calls ``fill_recipe`` -> ``create_job`` directly. Where a surface is just a
|
||||
chat line, the user pastes a pre-filled slash command and this handler
|
||||
parses it; any missing or invalid slot is reported so the agent can ask.
|
||||
chat line, the user picks a recipe by name and the agent asks for what it
|
||||
needs — pick a recipe by name and the agent asks you for what it needs, one
|
||||
question at a time (the messaging-assistant model: pick a recipe → it asks you
|
||||
a couple things → done).
|
||||
|
||||
Subcommand shapes:
|
||||
/cron-recipe list the catalog (numbered + copy commands)
|
||||
/cron-recipe <key> show that recipe's slots + a ready command
|
||||
/cron-recipe <key> slot=val … fill + create the cron job
|
||||
/cron-recipe list the catalog
|
||||
/cron-recipe <name> name-match a recipe, then SEED THE AGENT to
|
||||
ask the user for each value conversationally
|
||||
/cron-recipe <name> slot=val … fill + create the cron job directly
|
||||
(the deterministic dashboard / docs / power-
|
||||
user shortcut — no agent turn)
|
||||
|
||||
The ``<name>`` form is forgiving: exact key, unique prefix, or fuzzy match all
|
||||
resolve; an ambiguous query lists the candidates; an unknown one suggests the
|
||||
closest. When it resolves, the handler returns an ``agent_seed`` — a natural-
|
||||
language instruction built from the recipe's typed slots + schedule/prompt
|
||||
templates — that the calling surface feeds to the agent as a normal user turn
|
||||
(gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI:
|
||||
a one-shot pending seed the main loop runs). The agent then asks for each slot
|
||||
and calls the existing ``cronjob`` tool. No new tool, no second job engine.
|
||||
|
||||
Parsing is shlex-based so quoted free-text values (``criteria="from my boss"``)
|
||||
survive. On a fill error the message names the slot, which is exactly what the
|
||||
agent needs to ask a targeted follow-up rather than re-prompting everything.
|
||||
survive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import logging
|
||||
import shlex
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeCommandResult:
|
||||
"""Outcome of a ``/cron-recipe`` invocation.
|
||||
|
||||
``text`` is always shown to the user. When ``agent_seed`` is set, the
|
||||
calling surface should ALSO hand that seed to the agent as the user's next
|
||||
turn (the recipe was matched and now the agent gathers the slot values
|
||||
conversationally). When ``agent_seed`` is None the command is fully handled
|
||||
(catalog listing, direct create, or an error) and nothing is sent to the
|
||||
agent.
|
||||
"""
|
||||
|
||||
text: str
|
||||
agent_seed: Optional[str] = None
|
||||
|
||||
|
||||
def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
|
|
@ -60,48 +91,168 @@ def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
|
|||
return values, leftovers
|
||||
|
||||
|
||||
def _fmt_catalog() -> str:
|
||||
from cron.recipe_catalog import CATALOG, recipe_slash_command
|
||||
def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
|
||||
"""Resolve a free-typed recipe name to a recipe.
|
||||
|
||||
lines = ["Cron Recipes — `/cron-recipe <name>` to set one up:\n"]
|
||||
Returns ``(recipe, candidates)``:
|
||||
* exact key or unique prefix / fuzzy match -> ``(recipe, [])``
|
||||
* ambiguous (2+ plausible) -> ``(None, [candidates…])``
|
||||
* no plausible match -> ``(None, [])``
|
||||
|
||||
Matching is forgiving because chat-line users type the name (unlike the
|
||||
dashboard/Discord where it's picked): exact key first, then case-insensitive
|
||||
prefix on key or title, then a difflib fuzzy pass.
|
||||
"""
|
||||
from cron.recipe_catalog import CATALOG, get_recipe
|
||||
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return None, []
|
||||
|
||||
exact = get_recipe(q)
|
||||
if exact is not None:
|
||||
return exact, []
|
||||
|
||||
# Prefix match on key or title word-start.
|
||||
prefix = [
|
||||
r for r in CATALOG
|
||||
if r.key.lower().startswith(q)
|
||||
or any(w.lower().startswith(q) for w in r.title.split())
|
||||
]
|
||||
if len(prefix) == 1:
|
||||
return prefix[0], []
|
||||
if len(prefix) > 1:
|
||||
return None, prefix
|
||||
|
||||
# Substring match anywhere in key/title/description.
|
||||
substr = [
|
||||
r for r in CATALOG
|
||||
if q in r.key.lower() or q in r.title.lower() or q in r.description.lower()
|
||||
]
|
||||
if len(substr) == 1:
|
||||
return substr[0], []
|
||||
if len(substr) > 1:
|
||||
return None, substr
|
||||
|
||||
# Fuzzy on keys (typo tolerance).
|
||||
keys = [r.key for r in CATALOG]
|
||||
close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6)
|
||||
if len(close) == 1:
|
||||
return get_recipe(close[0]), []
|
||||
if len(close) > 1:
|
||||
return None, [get_recipe(k) for k in close]
|
||||
|
||||
return None, []
|
||||
|
||||
|
||||
def _humanize_schedule(recipe) -> str:
|
||||
from cron.recipe_catalog import _humanize_schedule as _h
|
||||
|
||||
try:
|
||||
return _h(recipe)
|
||||
except Exception:
|
||||
return "on a schedule"
|
||||
|
||||
|
||||
def build_recipe_seed(recipe) -> str:
|
||||
"""Build the natural-language fill-request the agent will act on.
|
||||
|
||||
The agent reads this as a normal user turn, asks the user for each unfilled
|
||||
slot one at a time, then calls the ``cronjob`` tool with the
|
||||
cron expression it builds from the recipe's ``schedule_template`` and the
|
||||
rendered prompt. Defaults are stated so the agent can offer them.
|
||||
"""
|
||||
from cron.recipe_catalog import WEEKDAY_PRESETS
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append(
|
||||
f"Set up the '{recipe.title}' automation for me (cron recipe "
|
||||
f"'{recipe.key}'). {recipe.description}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Ask me for each of these, one at a time, offering the default in "
|
||||
"brackets if I don't have a preference:"
|
||||
)
|
||||
for s in recipe.slots:
|
||||
bits = [f"- {s.label} ({s.name})"]
|
||||
if s.options:
|
||||
bits.append(f" — one of: {', '.join(map(str, s.options))}")
|
||||
if s.default not in (None, ""):
|
||||
bits.append(f" [default: {s.default}]")
|
||||
if s.optional:
|
||||
bits.append(" (optional)")
|
||||
if s.help:
|
||||
bits.append(f" — {s.help}")
|
||||
lines.append("".join(bits))
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Once you have my answers, create the job by calling the cronjob tool "
|
||||
"with action='create'. Build the schedule as a cron expression from "
|
||||
f"this template: `{recipe.schedule_template}` "
|
||||
"(fill {minute}/{hour} from the chosen time, {dow} from the weekday "
|
||||
f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any "
|
||||
"interval). Use this exact prompt for the job (substituting my "
|
||||
f"answers into any {{slot}} placeholders): \"{recipe.prompt_template}\". "
|
||||
"Confirm the schedule and what it will do before you create it."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_catalog() -> str:
|
||||
from cron.recipe_catalog import CATALOG
|
||||
|
||||
lines = ["Cron Recipes — `/cron-recipe <name>` and I'll ask you what I need:\n"]
|
||||
for r in CATALOG:
|
||||
lines.append(f" • {r.key} — {r.title}")
|
||||
lines.append(f" {r.description}")
|
||||
lines.append(f" ↳ {recipe_slash_command(r)}")
|
||||
lines.append("\nEdit the values then send, or just send to use the defaults.")
|
||||
lines.append(
|
||||
"\nTip: `/cron-recipe <name>` walks you through it. Power users can "
|
||||
"pass values inline, e.g. `/cron-recipe morning-brief time=08:00`."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_recipe(recipe) -> str:
|
||||
from cron.recipe_catalog import recipe_slash_command
|
||||
|
||||
lines = [f"{recipe.title} — {recipe.description}\n", "Fields:"]
|
||||
for s in recipe.slots:
|
||||
opts = f" (one of: {', '.join(map(str, s.options))})" if s.options else ""
|
||||
dflt = f" [default: {s.default}]" if s.default not in (None, "") else ""
|
||||
opt = " (optional)" if s.optional else ""
|
||||
lines.append(f" • {s.name}: {s.label}{opts}{dflt}{opt}")
|
||||
lines.append("\nReady-to-edit command:")
|
||||
lines.append(f" {recipe_slash_command(recipe)}")
|
||||
def _fmt_candidates(query: str, candidates: List[Any]) -> str:
|
||||
lines = [f"'{query}' matches several recipes — which one?\n"]
|
||||
for r in candidates:
|
||||
lines.append(f" • {r.key} — {r.title}")
|
||||
lines.append("\nRun `/cron-recipe <name>` with one of the names above.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_no_match(query: str) -> str:
|
||||
from cron.recipe_catalog import CATALOG
|
||||
|
||||
keys = [r.key for r in CATALOG]
|
||||
close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4)
|
||||
msg = f"No cron recipe matches '{query}'."
|
||||
if close:
|
||||
msg += " Did you mean: " + ", ".join(close) + "?"
|
||||
msg += " Run /cron-recipe to see the catalog."
|
||||
return msg
|
||||
|
||||
|
||||
def handle_cron_recipe_command(
|
||||
args: str,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Dispatch a ``/cron-recipe`` invocation. Returns text to show the user.
|
||||
) -> RecipeCommandResult:
|
||||
"""Dispatch a ``/cron-recipe`` invocation.
|
||||
|
||||
``args`` is everything after ``/cron-recipe``. ``origin`` lets an accepted
|
||||
recipe's job deliver back to the chat it was created from; resolved from
|
||||
session env when omitted.
|
||||
Returns a :class:`RecipeCommandResult`. When ``agent_seed`` is set the
|
||||
caller must feed it to the agent as the next user turn; otherwise the
|
||||
command is fully handled and only ``text`` is shown.
|
||||
|
||||
``args`` is everything after ``/cron-recipe``. ``origin`` lets a directly
|
||||
created job deliver back to the chat it was set up from.
|
||||
"""
|
||||
try:
|
||||
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
|
||||
from cron.recipe_catalog import fill_recipe, RecipeFillError
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
logger.debug("recipe catalog import failed: %s", e)
|
||||
return "Cron Recipes are unavailable in this build."
|
||||
return RecipeCommandResult("Cron Recipes are unavailable in this build.")
|
||||
|
||||
try:
|
||||
tokens = shlex.split(args or "")
|
||||
|
|
@ -110,26 +261,34 @@ def handle_cron_recipe_command(
|
|||
|
||||
# Bare -> list catalog.
|
||||
if not tokens:
|
||||
return _fmt_catalog()
|
||||
|
||||
key = tokens[0]
|
||||
recipe = get_recipe(key)
|
||||
if recipe is None:
|
||||
return (
|
||||
f"No cron recipe named '{key}'. Run /cron-recipe to see the catalog."
|
||||
)
|
||||
return RecipeCommandResult(_fmt_catalog())
|
||||
|
||||
query = tokens[0]
|
||||
values, _leftover = _parse_kv(tokens[1:])
|
||||
|
||||
# `<key>` with no slot args -> show the recipe's fields + a ready command.
|
||||
if not values:
|
||||
return _fmt_recipe(recipe)
|
||||
recipe, candidates = match_recipe(query)
|
||||
if recipe is None:
|
||||
if candidates:
|
||||
return RecipeCommandResult(_fmt_candidates(query, candidates))
|
||||
return RecipeCommandResult(_fmt_no_match(query))
|
||||
|
||||
# `<key> slot=val …` -> fill + create.
|
||||
# `<name>` with no inline slot values -> seed the agent to ask for them.
|
||||
if not values:
|
||||
seed = build_recipe_seed(recipe)
|
||||
text = (
|
||||
f"Setting up '{recipe.title}' ({_humanize_schedule(recipe)}). "
|
||||
"I'll ask you a couple of things…"
|
||||
)
|
||||
return RecipeCommandResult(text, agent_seed=seed)
|
||||
|
||||
# `<name> slot=val …` -> fill + create directly (deterministic shortcut).
|
||||
try:
|
||||
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin))
|
||||
except RecipeFillError as e:
|
||||
return f"Can't set up '{recipe.title}': {e}\nRun /cron-recipe {key} to see its fields."
|
||||
return RecipeCommandResult(
|
||||
f"Can't set up '{recipe.title}': {e}\n"
|
||||
f"Or just run /cron-recipe {recipe.key} and I'll ask you for the values."
|
||||
)
|
||||
|
||||
try:
|
||||
from cron.jobs import create_job
|
||||
|
|
@ -137,10 +296,10 @@ def handle_cron_recipe_command(
|
|||
job = create_job(**spec)
|
||||
except Exception as e:
|
||||
logger.debug("cron-recipe create_job failed: %s", e)
|
||||
return f"Failed to create the job: {e}"
|
||||
return RecipeCommandResult(f"Failed to create the job: {e}")
|
||||
|
||||
sched = job.get("schedule_display") or spec.get("schedule", "")
|
||||
return (
|
||||
return RecipeCommandResult(
|
||||
f"Scheduled '{recipe.title}'"
|
||||
+ (f" ({sched})" if sched else "")
|
||||
+ f", delivering to {spec.get('deliver', 'origin')}. Manage it with /cron."
|
||||
|
|
|
|||
|
|
@ -143,20 +143,41 @@ class TestCommandHandler:
|
|||
def test_bare_lists_catalog(self, isolated_home):
|
||||
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
|
||||
|
||||
out = handle_cron_recipe_command("")
|
||||
assert "morning-brief" in out and "Cron Recipes" in out
|
||||
res = handle_cron_recipe_command("")
|
||||
assert "morning-brief" in res.text and "Cron Recipes" in res.text
|
||||
assert res.agent_seed is None
|
||||
|
||||
def test_show_recipe_fields(self, isolated_home):
|
||||
def test_name_seeds_agent(self, isolated_home):
|
||||
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
|
||||
|
||||
out = handle_cron_recipe_command("morning-brief")
|
||||
assert "Fields:" in out and "time" in out
|
||||
# `/cron-recipe <name>` (no inline slots) now seeds the agent to ask
|
||||
# the user for each value conversationally instead of dumping fields.
|
||||
res = handle_cron_recipe_command("morning-brief")
|
||||
assert res.agent_seed is not None
|
||||
assert "morning-brief" in res.agent_seed
|
||||
assert "cronjob tool" in res.agent_seed
|
||||
# the schedule template is handed to the agent to build the cron expr
|
||||
assert "* * *" in res.agent_seed
|
||||
|
||||
def test_name_match_is_forgiving(self, isolated_home):
|
||||
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command, match_recipe
|
||||
|
||||
# prefix match
|
||||
r, cands = match_recipe("morning")
|
||||
assert r is not None and r.key == "morning-brief"
|
||||
# fuzzy / typo
|
||||
r2, _ = match_recipe("mornning-brief")
|
||||
assert r2 is not None and r2.key == "morning-brief"
|
||||
# a forgiving name still seeds the agent
|
||||
res = handle_cron_recipe_command("morning")
|
||||
assert res.agent_seed is not None
|
||||
|
||||
def test_fill_creates_job(self, isolated_home):
|
||||
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
|
||||
|
||||
out = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram")
|
||||
assert "Scheduled" in out
|
||||
res = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram")
|
||||
assert "Scheduled" in res.text
|
||||
assert res.agent_seed is None
|
||||
jobs = isolated_home.load_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert (jobs[0].get("schedule_display") or jobs[0].get("schedule")) == "30 7 * * *"
|
||||
|
|
@ -165,14 +186,16 @@ class TestCommandHandler:
|
|||
def test_unknown_recipe(self, isolated_home):
|
||||
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
|
||||
|
||||
out = handle_cron_recipe_command("does-not-exist")
|
||||
assert "No cron recipe" in out
|
||||
res = handle_cron_recipe_command("zzz-nope-nothing")
|
||||
assert "No cron recipe" in res.text
|
||||
assert res.agent_seed is None
|
||||
|
||||
def test_bad_value_names_slot(self, isolated_home):
|
||||
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
|
||||
|
||||
out = handle_cron_recipe_command("morning-brief time=99:99")
|
||||
assert "Can't set up" in out and "time" in out
|
||||
res = handle_cron_recipe_command("morning-brief time=99:99")
|
||||
assert "Can't set up" in res.text and "time" in res.text
|
||||
assert res.agent_seed is None
|
||||
|
||||
|
||||
class TestDocsGenerator:
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ Every recipe works from **every surface**:
|
|||
|
||||
- **Dashboard / desktop app** — open the Cron page, switch to the **Recipes**
|
||||
tab, fill the form, and click *Schedule it*.
|
||||
- **CLI, TUI, and messengers** — copy a recipe's `/cron-recipe` command below,
|
||||
edit the values, and send it. Hermes fills in anything you leave out and
|
||||
asks if something's ambiguous.
|
||||
- **CLI, TUI, and messengers** — type `/cron-recipe <name>` (e.g.
|
||||
`/cron-recipe morning-brief`) and Hermes asks you for what it needs, one
|
||||
question at a time, then schedules it. The name match is forgiving — a
|
||||
prefix or near-spelling resolves. Power users can skip the questions by
|
||||
passing values inline: `/cron-recipe morning-brief time=08:00`.
|
||||
- **Desktop app** — click **Send to App** on any recipe and it opens with the
|
||||
command pre-loaded in your composer.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue