Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4503,6 +4503,7 @@ def _parse_flags(tokens):
opts = {
"name": None,
"deliver": None,
"into_history": None,
"repeat": None,
"skills": [],
"add_skills": [],
Expand All @@ -4522,6 +4523,12 @@ def _parse_flags(tokens):
elif token == "--deliver" and i + 1 < len(tokens):
opts["deliver"] = tokens[i + 1]
i += 2
elif token == "--into-history":
opts["into_history"] = True
i += 1
elif token == "--no-into-history":
opts["into_history"] = False
i += 1
elif token == "--repeat" and i + 1 < len(tokens):
try:
opts["repeat"] = int(tokens[i + 1])
Expand Down Expand Up @@ -4565,7 +4572,7 @@ def _parse_flags(tokens):
print()
print(" Commands:")
print(" /cron list")
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher] [--into-history]')
print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task"')
print(" /cron edit <job_id> --skill blogwatcher --skill find-nearby")
print(" /cron edit <job_id> --remove-skill blogwatcher")
Expand Down Expand Up @@ -4615,6 +4622,7 @@ def _parse_flags(tokens):
print(f" State: {job.get('state', '?')}")
print(f" Schedule: {job['schedule']} ({job.get('repeat', '?')})")
print(f" Next run: {job.get('next_run_at', 'N/A')}")
print(f" History: {'session' if job.get('into_history') else 'isolated'}")
if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
print(f" Prompt: {job.get('prompt_preview', '')}")
Expand All @@ -4640,12 +4648,14 @@ def _parse_flags(tokens):
prompt=prompt or None,
name=opts["name"],
deliver=opts["deliver"],
into_history=opts["into_history"],
repeat=opts["repeat"],
skills=skills or None,
)
if result.get("success"):
print(f"(^_^)b Created job: {result['job_id']}")
print(f" Schedule: {result['schedule']}")
print(f" History: {'session' if result.get('into_history') else 'isolated'}")
if result.get("skills"):
print(f" Skills: {', '.join(result['skills'])}")
print(f" Next run: {result['next_run_at']}")
Expand Down Expand Up @@ -4686,13 +4696,15 @@ def _parse_flags(tokens):
prompt=opts["prompt"],
name=opts["name"],
deliver=opts["deliver"],
into_history=opts["into_history"],
repeat=opts["repeat"],
skills=final_skills,
)
if result.get("success"):
job = result["job"]
print(f"(^_^)b Updated job: {job['job_id']}")
print(f" Schedule: {job['schedule']}")
print(f" History: {'session' if job.get('into_history') else 'isolated'}")
if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
else:
Expand Down
4 changes: 4 additions & 0 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ def create_job(
name: Optional[str] = None,
repeat: Optional[int] = None,
deliver: Optional[str] = None,
into_history: bool = False,
origin: Optional[Dict[str, Any]] = None,
skill: Optional[str] = None,
skills: Optional[List[str]] = None,
Expand All @@ -386,6 +387,8 @@ def create_job(
name: Optional friendly name
repeat: How many times to run (None = forever, 1 = once)
deliver: Where to deliver output ("origin", "local", "telegram", etc.)
into_history: When true, mirror delivered output into the target gateway
session history so future turns can see it
origin: Source info where job was created (for "origin" delivery)
skill: Optional legacy single skill name to load before running the prompt
skills: Optional ordered list of skills to load before running the prompt
Expand Down Expand Up @@ -454,6 +457,7 @@ def create_job(
"last_error": None,
# Delivery configuration
"deliver": deliver,
"into_history": bool(into_history),
"origin": origin, # Tracks where job was created for "origin" delivery
}

Expand Down
38 changes: 37 additions & 1 deletion cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,20 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
except Exception:
pass

into_history = bool(job.get("into_history", False))

if wrap_response:
task_name = job.get("name", job["id"])
footer = (
"Note: This scheduled update has been added to the conversation history for future replies."
if into_history
else "Note: The agent cannot see this message, and therefore cannot respond to it."
)
delivery_content = (
f"Cronjob Response: {task_name}\n"
f"-------------\n\n"
f"{content}\n\n"
f"Note: The agent cannot see this message, and therefore cannot respond to it."
f"{footer}"
)
else:
delivery_content = content
Expand Down Expand Up @@ -311,6 +318,18 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
_send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job)

if adapter_ok:
if into_history:
try:
from gateway.mirror import mirror_to_session
mirror_to_session(
platform_name,
chat_id,
cleaned_delivery_content,
source_label=f"cron:{job.get('name', job.get('id', '?'))}",
thread_id=thread_id,
)
except Exception:
pass
logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id)
return None
except Exception as e:
Expand Down Expand Up @@ -343,6 +362,19 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
logger.error("Job '%s': %s", job["id"], msg)
return msg

