Skip to content

feat(cron): agent-written channel summary for continuable cron deliveries - #91792

Open
luijoc wants to merge 2 commits into
NousResearch:mainfrom
luijoc:feat/cron-channel-summary
Open

luijoc wants to merge 2 commits into
NousResearch:mainfrom
luijoc:feat/cron-channel-summary

Conversation

@luijoc

@luijoc luijoc commented Aug 21, 2026

Copy link
Copy Markdown

Summary

Per-job channel_summary: true turns a continuable cron's channel-root line into an agent-written TL;DR of the run — the fixed :thread: Hermes handoff — *<name>* seed becomes "All 3 feeds healthy; ingest lag 2m (normal)." while the verbose brief stays threaded under it. Today a continuable delivery (attach_to_session / cron.mirror_delivery) already threads the brief, but the channel line is a static label carrying no information about the run; verbose recurring jobs make busy channels noisy for everyone who only needs the headline. The job's agent is prompted to lead its final response with [SUMMARY] <1-2 lines>; at delivery time the summary is lifted and rides create_handoff_thread as the seed body. Extraction is non-destructive by construction — only the marker token is stripped, the body keeps its opening lines — so every lane where no dedicated thread opens (flat delivery, fan-out targets, pinned origin threads, gateway offline, legacy adapters) delivers the complete text unchanged. No existing issue; motivated by production use (multiple Hermes bots + crons sharing Slack channels).

Also fixes a latent bug this feature depends on: the cronjob registry handler declared attach_to_session in CRONJOB_SCHEMA but never read it from the model's arguments, so agent-set values were silently dropped.

