Skip to content
Open
Show file tree
Hide file tree
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
20 changes: 18 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3429,6 +3429,7 @@ def _parse_flags(tokens):
"all": False,
"prompt": None,
"schedule": None,
"wrap_response": None,
"positionals": [],
}
i = 0
Expand Down Expand Up @@ -3468,6 +3469,19 @@ def _parse_flags(tokens):
elif token == "--schedule" and i + 1 < len(tokens):
opts["schedule"] = tokens[i + 1]
i += 2
elif token == "--wrap-response" and i + 1 < len(tokens):
val = tokens[i + 1].lower()
if val in {"true", "1", "yes", "on"}:
opts["wrap_response"] = True
elif val in {"false", "0", "no", "off"}:
opts["wrap_response"] = False
else:
print("(._.) --wrap-response must be true or false")
return None
i += 2
elif token == "--no-wrap":
opts["wrap_response"] = False
i += 1
else:
opts["positionals"].append(token)
i += 1
Expand All @@ -3483,8 +3497,8 @@ def _parse_flags(tokens):
print()
print(" Commands:")
print(" /cron list")
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task"')
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher] [--no-wrap]')
print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task" [--wrap-response true]')
print(" /cron edit <job_id> --skill blogwatcher --skill find-nearby")
print(" /cron edit <job_id> --remove-skill blogwatcher")
print(" /cron edit <job_id> --clear-skills")
Expand Down Expand Up @@ -3560,6 +3574,7 @@ def _parse_flags(tokens):
deliver=opts["deliver"],
repeat=opts["repeat"],
skills=skills or None,
wrap_response=opts["wrap_response"],
)
if result.get("success"):
print(f"(^_^)b Created job: {result['job_id']}")
Expand Down Expand Up @@ -3606,6 +3621,7 @@ def _parse_flags(tokens):
deliver=opts["deliver"],
repeat=opts["repeat"],
skills=final_skills,
wrap_response=opts["wrap_response"],
)
if result.get("success"):
job = result["job"]
Expand Down
4 changes: 4 additions & 0 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ def create_job(
model: Optional[str] = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
wrap_response: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Create a new cron job.
Expand All @@ -391,6 +392,7 @@ def create_job(
model: Optional per-job model override
provider: Optional per-job provider override
base_url: Optional per-job base URL override
wrap_response: Optional per-job override for delivery wrapping (None = use global config)

Returns:
The created job dict
Expand Down Expand Up @@ -448,6 +450,8 @@ def create_job(
# Delivery configuration
"deliver": deliver,
"origin": origin, # Tracks where job was created for "origin" delivery
# Per-job wrap_response override (None = defer to global cron.wrap_response config)
"wrap_response": wrap_response,
}

jobs = load_jobs()
Expand Down
18 changes: 10 additions & 8 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,16 @@ def _deliver_result(job: dict, content: str) -> None:
return

# Optionally wrap the content with a header/footer so the user knows this
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false
# in config.yaml for clean output.
wrap_response = True
try:
user_cfg = load_config()
wrap_response = user_cfg.get("cron", {}).get("wrap_response", True)
except Exception:
pass
# is a cron delivery. Per-job wrap_response takes precedence; otherwise
# fall back to the global cron.wrap_response config (default: True).
wrap_response = job.get("wrap_response")
if wrap_response is None:
wrap_response = True
try:
user_cfg = load_config()
wrap_response = user_cfg.get("cron", {}).get("wrap_response", True)
except Exception:
pass

if wrap_response:
task_name = job.get("name", job["id"])
Expand Down
52 changes: 52 additions & 0 deletions tests/cron/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,58 @@ def test_delivery_skips_wrapping_when_config_disabled(self):
assert "Cronjob Response" not in sent_content
assert "The agent cannot see" not in sent_content

def test_delivery_per_job_wrap_response_false_overrides_config(self):
"""Per-job wrap_response=false overrides the global config (default true)."""
from gateway.config import Platform

pconfig = MagicMock()
pconfig.enabled = True
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}

with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": True}}):
job = {
"id": "test-job",
"name": "daily-report",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "123"},
"wrap_response": False,
}
_deliver_result(job, "Raw output.")

send_mock.assert_called_once()
sent_content = send_mock.call_args.kwargs.get("content") or send_mock.call_args[0][-1]
assert sent_content == "Raw output."
assert "Cronjob Response" not in sent_content

def test_delivery_per_job_wrap_response_true_overrides_config(self):
"""Per-job wrap_response=true overrides global config false."""
from gateway.config import Platform

pconfig = MagicMock()
pconfig.enabled = True
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}

with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}):
job = {
"id": "test-job",
"name": "daily-report",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "123"},
"wrap_response": True,
}
_deliver_result(job, "Wrapped output.")

send_mock.assert_called_once()
sent_content = send_mock.call_args.kwargs.get("content") or send_mock.call_args[0][-1]
assert "Cronjob Response: daily-report" in sent_content
assert "Wrapped output." in sent_content

def test_no_mirror_to_session_call(self):
"""Cron deliveries should NOT mirror into the gateway session."""
from gateway.config import Platform
Expand Down
9 changes: 9 additions & 0 deletions tools/cronjob_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def cronjob(
model: Optional[str] = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
wrap_response: Optional[bool] = None,
reason: Optional[str] = None,
task_id: str = None,
) -> str:
Expand Down Expand Up @@ -183,6 +184,7 @@ def cronjob(
model=_normalize_optional_job_value(model),
provider=_normalize_optional_job_value(provider),
base_url=_normalize_optional_job_value(base_url, strip_trailing_slash=True),
wrap_response=wrap_response,
)
return json.dumps(
{
Expand Down Expand Up @@ -265,6 +267,8 @@ def cronjob(
updates["provider"] = _normalize_optional_job_value(provider)
if base_url is not None:
updates["base_url"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True)
if wrap_response is not None:
updates["wrap_response"] = wrap_response
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 @@ -399,6 +403,10 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str:
"items": {"type": "string"},
"description": "Optional ordered list of skills to load before executing the cron prompt. On update, pass an empty array to clear attached skills."
},
"wrap_response": {
"type": "boolean",
"description": "Optional per-job override for delivery wrapping. When true, delivered output includes a header/footer. When false, raw output is delivered. Omit to use the global cron.wrap_response config."
},
"reason": {
"type": "string",
"description": "Optional pause reason"
Expand Down Expand Up @@ -450,6 +458,7 @@ def get_cronjob_tool_definitions():
model=args.get("model"),
provider=args.get("provider"),
base_url=args.get("base_url"),
wrap_response=args.get("wrap_response"),
reason=args.get("reason"),
task_id=kw.get("task_id"),
),
Expand Down
16 changes: 15 additions & 1 deletion website/docs/user-guide/features/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,28 @@ Cronjob Response: Morning feeds
Note: The agent cannot see this message, and therefore cannot respond to it.
```

To deliver the raw agent output without the wrapper, set `cron.wrap_response` to `false`:
To deliver the raw agent output without the wrapper, set `cron.wrap_response` to `false` globally:

```yaml
# ~/.hermes/config.yaml
cron:
wrap_response: false
```

You can also override this per-job at creation time. The per-job setting takes precedence over the global config:

```text
Create a cron job that runs every morning at 9am, delivers to telegram, and set wrap_response to false
```

Existing jobs can be updated to change their wrapping behavior:

```text
Update cron job abc123 and set wrap_response to true
```

When a job has `wrap_response` set, that value is used. When it is not set (the default), the global `cron.wrap_response` config is used.

### Silent suppression

If the agent's final response starts with `[SILENT]`, delivery is suppressed entirely. The output is still saved locally for audit (in `~/.hermes/cron/output/`), but no message is sent to the delivery target.
Expand Down