From e976faac7adb90618a6f7b3b5d0d6461cbc0afd3 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 8 Jun 2026 07:32:17 -0700 Subject: [PATCH] feat(cron-recipes): /cron-recipe seeds a conversational fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -> 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 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. --- cli.py | 16 +- cron/scripts/classify_items.py | 4 +- gateway/run.py | 28 +- hermes_cli/cli_commands_mixin.py | 22 +- hermes_cli/cron_recipe_cmd.py | 251 ++++++++++++++---- tests/cron/test_recipe_catalog.py | 45 +++- .../docs/reference/cron-recipes-catalog.mdx | 8 +- 7 files changed, 299 insertions(+), 75 deletions(-) diff --git a/cli.py b/cli.py index 8834f125475..40d5d816caf 100644 --- a/cli.py +++ b/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 ) + # 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 ) 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 (.+?)\]') diff --git a/cron/scripts/classify_items.py b/cron/scripts/classify_items.py index d31b1f7427c..ba0cd42d4b4 100644 --- a/cron/scripts/classify_items.py +++ b/cron/scripts/classify_items.py @@ -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 diff --git a/gateway/run.py b/gateway/run.py index ff6de3289f7..e161d8ed13b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 97e84d86a0d..ec2927675d7 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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-matches a recipe and seeds the + agent to ask the user for each value conversationally (the result's + ``agent_seed``); ``/cron-recipe 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. diff --git a/hermes_cli/cron_recipe_cmd.py b/hermes_cli/cron_recipe_cmd.py index cb542f48622..0f97c69ff28 100644 --- a/hermes_cli/cron_recipe_cmd.py +++ b/hermes_cli/cron_recipe_cmd.py @@ -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 show that recipe's slots + a ready command - /cron-recipe slot=val … fill + create the cron job + /cron-recipe list the catalog + /cron-recipe name-match a recipe, then SEED THE AGENT to + ask the user for each value conversationally + /cron-recipe slot=val … fill + create the cron job directly + (the deterministic dashboard / docs / power- + user shortcut — no agent turn) + +The ```` 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 ` 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 ` 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 ` 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 ` 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:]) - # `` 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)) - # ` slot=val …` -> fill + create. + # `` 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) + + # ` 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." diff --git a/tests/cron/test_recipe_catalog.py b/tests/cron/test_recipe_catalog.py index afc4dbeeb86..7ccc8f79aad 100644 --- a/tests/cron/test_recipe_catalog.py +++ b/tests/cron/test_recipe_catalog.py @@ -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 ` (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: diff --git a/website/docs/reference/cron-recipes-catalog.mdx b/website/docs/reference/cron-recipes-catalog.mdx index 988b976b579..6e4ccff4240 100644 --- a/website/docs/reference/cron-recipes-catalog.mdx +++ b/website/docs/reference/cron-recipes-catalog.mdx @@ -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 ` (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.