Changes

  • cron/scheduler.py_extract_cron_summary (first-line-anchored [SUMMARY] marker, 2-line/400-char guardrails fall back to the fixed label; non-destructive split); _deliver_result gates extraction on the per-job flag, MEDIA-sanitizes the lifted summary (the seed rides a raw chat_postMessage that bypasses the send pipeline where tags are normally extracted — a MEDIA: line must not print a local path at channel root), and passes it as seed_text= to _open_continuable_cron_thread; the thread opener forwards seed_text with bind-time AND await-time TypeError retries so out-of-tree adapters predating the kwarg (including *args/**kwargs wrappers) still open their thread with the label seed; _cron_silence_suppresses_delivery — shared predicate for the delivery gate and the usage-audit response_silent field: a [SUMMARY]-led response with substantive body is never swallowed by a stray trailing [SILENT] (marker presence, not guardrail-valid extraction, so a format miss can't become data loss; content-free [SUMMARY]\n\n[SILENT] stays suppressed); the persisted output doc stores the marker-stripped body so context_from/continuity chains never re-ingest the control token; flagged jobs get a [CHANNEL SUMMARY: ...] block appended to the cron hint — unflagged jobs' hint is byte-identical.
  • cron/jobs.pycreate_job(channel_summary=...), conditional-persist like its prerequisite sibling attach_to_session (absent key = pre-feature behavior byte-for-byte). REST/dashboard/CLI create surfaces deliberately not wired: none of them expose the attach_to_session family today, and a summary toggle whose prerequisite can't be set there would be worse than none — rationale comment in the code, expose the family together when those surfaces grow.
  • tools/cronjob_tools.pychannel_summary on the cronjob tool (create/update/schema/_format_job); fixes the registry handler dropping the schema-declared attach_to_session argument (channel_summary depends on it); _format_job now surfaces both flags (explicit bools only, absent ≠ false).
  • gateway/platforms/base.pycreate_handoff_thread(seed_text=None) on the adapter contract: seed-message-anchored platforms use it as the seed body; platforms whose threads have no seed message ignore it.
  • plugins/platforms/slack/adapter.py — seed body from seed_text, broadcast mentions escaped exactly as format_message (<!channel>/<!here>/<!everyone> → literal) so this raw chat_postMessage lane is never the one place agent text can ping a workspace; fixed-label fallback stays raw (trusted constant).
  • plugins/platforms/discord/adapter.pyseed_text on the seed-message fallback only (direct create_thread has no seed message), with a pinned per-send AllowedMentions(everyone=False, roles=False) because the client-level default is env-overridable and must not govern agent-authored seeds; label sends keep the legacy call untouched.
  • plugins/platforms/telegram/adapter.py — accepts and ignores seed_text for signature parity (forum topics have no seed message).
  • gateway/relay/adapter.pyseed_text rides the thread_create op as an additive optional field, capped [:2000]; docstring states the connector-side hygiene requirement.
  • docs/relay-connector-contract.mdseed_text? documented on thread_create: connectors MUST render it through the same outbound broadcast-mention hygiene as send content (additive within contract_version 1; connectors predating the field ignore it and the gateway degrades to the label seed).
  • website/docs/user-guide/features/cron.md + zh-Hans mirror — new "Agent-written channel summary" section under Continuable jobs (same-commit mirror sync per the in_channel precedent).
  • hermes_cli/config_defaults.py — continuable-jobs config comment mentions the per-job flag.
  • tests/tests/cron/test_cron_channel_summary.py (new, 48 tests: extractor edge cases incl. guardrails and colon/case variants, seed forwarding + both TypeError retries, _deliver_result gating + MEDIA strip, persisted-output strip, job-store conditional persist, tool schema/update/list, registry-handler regression both directions, silence predicate + tick-level gate, prompt hint byte-identity); Slack seed tests in test_slack_sdk_response.py (+4 incl. mention-escape regression); relay op-field tests in test_relay_threads.py (+3); Discord fallback AllowedMentions tests in test_discord_allowed_mentions.py (+2).

Design constraints

  • Zero behavior change without the flag, asserted not claimed: unflagged jobs' prompt hint, delivered content, persisted output, and handler argument passthrough are pinned byte-identical by dedicated tests (test_without_the_flag_content_is_byte_identical, test_registry_handler_passes_none_when_flags_omitted, prompt-hint identity test).
  • Seed hygiene equals each platform's model-text send lane, by design — the identical summary lines ride the threaded body through the normal send pipeline, so stricter-than-body seed rules would be theater; the Slack escape and Discord AllowedMentions pin bring the new raw lane up to parity, not beyond.
  • Graceful degradation on every lane: guardrail miss → label seed, full text threaded; no thread capability → summary-led flat text; legacy adapter (either TypeError shape) → label-seed thread; relay connector predating the op field → label seed. Nothing is ever dropped.
  • Stray [SILENT] after a real summary delivers instead of vanishing — a visible stray marker beats invisible loss; the documented "why-quiet note + [SILENT]" suppression pattern is untouched (it never leads with [SUMMARY]).

Validation

Check Result
tests/cron/test_cron_channel_summary.py (new) 48/48 pass
tests/gateway/test_slack_sdk_response.py 23/23 pass
tests/gateway/relay/test_relay_threads.py 21/21 pass
tests/gateway/test_discord_allowed_mentions.py 4/4 pass
tests/cron/ full suite 897 pass, 5 pre-existing failures (test_monitor_kind.py host-config drift-skip; failure set byte-identical on main, verified in a clean main worktree with the same interpreter)
Full suite (scripts/run_tests.sh) 36,125 pass, 107 fail — the 107 FAILED ids diff empty against main on this host (local env: host ~/.hermes config leakage), 0 introduced by this branch
ruff check (all changed files) clean
E2E Isolated HERMES_HOME (mktemp), real create_job → real _deliver_result → real _open_continuable_cron_thread → stub Slack adapter; only config load, DeliveryRouter, and session mirror stubbed. Job deliver=origin, attach_to_session=true, channel_summary=true; agent response "[SUMMARY] All 3 feeds healthy; ingest lag 2m (normal).\n\nFull report:\n- feed A: 1,204 items..." → channel root received exactly All 3 feeds healthy; ingest lag 2m (normal)., thread 1724300000.000100 received the 205-char marker-stripped body opening with the same line.

Tested on macOS 15 (Darwin 25.6.0), Python 3.12.14.


Type of Change

  • ✨ New feature (non-breaking change that adds functionality)
  • 🐛 Bug fix (the schema-declared attach_to_session argument was silently dropped by the tool handler — fixed because this feature depends on it)

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched existing PRs and issues — no duplicate (closest prior art is the continuable-jobs surface this builds on)
  • My PR contains only changes related to this feature (single commit; the attach_to_session handler fix is a dependency of the feature, called out above)
  • Tests run via scripts/run_tests.sh — feature suites green; full-suite failures verified pre-existing on main (table above)
  • I've added tests for my changes (48 new + 9 across adapter suites, including regression tests for every review finding)
  • I've tested on my platform: macOS 15 (Darwin 25.6.0)

Documentation & Housekeeping

  • Docs updated: website/docs/user-guide/features/cron.md + zh-Hans mirror, docs/relay-connector-contract.md, tool schema descriptions
  • cli-config.yaml.example — N/A: no new global config key (per-job field set via the cronjob tool; the continuable-jobs family it extends is likewise not in the example config)
  • CONTRIBUTING.md/AGENTS.md — N/A: no architecture or workflow change
  • Cross-platform impact considered — pure-Python string handling on the delivery path; per-adapter behavior stated in Changes (Slack/relay seed, Discord fallback-only, Telegram ignore)
  • Tool descriptions/schemas updated (CRONJOB_SCHEMA.channel_summary, refined attach_to_session interplay)

Security note

The seed message is a new outbound lane for agent-authored text posted at channel root. Both in-tree raw lanes are brought to send-lane parity in this PR (Slack broadcast-mention escape identical to format_message; Discord pinned AllowedMentions), the relay contract now states the connector-side requirement in MUST language, and the lifted summary is MEDIA-tag-sanitized so a tag line can't leak a local filesystem path into the channel. Mention-escape and MEDIA-strip regressions are pinned by tests.

🤖 Generated with Claude Code

@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 comp/plugins Plugin system and bundled plugins platform/discord Discord bot adapter platform/slack Slack app adapter platform/telegram Telegram bot adapter area/config Config system, migrations, profiles sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 21, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference; please use your judgment.

  • cron/scheduler.py:1884-1897 — the await-time TypeError retry fires whenever future.result() raises TypeError on a seeded call, but a modern adapter whose coroutine body hits a genuine internal TypeError (bad .format on None, etc.) is indistinguishable from the legacy-kwarg wrapper case, so the real bug is swallowed, the thread silently opens with the fixed label, and the failure is only visible at debug. Gate the retry on the exception carrying the kwarg signature too ("seed_text" in str(e) alongside used_seed_kwarg), keeping the degradation contract while unmasking true defects.
  • cron/scheduler.py:587 — _CRON_SUMMARY_MARKER_RE anchors on a bare [summary] token, so a model that emits the taught format with trivial decoration (**[SUMMARY]** All green, > [SUMMARY] ...) gets neither the lifted seed nor the stray-[SILENT] carve-out — and that second miss is the dangerous one: a decorated summary followed by a trailing [SILENT] is suppressed as silence and the report is lost, exactly the data-loss mode the docstring claims the design prevents. Tolerate lightweight emphasis prefixes in the regex (and/or teach the prompt hint the exact literal), and add a decorated-lead regression case.
  • plugins/platforms/slack/adapter.py:2306-2310 — neutralization relies on _SLACK_SPECIAL_MENTION_RE covering every broadcast form (<!channel>, <!here>, <!everyone>, <!subteam^ID>, plus pipe-aliased variants like <!here|here>); the diff doesn't show the pattern, and the replace("<", "&lt;", 1) trick will double-escape text the agent legitimately wrote as &lt;. Please confirm the regex inventory against format_message's set and add table-driven cases for the aliased/subteam forms.
  • gateway/relay/adapter.py:3196-3197 — seed[:2000] truncates blindly, potentially splitting a surrogate pair, an escaped entity (&lt;&l), or mid-markdown; combined with the divergent caps (Discord clamps at 1900, Slack at 2000, relay at 2000) the same summary renders differently per lane. Centralize one cap constant, cut at a whitespace boundary, and let platform-specific limits clamp afterwards.
  • plugins/platforms/discord/adapter.py:7413-7418 — getattr(discord, "AllowedMentions", None) runs per call and quietly degrades to no override if the attribute lookup fails, reopening the @everyone hole this code exists to close; import at module scope next to the rest of the discord usage so a broken import fails loudly instead of silently. (nit)

luijoc and others added 2 commits August 24, 2026 09:38
…ries

Per-job `channel_summary: true` teaches the job's agent to lead its final
response with "[SUMMARY] <1-2 lines>"; at delivery time the summary is
lifted (non-destructively — only the marker token is stripped, the body
keeps its opening lines) and becomes the continuable thread's channel-root
seed message in place of the fixed "Hermes handoff — <name>" label, so
busy channels get a real TL;DR with the verbose brief threaded under it.

- seed_text rides create_handoff_thread as an optional kwarg (base + slack/
  discord/telegram/relay) with a bind-time TypeError retry for out-of-tree
  adapters; relay carries it as an additive thread_create op field
- Slack seed escapes broadcast mentions exactly as format_message; the
  summary is MEDIA-tag-sanitized before seeding; guardrails (2 lines /
  400 chars) fall back to the fixed label
- also fixes the cronjob registry handler silently dropping the
  schema-declared attach_to_session argument (channel_summary depends on it)
- docs: cron.md (+zh-Hans mirror), relay connector contract (additive
  seed_text with mandatory outbound mention hygiene), config_defaults note

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry on the kwarg signature

Review follow-up (NousResearch#91792):
- _CRON_SUMMARY_MARKER_RE accepts lightweight decoration (**[SUMMARY]**,
  > [SUMMARY], *[SUMMARY]:*) so a cosmetic format miss cannot dodge the
  stray-[SILENT] carve-out and swallow a real report; the trailing
  decoration class is whitespace-terminated so undecorated summary text
  is never consumed. Prompt hint now teaches the exact literal.
- The await-time TypeError retry in _open_continuable_cron_thread fires
  only when the error names the seed_text kwarg — a genuine TypeError in
  a modern adapter propagates instead of being masked (and can no longer
  re-run an adapter that posted its seed before raising).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@luijoc
luijoc force-pushed the feat/cron-channel-summary branch from 7005ed1 to 1202119 Compare August 24, 2026 13:46
@luijoc

luijoc commented Aug 24, 2026

Copy link
Copy Markdown
Author

Thanks — two of these were real; both are fixed in the follow-up commit (branch also rebased onto current main, clean). Point-by-point:

1. Await-time TypeError retry masking genuine adapter bugs — fixed. The retry now fires only when the seeded call's error names the kwarg (used_seed_kwarg and "seed_text" in str(e), matching CPython's bind-failure spelling). A genuine internal TypeError in a modern adapter propagates to the outer handler — which also closes an edge sharper than masking: an adapter that raises after posting its seed would previously have been re-run by the retry and posted a second seed message. Regression tests cover both directions (legacy-wrapper retry still opens the label thread; unrelated TypeError → one call, no retry).

2. Decorated marker missing the lift and the stray-[SILENT] carve-out — fixed. _CRON_SUMMARY_MARKER_RE now tolerates lightweight decoration (**[SUMMARY]**, > [SUMMARY], *[SUMMARY]:*); since _cron_silence_suppresses_delivery keys on the same regex, the data-loss mode (decorated lead + trailing [SILENT] → swallowed) is gone. The trailing decoration class is whitespace-terminated so an undecorated summary's own text (e.g. [SUMMARY] *emphasis* lead) is never consumed. The prompt hint now also teaches the exact literal ("no bold, quote, or other decoration"). Regression cases added for all three decorated forms plus the decorated+stray-[SILENT] delivery.

3. Slack mention inventory — verified at parity, no change. _SLACK_SPECIAL_MENTION_RE is <!(?:everyone|channel|here)(?:\|[^>\n]*)?> — pipe-aliased forms (<!here|here>) are covered. <!subteam^ID> is not — but format_message (the body lane every send goes through) uses the same regex, and the PR's declared constraint is send-lane parity: the identical summary lines ride the threaded body through format_message, so a stricter seed-only rule adds nothing, and widening the shared regex would change every Slack send lane — out of scope here (happy to file it separately as a format_message gap if maintainers want subteam covered). The double-escape concern doesn't apply: the escape rewrites only regex matches, and agent-written &lt; contains no literal <! to match.

4. [:2000] truncation — defensive-only, cannot bite. _extract_cron_summary rejects summaries over 400 chars (falls back to the fixed label) before any adapter cap is reached, so the per-adapter caps only guard hypothetical non-cron callers. Python str slicing operates on code points — it cannot split a surrogate pair. The divergent caps mirror each platform's own message limits deliberately (Discord 2000-char hard limit → 1900 with headroom; Slack 40k → nowhere near).

5. Discord per-call getattr — matches the file's own idiom. The approval-prompt sends in the same adapter resolve AllowedMentions the same way at call time (_approval send path); a module-scope import here would diverge from the surrounding convention. If the attribute is missing the library is absent/mocked, in which case parent.send itself is a test double — the tests pin the override kwargs explicitly, so a silent regression would fail the suite rather than ship.

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

Labels

area/config Config system, migrations, profiles comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/discord Discord bot adapter platform/slack Slack app adapter platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants