Feat/webhook session key - #57972
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused persistent-webhook-session proposal; the current delivery-id session construction on main means the capability is still needed.
Problems
gateway/platforms/webhook.py:671-674deliberately falls back to a delivery-specific chat ID when a template is unresolved, but:758-761skips_end_webhook_sessionfor every route that declaressession_key. Those fallback one-shot sessions therefore violate the existing close/prune invariant.gateway/platforms/webhook.py:674-687uses the shared persistent chat ID as the_delivery_infokey.send()reads response routing only from that key (gateway/platforms/webhook.py:277), while same-session events are queued ingateway/platforms/base.py:4675-4825; a later delivery can replace the earlier delivery's rendered destination before the earlier response is sent.
Suggested changes
- Make lifecycle behavior depend on a successfully resolved persistent key for the specific event, not merely route configuration.
- Keep response-routing state per delivery while separately deriving the stable conversation/session identity, and add pipeline coverage for both fallback cleanup and same-key deliveries with distinct
deliver_extra.
Automated hermes-sweeper review.
| row, so this never clobbers a ``compression``/``agent_close`` reason. | ||
| """ | ||
| route_name = (event.source.user_id or "").removeprefix("webhook:") | ||
| if self._routes.get(route_name, {}).get("session_key"): |
There was a problem hiding this comment.
This checks only whether the route declares session_key. If rendering at lines 671-673 leaves an unresolved token, line 674 falls back to a delivery-specific one-shot session but this return still prevents _end_webhook_session; that recreates the unprunable-session leak. Track whether this individual event actually resolved a persistent key.
| session_key = self._render_prompt(session_key_tpl, payload, event_type, route_name).strip() | ||
| if "{" in session_key: | ||
| session_key = "" | ||
| session_chat_id = f"webhook:{route_name}:{session_key or delivery_id}" |
There was a problem hiding this comment.
With a stable value this becomes the shared key for _delivery_info below. send() resolves deliver_extra only by chat ID, so a second same-key POST can overwrite the first delivery's response destination while the first run is active/queued. Keep response-routing state per delivery rather than using the conversation key for both roles.
|
We have a workflow-webhook use case (agent-to-agent relay) and agree this capability is valuable. One caution from the current diff: the fallback delivery-id path still skips auto-close when the route declares |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Two open PRs address persistent webhook conversations: #57972 adds rendered per-payload session identities plus lifecycle handling and documentation, while #71570 adds a narrower constant shared session per route. The current diffs do not yet safely resolve the reported cause: #57972 conflates conversation identity with delivery routing and mishandles fallback lifecycle, whereas #71570 changes only the chat ID and leaves persistent-session lifecycle unresolved.
Related pull requests
- #57972
related— (+38/-1) — keep open, changes required: The renderedsession_keysupports both per-source persistent conversations and per-delivery fallback, but the diff skips auto-close based on route configuration rather than successful per-event resolution, leaking fallback sessions, and keys_delivery_infoby the shared conversation ID, allowing queued deliveries to overwrite response routing. This follows the contributor keep_open review on #57972; merge requires separating stable conversation identity from per-delivery routing and making lifecycle conditional on actual key resolution. - #71570
related— (+13/-3) — superseded by #57972: The diff provides only one shared conversation per route and does not cover rendered per-payload identities; more importantly, it changes the session chat ID without adjusting the webhook completion lifecycle, so the shared session can still be ended after each delivery. Its constant shared key also does not separate conversation identity from mutable per-delivery response-routing state.
Duplicates
#57972 and #71570 substantially duplicate the opt-in persistent-webhook-session capability, but #57972 is the broader implementation because it can represent both route-level and per-payload identities after correction.
Suggested consolidation
Merge #57972 after addressing its contributor-reviewed lifecycle and response-routing blockers: track routing per delivery, derive conversation identity separately, and skip auto-close only when that delivery successfully resolved a persistent key. Then close #71570 as the narrower duplicate; its diff neither implements per-payload session partitioning nor completes the lifecycle changes needed for persistence.
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 7 kB of PR diffs, 11 kB of issue/PR text, 2 kB of discussion (4 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Delta since our previous triage comment
@teknium1’s new contributor review confirms and strengthens our earlier assessment of #71570: changing only the routing key does not preserve history because on_processing_complete() still closes the session, and the route-scoped _delivery_info key can misroute responses when deliveries overlap. The review adds authoritative code-path evidence but does not change the consolidation outcome.
Changed pull requests
- #71570
duplicate— (+13/-3) — keep open for revision, but superseded for consolidation by #57972: @teknium1 confirms that the diff changes only the session key while leaving unconditional completion-time closure and shared-key response-routing aliasing unresolved. Despite the new keep_open review on #71570, consolidating into corrected #57972 remains preferable because #71570 is the narrower route-only design; this does not recommend closing it until #57972 has addressed the same lifecycle and routing blockers.
Suggested consolidation
The recommendation is unchanged: merge #57972 only after its reviewed lifecycle and routing fixes, then close #71570 as the narrower duplicate.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup57972 ["PRs duplicating each other"]
P57972["PR #57972 (open)"]
P71570["PR #71570 (open)"]
end
class P57972 open
class P71570 open
class P57972 target
click P57972 "https://github.com/NousResearch/hermes-agent/pull/57972"
click P71570 "https://github.com/NousResearch/hermes-agent/pull/71570"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 7 kB of PR diffs, 11 kB of issue/PR text, 4 kB of discussion (6 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
…conversations
What does this PR do?
Adds an opt-in
session_keyfield to webhook route configuration, allowing a route to maintain a persistent conversation across deliveries.Problem: The webhook platform derives the session chat key from the delivery ID (
webhook:{route_name}:{delivery_id}), so every POST creates a brand-new session, and TTL prevents reuse. This is correct for fire-and-forget event routes, but makes multi-turn use cases impossible — each utterance from a voice satellite (my use case) arrives with total amnesia, and sessions multiply per event (the "ghost session" accumulation that v0.18.0's auto-close addressed).Approach: Delivery and conversation identity are separated. A route may set
session_key, a template rendered from the payload (e.g."{satellite}"), which pins the chat key to a stable value:webhook:{route_name}:{rendered_key}. Repeat events from the same source then share one session, like a WhatsApp or Telegram thread. Idempotency still keys ondelivery_id, and routes withoutsession_keyare completely unaffected — per-delivery remains the default. If the template doesn't resolve against a payload, the route falls back to per-delivery behavior for that event.Because persistent routes expect follow-up turns, they are exempted from the
on_processing_complete→_end_webhook_sessionauto-close introduced in v0.18.0; their lifecycle is managed by idle timeout /hermes sessions pruneinstead. This does not reintroduce the ghost-session problem the auto-close fixed — session count is bounded by distinct rendered keys (one per satellite per route in my deployment), not by delivery count.Security note: the
session_keytemplate is rendered from the request payload, so a sender controls which session their events land in. This is inherent to the feature and scoped to the route: a sender must already hold the route's HMAC secret, and cross-route access is not possible since the route name is part of the chat key. Signature validation and idempotency are unchanged.Trade-off worth flagging: the original per-delivery key also served to keep concurrent POSTs on one route from serializing on a single session. With a shared
session_key, rapid successive events from the same source will queue on one session. For conversational sources this is desirable (turns should be ordered); routes that need concurrency simply don't setsession_key.Related Issue
Fixes #
Type of Change
Changes Made
gateway/platforms/webhook.py— render optionalsession_keyroute config (template over payload) into the session chat key, falling back todelivery_idwhen unset or unresolvedgateway/platforms/webhook.py— exemptsession_keyroutes from_end_webhook_sessionauto-close inon_processing_completeHow to Test
~/.hermes/config.yamlunderplatforms: webhook: extra::satellitevalue:hermes sessions listshows one new session, not two. A route withoutsession_keystill produces one session per delivery (unchanged default).Tested end-to-end in a live deployment: voice satellites (Home Assistant → webhook) with per-satellite persistent sessions.
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AScreenshots / Logs
Contents of ~/.hermes/voice-test.sh:
SECRET="same key as in config.yaml. ex: openssl rand -hex 24"
hermes-agent$ BODY='{"satellite":"assist_satellite.pod1","message":"My favorite color is teal. Remember it."}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -si -X POST http://127.0.0.1:8644/webhooks/voice-test -H "Content-Type: application/json" -H "X-Webhook-Signature: $SIG" -d "$BODY"
sleep 35
BODY='{"satellite":"assist_satellite.pod1","message":"What is my favorite color?"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -si -X POST http://127.0.0.1:8644/webhooks/voice-test -H "Content-Type: application/json" -H "X-Webhook-Signature: $SIG" -d "$BODY"
sleep 35
hermes logs | grep -E "turn_context|Response for" | tail -4
hermes sessions list | head -4
gilles@spark-240:~/.hermes$ ./voice-test.sh
HTTP/1.1 202 Accepted
Content-Type: application/json; charset=utf-8
Content-Length: 97
Date: Fri, 03 Jul 2026 22:11:26 GMT
Server: Python/3.11 aiohttp/3.14.1
{"status": "accepted", "route": "voice-test", "event": "unknown", "delivery_id": "1783116686765"}HTTP/1.1 202 Accepted
Content-Type: application/json; charset=utf-8
Content-Length: 97
Date: Fri, 03 Jul 2026 22:12:01 GMT
Server: Python/3.11 aiohttp/3.14.1
{"status": "accepted", "route": "voice-test", "event": "unknown", "delivery_id": "1783116721786"}2026-07-03 12:10:31,853 INFO gateway.platforms.webhook: [webhook] Response for webhook:voice-test:assist_satellite.pod1: Oh, you're testing me?
2026-07-03 15:11:27,214 INFO [20260703_120854_0b7ee4f8] agent.turn_context: conversation turn: session=20260703_120854_0b7ee4f8 model=qwen3.6:27b provider=custom platform=webhook history=0 msg='[voice from assist_satellite.pod1] My favorite color is teal. Remember it.'
2026-07-03 15:11:54,685 INFO gateway.platforms.webhook: [webhook] Response for webhook:voice-test:assist_satellite.pod1: Teal. Consider it etched into my heart.
2026-07-03 15:12:01,847 INFO [20260703_120854_0b7ee4f8] agent.turn_context: conversation turn: session=20260703_120854_0b7ee4f8 model=qwen3.6:27b provider=custom platform=webhook history=2 msg='[voice from assist_satellite.pod1] What is my favorite color?'
Title Preview Last Active ID
──────────────────────────────────────────────────────────────────────────────────────────────────────────────
— [voice from assist_satellite.pod1] My just now 20260703_120854_0b7ee4f8
— What is my favorite color? 16h ago 20260702_225954_64eb4c4e