From 1485323be69395d17495a3b5d238edc604f8381e Mon Sep 17 00:00:00 2001 From: beardthelion Date: Mon, 1 Jun 2026 16:59:07 -0500 Subject: [PATCH 1/2] feat(cron): add action='output' to read recently delivered cron results Cron deliveries don't enter the interactive conversation history (the assistant-role mirror was removed in #2313 because it broke message alternation). This adds the pull side of cron session-awareness: the agent can read back what its jobs delivered, on demand. - cronjob(action='output'[, job_id][, limit]) returns recent deliveries, newest first, parsed from ~/.hermes/cron/output//*.md - _extract_delivered_content handles both saved-file shapes: agent-mode ("## Response") and no_agent ("---" then script stdout) - schema: new 'output' action + 'limit' param Pull side only. Push side (auto-injecting a [System note] into the next turn's system prompt) is the next step and is not in this commit. Tests: 6 new in TestReadRecentOutputs; 62 passed. (cherry picked from commit 0bc07530f45195a82636b9243d3cbe64c8635277) --- tests/tools/test_cronjob_tools.py | 68 ++++++++++++++++++ tools/cronjob_tools.py | 111 +++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 41aea33c7dc0a..9818e2593ef8c 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -704,3 +704,71 @@ def test_named_registry_offhost_blocked(self): def test_base_url_without_provider_rejected(self): assert self._v(None, "https://x.example/v1") is not None + + +# ========================================================================= +# Recent delivered output (pull side of cron session-awareness) +# ========================================================================= + +class TestReadRecentOutputs: + """_extract_delivered_content / _read_recent_outputs / action='output'.""" + + def _write(self, root, job_id, fname, body): + from pathlib import Path + d = Path(root) / job_id + d.mkdir(parents=True, exist_ok=True) + (d / fname).write_text(body, encoding="utf-8") + + def test_extract_agent_mode_response(self): + from tools.cronjob_tools import _extract_delivered_content + doc = ( + "# Cron Job: foo\n\n**Job ID:** abc\n**Run Time:** 2026-06-01 09:00:00\n" + "**Schedule:** 0 9 * * *\n\n## Prompt\n\nsome prompt with --- inside\n\n" + "## Response\n\nGood morning, readiness is 88.\n" + ) + assert _extract_delivered_content(doc) == "Good morning, readiness is 88." + + def test_extract_no_agent_after_hr(self): + from tools.cronjob_tools import _extract_delivered_content + doc = ( + "# Cron Job: bar\n\n**Job ID:** def\n**Run Time:** 2026-06-01 16:00:00\n" + "**Mode:** no_agent (script)\n\n---\n\nPR Watch found 3 issues.\n" + ) + assert _extract_delivered_content(doc) == "PR Watch found 3 issues." + + def test_read_recent_orders_newest_first_and_parses(self, tmp_path): + from tools.cronjob_tools import _read_recent_outputs + self._write(tmp_path, "j1", "2026-06-01_08-00-00.md", + "# Cron Job: A\n\n**Job ID:** j1\n**Run Time:** 2026-06-01 08:00:00\n\n---\n\nold\n") + self._write(tmp_path, "j2", "2026-06-01_09-00-00.md", + "# Cron Job: B\n\n**Job ID:** j2\n**Run Time:** 2026-06-01 09:00:00\n\n## Response\n\nnew\n") + import os, time + # ensure deterministic mtime ordering (j2 newer) + os.utime(tmp_path / "j1" / "2026-06-01_08-00-00.md", (time.time() - 100,) * 2) + os.utime(tmp_path / "j2" / "2026-06-01_09-00-00.md", (time.time(),) * 2) + got = _read_recent_outputs(output_root=tmp_path, limit=5) + assert [o["name"] for o in got] == ["B", "A"] + assert got[0]["content"] == "new" + assert got[0]["job_id"] == "j2" + assert got[1]["content"] == "old" + + def test_read_recent_scopes_to_job_id(self, tmp_path): + from tools.cronjob_tools import _read_recent_outputs + self._write(tmp_path, "j1", "a.md", + "# Cron Job: A\n\n**Job ID:** j1\n**Run Time:** t\n\n---\n\naaa\n") + self._write(tmp_path, "j2", "b.md", + "# Cron Job: B\n\n**Job ID:** j2\n**Run Time:** t\n\n---\n\nbbb\n") + got = _read_recent_outputs(job_id="j1", output_root=tmp_path, limit=5) + assert len(got) == 1 and got[0]["name"] == "A" + + def test_read_recent_respects_limit(self, tmp_path): + from tools.cronjob_tools import _read_recent_outputs + for i in range(4): + self._write(tmp_path, "j1", f"{i}.md", + f"# Cron Job: A\n\n**Job ID:** j1\n**Run Time:** t\n\n---\n\nn{i}\n") + got = _read_recent_outputs(job_id="j1", output_root=tmp_path, limit=2) + assert len(got) == 2 + + def test_read_recent_missing_root_is_empty(self, tmp_path): + from tools.cronjob_tools import _read_recent_outputs + assert _read_recent_outputs(output_root=tmp_path / "nope", limit=5) == [] diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 430cd1dda4014..3a5631c9c1a22 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -654,6 +654,96 @@ def _execute_job_now(job: Dict[str, Any]) -> Dict[str, Any]: except Exception: pass return {"claimed": True, "success": False, "error": str(e)} +# --------------------------------------------------------------------------- +# Recent delivered output (the "pull" side of cron session-awareness) +# --------------------------------------------------------------------------- +# +# Cron deliveries do NOT land in the interactive conversation history (that was +# removed in #2313 because assistant-role mirrors broke message alternation). +# These helpers let the agent read back what its jobs delivered, on demand, by +# parsing the per-run markdown saved under ~/.hermes/cron/output//. +# +# The saved-file formats are fixed by the scheduler (cron/scheduler.py): +# - agent-mode: "## Response\n\n" +# - no_agent: "
\n---\n\n