{"repo":"NousResearch/hermes-agent","public_id":"5439","display_label":"Cron job chaining and output passing between jobs","summary":"These items concern linking cron jobs so one job's completion or output can trigger or feed into another, including fields like context_from and trigger_on_complete and sanitization of context payloads.","subsystem":"cron","counts":{"members":6,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15606},"resolution":{"state":"partially_resolved","by":"pr-15606"},"freshness":{"state":"stale","last_verified_at":"2026-04-25T11:49:28+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.24799999999999997},"fix_edges":[{"issue_number":5439,"pr_number":15606,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5439,"title":"Feature: Cron job output chaining — let one job's output feed into another","url":null,"state":"closed"},{"kind":"issue","number":15831,"title":"Feature: Job chaining — trigger one cron job from another's completion","url":null,"state":"open"},{"kind":"issue","number":56692,"title":"Cron pipeline: durable structured data passing between cron jobs","url":null,"state":"open"},{"kind":"pr","number":14190,"title":"feat(cron): add context_from field for cron job output chaining","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15606","number":15606}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15606,"title":"feat(cron): add context_from field for cron job output chaining","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","cron/scheduler.py","tests/cron/test_cron_context_from.py","tools/cronjob_tools.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -16,7 +16,7 @@\n from datetime import datetime, timedelta\n from pathlib import Path\n from hermes_constants import get_hermes_home\n-from typing import Optional, Dict, List, Any\n+from typing import Optional, Dict, List, Any, Union\n \n logger = logging.getLogger(__name__)\n ","@@ -417,6 +417,7 @@ def create_job(\n     provider: Optional[str] = None,\n     base_url: Optional[str] = None,\n     script: Optional[str] = None,\n+    context_from: Optional[Union[str, List[str]]] = None,\n     enabled_toolsets: Optional[List[str]] = None,\n     workdir: Optional[str] = None,\n ) -> Dict[str, Any]:","@@ -438,6 +439,9 @@ def create_job(\n         script: Optional path to a Python script whose stdout is injected into the\n                 prompt each run.  The script runs before the agent turn, and its output\n                 is prepended as context.  Useful for data collection / change detection.\n+        context_from: Optional job ID (or list of job IDs) whose most recent output\n+                      is injected into the prompt as context before each run.\n+                      Useful for chaining cron jobs: job A finds data, job B processes it.\n         enabled_toolsets: Optional list of toolset names to restrict the agent to.\n                           When set, only tools from these toolsets are loaded, reducing\n                           token overhead. When omitted, all default tools are loaded.","@@ -481,6 +485,14 @@ def create_job(\n     normalized_toolsets = normalized_toolsets or None\n     normalized_workdir = _normalize_workdir(workdir)\n \n+    # Normalize context_from: accept str or list of str, store as list or None\n+    if isinstance(context_from, str):\n+        context_from = [context_from.strip()] if context_from.strip() else None\n+    elif isinstance(context_from, list):\n+        context_from = [str(j).strip() for j in context_from if str(j).strip()] or None\n+    else:\n+        context_from = None\n+\n     label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or \"cron job\"\n     job = {\n         \"id\": job_id,","@@ -492,6 +504,7 @@ def create_job(\n         \"provider\": normalized_provider,\n         \"base_url\": normalized_base_url,\n         \"script\": normalized_script,\n+        \"context_from\": context_from,\n         \"schedule\": parsed_schedule,\n         \"schedule_display\": parsed_schedule.get(\"display\", schedule),\n         \"repeat\": {"]},{"file":"cron/scheduler.py","hunks":["@@ -671,6 +671,47 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:\n                 f\"{prompt}\"\n             )\n \n+    # Inject output from referenced cron jobs as context.\n+    context_from = job.get(\"context_from\")\n+    if context_from:\n+        from cron.jobs import OUTPUT_DIR\n+        if isinstance(context_from, str):\n+            context_from = [context_from]\n+        for source_job_id in context_from:\n+            # Guard against path traversal — valid job IDs are 12-char hex strings\n+            if not source_job_id or not all(c in \"0123456789abcdef\" for c in source_job_id):\n+                logger.warning(\"context_from: skipping invalid job_id %r\", source_job_id)\n+                continue\n+            try:\n+                job_output_dir = OUTPUT_DIR / source_job_id\n+                if not job_output_dir.exists():\n+                    continue  # silent skip — no output yet\n+                output_files = sorted(\n+                    job_output_dir.glob(\"*.md\"),\n+                    key=lambda f: f.stat().st_mtime,\n+                    reverse=True,\n+                )\n+                if not output_files:\n+                    continue  # silent skip — no output yet\n+                latest_output = output_files[0].read_text(encoding=\"utf-8\").strip()\n+                # Truncate to 8K characters to avoid prompt bloat\n+                _MAX_CONTEXT_CHARS = 8000\n+                if len(latest_output) > _MAX_CONTEXT_CHARS:\n+                    latest_output = latest_output[:_MAX_CONTEXT_CHARS] + \"\\n\\n[... output truncated ...]\"\n+                if latest_output:\n+                    prompt = (\n+                        f\"## Output from job '{source_job_id}'\\n\"\n+                        \"The following is the most recent output from a preceding \"\n+                        \"cron job. Use it as context for your analysis.\\n\\n\"\n+                        f\"```\\n{latest_output}\\n```\\n\\n\"\n+                        f\"{prompt}\"\n+                    )\n+                else:\n+                    continue  # silent skip — empty output\n+            except (OSError, PermissionError) as e:\n+                logger.warning(\"context_from: failed to read output for job %r: %s\", source_job_id, e)\n+                # silent skip — do not pollute the prompt with error messages\n+\n     # Always prepend cron execution guidance so the agent knows how\n     # delivery works and can suppress delivery when appropriate.\n     cron_hint = ("]},{"file":"tests/cron/test_cron_context_from.py","hunks":["@@ -0,0 +1,390 @@\n+\"\"\"Tests for cron job context_from feature (issue #5439 Option C).\"\"\"\n+\n+import sys\n+from pathlib import Path\n+\n+import pytest\n+\n+sys.path.insert(0, str(Path(__file__).parent.parent.parent))\n+\n+\n[redacted:email]\n+def cron_env(tmp_path, monkeypatch):\n+    \"\"\"Isolated cron environment with temp HERMES_HOME.\"\"\"\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    (hermes_home / \"cron\").mkdir()\n+    (hermes_home / \"cron\" / \"output\").mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    import cron.jobs as jobs_mod\n+    monkeypatch.setattr(jobs_mod, \"HERMES_DIR\", hermes_home)\n+    monkeypatch.setattr(jobs_mod, \"CRON_DIR\", hermes_home / \"cron\")\n+    monkeypatch.setattr(jobs_mod, \"JOBS_FILE\", hermes_home / \"cron\" / \"jobs.json\")\n+    monkeypatch.setattr(jobs_mod, \"OUTPUT_DIR\", hermes_home / \"cron\" / \"output\")\n+\n+    return hermes_home\n+\n+\n+class TestJobContextFromField:\n+    \"\"\"Test that context_from is stored and retrieved correctly.\"\"\"\n+\n+    def test_create_job_with_context_from_string(self, cron_env):\n+        from cron.jobs import create_job, get_job\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_b = create_job(\n+            prompt=\"Summarize findings\",\n+            schedule=\"every 2h\",\n+            context_from=job_a[\"id\"],\n+        )\n+\n+        assert job_b[\"context_from\"] == [job_a[\"id\"]]\n+        loaded = get_job(job_b[\"id\"])\n+        assert loaded[\"context_from\"] == [job_a[\"id\"]]\n+\n+    def test_create_job_with_context_from_list(self, cron_env):\n+        from cron.jobs import create_job, get_job\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_b = create_job(prompt=\"Find weather\", schedule=\"every 1h\")\n+        job_c = create_job(\n+            prompt=\"Summarize everything\",\n+            schedule=\"every 2h\",\n+            context_from=[job_a[\"id\"], job_b[\"id\"]],\n+        )\n+\n+        assert job_c[\"context_from\"] == [job_a[\"id\"], job_b[\"id\"]]\n+\n+    def test_create_job_without_context_from(self, cron_env):\n+        from cron.jobs import create_job\n+\n+        job = create_job(prompt=\"Hello\", schedule=\"every 1h\")\n+        assert job.get(\"context_from\") is None\n+\n+    def test_context_from_empty_string_normalized_to_none(self, cron_env):\n+        from cron.jobs import create_job\n+\n+        job = create_job(prompt=\"Hello\", schedule=\"every 1h\", context_from=\"\")\n+        assert job.get(\"context_from\") is None\n+\n+    def test_context_from_empty_list_normalized_to_none(self, cron_env):\n+        from cron.jobs import create_job\n+\n+        job = create_job(prompt=\"Hello\", schedule=\"every 1h\", context_from=[])\n+        assert job.get(\"context_from\") is None\n+\n+\n+class TestBuildJobPromptContextFrom:\n+    \"\"\"Test that _build_job_prompt() injects context from referenced jobs.\"\"\"\n+\n+    def test_injects_latest_output(self, cron_env):\n+        from cron.jobs import create_job, OUTPUT_DIR\n+        from cron.scheduler import _build_job_prompt\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+\n+        # Записываем output для job_a\n+        output_dir = OUTPUT_DIR / job_a[\"id\"]\n+        output_dir.mkdir(parents=True, exist_ok=True)\n+        (output_dir / \"2026-04-22_10-00-00.md\").write_text(\n+            \"Today's top story: AI is everywhere.\", encoding=\"utf-8\"\n+        )\n+\n+        job_b = create_job(\n+            prompt=\"Summarize the news\",\n+            schedule=\"every 2h\",\n+            context_from=job_a[\"id\"],\n+        )\n+\n+        prompt = _build_job_prompt(job_b)\n+        assert \"Today's top story: AI is everywhere.\" in prompt\n+        assert f\"Output from job '{job_a['id']}'\" in prompt\n+\n+    def test_uses_most_recent_output(self, cron_env):\n+        from cron.jobs import create_job, OUTPUT_DIR\n+        from cron.scheduler import _build_job_prompt\n+        import time\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        output_dir = OUTPUT_DIR / job_a[\"id\"]\n+        output_dir.mkdir(parents=True, exist_ok=True)\n+\n+        old_file = output_dir / \"2026-04-22_08-00-00.md\"\n+        old_file.write_text(\"Old output\", encoding=\"utf-8\")\n+        time.sleep(0.01)\n+        new_file = output_dir / \"2026-04-22_10-00-00.md\"\n+        new_file.write_text(\"New output\", encoding=\"utf-8\")\n+\n+        job_b = create_job(\n+            prompt=\"Summarize\", schedule=\"every 2h\", context_from=job_a[\"id\"]\n+        )\n+        prompt = _build_job_prompt(job_b)\n+        assert \"New output\" in prompt\n+        assert \"Old output\" not in prompt\n+\n+    def test_graceful_when_no_output_yet(self, cron_env):\n+        from cron.jobs import create_job\n+        from cron.scheduler import _build_job_prompt\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_b = create_job(\n+            prompt=\"Summarize\", schedule=\"every 2h\", context_from=job_a[\"id\"]\n+        )\n+\n+        # job_a never ran — output dir does not exist\n+        # expect silent skip: no placeholder injected, base prompt intact\n+        prompt = _build_job_prompt(job_b)\n+        assert \"no output\" not in prompt.lower()\n+        assert \"not found\" not in prompt.lower()\n+        assert \"Summarize\" in prompt\n+\n+    def test_injects_multiple_context_jobs(self, cron_env):\n+        from cron.jobs import create_job, OUTPUT_DIR\n+        from cron.scheduler import _build_job_prompt\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_b = create_job(prompt=\"Find weather\", schedule=\"every 1h\")\n+\n+        for job, content in [(job_a, \"News: AI boom\"), (job_b, \"Weather: Sunny\")]:\n+            out_dir = OUTPUT_DIR / job[\"id\"]\n+            out_dir.mkdir(parents=True, exist_ok=True)\n+            (out_dir / \"2026-04-22_10-00-00.md\").write_text(content, encoding=\"utf-8\")\n+\n+        job_c = create_job(\n+            prompt=\"Daily briefing\",\n+            schedule=\"every 2h\",\n+            context_from=[job_a[\"id\"], job_b[\"id\"]],\n+        )\n+        prompt = _build_job_prompt(job_c)\n+        assert \"News: AI boom\" in prompt\n+        assert \"Weather: Sunny\" in prompt\n+\n+    def test_context_injected_before_prompt(self, cron_env):\n+        \"\"\"Context should appear before the job's own prompt.\"\"\"\n+        from cron.jobs import create_job, OUTPUT_DIR\n+        from cron.scheduler import _build_job_prompt\n+\n+        job_a = create_job(prompt=\"Find data\", schedule=\"every 1h\")\n+        out_dir = OUTPUT_DIR / job_a[\"id\"]\n+        out_dir.mkdir(parents=True, exist_ok=True)\n+        (out_dir / \"2026-04-22_10-00-00.md\").write_text(\"Context data\", encoding=\"utf-8\")\n+\n+        job_b = create_job(\n+            prompt=\"Process the data above\",\n+            schedule=\"every 2h\",\n+            context_from=job_a[\"id\"],\n+        )\n+        prompt = _build_job_prompt(job_b)\n+        context_pos = prompt.find(\"Context data\")\n+        prompt_pos = prompt.find(\"Process the data above\")\n+        assert context_pos < prompt_pos\n+\n+    def test_output_truncated_at_8k_chars(self, cron_env):\n+        \"\"\"Output longer than 8000 chars should be truncated.\"\"\"\n+        from cron.jobs import create_job, OUTPUT_DIR\n+        from cron.scheduler import _build_job_prompt\n+\n+        job_a = create_job(prompt=\"Find data\", schedule=\"every 1h\")\n+        out_dir = OUTPUT_DIR / job_a[\"id\"]\n+        out_dir.mkdir(parents=True, exist_ok=True)\n+        big_output = \"x\" * 10000\n+        (out_dir / \"2026-04-22_10-00-00.md\").write_text(big_output, encoding=\"utf-8\")\n+\n+        job_b = create_job(\n+            prompt=\"Process\", schedule=\"every 2h\", context_from=job_a[\"id\"]\n+        )\n+        prompt = _build_job_prompt(job_b)\n+        assert \"truncated\" in prompt\n+        assert \"x\" * 10000 not in prompt\n+\n+    def test_graceful_when_file_deleted_between_listing_and_reading(self, cron_env):\n+        \"\"\"Job should not crash if output file is deleted mid-read.\"\"\"\n+        from cron.jobs import create_job, OUTPUT_DIR\n+        from cron.scheduler import _build_job_prompt\n+        from unittest.mock import patch\n+\n+        job_a = create_job(prompt=\"Find data\", schedule=\"every 1h\")\n+        out_dir = OUTPUT_DIR / job_a[\"id\"]\n+        out_dir.mkdir(parents=True, exist_ok=True)\n+        (out_dir / \"2026-04-22_10-00-00.md\").write_text(\"Some output\", encoding=\"utf-8\")\n+\n+        job_b = create_job(\n+            prompt=\"Process\", schedule=\"every 2h\", context_from=job_a[\"id\"]\n+        )\n+\n+        # Simulate file deleted between glob() and read_text()\n+        original_read = Path.read_text\n+        def mock_read_text(self, *args, **kwargs):\n+            if self.suffix == \".md\":\n+                raise FileNotFoundError(\"file deleted mid-read\")\n+            return original_read(self, *args, **kwargs)\n+\n+        with patch.object(Path, \"read_text\", mock_read_text):\n+            prompt = _build_job_prompt(job_b)\n+\n+        # Job should not crash, prompt should still contain the base prompt\n+        assert \"Process\" in prompt\n+\n+    def test_graceful_when_permission_error(self, cron_env):\n+        \"\"\"Job should not crash if output directory is not readable.\"\"\"\n+        from cron.jobs import create_job, OUTPUT_DIR\n+        from cron.scheduler import _build_job_prompt\n+        from unittest.mock import patch\n+\n+        job_a = create_job(prompt=\"Find data\", schedule=\"every 1h\")\n+        out_dir = OUTPUT_DIR / job_a[\"id\"]\n+        out_dir.mkdir(parents=True, exist_ok=True)\n+        (out_dir / \"2026-04-22_10-00-00.md\").write_text(\"Some output\", encoding=\"utf-8\")\n+\n+        job_b = create_job(\n+            prompt=\"Process\", schedule=\"every 2h\", context_from=job_a[\"id\"]\n+        )\n+\n+        # Simulate permission error on read\n+        original_read = Path.read_text\n+        def mock_read_text(self, *args, **kwargs):\n+            if self.suffix == \".md\":\n+                raise PermissionError(\"permission denied\")\n+            return original_read(self, *args, **kwargs)\n+\n+        with patch.object(Path, \"read_text\", mock_read_text):\n+            prompt = _build_job_prompt(job_b)\n+\n+        # Job should not crash, prompt should still contain the base prompt\n+        assert \"Process\" in prompt\n+\n+    def test_invalid_job_id_skipped(self, cron_env):\n+        \"\"\"context_from with path traversal job_id should be skipped.\"\"\"\n+        from cron.jobs import create_job\n+        from cron.scheduler import _build_job_prompt\n+\n+        job = create_job(prompt=\"Process\", schedule=\"every 2h\")\n+        # Manually inject invalid context_from (simulating tampered jobs.json)\n+        job[\"context_from\"] = [\"../../../etc/passwd\"]\n+        prompt = _build_job_prompt(job)\n+        # Should not crash and should not inject anything malicious\n+        assert \"Process\" in prompt\n+        assert \"etc/passwd\" not in prompt\n+\n+\n+\n+class TestUpdateContextFrom:\n+    \"\"\"Verify the cronjob tool's `update` action wires context_from through.\n+\n+    Without this, the create-path stores the field but users can never modify\n+    or clear it via the tool (schema promises \"pass an empty array to clear\").\n+    \"\"\"\n+\n+    def test_update_adds_context_from_to_existing_job(self, cron_env):\n+        from cron.jobs import create_job, get_job\n+        from tools.cronjob_tools import cronjob\n+        import json\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_b = create_job(prompt=\"Summarize\", schedule=\"every 2h\")\n+        assert job_b.get(\"context_from\") is None\n+\n+        result = json.loads(cronjob(\n+            action=\"update\",\n+            job_id=job_b[\"id\"],\n+            context_from=job_a[\"id\"],\n+        ))\n+        assert result[\"success\"] is True\n+\n+        reloaded = get_job(job_b[\"id\"])\n+        assert reloaded[\"context_from\"] == [job_a[\"id\"]]\n+\n+    def test_update_changes_context_from_reference(self, cron_env):\n+        from cron.jobs import create_job, get_job\n+        from tools.cronjob_tools import cronjob\n+        import json\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_a2 = create_job(prompt=\"Find weather\", schedule=\"every 1h\")\n+        job_b = create_job(\n+            prompt=\"Summarize\", schedule=\"every 2h\", context_from=job_a[\"id\"],\n+        )\n+        assert job_b[\"context_from\"] == [job_a[\"id\"]]\n+\n+        result = json.loads(cronjob(\n+            action=\"update\",\n+            job_id=job_b[\"id\"],\n+            context_from=[job_a2[\"id\"]],\n+        ))\n+        assert result[\"success\"] is True\n+        assert get_job(job_b[\"id\"])[\"context_from\"] == [job_a2[\"id\"]]\n+\n+    def test_update_clears_context_from_with_empty_list(self, cron_env):\n+        from cron.jobs import create_job, get_job\n+        from tools.cronjob_tools import cronjob\n+        import json\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_b = create_job(\n+            prompt=\"Summarize\", schedule=\"every 2h\", context_from=job_a[\"id\"],\n+        )\n+        assert get_job(job_b[\"id\"])[\"context_from\"] == [job_a[\"id\"]]\n+\n+        result = json.loads(cronjob(\n+            action=\"update\",\n+            job_id=job_b[\"id\"],\n+            context_from=[],\n+        ))\n+        assert result[\"success\"] is True\n+        assert get_job(job_b[\"id\"])[\"context_from\"] is None\n+\n+    def test_update_clears_context_from_with_empty_string(self, cron_env):\n+        from cron.jobs import create_job, get_job\n+        from tools.cronjob_tools import cronjob\n+        import json\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_b = create_job(\n+            prompt=\"Summarize\", schedule=\"every 2h\", context_from=job_a[\"id\"],\n+        )\n+\n+        result = json.loads(cronjob(\n+            action=\"update\",\n+            job_id=job_b[\"id\"],\n+            context_from=\"\",\n+        ))\n+        assert result[\"success\"] is True\n+        assert get_job(job_b[\"id\"])[\"context_from\"] is None\n+\n+    def test_update_rejects_unknown_job_reference(self, cron_env):\n+        from cron.jobs import create_job\n+        from tools.cronjob_tools import cronjob\n+        import json\n+\n+        job_b = create_job(prompt=\"Summarize\", schedule=\"every 2h\")\n+\n+        result = json.loads(cronjob(\n+            action=\"update\",\n+            job_id=job_b[\"id\"],\n+            context_from=[\"deadbeef0000\"],\n+        ))\n+        assert result[\"success\"] is False\n+        assert \"not found\" in result[\"error\"]\n+\n+    def test_update_preserves_context_from_when_not_passed(self, cron_env):\n+        \"\"\"Updating other fields must not clobber context_from.\"\"\"\n+        from cron.jobs import create_job, get_job\n+        from tools.cronjob_tools import cronjob\n+        import json\n+\n+        job_a = create_job(prompt=\"Find news\", schedule=\"every 1h\")\n+        job_b = create_job(\n+            prompt=\"Summarize\", schedule=\"every 2h\", context_from=job_a[\"id\"],\n+        )\n+\n+        # Update an unrelated field\n+        result = json.loads(cronjob(\n+            action=\"update\",\n+            job_id=job_b[\"id\"],\n+            prompt=\"Summarize v2\",\n+        ))\n+        assert result[\"success\"] is True\n+        reloaded = get_job(job_b[\"id\"])\n+        assert reloaded[\"prompt\"] == \"Summarize v2\"\n+        assert reloaded[\"context_from\"] == [job_a[\"id\"]]"]},{"file":"tools/cronjob_tools.py","hunks":["@@ -11,7 +11,7 @@\n import re\n import sys\n from pathlib import Path\n-from typing import Any, Dict, List, Optional\n+from typing import Any, Dict, List, Optional, Union\n \n from hermes_constants import display_hermes_home\n ","@@ -238,6 +238,7 @@ def cronjob(\n     base_url: Optional[str] = None,\n     reason: Optional[str] = None,\n     script: Optional[str] = None,\n+    context_from: Optional[Union[str, List[str]]] = None,\n     enabled_toolsets: Optional[List[str]] = None,\n     workdir: Optional[str] = None,\n     task_id: str = None,","@@ -265,6 +266,18 @@ def cronjob(\n                 if script_error:\n                     return tool_error(script_error, success=False)\n \n+            # Validate context_from references existing jobs\n+            if context_from:\n+                from cron.jobs import get_job as _get_job\n+                refs = [context_from] if isinstance(context_from, str) else context_from\n+                for ref_id in refs:\n+                    if not _get_job(ref_id):\n+                        return tool_error(\n+                            f\"context_from job '{ref_id}' not found. \"\n+                            \"Use cronjob(action='list') to see available jobs.\",\n+                            success=False,\n+                        )\n+\n             job = create_job(\n                 prompt=prompt or \"\",\n                 schedule=schedule,","@@ -277,6 +290,7 @@ def cronjob(\n                 provider=_normalize_optional_job_value(provider),\n                 base_url=_normalize_optional_job_value(base_url, strip_trailing_slash=True),\n                 script=_normalize_optional_job_value(script),\n+                context_from=context_from,\n                 enabled_toolsets=enabled_toolsets or None,\n                 workdir=_normalize_optional_job_value(workdir),\n             )","@@ -368,6 +382,24 @@ def cronjob(\n                     if script_error:\n                         return tool_error(script_error, success=False)\n                 updates[\"script\"] = _normalize_optional_job_value(script) if script else None\n+            if context_from is not None:\n+                # Empty string / empty list clears the field; otherwise validate\n+                # each referenced job exists before storing. Normalized to a list\n+                # (or None) to match the shape stored by create_job().\n+                if isinstance(context_from, str):\n+                    refs = [context_from.strip()] if context_from.strip() else []\n+                else:\n+                    refs = [str(j).strip() for j in context_from if str(j).strip()]\n+                if refs:\n+                    from cron.jobs import get_job as _get_job\n+                    for ref_id in refs:\n+                        if not _get_job(ref_id):\n+                            return tool_error(\n+                                f\"context_from job '{ref_id}' not found. \"\n+                                \"Use cronjob(action='list') to see available jobs.\",\n+                                success=False,\n+                            )\n+                updates[\"context_from\"] = refs or None\n             if enabled_toolsets is not None:\n                 updates[\"enabled_toolsets\"] = enabled_toolsets or None\n             if workdir is not None:","@@ -473,6 +505,19 @@ def cronjob(\n                 \"type\": \"string\",\n                 \"description\": f\"Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under {display_hermes_home()}/scripts/. On update, pass empty string to clear.\"\n             },\n+            \"context_from\": {\n+                \"type\": \"array\",\n+                \"items\": {\"type\": \"string\"},\n+                \"description\": (\n+                    \"Optional job ID or list of job IDs whose most recent completed output is \"\n+                    \"injected into the prompt as context before each run. \"\n+                    \"Use this to chain cron jobs: job A collects data, job B processes it. \"\n+                    \"Each entry must be a valid job ID (from cronjob action='list'). \"\n+                    \"Note: injects the most recent completed output — does not wait for \"\n+                    \"upstream jobs running in the same tick. \"\n+                    \"On update, pass an empty array to clear.\"\n+                ),\n+            },\n             \"enabled_toolsets\": {\n                 \"type\": \"array\",\n                 \"items\": {\"type\": \"string\"},","@@ -526,6 +571,7 @@ def check_cronjob_requirements() -> bool:\n         base_url=args.get(\"base_url\"),\n         reason=args.get(\"reason\"),\n         script=args.get(\"script\"),\n+        context_from=args.get(\"context_from\"),\n         enabled_toolsets=args.get(\"enabled_toolsets\"),\n         workdir=args.get(\"workdir\"),\n         task_id=kw.get(\"task_id\"),\n"]}]},{"kind":"pr","number":82827,"title":"feat(cron): add reactive job chaining via trigger_on_complete","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}