Skip to content

feat(cron): opt-in clarify support for cron agents via cron.allow_clarify - #74113

Open
agastya-raj wants to merge 3 commits into
NousResearch:mainfrom
agastya-raj:feat/cron-allow-clarify
Open

feat(cron): opt-in clarify support for cron agents via cron.allow_clarify#74113
agastya-raj wants to merge 3 commits into
NousResearch:mainfrom
agastya-raj:feat/cron-allow-clarify

Conversation

@agastya-raj

@agastya-raj agastya-raj commented Jul 29, 2026

Copy link
Copy Markdown

What does this PR do?

Cron agents currently cannot use the clarify tool at all: 4494c0b hard-disabled the clarify toolset in cron context because jobs run unattended — "there's nobody to ask questions to". That is the right default, but it also makes a legitimate use case impossible even as an opt-in: scheduled jobs that need a human-in-the-loop decision (e.g. a nightly job that asks "deploy this migration? yes/no" before acting, or a weekly audit that asks which finding to escalate). The job already has a delivery channel to the user — delivery just happens at the end of the run, while clarify needs it mid-run.

This PR adds an opt-in config gate, cron.allow_clarify (default false, preserving current behavior exactly). When enabled and the job is fired by the gateway ticker (live platform adapters + event loop present):

  1. _resolve_cron_disabled_toolsets no longer hard-disables clarify.
  2. run_job builds a clarify callback via the new _build_cron_clarify_callback: it resolves the job's first delivery target, finds the live adapter for that platform (via the existing resolve_delivery_transport), and renders the clarify prompt through it — e.g. as Discord buttons in the job's delivery channel. The design mirrors gateway/run.py's _clarify_callback_sync; the only cron-specific difference is that the target chat comes from the job's delivery config instead of an inbound message event (a cron session has no attached chat).
  3. The cron platform hint is swapped for a human-in-the-loop variant so the system prompt ("you cannot ask questions") doesn't contradict the newly available tool. An explicit agent.platform_hints.cron override in config.yaml still wins.

Key behaviors:

  • Graceful degradation without adapters: standalone hermes cron run fires have no live adapter, so no callback is attached and clarify keeps reporting its standard "not available in this execution context" — unchanged from today. Same when the delivery platform's adapter lacks send_clarify, or gateway config fails to load.
  • Watchdog-safe wait: the wait reuses clarify_gateway.wait_for_response, which polls in 1-second slices and heartbeats the activity tracker, so the cron inactivity watchdog (HERMES_CRON_TIMEOUT, default 600s) does not kill the run while the user is deciding.
  • Timeout behavior: on agent.clarify_timeout (default 1h, 0 = unlimited) or a failed send, the tool returns a sentinel string ([user did not respond within Nm] / [clarify prompt could not be delivered]) so the agent proceeds autonomously instead of hanging — identical to the gateway path.
  • Setup is non-fatal: any exception while wiring the callback logs a warning and the job continues without clarify.
  • Typed answers resolve from the delivery chat: the pending entry registers under the delivery chat's own gateway session key — the same key the gateway's inbound text intercept resolves against — computed by _cron_clarify_reply_session_key with platform-aware chat-type resolution (live adapter get_chat_info → origin chat_type/scope_id stamp → Slack/Telegram id heuristics). Supported matrix: DM targets ✓, thread/forum targets ✓ (participant-shared; Discord parent:thread targets remap to the thread id), group/channel the job was created from ✓ (bound to the scheduling member), group/channel fan-out ✗ (typed replies only — button clicks resolve by clarify_id either way). Multiplex profiles and thread_sessions_per_user: true limit typed-reply binding; a cron clarify and an interactive clarify pending in the same chat share FIFO-oldest text resolution.
  • Relay-fronted platforms supported: when the resolved DeliveryTransport.is_relay, the clarify send stamps the logical platform via the existing _relay_logical_platform metadata escape hatch (the same convention cron delivery uses), extended to the Relay prompt lane — a scheduled send has no inbound event to populate the relay's per-chat platform map.

Related Issue

No existing issue or PR covers this. Searched open + closed issues and PRs for clarify/cron combinations; the nearest neighbors are #36731 (CLI wording of cron delivery error status — unrelated) and #61438 (clarify constrained-choice UX across surfaces + profile-scoped cron storage — its cron/scheduler.py change is limited to context_from output paths, no overlap). The hard-disable this PR gates was introduced in 4494c0b.

Type of Change

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

Changes Made

  • cron/scheduler.py
    • _resolve_cron_disabled_toolsets: clarify only hard-disabled when cron.allow_clarify is not set (docstring updated to match).
    • New _CRON_CLARIFY_PLATFORM_HINT + _build_cron_clarify_callback(job, adapters, loop): delivery-target resolution, live-adapter lookup, fire-time get_chat_info chat-type hint, gateway-style clarify registration under the delivery chat's reply-resolution session key, Relay-aware send metadata, targeted send-failure cleanup + future cancel, watchdog-safe wait, sentinel returns on send failure/timeout.
    • New _cron_clarify_reply_session_key + _platform_dm_hint: platform-aware reply-key computation (Discord/Telegram/Slack DM & thread shapes, Slack scope_id, Discord parent:thread remap, origin-user group binding, fan-out/thread_sessions_per_user null-outs).
    • run_job: new optional adapters/loop kwargs (default None — every existing caller unchanged; docstring updated); attaches the callback + hint swap after AIAgent construction, non-fatally.
    • run_one_job: plumbs its existing adapters/loop through to run_job.
  • tools/cronjob_tools.py: _origin_from_env stamps chat_type + scope_id from the bound session source so chat-created jobs carry the chat's true type/scope (justification: without it, DM detection for Discord/Telegram origins is impossible at fire time).
  • tools/clarify_gateway.py: new discard(clarify_id) targeted-removal API (justification: the only removal was clear_session, which cancels every pending clarify in a chat — including an unrelated interactive session's prompt).
  • gateway/relay/adapter.py: _send_prompt learns the _relay_logical_platform metadata escape hatch (10 lines, mirrors send() exactly; interactive path unchanged — it never sets the key).
  • hermes_cli/config_defaults.py: registers the cron.allow_clarify: False default with explanatory comment.
  • website/docs/user-guide/features/cron.md: new "Human-in-the-loop jobs (clarify)" section (config, timeout semantics, the typed-reply supported matrix, known limitations, Relay note, platform-hint override note).
  • tests/cron/test_cron_clarify.py (new): toolset-gate tests; reply-key matrix unit tests (origin-stamp/adapter-hint/id-heuristic DM detection, Slack scope, Telegram DM/forum/group shapes, Discord parent:thread remap, Slack thread, thread_sessions_per_user null-out); callback-construction degradation tests; deliver + button-resolve round-trip; targeted send-failure discard (interactive entry survives); seconds-rendering timeout sentinel; text-reply binding tests with origins built through the real _origin_from_env; Relay metadata stamping tests; run_job wiring tests (gate + attach + hint swap + operator override + gate-off).
  • tests/gateway/relay/test_relay_interactive.py: _send_prompt honors _relay_logical_platform and strips it from outbound metadata.
  • tests/cron/test_run_one_job.py, tests/cron/test_scheduler.py, tests/cron/test_parallel_pool.py, tests/cron/test_execution_ledger.py: existing run_job fakes/lambdas updated to accept the new kwargs; new test in test_run_one_job.py asserting run_one_job forwards adapters/loop to run_job.

How to Test

  1. scripts/run_tests.sh tests/cron/ -q — new + existing cron tests pass.
  2. Manual (gateway): set cron.allow_clarify: true in ~/.hermes/config.yaml, create a job whose prompt calls the clarify tool and whose deliver targets a Discord channel, and let the gateway ticker fire it. The clarify prompt renders as buttons in the delivery channel; clicking resolves the tool call and the job completes.
  3. Timeout path: don't click — after agent.clarify_timeout the agent receives [user did not respond within Nm] and continues; the scheduler does not stall and the inactivity watchdog does not fire.
  4. Default path: with the config unset, _resolve_cron_disabled_toolsets still returns clarify and behavior is identical to before.

Tested on Ubuntu 24.04 (x86_64), Python 3.11:

  • pytest tests/cron/ tests/gateway/ -q4869 passed, 7 failed — the 7 failures are pre-existing suite-order flakiness in test_discord_send.py / test_send_multiple_images.py / test_session_store_prune.py / test_compression_concurrent_sessions.py: the same files fail on pristine main @ 470cf66b0 without this branch (the exact set wobbles run-to-run) and all pass in isolation.
  • Live end-to-end on a production gateway install, run against this exact branch: a ticker-fired cron job with an origin-bound Discord delivery rendered the clarify prompt in-channel; logs confirmed the pending entry registered under the delivery chat's gateway session key (clarify text replies bind to session key agent:main:discord:group:<chat>:<user>), and the timeout path completed cleanly — the clarify tool returned after exactly agent.clarify_timeout (600s), the agent continued autonomously, the final response was delivered, and the scheduler ticker stayed healthy throughout. An earlier revision was additionally verified live with button rendering and a real user click resolving the clarify call (same resolve_gateway_clarify round-trip the interactive path uses).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(cron): …)
  • I searched for existing PRs to make sure this isn't a duplicate (see "Related Issue")
  • My PR contains only changes related to this feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — tests/cron/ + tests/gateway/ green apart from 7 pre-existing suite-order flakes that fail identically on pristine main (see "How to Test")
  • I've added tests for my changes
  • I've tested on my platform: Ubuntu 24.04, Python 3.11 (live gateway + Discord adapter)

Documentation & Housekeeping

  • I've updated relevant documentation — website/docs/user-guide/features/cron.md (English; i18n zh-Hans copy intentionally untouched)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A: the example file has no cron: section at all (cron keys live in hermes_cli/config_defaults.py defaults); the new key is registered there with a comment
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact — pure-Python change, no OS-specific calls (no process/signal/path handling); the callback thread model matches the existing gateway clarify path
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A: the clarify tool schema/handler is unchanged; only its availability in cron context is gated

Screenshots / Logs

$ pytest tests/cron/ tests/gateway/ -q
4869 passed, 7 failed, 23 skipped in 425.60s   # 7 failures = pre-existing flakes, fail identically on pristine main

Live verification log lines from a ticker-fired run of THIS branch with cron.allow_clarify: true (origin-bound Discord delivery, no config-level platform-hint override):

Job '76555094e3c9': clarify text replies bind to session key agent:main:discord:group:1531975661911277588:1019361483311829003
Job '76555094e3c9': clarify enabled (live adapter attached)
tool clarify completed (600.48s, 146 chars)            # full agent.clarify_timeout wait
Job '76555094e3c9': delivered to discord:1531975661911277588 via live adapter

The Discord delivery channel showed the clarify prompt and the pending entry registered under the delivery chat's own gateway session key (the key an inbound typed reply resolves against); when the prompt expired unanswered the agent reported the timeout and its final response was delivered as usual, with the next tick on schedule. An earlier revision of this change was additionally verified with rendered buttons and a real user click resolving the clarify call (same resolve_gateway_clarify round-trip).

@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/cli CLI entry point, hermes_cli/, setup wizard area/config Config system, migrations, profiles labels Jul 29, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for preserving the unattended default and for covering the direct callback round trip. I found two delivery-path gaps that need correction.

Problems

  • tools/clarify_gateway.py:193-201 indexes pending text replies by session key, but gateway/run.py:13824-13846 resolves using the inbound chat's _quick_key. The new callback registers the cron session id, so open-ended clarifies, text fallback, and the native-button Other path cannot be resolved by a reply from the delivery chat.
  • The callback obtains resolve_delivery_transport() but then calls adapter.send_clarify() directly. Relay requires the logical platform for scheduled/persisted sends (gateway/relay/adapter.py:879-903); its prompt path otherwise reads _platform_by_chat (gateway/relay/adapter.py:1656-1659). Relay-only delivery is therefore not preserved.

Suggested changes

  • Bind text-response lookup to the target chat's real gateway session key and add gateway-level open-ended/Other tests.
  • Add a transport-aware clarify path and a Relay-only scheduled-delivery test.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
@agastya-raj
agastya-raj force-pushed the feat/cron-allow-clarify branch from 88b5b2f to 0b3b280 Compare August 1, 2026 11:41
@agastya-raj

Copy link
Copy Markdown
Author

Thanks @hermes-sweeper — both findings were real. Fixed in 0b3b280d4 (fix(cron): resolve cron clarify text replies + relay transport), which also rebases the branch onto current main (15cb86eba, +124 commits — the cron test suite and config-defaults layout moved, so the base commit was regenerated; no semantic changes to the original feature).

1. Text-reply session-key mismatch — root cause confirmed: the gateway intercepts typed clarify answers by the inbound chat's session key (gateway/run.py_session_key_for_source(source)get_pending_for_session(_quick_key, ...) / resolve_text_response_for_session(_quick_key, ...)), while the cron callback registered under the cron job's own session id, so open-ended clarifies, text fallback, and the native-button "Other" path could never resolve from the delivery chat.

Fix: new _cron_clarify_reply_session_key() in cron/scheduler.py computes the key the user's reply will produce, using build_session_key with the exact semantics upstream already documents for the delivery-mirror seed (_seed_cron_channel_session / _seed_cron_thread_session): thread targets are participant-shared, DM targets key on chat_id alone, and group/channel targets bind to the job's origin user_id — only when the delivery target is the origin conversation. Fan-out group targets (no predictable replying user) return None and registration falls back to cron:<job_id> — buttons resolve by clarify_id either way, so that degradation is typed-replies-only and is documented in the cron docs page. The binding is logged (clarify text replies bind to session key …) and the cron session id is still available for correlation via the job id in logs.

2. Relay transport not preserved — fix mirrors the existing convention rather than inventing a parallel one: cron delivery already stamps _relay_logical_platform in send metadata for relay-fronted targets (send() pops it and routes via send_for_platform). The clarify send now stamps the same key when the resolved DeliveryTransport.is_relay, and the Relay prompt lane learned the same escape hatch — a 10-line addition to gateway/relay/adapter.py::_send_prompt (pop _relay_logical_platform from prompt metadata, use it as the outbound frame platform, stripped before egress), mirroring send() exactly. This is the only shared-code touch; the interactive path is unchanged (it never sets the key, so _platform_by_chat behavior is byte-identical there).

Tests (538 passed across tests/cron/ + tests/gateway/relay/):

  • tests/cron/test_cron_clarify.py: open-ended typed answer resolving via the DM chat key; "Other"-path typed answer resolving via the group origin-user key (and not via another member's key); fan-out group fallback to cron:<id> with button resolution still working; participant-shared thread key; Relay metadata stamping (_relay_logical_platform + thread); native adapter gets no Relay stamp.
  • tests/gateway/relay/test_relay_interactive.py: _send_prompt honors _relay_logical_platform (frame tagged discord, key stripped from outbound metadata).

Live-verified on a production gateway against the previous revision (button render + timeout path); re-verifying this revision now and will report.

@agastya-raj

Copy link
Copy Markdown
Author

Round 2 — an independent review of the round-1 diff found real holes in the reply-key fix. All addressed in 070795783 (fix(cron): platform-aware cron clarify reply keys + targeted cleanup); branch rebased onto main @ 470cf66b0 (force-with-lease).

1. DM misdetection (was HIGH). Correct: _origin_from_env never stamped chat_type, so DM detection fell to the Slack-only chat_id.startswith("D") heuristic and Discord/Telegram DMs misclassified as group. Fixed on three layers, mirroring how the gateway itself knows chat types: (a) _origin_from_env now stamps chat_type and scope_id from the bound session source (HERMES_SESSION_SOURCE — small justified change in tools/cronjob_tools.py), so chat-created jobs carry the truth; (b) at fire time the callback builder resolves the target chat's type from the live adapter's get_chat_info (one bounded call per job fire, all failures degrade); (c) deterministic id heuristics as last resort — Slack D… prefix and Telegram positive-int DMs (reusing gateway.delivery.looks_like_telegram_private_chat_id).

2. Unrealistic test origins (was HIGH, masked #1). Tests no longer hand-craft origins — they build them through the real _origin_from_env (env/ContextVar patched), so the suite sees the exact production shape. Includes a Discord-DM-shaped case (numeric snowflake, no chat_type) proving typed answers bind …:dm:<chat> via the live-adapter hint.

3. Missing scope_id (was HIGH). Stamped in origin from the bound source (scope_id/guild_id dual-read, matching SessionSource.__post_init__ reconciliation) and carried into the key-building SessionSource — Slack workspace-scoped keys now match (agent:main:slack:dm:T123:D123).

4. Hardcoded chat_type="thread" (was MED). Platform-aware: Discord thread surfaces key thread, Telegram forum topics key forum, Slack threads key as the container (dm/group) + thread_id — matching each adapter's inbound stamping.

5. Discord explicit parent:thread targets (was MED). Correct: inbound thread messages stamp chat_id = thread_id, so the key is …:thread:<T>:<T> — the helper now remaps chat_id to thread_id for Discord thread targets.

6. clear_session cross-talk (was MED). Send-failure cleanup now uses a new targeted clarify_gateway.discard(clarify_id) (small registry API, justified — no targeted removal existed), so a failed cron clarify send no longer cancels an unrelated interactive clarify pending in the same chat.

7–9. Send future is cancelled on timeout/failure (no late buttons for a discarded entry); test threads are daemon + loop closed; sub-minute timeouts render 1s instead of 0m.

Docs — the "typed answers always resolve for threads and DMs" claim was wrong; replaced with the actual supported matrix (DM ✓ / thread ✓ / origin group ✓ / fan-out group ✗ buttons-only) and a known-limitations block (FIFO-oldest cross-talk with interactive clarifies in the same chat, any-member button clicks on fan-out, multiplex/thread_sessions_per_user limits, enabled_toolsets must include clarify).

Testspytest tests/cron/ tests/gateway/ -q: 4869 passed, 7 failed — the 7 failures are pre-existing suite-order flakiness in test_discord_send.py / test_send_multiple_images.py / test_session_store_prune.py / test_compression_concurrent_sessions.py: the same files fail on pristine main @ 470cf66b0 without this branch (6 failed there — the exact set wobbles run-to-run), and all 28 tests in the named files pass in isolation. New coverage: key-matrix unit tests (Discord DM via origin stamp + via adapter hint, Slack scope, Telegram DM/forum/group shapes, Discord parent:thread remap, Slack thread, thread_sessions_per_user null-out), send-failure targeted-discard (interactive entry survives), seconds rendering, and run_job wiring tests (gate + callback attach + hint swap + operator-override-wins + gate-off no-op) — the gap that let #1#4 slip.

Live-verified on a production gateway (buttons render from a ticker-fired job, registration log shows the delivery-chat key, timeout path clean); typed-reply resolution itself can't be live-proven without a human typing — unit coverage is the proof for that direction.

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/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages 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