-
Notifications
You must be signed in to change notification settings - Fork 52.6k
feat(cron): add cronjob(action='output') to read recently delivered cron results #37071
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
beardthelion
wants to merge
2
commits into
NousResearch:main
Choose a base branch
from
beardthelion:feat/cron-output-action
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+241
−1
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -654,6 +654,118 @@ 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/<job_id>/. | ||
| # | ||
| # The saved-file formats are fixed by the scheduler (cron/scheduler.py): | ||
| # - agent-mode: "## Response\n\n<delivered text>" | ||
| # - no_agent: "<header>\n---\n\n<script stdout>" | ||
|
|
||
|
|
||
| def _extract_delivered_content(text: str) -> str: | ||
| """Pull the delivered message body out of a saved cron output document. | ||
|
|
||
| Tries the two real delivery shapes in order, then falls back to stripping | ||
| the leading metadata header so status/failure docs still yield something. | ||
| """ | ||
| m = re.search(r"\n##\s*Response\s*\n", text) | ||
| if m: | ||
| return text[m.end():].strip() | ||
| m = re.search(r"\n---[ \t]*\n", text) | ||
| if m: | ||
| return text[m.end():].strip() | ||
| lines = text.splitlines() | ||
| body_start = 0 | ||
| for i, line in enumerate(lines): | ||
| if line.startswith(("#", "**")) or not line.strip(): | ||
| body_start = i + 1 | ||
| else: | ||
| break | ||
| return "\n".join(lines[body_start:]).strip() | ||
|
|
||
|
|
||
| def _parse_output_file(path: Path) -> Dict[str, Any]: | ||
| """Parse one saved cron output ``.md`` into structured fields.""" | ||
| text = path.read_text(encoding="utf-8", errors="replace") | ||
|
|
||
| def _grab(pattern: str) -> Optional[str]: | ||
| m = re.search(pattern, text, re.MULTILINE) | ||
| return m.group(1).strip() if m else None | ||
|
|
||
| return { | ||
| "job_id": _grab(r"^\*\*Job ID:\*\*\s*(.+)$"), | ||
| "name": _grab(r"^#\s*Cron Job:\s*(.+)$"), | ||
| "run_time": _grab(r"^\*\*Run Time:\*\*\s*(.+)$"), | ||
| "content": _extract_delivered_content(text), | ||
| } | ||
|
|
||
|
|
||
| def _is_safe_output_component(job_id: str) -> bool: | ||
| """Whether ``job_id`` is a single safe path component under the output root. | ||
|
|
||
| Job IDs scope a filesystem read under the cron output dir. A crafted or | ||
| unresolved id containing ``..``, path separators, or an absolute path would | ||
| let the read escape the sandbox and glob ``*.md`` elsewhere. Mirrors the | ||
| containment guard in ``cron.jobs._job_output_dir`` (which protects the | ||
| write/delete side) so the read side is equally contained. | ||
| """ | ||
| text = str(job_id or "").strip() | ||
| if not text or text in {".", ".."} or "/" in text or "\\" in text: | ||
| return False | ||
| if Path(text).is_absolute() or Path(text).drive: | ||
| return False | ||
| return True | ||
|
|
||
|
|
||
| def _read_recent_outputs( | ||
| job_id: Optional[str] = None, | ||
| limit: int = 5, | ||
| output_root: Optional[Path] = None, | ||
| ) -> List[Dict[str, Any]]: | ||
| """Return the most recent delivered cron outputs, newest first. | ||
|
|
||
| When ``job_id`` is given, scope to that one job; otherwise scan every job's | ||
| output directory and interleave by recency. ``output_root`` is injectable | ||
| for tests; production resolves it under the Hermes home. | ||
| """ | ||
| if output_root is not None: | ||
| root = Path(output_root) | ||
| else: | ||
| from hermes_constants import get_hermes_home | ||
| root = get_hermes_home() / "cron" / "output" | ||
|
|
||
| if not root.is_dir(): | ||
| return [] | ||
|
|
||
| if job_id: | ||
| if not _is_safe_output_component(job_id): | ||
| logger.warning( | ||
| "Ignoring unsafe cron output job_id %r (path-escape attempt)", job_id | ||
| ) | ||
| return [] | ||
| job_dir = root / job_id | ||
| dirs = [job_dir] if job_dir.is_dir() else [] | ||
| else: | ||
| dirs = [d for d in root.iterdir() if d.is_dir()] | ||
|
|
||
| files: List[Path] = [] | ||
| for d in dirs: | ||
| files.extend(d.glob("*.md")) | ||
| files.sort(key=lambda p: p.stat().st_mtime, reverse=True) | ||
|
|
||
| outputs: List[Dict[str, Any]] = [] | ||
| for p in files[: max(1, limit)]: | ||
| try: | ||
| outputs.append(_parse_output_file(p)) | ||
| except Exception as e: # never let one bad file sink the read | ||
| logger.debug("Skipping unreadable cron output %s: %s", p, e) | ||
| return outputs | ||
|
|
||
|
|
||
| def cronjob( | ||
|
|
@@ -677,6 +789,7 @@ def cronjob( | |
| workdir: Optional[str] = None, | ||
| no_agent: Optional[bool] = None, | ||
| attach_to_session: Optional[bool] = None, | ||
| limit: Optional[int] = None, | ||
| task_id: str = None, | ||
| ) -> str: | ||
| """Unified cron job management tool.""" | ||
|
|
@@ -777,6 +890,19 @@ def cronjob( | |
| jobs = [_format_job(job) for job in list_jobs(include_disabled=include_disabled)] | ||
| return json.dumps({"success": True, "count": len(jobs), "jobs": jobs}, indent=2) | ||
|
|
||
| if normalized in {"output", "outputs", "history"}: | ||
| # Read what jobs actually delivered. job_id is optional here: omit | ||
| # to scan all jobs by recency, or pass one to scope to a single job. | ||
| try: | ||
| resolved_id = resolve_job_ref(job_id)["id"] if job_id else None | ||
| except (AmbiguousJobReference, TypeError, KeyError): | ||
| resolved_id = job_id # fall back to raw id; reader tolerates misses | ||
| outputs = _read_recent_outputs(job_id=resolved_id, limit=limit or 5) | ||
| return json.dumps( | ||
| {"success": True, "count": len(outputs), "outputs": outputs}, | ||
| indent=2, | ||
| ) | ||
|
|
||
| if not job_id: | ||
| return tool_error(f"job_id is required for action '{normalized}'", success=False) | ||
|
|
||
|
|
@@ -973,6 +1099,7 @@ def cronjob( | |
|
|
||
| Use action='create' to schedule a new job from a prompt or one or more skills. | ||
| Use action='list' to inspect jobs. | ||
| Use action='output' to read back what jobs recently DELIVERED (their messages do not appear in this chat's history, so this is how you recall what a cron sent). Omit job_id for the latest across all jobs, or pass job_id to scope to one. Use 'limit' to control how many. | ||
| Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job. | ||
|
|
||
| To stop a job the user no longer wants: first action='list' to find the job_id, then action='remove' with that job_id. Never guess job IDs — always list first. | ||
|
|
@@ -991,7 +1118,11 @@ def cronjob( | |
| "properties": { | ||
| "action": { | ||
| "type": "string", | ||
| "description": "One of: create, list, update, pause, resume, remove, run. When action=create, the 'schedule' and 'prompt' fields are REQUIRED." | ||
| "description": "One of: create, list, output, update, pause, resume, remove, run. When action=create, the 'schedule' and 'prompt' fields are REQUIRED. action=output reads recently delivered cron results (job_id optional)." | ||
| }, | ||
| "limit": { | ||
| "type": "integer", | ||
| "description": "For action=output: how many recent deliveries to return (default 5)." | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This schema parameter is not forwarded by the registered handler: the handler explicitly maps arguments into |
||
| }, | ||
| "job_id": { | ||
| "type": "string", | ||
|
|
@@ -1140,6 +1271,7 @@ def check_cronjob_requirements() -> bool: | |
| enabled_toolsets=args.get("enabled_toolsets"), | ||
| workdir=args.get("workdir"), | ||
| no_agent=args.get("no_agent"), | ||
| limit=args.get("limit"), | ||
| task_id=kw.get("task_id"), | ||
| ))(), | ||
| check_fn=check_cronjob_requirements, | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This raw path join bypasses current main's output-path validation in
cron.jobs._job_output_dir(). Because line 628 retains an unresolvedjob_id, inputs such as../...can escape the cron output root when globbing Markdown files. Require a canonical resolved job ID or apply the same containment validation before reading.