Skip to content
Open
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
30 changes: 30 additions & 0 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ def create_job(
workdir: Optional[str] = None,
no_agent: bool = False,
attach_to_session: Optional[bool] = None,
delivery_policy: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a new cron job.
Expand Down Expand Up @@ -833,6 +834,11 @@ def create_job(
and deliver its stdout directly. Empty stdout = silent (no
delivery). Requires ``script`` to be set. Ideal for classic
watchdogs and periodic alerts that don't need LLM reasoning.
delivery_policy: Optional delivery policy. Set to ``"always"`` to bypass
the generic ``[SILENT]`` suppression hint. Use for recurring
briefing / report jobs that must deliver on every run, even an
all-clear. Default ``None`` (standard silent-suppression
behaviour). See #53230.

Returns:
The created job dict
Expand Down Expand Up @@ -868,6 +874,11 @@ def create_job(
normalized_workdir = _normalize_workdir(workdir)
normalized_no_agent = bool(no_agent)
normalized_attach = attach_to_session if isinstance(attach_to_session, bool) else None
normalized_delivery_policy = str(delivery_policy).strip().lower() if isinstance(delivery_policy, str) else None
if normalized_delivery_policy not in (None, "always"):
raise ValueError(
f"delivery_policy must be None or 'always', got {delivery_policy!r}"
)

# no_agent jobs are meaningless without a script — the script IS the job.
# Surface this as a clear ValueError at create time so bad configs never
Expand Down Expand Up @@ -974,6 +985,12 @@ def create_job(
if normalized_attach is not None:
job["attach_to_session"] = normalized_attach

# Only persist delivery_policy when explicitly set ("always"), so
# existing jobs and the common case stay byte-identical (absent key
# => standard [SILENT] suppression behaviour). See #53230.
if normalized_delivery_policy is not None:
job["delivery_policy"] = normalized_delivery_policy

with _jobs_lock():
jobs = load_jobs()
jobs.append(job)
Expand Down Expand Up @@ -1063,6 +1080,19 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
else:
updates["workdir"] = _normalize_workdir(_wd)

# Normalize delivery_policy: None / empty / "auto" => remove key
# (restores default [SILENT] behaviour). Only "always" persists.
if "delivery_policy" in updates:
_dp = updates["delivery_policy"]
if _dp in {None, "", False, "auto"}:
updates.pop("delivery_policy")
# Also remove from existing job so the key is gone
job.pop("delivery_policy", None)
elif _dp != "always":
raise ValueError(
f"delivery_policy must be 'always' or 'auto', got {_dp!r}"
)

updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates

Expand Down
45 changes: 33 additions & 12 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1786,17 +1786,34 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:

# Always prepend cron execution guidance so the agent knows how
# delivery works and can suppress delivery when appropriate.
cron_hint = (
"[IMPORTANT: You are running as a scheduled cron job. "
"DELIVERY: Your final response will be automatically delivered "
"to the user — do NOT use send_message or try to deliver "
"the output yourself. Just produce your report/output as your "
"final response and the system handles the rest. "
"SILENT: If there is genuinely nothing new to report, respond "
"with exactly \"[SILENT]\" (nothing else) to suppress delivery. "
"Never combine [SILENT] with content — either report your "
"findings normally, or say [SILENT] and nothing more.]\n\n"
)
# Jobs with delivery_policy="always" skip the generic [SILENT] suppression
# instruction. Recurring briefing / report jobs that always need to
# deliver a concise update (even an all-clear) set this flag so the
# scheduler does not inject contradictory "suppress when nothing new"
# guidance that conflicts with their task-specific prompt. See #53230.
_always_deliver = job.get("delivery_policy") == "always"
if _always_deliver:
cron_hint = (
"[IMPORTANT: You are running as a scheduled cron job. "
"DELIVERY: Your final response will be automatically delivered "
"to the user — do NOT use send_message or try to deliver "
"the output yourself. Just produce your report/output as your "
"final response and the system handles the rest. "
"This job requires delivery on every run — do NOT suppress "
"output. Always produce a concise report, even an all-clear.]\n\n"
)
else:
cron_hint = (
"[IMPORTANT: You are running as a scheduled cron job. "
"DELIVERY: Your final response will be automatically delivered "
"to the user — do NOT use send_message or try to deliver "
"the output yourself. Just produce your report/output as your "
"final response and the system handles the rest. "
"SILENT: If there is genuinely nothing new to report, respond "
"with exactly \"[SILENT]\" (nothing else) to suppress delivery. "
"Never combine [SILENT] with content — either report your "
"findings normally, or say [SILENT] and nothing more.]\n\n"
)
prompt = cron_hint + prompt
if skills is None:
legacy = job.get("skill")
Expand Down Expand Up @@ -2781,7 +2798,11 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -
# a real report that merely quoted "[SILENT]" mid-sentence (#51438,
# #46917). Keeps the intentional bracketed-prefix / trailing-line
# tolerance the cron contract relies on.
if should_deliver and success and _is_cron_silence_response(deliver_content):
# Jobs with delivery_policy="always" bypass silence suppression —
# they are committed to delivering on every run (#53230).
if (should_deliver and success
and job.get("delivery_policy") != "always"
and _is_cron_silence_response(deliver_content)):
logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER)
should_deliver = False

Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1155,7 +1155,7 @@ def _probe_container(cmd: list, backend: str, via_sudo: bool = False):
all other exceptions propagate naturally.
"""
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return subprocess.run(cmd, capture_output=True, text=True, timeout=15, encoding="utf-8")
except subprocess.TimeoutExpired:
label = f"sudo {backend}" if via_sudo else backend
print(
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1440,7 +1440,7 @@ def setup_terminal_backend(config: dict):
ssh_cmd.extend(["-p", port])
ssh_cmd.append(f"{user}@{host}" if user else host)
ssh_cmd.append("echo ok")
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=10)
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=10, encoding="utf-8")
if result.returncode == 0:
print_success(" SSH connection successful!")
else:
Expand Down
2 changes: 1 addition & 1 deletion skills/creative/comfyui/scripts/auto_fix_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def run_cmd(cmd: list[str], *, dry_run: bool = False) -> tuple[int, str]:
if dry_run:
return 0, "[dry-run]"
log(f"$ {' '.join(cmd)}")
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, encoding="utf-8")
out = (proc.stdout or "") + (proc.stderr or "")
return proc.returncode, out

Expand Down
78 changes: 78 additions & 0 deletions tests/cron/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2500,6 +2500,84 @@ def test_delivery_guidance_precedes_user_prompt(self):
prompt_pos = result.index("My custom prompt")
assert system_pos < prompt_pos

def test_hint_omitted_when_delivery_policy_always(self):
"""delivery_policy='always' suppresses the [SILENT] instruction (#53230)."""
job = {"prompt": "Send daily briefing", "delivery_policy": "always"}
result = _build_job_prompt(job)
assert "[SILENT]" not in result
assert "do NOT suppress" in result
assert "Send daily briefing" in result

def test_hint_present_when_delivery_policy_auto(self):
"""delivery_policy='auto' (or absent) keeps the [SILENT] instruction."""
job = {"prompt": "Check for updates", "delivery_policy": "auto"}
result = _build_job_prompt(job)
assert "[SILENT]" in result

def test_hint_present_when_delivery_policy_absent(self):
"""No delivery_policy key => default [SILENT] behaviour."""
job = {"prompt": "Check for updates"}
result = _build_job_prompt(job)
assert "[SILENT]" in result

def test_delivery_guidance_present_with_always(self):
"""delivery_policy='always' still tells agent about auto-delivery."""
job = {"prompt": "Report", "delivery_policy": "always"}
result = _build_job_prompt(job)
assert "do NOT use send_message" in result
assert "automatically delivered" in result


class TestDeliveryPolicyAlwaysSuppressesSilent:
"""delivery_policy='always' bypasses [SILENT] delivery suppression (#53230)."""

def _make_job(self, delivery_policy=None):
job = {
"id": "briefing-job",
"name": "daily briefing",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "123"},
}
if delivery_policy:
job["delivery_policy"] = delivery_policy
return job

def test_always_policy_delivers_despite_silent_marker(self):
"""Agent returned [SILENT] but delivery_policy='always' forces delivery."""
job = self._make_job(delivery_policy="always")
with patch("cron.scheduler.get_due_jobs", return_value=[job]), \
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT]", None)), \
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
patch("cron.scheduler._deliver_result") as deliver_mock, \
patch("cron.scheduler.mark_job_run"):
from cron.scheduler import tick
tick(verbose=False)
deliver_mock.assert_called_once()

def test_default_policy_still_suppresses(self):
"""Without delivery_policy, [SILENT] still suppresses as before."""
job = self._make_job()
with patch("cron.scheduler.get_due_jobs", return_value=[job]), \
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT]", None)), \
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
patch("cron.scheduler._deliver_result") as deliver_mock, \
patch("cron.scheduler.mark_job_run"):
from cron.scheduler import tick
tick(verbose=False)
deliver_mock.assert_not_called()

def test_always_policy_delivers_normal_report(self):
"""Normal (non-SILENT) report still delivered with delivery_policy='always'."""
job = self._make_job(delivery_policy="always")
with patch("cron.scheduler.get_due_jobs", return_value=[job]), \
patch("cron.scheduler.run_job", return_value=(True, "# report", "3 items merged", None)), \
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
patch("cron.scheduler._deliver_result") as deliver_mock, \
patch("cron.scheduler.mark_job_run"):
from cron.scheduler import tick
tick(verbose=False)
deliver_mock.assert_called_once()


class TestParseWakeGate:
"""Unit tests for _parse_wake_gate — pure function, no side effects."""
Expand Down
19 changes: 19 additions & 0 deletions tools/cronjob_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
result["enabled_toolsets"] = job["enabled_toolsets"]
if job.get("workdir"):
result["workdir"] = job["workdir"]
if job.get("delivery_policy"):
result["delivery_policy"] = job["delivery_policy"]
return result


Expand Down Expand Up @@ -583,6 +585,7 @@ def cronjob(
workdir: Optional[str] = None,
no_agent: Optional[bool] = None,
attach_to_session: Optional[bool] = None,
delivery_policy: Optional[str] = None,
task_id: str = None,
) -> str:
"""Unified cron job management tool."""
Expand Down Expand Up @@ -650,6 +653,7 @@ def cronjob(
workdir=_normalize_optional_job_value(workdir),
no_agent=_no_agent,
attach_to_session=attach_to_session,
delivery_policy=delivery_policy,
)
_notify_provider_jobs_changed_safe()
_create_message = f"Cron job '{job['name']}' created."
Expand Down Expand Up @@ -822,6 +826,15 @@ def cronjob(
success=False,
)
updates["no_agent"] = target_no_agent
if delivery_policy is not None:
_dp = str(delivery_policy).strip().lower()
if _dp not in ("always", "auto", ""):
return tool_error(
f"delivery_policy must be 'always' or 'auto', got {delivery_policy!r}",
success=False,
)
# Empty string or 'auto' clears the override (back to default)
updates["delivery_policy"] = _dp if _dp == "always" else None
if repeat is not None:
# Normalize: treat 0 or negative as None (infinite)
normalized_repeat = None if repeat <= 0 else repeat
Expand Down Expand Up @@ -966,6 +979,11 @@ def cronjob(
"type": "boolean",
"description": "When True, this job becomes CONTINUABLE: the user can reply to its delivery and the agent has the brief in context instead of asking 'what is that?'. On thread-capable platforms (Telegram topics, Discord/Slack threads) a dedicated thread is opened for the job and its replies; on DM-only platforms (WhatsApp/Signal) the brief is mirrored into the origin DM session. Use this for conversational recurring jobs the user will reply to — daily briefings, reminders that kick off follow-up work. Leave unset for fire-and-forget alerts/watchdogs. Overrides the global cron.mirror_delivery config for this one job. Only the origin chat is touched (never fan-out targets); no effect when deliver='local'."
},
"delivery_policy": {
"type": "string",
"enum": ["always", "auto"],
"description": "Set to 'always' for jobs that must deliver on every run (recurring briefings, daily reports). This bypasses the generic [SILENT] suppression so the agent cannot self-suppress delivery. Default unset/omit = standard behaviour (agent may return [SILENT] to skip delivery when nothing new to report). On update, pass 'auto' or empty string to clear and restore default behaviour."
},
},
"required": ["action"]
}
Expand Down Expand Up @@ -1021,6 +1039,7 @@ def check_cronjob_requirements() -> bool:
enabled_toolsets=args.get("enabled_toolsets"),
workdir=args.get("workdir"),
no_agent=args.get("no_agent"),
delivery_policy=args.get("delivery_policy"),
task_id=kw.get("task_id"),
))(),
check_fn=check_cronjob_requirements,
Expand Down
2 changes: 1 addition & 1 deletion tools/environments/singularity.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def _start_instance(self):
cmd.extend([str(self.image), self.instance_id])

try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL, encoding="utf-8")
if result.returncode != 0:
raise RuntimeError(f"Failed to start instance: {result.stderr}")
self._instance_started = True
Expand Down
4 changes: 2 additions & 2 deletions tools/transcription_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1187,7 +1187,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str],
command = [ffmpeg, "-y", "-i", file_path, converted_path]

try:
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, encoding="utf-8")
return converted_path, None
except subprocess.TimeoutExpired:
logger.error("ffmpeg conversion timed out for %s", file_path)
Expand Down Expand Up @@ -1233,7 +1233,7 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
# User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode.
use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip())
if use_shell:
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, encoding="utf-8")
else:
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)

Expand Down
2 changes: 1 addition & 1 deletion tools/tts_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1859,7 +1859,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
"--device", device,
]

result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL, encoding="utf-8")
if result.returncode != 0:
stderr = result.stderr.strip()
# Filter out the "OK:" line from stderr
Expand Down
4 changes: 2 additions & 2 deletions tools/voice_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ def start(self, on_silence_stop=None) -> None:
"-c", str(CHANNELS),
]
try:
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True, stdin=subprocess.DEVNULL)
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True, stdin=subprocess.DEVNULL, encoding="utf-8")
except subprocess.CalledProcessError as e:
details = (e.stderr or e.stdout or str(e)).strip()
raise RuntimeError(f"Termux microphone start failed: {details}") from e
Expand All @@ -406,7 +406,7 @@ def _stop_termux_recording(self) -> None:
mic_cmd = _termux_microphone_command()
if not mic_cmd:
return
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False, stdin=subprocess.DEVNULL)
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False, stdin=subprocess.DEVNULL, encoding="utf-8")

def stop(self) -> Optional[str]:
with self._lock:
Expand Down
2 changes: 1 addition & 1 deletion tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9044,7 +9044,7 @@ def _(rid, params: dict) -> dict:
str(pdf_path), str(out_prefix),
]
try:
res = subprocess.run(argv, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
res = subprocess.run(argv, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL, encoding="utf-8")
except subprocess.TimeoutExpired:
return _err(rid, 5028, "pdftoppm timed out (>120s)")
if res.returncode != 0:
Expand Down