Skip to content

feat(cron): continuable cron jobs — thread-preferred continuation with DM-mirror fallback - #51077

Closed
victor-kyriazakos wants to merge 5 commits into
NousResearch:mainfrom
victor-kyriazakos:feat/cron-mirror-delivery
Closed

feat(cron): continuable cron jobs — thread-preferred continuation with DM-mirror fallback#51077
victor-kyriazakos wants to merge 5 commits into
NousResearch:mainfrom
victor-kyriazakos:feat/cron-mirror-delivery

Conversation

@victor-kyriazakos

Copy link
Copy Markdown
Contributor

What & why

Cron deliveries today live only in the cron job's own isolated session. When a brief is delivered to a chat and the user replies ("start Task #2"), that reply lands on a target session with no record of the brief — the agent asks "what is Task #2?". This makes recurring conversational jobs (daily briefings, reminders that kick off follow-up work) a dead end.

This PR makes a cron job optionally continuable: the delivered result becomes a conversation the user can reply into, with the brief already in context. Opt-in, default OFF — the historical isolation guarantee is preserved unless a job (or an operator) turns it on.

Design: thread-preferred, with a DM-mirror fallback

The continuation surface is chosen by a single platform-capability fork, scoped to the job's origin conversation only:

Platform class Behaviour
Thread-capable (Telegram topics, Discord / Slack threads) A dedicated thread is opened for the job via the adapter's create_handoff_thread, the brief is routed into it, and the thread-keyed session is seeded so the user's in-thread reply continues with full context. Each continuable job gets its own scrollback, isolated from the parent channel.
DM-only (WhatsApp / Signal / SMS) No thread primitive exists (create_handoff_thread returns None), so the brief is mirrored into the origin DM session — the DM itself is the continuation surface.

A None return from create_handoff_thread is the fallback signal, so there is one code path, not two features. This mirrors GatewayRunner._process_handoff's existing open-thread-or-fallback + seed pattern already used for CLI→platform handoffs.

Footprint (per AGENTS.md — expansive at the edges, conservative at the waist)

  • No new core tool, no new model-tool schema surface.
  • No new adapter method and no supports_threads flag — reuses the shipped per-platform create_handoff_thread; its None return is the capability probe.
  • No provider-chain signature change — reaches the live SessionStore via the adapter's existing _session_store handle rather than threading a new param through the frozen CronScheduler.start() contract.
  • No new HERMES_* env var — the behavioural setting is cron.mirror_delivery in config.yaml, overridable per-job via the cronjob tool's attach_to_session.
  • Existing per-target delivery routing carries the new thread_id for free.

Net diff is +711 lines, additive only, across 5 files.

Invariants

  • Origin-scoped only. Threading/mirroring fires only for the delivery target whose (platform, chat_id[, thread_id]) equals the job's origin. Fan-out / broadcast / home-channel-fallback targets are never threaded or mirrored — they are not a continuation of a conversation and may legitimately have no session.
  • Cache- and alternation-safe. The brief is appended as an assistant turn at a turn boundary, never mid-loop, and never mutates the cached system prompt. A thread_seeded guard prevents a double-mirror after seeding.
  • Conservative on ambiguity. The DM path reuses gateway/mirror.py's _find_session_id, which returns None (no-op) rather than guess when a chat has multiple participant sessions and none matches the origin sender — parity with interactive send_message. The thread path sidesteps this entirely: a fresh chat_type="thread" session is participant-shared by construction.
  • Best-effort continuation. A mirror/seed failure never fails a delivery that already succeeded.

Config / surface

cron:
  # Make cron deliveries CONTINUABLE (default false). Per-job
  # attach_to_session overrides this.
  mirror_delivery: false

Per-job opt-in via the cronjob tool: attach_to_session: true.

Testing

  • tests/cron/test_scheduler.py185 passed, including the new mirror/thread/continuable coverage: thread id returned on a thread platform; None on a DM platform; None without capability/loop; seed creates the thread session + mirrors; seed no-op on empty content; plus the origin-scoping suite (exact match; other-chat / other-platform / no-origin rejection; thread scoping; fan-out mirrors ONLY the origin target).
  • Full tests/cron/ suite: 538 passed, 7 skipped; the 4 failures are pre-existing croniter-not-installed env failures (compute next_run from cron expressions), unrelated to this change and identical on untouched main.
  • Syntax + imports clean, no new Pyright errors. Rebased cleanly onto current main.

Commits

  • feat(cron): optional mirror of cron delivery into target chat session
  • refactor(cron): scope delivery mirror to the origin conversation
  • feat(cron): pass origin user_id to delivery mirror (send_message parity)
  • feat(cron): thread-preferred continuable delivery (open a thread, mirror DM fallback)

