feat(cron): surface cron deliveries via system-prompt note or accept/dismiss buttons - #37073
feat(cron): surface cron deliveries via system-prompt note or accept/dismiss buttons#37073beardthelion wants to merge 8 commits into
Conversation
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.
|
Pushed Implementation notes:
Happy to split |
teknium1
left a comment
There was a problem hiding this comment.
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:49keys only by platform and chat ID. Althoughthread_idis stored (:133),gateway/run.py:7167drains 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 incron/scheduler.py:642-645.cron/scheduler.py:896ignores a returnedSendResult(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:8805changes the per-turn system prompt. Main signscombined_ephemeralinto 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_deliverypath, 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
SendResultas the documented auto fallback.
Automated hermes-sweeper review.
|
|
||
|
|
||
| def _key(platform: str, chat_id) -> str: | ||
| return f"{str(platform).lower()}:{chat_id}" |
There was a problem hiding this comment.
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.
| loop, | ||
| ) | ||
| if future is not None: | ||
| future.result(timeout=30) |
There was a problem hiding this comment.
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.
| try: | ||
| cron_note = self._build_cron_delivery_note(source) | ||
| if cron_note: | ||
| context_prompt = cron_note + "\n\n" + context_prompt |
There was a problem hiding this comment.
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.
|
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. |
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_sessionselects 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 (truemaps toauto,falsemaps tooff).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_callstill passes.How the gating works without making the drain mode-aware: each buffered entry carries an
injectflag.autobuffers it injectable;buttonbuffers it held (inject=False) and the Add tap flips it. The drain ingateway/run.pyreturns only injectable entries and is otherwise unchanged. Button callbacks resolve against the on-disk buffer (keyedplatform:chat_id), so they survive a gateway restart; platforms without inline keyboards (and the standalone no-adapter delivery path) fall back toauto, so awareness is never lost.Related Issue
Part of #37070. (Not using the
Fixeskeyword: #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
buttonmode 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
Changes Made
cron/pending_notices.py(new):record()/drain()/mark_accepted()/dismiss()over a single JSON store at~/.hermes/cron/pending_notices.json, keyedplatform:chat_id, capped per key, with a lock and atomic replace. Entries carry a shortidand aninjectflag;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_resultreads the normalized mode and records a notice after each successful target send (live-adapter and standalone paths). Inbuttonmode 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_BUTTONScapability flag (default False), mirroring the existingREQUIRES_EDIT_FINALIZEpattern.gateway/platforms/telegram.py:SUPPORTS_CRON_BUTTONS = True,send_cron_notice()(the Add-to-context / Dismiss prompt), and acron:branch in_handle_callback_querythat authorizes the caller, then callsmark_accepted/dismiss, edits the message, and removes the keyboard.gateway/run.py:_build_cron_delivery_notedrains injectable notices for the source chat and prepends the system note tocontext_prompt. Unchanged by the button work, since the gating decision lives in the buffer'sinjectflag.website/docs/user-guide/features/cron.mdandwebsite/docs/developer-guide/cron-internals.md: document all threecron.notify_sessionmodes.How to Test
hermes cron run <job_id>); confirm~/.hermes/cron/pending_notices.jsongains aninject: trueentry. Send any message; the agent's system prompt now includes a[System note: ...]block and the buffer drains.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 isinject: false. Tap Add and the entry flips to injectable and surfaces on the next turn; tap Dismiss and the entry is removed.cron.notify_session: false(oroff) and confirm nothing is buffered.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
feat(cron):,feat(telegram):,docs(cron):)scripts/run_tests.sh ...=> 175 passed)Documentation & Housekeeping
cron.notify_sessionmodes inwebsite/docs/user-guide/features/cron.md("Session awareness") andwebsite/docs/developer-guide/cron-internals.md("Session Isolation").cli-config.yaml.example: N/A (cron config keys are documented inwebsite/docs, not this file)CONTRIBUTING.md/AGENTS.md: N/ASUPPORTS_CRON_BUTTONSdefaults False so non-Telegram adapters fall back toauto; the buffer is pure stdlibjson/threading/pathlib.Screenshots / Logs