if into_history:
try:
from gateway.mirror import mirror_to_session
mirror_to_session(
platform_name,
chat_id,
cleaned_delivery_content,
source_label=f"cron:{job.get('name', job.get('id', '?'))}",
thread_id=thread_id,
)
except Exception:
pass

logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id)
return None

Expand Down Expand Up @@ -810,6 +842,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
**Job ID:** {job_id}
**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}
**Schedule:** {job.get('schedule_display', 'N/A')}
**Deliver:** {job.get('deliver', 'local')}
**History:** {"session" if job.get("into_history", False) else "isolated"}

## Prompt

Expand All @@ -832,6 +866,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
**Job ID:** {job_id}
**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}
**Schedule:** {job.get('schedule_display', 'N/A')}
**Deliver:** {job.get('deliver', 'local')}
**History:** {"session" if job.get("into_history", False) else "isolated"}

## Prompt

Expand Down
4 changes: 3 additions & 1 deletion gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,7 +1091,7 @@ async def _handle_delete_response(self, request: "web.Request") -> "web.Response

_JOB_ID_RE = __import__("re").compile(r"[a-f0-9]{12}")
# Allowed fields for update — prevents clients injecting arbitrary keys
_UPDATE_ALLOWED_FIELDS = {"name", "schedule", "prompt", "deliver", "skills", "skill", "repeat", "enabled"}
_UPDATE_ALLOWED_FIELDS = {"name", "schedule", "prompt", "deliver", "into_history", "skills", "skill", "repeat", "enabled"}
_MAX_NAME_LENGTH = 200
_MAX_PROMPT_LENGTH = 5000

Expand Down Expand Up @@ -1141,6 +1141,7 @@ async def _handle_create_job(self, request: "web.Request") -> "web.Response":
schedule = (body.get("schedule") or "").strip()
prompt = body.get("prompt", "")
deliver = body.get("deliver", "local")
into_history = body.get("into_history", False)
skills = body.get("skills")
repeat = body.get("repeat")

Expand All @@ -1164,6 +1165,7 @@ async def _handle_create_job(self, request: "web.Request") -> "web.Response":
"schedule": schedule,
"name": name,
"deliver": deliver,
"into_history": bool(into_history),
}
if skills:
kwargs["skills"] = skills
Expand Down
54 changes: 54 additions & 0 deletions hermes_cli/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@
from hermes_cli.colors import Colors, color


_KNOWN_CRON_FLAGS = (
"--into-history",
"--no-into-history",
"--deliver",
"--name",
"--repeat",
"--skill",
"--add-skill",
"--remove-skill",
"--clear-skills",
"--script",
)


def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
if skills is None:
if single_skill is None:
Expand All @@ -38,6 +52,34 @@ def _cron_api(**kwargs):
return json.loads(cronjob_tool(**kwargs))


def _find_swallowed_cron_flags(prompt: Optional[str]) -> list[str]:
text = str(prompt or "")
return [flag for flag in _KNOWN_CRON_FLAGS if flag in text]


def _reject_swallowed_flags(prompt: Optional[str]) -> bool:
swallowed = _find_swallowed_cron_flags(prompt)
if not swallowed:
return False

joined = ", ".join(swallowed)
print(
color(
"Cron prompt contains CLI flag text that looks like it was accidentally "
f"placed inside the prompt: {joined}",
Colors.RED,
)
)
print(
color(
"Move those flags outside the quoted prompt, for example:",
Colors.YELLOW,
)
)
print(" hermes cron create \"2m\" \"Reply with exactly: CRON HISTORY TEST 1\" --deliver origin --into-history")
return True


def cron_list(show_all: bool = False):
"""List all scheduled jobs."""
from cron.jobs import list_jobs
Expand Down Expand Up @@ -71,6 +113,7 @@ def cron_list(show_all: bool = False):
if isinstance(deliver, str):
deliver = [deliver]
deliver_str = ", ".join(deliver)
history_mode = "session" if job.get("into_history") else "isolated"

skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
if state == "paused":
Expand All @@ -88,6 +131,7 @@ def cron_list(show_all: bool = False):
print(f" Repeat: {repeat_str}")
print(f" Next run: {next_run}")
print(f" Deliver: {deliver_str}")
print(f" History: {history_mode}")
if skills:
print(f" Skills: {', '.join(skills)}")
script = job.get("script")
Expand Down Expand Up @@ -158,12 +202,16 @@ def cron_status():


def cron_create(args):
if _reject_swallowed_flags(getattr(args, "prompt", None)):
return 1

result = _cron_api(
action="create",
schedule=args.schedule,
prompt=args.prompt,
name=getattr(args, "name", None),
deliver=getattr(args, "deliver", None),
into_history=getattr(args, "into_history", None),
repeat=getattr(args, "repeat", None),
skill=getattr(args, "skill", None),
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
Expand All @@ -177,6 +225,7 @@ def cron_create(args):
print(f" Schedule: {result['schedule']}")
if result.get("skills"):
print(f" Skills: {', '.join(result['skills'])}")
print(f" History: {'session' if result.get('into_history') else 'isolated'}")
job_data = result.get("job", {})
if job_data.get("script"):
print(f" Script: {job_data['script']}")
Expand All @@ -192,6 +241,9 @@ def cron_edit(args):
print(color(f"Job not found: {args.job_id}", Colors.RED))
return 1

if _reject_swallowed_flags(getattr(args, "prompt", None)):
return 1

existing_skills = list(job.get("skills") or ([] if not job.get("skill") else [job.get("skill")]))
replacement_skills = _normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None))
add_skills = _normalize_skills(None, getattr(args, "add_skills", None)) or []
Expand All @@ -215,6 +267,7 @@ def cron_edit(args):
prompt=getattr(args, "prompt", None),
name=getattr(args, "name", None),
deliver=getattr(args, "deliver", None),
into_history=getattr(args, "into_history", None),
repeat=getattr(args, "repeat", None),
skills=final_skills,
script=getattr(args, "script", None),
Expand All @@ -231,6 +284,7 @@ def cron_edit(args):
print(f" Skills: {', '.join(updated['skills'])}")
else:
print(" Skills: none")
print(f" History: {'session' if updated.get('into_history') else 'isolated'}")
if updated.get("script"):
print(f" Script: {updated['script']}")
return 0
Expand Down
10 changes: 10 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4663,6 +4663,11 @@ def main():
cron_create.add_argument("prompt", nargs="?", help="Optional self-contained prompt or task instruction")
cron_create.add_argument("--name", help="Optional human-friendly job name")
cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id")
cron_create.add_argument("--into-history", dest="into_history", action="store_true",
help="Also mirror delivered output into the target chat's session history")
cron_create.add_argument("--no-into-history", dest="into_history", action="store_false",
help="Do not mirror delivered output into the target chat's session history")
cron_create.set_defaults(into_history=None)
cron_create.add_argument("--repeat", type=int, help="Optional repeat count")
cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.")
cron_create.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run")
Expand All @@ -4674,6 +4679,11 @@ def main():
cron_edit.add_argument("--prompt", help="New prompt/task instruction")
cron_edit.add_argument("--name", help="New job name")
cron_edit.add_argument("--deliver", help="New delivery target")
cron_edit.add_argument("--into-history", dest="into_history", action="store_true",
help="Mirror delivered output into the target chat's session history")
cron_edit.add_argument("--no-into-history", dest="into_history", action="store_false",
help="Stop mirroring delivered output into the target chat's session history")
cron_edit.set_defaults(into_history=None)
cron_edit.add_argument("--repeat", type=int, help="New repeat count")
cron_edit.add_argument("--skill", dest="skills", action="append", help="Replace the job's skills with this set. Repeat to attach multiple skills.")
cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.")
Expand Down
15 changes: 15 additions & 0 deletions tests/cron/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ def test_default_delivery_local_no_origin(self, tmp_cron_dir):
job = create_job(prompt="Test", schedule="30m")
assert job["deliver"] == "local"

def test_into_history_defaults_false(self, tmp_cron_dir):
job = create_job(prompt="Test", schedule="30m")
assert job["into_history"] is False

def test_into_history_can_be_enabled(self, tmp_cron_dir):
job = create_job(prompt="Test", schedule="30m", into_history=True)
assert job["into_history"] is True


class TestUpdateJob:
def test_update_name(self, tmp_cron_dir):
Expand Down Expand Up @@ -275,6 +283,13 @@ def test_update_enable_disable(self, tmp_cron_dir):
fetched = get_job(job["id"])
assert fetched["enabled"] is False

def test_update_into_history(self, tmp_cron_dir):
job = create_job(prompt="Toggle me", schedule="every 1h")
updated = update_job(job["id"], {"into_history": True})
assert updated["into_history"] is True
fetched = get_job(job["id"])
assert fetched["into_history"] is True

def test_update_nonexistent_returns_none(self, tmp_cron_dir):
result = update_job("nonexistent_id", {"name": "X"})
assert result is None
Expand Down
Loading
Loading