@alt-glitch alt-glitch added type/feature New feature or request comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have labels Jun 22, 2026
Adds an opt-in path so a cron job's delivered output is also appended to
the TARGET chat's gateway session transcript (as an assistant turn), so a
user reply to a recurring delivery (daily brief, reminder) is answered with
the delivery in context instead of 'what is that?' amnesia.

- Reuses the shipped gateway.mirror.mirror_to_session — the same primitive
  interactive send_message mirroring already uses. No messaging-toolset
  change (cron still can't call send_message; this rides delivery).
- Gated: per-job attach_to_session overrides global cron.mirror_delivery
  (config.yaml). Default OFF — historical isolation preserved byte-for-byte.
- Mirrors the CLEAN agent output, not the cron header/footer wrapper.
- Alternation/cache-safe: append lands at a turn boundary, never mid-loop,
  never mutates the cached system prompt. Cold-start (no target session)
  is a silent no-op; mirror errors never fail a successful delivery.
- Surfaced on the cronjob tool (attach_to_session) + config schema.

Driven by enterprise cron-as-control-plane use case. 10 new tests; full
cron + cronjob-tool suites pass (600).
The cron->session mirror now fires ONLY for the delivery target that
equals the job's origin (platform+chat_id[+thread_id]). A job created
from a live gateway chat stamps that chat as origin, and that session is
guaranteed to exist (it is the conversation the user scheduled the job
in). Fan-out / broadcast / home-channel-fallback targets are never
mirrored: they are not a continuation of a conversation and may have no
session at all.

This makes the prior 'cold-start session seeding' concern a non-case by
construction: when the mirror semantically applies the session exists;
when none exists the target was never the origin, so we no-op.

Adds _target_matches_origin() + origin-scoping tests (exact match,
other-chat/other-platform/no-origin rejection, thread scoping, fan-out
mirrors only the origin target).
Multi-participant parity with interactive send_message, which passes
HERMES_SESSION_USER_ID to gateway.mirror.mirror_to_session so the mirror
lands in the exact participant's session.

- cronjob_tools._origin_from_env now captures user_id from the session
  context at job-create time (alongside platform/chat_id/thread_id).
- _maybe_mirror_cron_delivery forwards user_id to mirror_to_session.
- _deliver_result threads origin.user_id through for the origin target.

Effect: in a per-user-isolated group chat (group_sessions_per_user=True,
the default), the mirror resolves to the member who scheduled the job
instead of conservatively no-op'ing on ambiguous candidates. DMs and
shared group/thread sessions are unaffected (single candidate). Default
still OFF.

Tests: helper forwards user_id; E2E _deliver_result forwards origin
user_id. 17/17 in TestCronDeliveryMirror; 527 cron tests pass (4 failures
pre-existing: croniter-not-installed + TZ, identical on baseline).
…ror DM fallback)

Continuable cron jobs (attach_to_session / cron.mirror_delivery, default
OFF) now prefer a dedicated thread on thread-capable platforms, falling
back to origin-DM mirroring where threads don't exist.

- Thread-capable (Telegram topics, Discord/Slack threads): open a fresh
  thread for the job via the shipped adapter.create_handoff_thread,
  route the brief into it, and seed the thread-keyed session so the
  user's in-thread reply continues with full context. This is the
  'continuable cron opens its own thread' interface.
- DM-only (WhatsApp/Signal/SMS): create_handoff_thread returns None ->
  fall back to mirroring into the origin DM session (existing behaviour).

Reuses existing infrastructure end-to-end — no new adapter surface, no
provider-chain signature change:
- adapter.create_handoff_thread (already implemented per-platform,
  returns None on unsupported platforms = the fallback signal)
- the live SessionStore via adapter._session_store (already set on every
  adapter), reached without threading a new param through the frozen
  CronScheduler.start() contract
- gateway.mirror.mirror_to_session for the seed/append
- existing per-target delivery routing carries the new thread_id for free

Mirrors GatewayRunner._process_handoff's open-thread-or-fallback +
seed pattern, standalone for the cron delivery path. thread_seeded
guards against a double-mirror after seeding. Scoped to the origin
target only; fan-out/broadcast targets are never threaded or mirrored.

Config docs updated (cron.mirror_delivery) + cronjob tool
attach_to_session description reframed around continuable/thread-preferred.

Tests: +5 (thread id returned on thread platform; None on DM platform;
None without capability/loop; seed creates thread session + mirrors;
seed no-op on empty). 22/22 in TestCronDeliveryMirror; 532 cron tests
pass (4 failures pre-existing: croniter-not-installed + TZ).
@victor-kyriazakos
victor-kyriazakos force-pushed the feat/cron-mirror-delivery branch from a32f334 to 397ba99 Compare June 24, 2026 18:22

@kshitijk4poor kshitijk4poor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Great PR — well-structured, opt-in, origin-scoped, and the thread-preferred + DM-fallback design mirrors the existing handoff pattern. All reuse claims check out (create_handoff_thread, mirror_to_session, safe_schedule_threadsafe, SessionSource, _session_store). CI green, 22 new tests pass.

However, this re-introduces the exact alternation violation that #2313 deliberately removed.

Background — issue #2221 and commit 37a9979

Cron deliveries used to be mirrored into gateway sessions as assistant-role messages. This caused consecutive assistant messages that violate role alternation (issue #2221). Teknium fixed this in #2313 by removing the mirror injection entirely — "Instead of fixing the role, remove the mirror injection entirely. Cron outputs already live in their own cron session and don't belong in the interactive conversation history." The existing test test_no_mirror_to_session_call (test_scheduler.py:879) enforces this: "Cron deliveries should NOT mirror into the gateway session."

This PR re-introduces that mirror, gated behind cron.mirror_delivery / attach_to_session (default OFF). The gate means the existing test still passes (no opt-in = no mirror), but the design decision it enforces is reversed when the feature is enabled.

Why the alternation violation persists

mirror_to_session writes role="assistant" to SQLite via _append_to_sqlite (mirror.py:157), which only passes role and content to SessionDB.append_message — the mirror: True and mirror_source metadata is dropped at the SQLite boundary. The gateway loads replay history from SQLite (load_transcriptget_messages_as_conversation), so the msg.get("mirror") check at run.py:814 that would prefix the content with [Delivered from <source>] always returns False on replay. The mirror message is indistinguishable from a real assistant turn.

repair_message_sequence (agent_runtime_helpers.py:347) merges consecutive user messages but does NOT merge consecutive assistant messages. The Anthropic adapter has _merge_consecutive_roles (anthropic_adapter.py:2069) that handles this, but the OpenAI/OpenRouter path does not.

Cron fires on a schedule, so the mirror lands right after the agent's last assistant turn (the common idle state). The next user reply sees assistant(real) → assistant(mirror, unlabeled) → user(reply) — two consecutive assistants, no label distinguishing the mirror from a real response. This causes silent empty responses on strict-alternation providers, triggering the empty-retry loop. This is the exact pattern described in issue #2221.

Fix direction

If the continuable-cron feature is wanted, the mirror should write as role="user" with a [Cron delivery: <task name>] prefix instead of role="assistant". The sequence becomes assistant(real) → user(cron brief) → user(reply)repair_message_sequence already merges consecutive user messages, so this is alternation-safe for all providers. This also preserves the [Delivered from cron] distinguishability that the SQLite metadata-drop currently loses. This would require either a new helper or extending mirror_to_session with a role parameter.

Second issue — thread_seeded=True suppresses fallback mirror when live delivery fails

thread_seeded is set to True immediately after _seed_cron_thread_session returns (scheduler.py:1177), before the live adapter delivery attempt. If the live adapter delivery fails and falls through to the standalone path (which succeeds), _maybe_mirror_cron_delivery is called with enabled=mirror_this_target and not thread_seededenabled=False. The brief is visible in the new thread but has no session transcript — the exact "what is Task #2?" amnesia this PR exists to prevent.

Fix: set thread_seeded only after successful delivery, or re-enable the mirror on fallback.

Minor nits

  • Duplicate import: BasePlatformAdapter imported twice in _deliver_result — as _BPA (line 1080) and as BasePlatformAdapter (line 1085), 5 lines apart. Use one import.
  • Missing user_id in seed mirror: _seed_cron_thread_session calls mirror_to_session without user_id (scheduler.py:571-576), but the session was created with user_id="system:cron". When multiple candidate sessions exist with distinct user_ids, _find_session_id returns None and the mirror silently fails. Low probability for fresh threads, but worth fixing.
  • No docs update: website/docs/user-guide/features/cron.md should document mirror_delivery / attach_to_session.
  • Excessive docstrings: Several new functions have 30-50 line docstrings describing mechanics rather than non-obvious WHY. Per the AGENTS.md rubric, keep only non-obvious WHY comments.

What looks good

  • Config key properly registered in DEFAULT_CONFIG["cron"].
  • No new tools, no toolset changes, no system prompt mutation, no hardcoded paths.
  • All reused primitives exist on main and are used correctly.
  • Default-off preserves existing behavior byte-for-byte.
  • Origin-scoping is correct — fan-out targets are never mirrored.
  • user_id passthrough mirrors the send_message pattern.
  • Test coverage is thorough (gate precedence, mirror calls/no-ops, cold-start, origin-scoping, fan-out, thread creation, session seeding).

…on-safe)

Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation NousResearch#2313 (37a9979)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue NousResearch#2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of NousResearch#2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
@victor-kyriazakos

Copy link
Copy Markdown
Contributor Author

Thanks @kshitijk4poor — excellent catch, and you're right on every point. Verified against source + history before fixing.

On the core concern (the #2313 reversal): confirmed. mirror_to_session writes role="assistant", so the continuable-cron mirror re-introduced exactly the assistant→assistant alternation violation that 37a9979 removed for #2221 — and the mirror/mirror_source metadata is dropped at the _append_to_sqlite boundary, so the [Delivered from cron] label is lost on replay, as you traced.

To be explicit about intent: this PR is a deliberate, opt-in, default-OFF reversal of #2313's "cron output doesn't belong in interactive history" — but only for the reply-to-cron use case, and only when cron.mirror_delivery / attach_to_session is set. The historical isolation guarantee is byte-for-byte unchanged for everyone who doesn't opt in (which is why test_no_mirror_to_session_call still passes). The alternation mechanics, though, were an unintended regression — that part should never have come back, opt-in or not. Fixed by your suggested direction:

Fixes pushed (bc245f217):

  1. Mirror as a labelled user turn, not assistant. mirror_to_session gains a role param (default "assistant" — the interactive send_message mirror is unchanged; that text is the agent speaking). The cron paths now pass role="user" with a [Cron delivery: <task name>] prefix. Sequence becomes assistant(real) → user(cron brief) → user(reply), which repair_message_sequence's consecutive-user merge collapses safely on all providers — and the prefix preserves the cron-provenance the dropped SQLite metadata would otherwise lose on replay. Exactly your proposed fix.
  2. thread_seeded ordering. Now deferred until delivery into the new thread actually succeeds. Previously set pre-delivery, so an open-succeeds / deliver-fails case both stranded a seeded-but-unseen brief and suppressed the DM-fallback mirror — fixed.
  3. Seed user_id. _seed_cron_thread_session now passes user_id="system:cron" so the mirror resolves the exact thread-keyed session it just created.
  4. Duplicate import removed (BasePlatformAdapter imported once).
  5. Docs added for cron.mirror_delivery / attach_to_session in website/docs/user-guide/features/cron.md.
  6. Docstrings trimmed to non-obvious WHY per the rubric.
  7. Test added asserting the cron mirror writes role="user" with the label prefix (regression guard for bug(gateway): cron job outputs injected as assistant role, breaking message alternation #2221/fix(cron): stop injecting cron outputs into gateway session history #2313).

204 cron+mirror tests pass.

One thing worth a maintainer eye beyond the mechanics: the principle of letting opt-in continuable cron land in interactive history is a product call that reverses half of #2313's rationale. The alternation half is now genuinely fixed (user-role); the "belongs in history at all" half is the deliberate, gated trade we're making for reply-to-cron. Flagging it explicitly so it's a conscious sign-off rather than something that slipped through on a clean role-fix.

teknium1 pushed a commit that referenced this pull request Jun 25, 2026
…on-safe)

Addresses review on #51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation #2313 (37a9979)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue #2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of #2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #52250. Your 5 commits were cherry-picked onto current main with your authorship preserved in git log (rebase-merge). Verified on main: tests 186/186 green, E2E (gate resolution + origin-scoping + mirror backward-compat) passed. Thanks for the clean, well-scoped feature — the thread-preferred / None-fallback design and the alternation fix-up were exactly right.

@teknium1 teknium1 closed this Jun 25, 2026
pai-scaffolde pushed a commit to pai-scaffolde/hermes-agent that referenced this pull request Jun 28, 2026
…on-safe)

Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation NousResearch#2313 (37a9979)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue NousResearch#2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of NousResearch#2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
…on-safe)

Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation NousResearch#2313 (37a9979)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue NousResearch#2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of NousResearch#2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
…on-safe)

Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation NousResearch#2313 (228472e)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue NousResearch#2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of NousResearch#2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
…on-safe)

Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation NousResearch#2313 (228472e)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue NousResearch#2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of NousResearch#2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…on-safe)

Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation NousResearch#2313 (37a9979)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue NousResearch#2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of NousResearch#2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
…on-safe)

Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation NousResearch#2313 (37a9979)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue NousResearch#2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of NousResearch#2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
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 type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants