Skip to content

feat(cron): surface cron deliveries via system-prompt note or accept/dismiss buttons - #37073

Closed
beardthelion wants to merge 8 commits into
NousResearch:mainfrom
beardthelion:feat/cron-session-notice
Closed

feat(cron): surface cron deliveries via system-prompt note or accept/dismiss buttons#37073
beardthelion wants to merge 8 commits into
NousResearch:mainfrom
beardthelion:feat/cron-session-notice

Conversation

@beardthelion

@beardthelion beardthelion commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Makes the interactive agent aware of what its own cron jobs deliver, without ever writing to the chat transcript. cron.notify_session selects how:

  • auto (default): each delivery is folded into the system prompt of the chat's next turn as a [System note: ...] block, then drained.
  • button: the delivery is followed by an inline Add to context / Dismiss prompt (Telegram today); the content reaches the agent only if the user taps Add. This is the accept/dismiss approach @teknium1 suggested in Agent has no awareness of information delivered by its own cron jobs #37070, for tight control over what enters context on smaller models.
  • off: fire-and-forget. The legacy boolean still works (true maps to auto, false maps to off).

Cron deliveries never enter the chat transcript, so the agent is blind to its own scheduled output. #2313 deliberately removed the old fix (mirroring cron output into history as assistant-role messages) because consecutive assistant turns break message alternation (#2221).

This does not revert #2313 and does not write to the message array. Both modes use system-prompt injection (the same vehicle as the existing auto-reset context note), so alternation is structurally untouched and #2221 cannot recur. tests/cron/test_scheduler.py::TestDeliverResultWrapping::test_no_mirror_to_session_call still passes.

How the gating works without making the drain mode-aware: each buffered entry carries an inject flag. auto buffers it injectable; button buffers it held (inject=False) and the Add tap flips it. The drain in gateway/run.py returns only injectable entries and is otherwise unchanged. Button callbacks resolve against the on-disk buffer (keyed platform:chat_id), so they survive a gateway restart; platforms without inline keyboards (and the standalone no-adapter delivery path) fall back to auto, so awareness is never lost.

Related Issue

Part of #37070. (Not using the Fixes keyword: #37070 is addressed by two independent PRs and should stay open until both land. This is the ambient/awareness half; the read-on-demand half is #37071.)

The button mode directly implements the accept/dismiss buttons @teknium1 described in #37070.

Related prior art: #34631 targets the same problem by re-adding a session-transcript mirror (cron.mirror_to_session, tagged [Delivered from cron]). This PR intentionally avoids the transcript mirror that #2313 removed and injects into the system prompt instead, so message alternation is structurally unaffected. The two are mutually exclusive approaches; maintainers may want to pick one.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • cron/pending_notices.py (new): record() / drain() / mark_accepted() / dismiss() over a single JSON store at ~/.hermes/cron/pending_notices.json, keyed platform:chat_id, capped per key, with a lock and atomic replace. Entries carry a short id and an inject flag; drain() returns only injectable entries and leaves held ones in place. normalize_notify_mode() maps the config value to off/auto/button (legacy bool preserved). Fully best-effort: every failure is swallowed so it can never break a delivery (already completed) or a user turn.
  • cron/scheduler.py: _deliver_result reads the normalized mode and records a notice after each successful target send (live-adapter and standalone paths). In button mode on a live adapter that supports inline buttons, it buffers the entry held and sends the accept/dismiss prompt; if that send fails the entry is auto-injected. Buffers the raw job output with MEDIA tags stripped, not the delivery wrapper.
  • gateway/platforms/base.py: SUPPORTS_CRON_BUTTONS capability flag (default False), mirroring the existing REQUIRES_EDIT_FINALIZE pattern.
  • gateway/platforms/telegram.py: SUPPORTS_CRON_BUTTONS = True, send_cron_notice() (the Add-to-context / Dismiss prompt), and a cron: branch in _handle_callback_query that authorizes the caller, then calls mark_accepted / dismiss, edits the message, and removes the keyboard.
  • gateway/run.py: _build_cron_delivery_note drains injectable notices for the source chat and prepends the system note to context_prompt. Unchanged by the button work, since the gating decision lives in the buffer's inject flag.
  • website/docs/user-guide/features/cron.md and website/docs/developer-guide/cron-internals.md: document all three cron.notify_session modes.

How to Test

  1. auto (default): trigger a cron job that delivers to a chat (e.g. hermes cron run <job_id>); confirm ~/.hermes/cron/pending_notices.json gains an inject: true entry. Send any message; the agent's system prompt now includes a [System note: ...] block and the buffer drains.
  2. button: set cron.notify_session: button, trigger a delivery to a Telegram chat; the delivery is followed by Add to context / Dismiss buttons and the buffered entry is inject: false. Tap Add and the entry flips to injectable and surfaces on the next turn; tap Dismiss and the entry is removed.
  3. off: set cron.notify_session: false (or off) and confirm nothing is buffered.
  4. Automated: scripts/run_tests.sh tests/cron/test_pending_notices.py tests/cron/test_scheduler.py tests/gateway/test_cron_delivery_note.py tests/gateway/test_telegram_cron_buttons.py => 175 passed, 0 failed.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(cron):, feat(telegram):, docs(cron):)
  • I searched for existing PRs; closest is feat(cron): mirror cron delivery output to target session for cross-session awareness #34631 (same problem, different approach), referenced above
  • My PR contains only changes related to this feature
  • I've run the test suite and all tests pass (scripts/run_tests.sh ... => 175 passed)
  • I've added tests for my changes (42 across four files: 18 for the system-prompt note plus 24 for the inject flag, mode normalizer, and Telegram buttons)
  • I've tested on my platform: Ubuntu (Linux 6.17)

Documentation & Housekeeping

  • Documentation: documented all three cron.notify_session modes in website/docs/user-guide/features/cron.md ("Session awareness") and website/docs/developer-guide/cron-internals.md ("Session Isolation").
  • cli-config.yaml.example: N/A (cron config keys are documented in website/docs, not this file)
  • CONTRIBUTING.md / AGENTS.md: N/A
  • Cross-platform impact: SUPPORTS_CRON_BUTTONS defaults False so non-Telegram adapters fall back to auto; the buffer is pure stdlib json / threading / pathlib.
  • Tool descriptions/schemas: N/A (no tool schema change; scheduler, adapter, and callback handler only)

Screenshots / Logs

$ scripts/run_tests.sh tests/cron/test_pending_notices.py tests/cron/test_scheduler.py tests/gateway/test_cron_delivery_note.py tests/gateway/test_telegram_cron_buttons.py
=== Summary: 4 files, 175 tests passed, 0 failed (100% complete) ===

Push side of cron session-awareness. Cron deliveries don't enter the
interactive message history (the assistant-role mirror was removed in
NousResearch#2313 because consecutive assistant turns break alternation, NousResearch#2221), so
the agent was blind to what its own jobs sent. This buffers each delivery
and folds it into the SYSTEM PROMPT of the chat's next interactive turn,
which is alternation-safe (same vehicle as the auto-reset context note).

- cron/pending_notices.py: record()/drain(), single JSON store keyed by
  platform:chat_id, per-key cap, lock + atomic replace, fully best-effort
- cron/scheduler.py: _deliver_result records a notice after each successful
  target send, gated by cron.notify_session (default True); buffers the raw
  job output (MEDIA stripped), not the delivery wrapper
- gateway/run.py: _build_cron_delivery_note drains pending notices for the
  source chat and prepends a [System note: ...] block to context_prompt,
  then the buffer is cleared

Does NOT reintroduce the message-history mirror; test_no_mirror_to_session_call
still passes.

Tests: 18 new (10 pending_notices, 2 scheduler, 6 run.py note); 208 impacted
tests pass under scripts/run_tests.sh isolation.

(cherry picked from commit 4f2155e3fa2e88fe89d1c3660763c97e65c3a38b)
Extend cron/pending_notices.py so a delivery can be held until the user
opts it into context, the groundwork for inline accept/dismiss buttons.

- record() now stamps each entry with a short id (new_notice_id, sized
  for Telegram's 64-byte callback_data) and an inject flag, and returns
  the id so a caller can mint the button before recording.
- drain() returns and clears only injectable entries, leaving held ones
  (inject=False) in place; entries predating the flag default to
  injectable, so auto-mode behavior is unchanged.
- mark_accepted() flips a held entry to injectable (accept button);
  dismiss() drops it (dismiss button).

run.py needs no change: the system-prompt fold stays mode-agnostic
because the inject decision is made at record/accept time.
normalize_notify_mode() maps the cron.notify_session config value to one
of three modes while preserving the original boolean knob: True/on-ish
becomes auto, False/None/off-ish becomes off, "button" selects inline
accept/dismiss buttons. An unrecognized but present value stays on (auto),
matching the prior "any truthy value enabled it" behavior. Pure function,
unit-tested alongside the buffer.
Button mode for cron deliveries. The cron message is sent normally, then
send_cron_notice posts a short prompt with two inline buttons whose
callback_data is cron:accept:<id> / cron:dismiss:<id>. The notice id is
the on-disk buffer key, so unlike the exec-approval in-memory counter the
buttons keep working after a gateway restart.

A SUPPORTS_CRON_BUTTONS capability flag (False on the base adapter, True
on Telegram) lets the scheduler fall back to automatic injection on
platforms without inline keyboards, so cron awareness is never lost.
Add a cron: branch to _handle_callback_query mirroring the ea: exec-
approval flow: authorize the caller, then accept flips the buffered notice
to injectable (pending_notices.mark_accepted) and dismiss drops it
(pending_notices.dismiss), keyed by platform:chat_id from the query. The
button message is edited to show the outcome and its keyboard removed.
Unauthorized taps never touch the buffer.
_deliver_result now reads cron.notify_session as a three-way mode
(normalize_notify_mode) and threads it into _record_session_notice.

In button mode, when delivery used a live adapter that supports inline
buttons, the notice is buffered as held (inject=False) and an
accept/dismiss prompt is sent via adapter.send_cron_notice; if that send
fails the entry is auto-injected so awareness is never lost. Auto mode and
platforms without button support buffer as injectable, unchanged.
Update the user guide and cron internals for the three-way
cron.notify_session knob (auto/button/off; the legacy bool still maps to
auto/off). Cover the inline Add-to-context / Dismiss buttons, the
SUPPORTS_CRON_BUTTONS platform fallback to auto, and the restart-durable
on-disk buffer the buttons resolve against.
@beardthelion beardthelion changed the title feat(cron): surface cron deliveries in the next turn's system prompt feat(cron): surface cron deliveries via system-prompt note or accept/dismiss buttons Jun 2, 2026
@beardthelion

Copy link
Copy Markdown
Contributor Author

Pushed button mode (6 commits on top of the original system-prompt note). cron.notify_session is now off / auto / button (the legacy bool still maps to auto/off), where button is the accept/dismiss approach you floated in #37070: the cron delivery is followed by an Add to context / Dismiss prompt, and nothing reaches the agent's context until the user taps Add. Default stays auto, so existing behavior is unchanged.

Implementation notes:

  • Gating is a per-entry inject flag in the buffer, so gateway/run.py (the system-prompt drain) is untouched and stays mode-agnostic: auto buffers injectable, button buffers held and the Add tap flips it.
  • Buttons resolve against the on-disk buffer (keyed platform:chat_id), so they survive a gateway restart, unlike the in-memory exec-approval counter.
  • A SUPPORTS_CRON_BUTTONS capability flag gates it per platform (Telegram only for now); everything else falls back to auto so awareness is never lost.

scripts/run_tests.sh across the four files: 175 passed, 0 failed.

Happy to split button mode into its own PR if you'd rather review the system-prompt note on its own.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery labels Jun 2, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for preserving the alternation concern and for building a durable accept/dismiss flow. Current main has since landed a different continuable-cron path: cron/scheduler.py:584-613 gates opt-in continuation, and cron/scheduler.py:1875-1879 mirrors only the originating conversation with its thread and user scope. That is not the same system-prompt/button design, so this needs a maintainer product decision rather than a stale-PR close.

Problems

  • cron/pending_notices.py:49 keys only by platform and chat ID. Although thread_id is stored (:133), gateway/run.py:7167 drains only by platform/chat. A delivery from one topic/thread can therefore enter another thread's context in the same chat. Main explicitly treats thread ID as part of the origin-conversation boundary in cron/scheduler.py:642-645.
  • cron/scheduler.py:896 ignores a returned SendResult(success=False). send_cron_notice() returns that value on failure (gateway/platforms/telegram.py:2717-2718,2753-2755), so the held entry is never auto-injected as the comment promises.
  • gateway/run.py:8805 changes the per-turn system prompt. Main signs combined_ephemeral into its cached-agent key (gateway/run.py:18155-18166), so this drains prompt-cache reuse for the affected turn.

Suggested changes

  • Build on the shipped attach_to_session / cron.mirror_delivery path, or first agree on a cache-safe approval design.
  • Scope notices by the complete conversation lane, test cross-thread isolation, and treat an unsuccessful button SendResult as the documented auto fallback.

Automated hermes-sweeper review.

Comment thread cron/pending_notices.py


def _key(platform: str, chat_id) -> str:
return f"{str(platform).lower()}:{chat_id}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This key drops thread_id, but records retain it and the gateway drain also selects only platform/chat. A notice delivered in one topic/thread can be injected into another conversation lane in the same chat; key all record/drain/accept/dismiss operations by the complete session lane and add a cross-thread regression test.

Comment thread cron/scheduler.py
loop,
)
if future is not None:
future.result(timeout=30)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send_cron_notice() returns SendResult(success=False) on ordinary failures rather than raising. This result is ignored, leaving the already-recorded inject=False notice stranded instead of taking the documented auto-inject fallback; inspect the returned result and add a failure-result test.

Comment thread gateway/run.py
try:
cron_note = self._build_cron_delivery_note(source)
if cron_note:
context_prompt = cron_note + "\n\n" + context_prompt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes cron payloads part of context_prompt. Current main includes the resulting combined_ephemeral value in the cached-agent signature (gateway/run.py:18155-18166), so every drained notice rebuilds the agent/system-prompt prefix. Please use a cache-safe continuation path rather than mutating the system prompt mid-conversation.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 13, 2026
@beardthelion

Copy link
Copy Markdown
Contributor Author

Closing. Main has since landed a different continuable-cron design (opt-in continuation in cron/scheduler.py), so this system-prompt/button approach diverges and would need a fresh product decision rather than a rebase.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants