Instructions to use Wellz1000/hermes-2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- HERMES
How to use Wellz1000/hermes-2 with HERMES:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
File size: 31,690 Bytes
34c3ee8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 | # Session Lifecycle
> **Audience:** Gateway developers and maintainers
> **Source files:** `gateway/session.py` (~1444 lines), `gateway/run.py` (~16800 lines), `gateway/config.py`
> **Last updated:** 2026-06-16
## Overview
A **session** represents a continuous conversation between the agent and one or more users on a
messaging platform. The session lifecycle governs when conversations persist, when they reset,
how they survive gateway restarts, and how messages queue during concurrent operations.
The session system lives primarily in two modules:
- `gateway/session.py` β Data model (`SessionSource`, `SessionEntry`, `SessionContext`),
key generation (`build_session_key`), and the main store (`SessionStore`).
- `gateway/run.py` β Gateway runner (`GatewayRunner`) that wires sessions into the message
processing pipeline: session expiry watching, agent caching, restart recovery, and message
queuing.
---
## 1. SessionSource β Message Origin Descriptor
`SessionSource` is a frozen record of *where a message came from*. It is attached to every
incoming `MessageEvent` and used for routing, isolation, and context injection.
### Fields
| Field | Type | Default | Description |
|---|---|---|---|
| `platform` | `Platform` | *(required)* | Enum identifying the messaging platform (telegram, discord, slack, signal, whatsapp, matrix, local, etc.). |
| `chat_id` | `str` | *(required)* | Platform-level chat/group/channel identifier. Routed through the adapter's `chat_id_key` transform. |
| `chat_name` | `Optional[str]` | `None` | Human-readable name of the chat or group. |
| `chat_type` | `str` | `"dm"` | One of `"dm"`, `"group"`, `"channel"`, `"thread"`. Controls session key generation and isolation. |
| `user_id` | `Optional[str]` | `None` | Platform-specific user identifier. Used for authorization and per-user session isolation. |
| `user_name` | `Optional[str]` | `None` | Display name of the message author. Injected into system prompt. |
| `thread_id` | `Optional[str]` | `None` | Forum topic / Discord thread / Slack thread identifier. Differentiates threaded conversations. |
| `chat_topic` | `Optional[str]` | `None` | Channel topic or description (Discord channel topic, Slack channel purpose). |
| `user_id_alt` | `Optional[str]` | `None` | Platform-specific stable alternative ID (Signal UUID, Feishu union_id). Used when `user_id` is ephemeral. |
| `chat_id_alt` | `Optional[str]` | `None` | Signal group internal ID β maps a Signal group V2 identifier to its canonical form. |
| `is_bot` | `bool` | `False` | True when the message author is a bot or webhook (Discord bots). |
| `guild_id` | `Optional[str]` | `None` | Discord guild / Slack workspace / Matrix server scope identifier. |
| `parent_chat_id` | `Optional[str]` | `None` | Parent channel when `chat_id` refers to a thread. |
| `message_id` | `Optional[str]` | `None` | ID of the triggering message. Used for pin/reply/react operations and Discord ID injection. |
| `role_authorized` | `bool` | `False` | True when adapter granted access via a platform role (not individual user ID). |
### Key Methods
- **`description`** (property: `str`) β Human-readable summary e.g. `"DM with Alice"`,
`"group: My Group, thread: 12345"`.
- **`to_dict()` / `from_dict()`** β Serialization round-trip for persistence in `sessions.json`.
---
## 2. SessionEntry β Active Session Record
`SessionEntry` is the per-session metadata record stored in memory and persisted to
`{sessions_dir}/sessions.json`. Each entry maps a `session_key` to its current `session_id`.
### Fields
| Field | Type | Default | Description |
|---|---|---|---|
| `session_key` | `str` | *(required)* | Deterministic key identifying the conversation lane (see Β§4). |
| `session_id` | `str` | *(required)* | Unique identifier for this specific conversation incarnation. Format: `YYYYMMDD_HHMMSS_<8hex>`. |
| `created_at` | `datetime` | *(required)* | When this session incarnation was created. |
| `updated_at` | `datetime` | *(required)* | Last activity timestamp. Used for idle timeout and expiry checks. |
| `origin` | `Optional[SessionSource]` | `None` | The source that created this session, used for delivery routing. |
| `display_name` | `Optional[str]` | `None` | Chat display name (sourced from `SessionSource.chat_name`). |
| `platform` | `Optional[Platform]` | `None` | Platform enum, persisted for expiry policy lookup across restarts. |
| `chat_type` | `str` | `"dm"` | Chat type, also persisted for policy lookup. |
| `input_tokens` | `int` | `0` | Cumulative LLM input (prompt) tokens consumed. |
| `output_tokens` | `int` | `0` | Cumulative LLM output (completion) tokens consumed. |
| `cache_read_tokens` | `int` | `0` | Cumulative prompt cache read tokens. |
| `cache_write_tokens` | `int` | `0` | Cumulative prompt cache write tokens. |
| `total_tokens` | `int` | `0` | Total token count across all turns. |
| `estimated_cost_usd` | `float` | `0.0` | Estimated cumulative USD cost. |
| `cost_status` | `str` | `"unknown"` | Cost tracking status label. |
| `last_prompt_tokens` | `int` | `0` | Last API-reported prompt token count. Used for accurate compression pre-check. |
### Boolean Flags (State Machine)
SessionEntry has several boolean flags that form a simple state machine governing session
behavior on the next access.
| Flag | Type | Default | Description |
|---|---|---|---|
| `was_auto_reset` | `bool` | `False` | Set when a session was auto-reset due to policy expiry (idle/daily). Consumed once to inject a context notice. |
| `auto_reset_reason` | `Optional[str]` | `None` | `"idle"` or `"daily"` β why the previous session was auto-reset. |
| `reset_had_activity` | `bool` | `False` | Whether the expired session had any messages (`total_tokens > 0`). |
| `is_fresh_reset` | `bool` | `False` | Set by explicit `/new` or `/reset`. Triggers topic/channel skill re-injection on first message. Distinguished from `was_auto_reset` to avoid misleading "session expired" notices. |
| `expiry_finalized` | `bool` | `False` | Set by background expiry watcher after invoking `on_session_finalize` hooks, cleaning tool resources, and evicting the cached agent. Prevents redundant finalization across restarts. |
| `suspended` | `bool` | `False` | Hard force-wipe signal. Set by `/stop` or stuck-loop escalation (3+ consecutive restart failures). On next `get_or_create_session()`, forces a new `session_id` regardless of `resume_pending`. |
| `resume_pending` | `bool` | `False` | Soft recovery marker. Set by `suspend_recently_active()` (crash recovery) or drain timeout. On next access, preserves the existing `session_id` β the user continues on the same transcript. Cleared after the next successful turn completes. |
| `resume_reason` | `Optional[str]` | `None` | Why resume was marked: `"restart_timeout"`, `"shutdown_timeout"`, `"restart_interrupted"`. |
| `last_resume_marked_at` | `Optional[datetime]` | `None` | Timestamp of the last resume-pending marking. |
### State Transition Logic (get_or_create_session)
```
ββββββββββββ
β Incoming β
β Message β
ββββββ¬ββββββ
β
βΌ
ββββββββββββββββββββββββ
β session_key exists βββββ No βββΊ Create fresh SessionEntry
β AND !force_new β
ββββββββββββ¬ββββββββββββ
β Yes
βΌ
ββββββββββββββββββββββββ
β entry.suspended? βββββ Yes βββΊ Auto-reset: new session_id
ββββββββββββ¬ββββββββββββ (reason="suspended")
β No
βΌ
ββββββββββββββββββββββββ
β entry.resume_pending?βββββ Yes βββΊ Return existing entry
ββββββββββββ¬ββββββββββββ (preserve session_id)
β No Clear flag on next successful turn
βΌ
ββββββββββββββββββββββββ
β Policy says reset? βββββ Yes βββΊ Auto-reset: new session_id
ββββββββββββ¬ββββββββββββ (reason="idle"/"daily")
β No
βΌ
ββββββββββββββββββββββββ
β Return existing β
β entry, bump β
β updated_at β
ββββββββββββββββββββββββ
```
**Priority order in `get_or_create_session()`:**
1. `suspended=True` β always force-reset (hard wipe)
2. `resume_pending=True` β preserve session_id (soft recovery)
3. Policy expiry (idle/daily) β auto-reset
4. No trigger β return existing entry (bump `updated_at`)
---
## 3. SessionStore β Storage and Operations
`SessionStore` is the main storage layer. It maintains an in-memory dict (`_entries`) persisted
to `sessions.json`, with SQLite (`SessionDB`) as the canonical store for session metadata and
message transcripts.
### Constructor
```python
SessionStore(sessions_dir: Path, config: GatewayConfig, has_active_processes_fn=None)
```
- `sessions_dir` β Directory where `sessions.json` lives.
- `config` β `GatewayConfig` instance for reset policy lookups.
- `has_active_processes_fn` β Optional callback keyed by `session_key` to check for running
background processes. Sessions with active processes are never expired or pruned.
### Operations (Methods)
| Method | Description |
|---|---|
| `get_or_create_session(source, force_new=False)` | Core entry point. Returns existing or creates new `SessionEntry`. Evaluates `suspended`, `resume_pending`, and reset policy. Creates/ends SQLite records. |
| `update_session(session_key, last_prompt_tokens=None)` | Lightweight metadata update after an interaction. Bumps `updated_at`, optionally records `last_prompt_tokens`. |
| `reset_session(session_key, display_name=None)` | Explicit reset (from `/new` or `/reset`). Creates new `session_id`, sets `is_fresh_reset=True`. Ends old SQLite session, creates new one. |
| `switch_session(session_key, target_session_id)` | Switch to a different existing session ID (from `/resume`). Ends current SQLite session, reopens target. |
| `suspend_session(session_key)` | Mark session as `suspended=True` (from `/stop`). Forces auto-reset on next access. |
| `mark_resume_pending(session_key, reason)` | Mark session as `resume_pending=True` (from drain timeout). Preserves session_id on next access. Will NOT override `suspended=True`. |
| `clear_resume_pending(session_key)` | Clear `resume_pending` after a successful resumed turn. Called from gateway after `run_conversation()` returns. |
| `suspend_recently_active(max_age_seconds=120)` | Crash recovery: mark recently-active sessions as `resume_pending=True`. Skips already-pending and already-suspended entries. Called on startup after unclean shutdown. |
| `prune_old_entries(max_age_days)` | Drop entries older than `max_age_days` (based on `updated_at`). Skips `suspended` entries and sessions with active processes. |
| `list_sessions(active_minutes=None)` | Return all sessions, optionally filtered by recent activity. Sorted by `updated_at` descending. |
| `lookup_by_session_id(session_id)` | Find the active `SessionEntry` for a persisted session ID. |
| `has_any_sessions()` | Check if any sessions have ever been created (uses SQLite for history, not just in-memory dict). |
| `append_to_transcript(session_id, message, skip_db=False)` | Append a message to SQLite transcript. `skip_db=True` prevents duplicate writes when the agent already persisted. |
| `rewrite_transcript(session_id, messages)` | Full replacement of session transcript (used by `/retry`, `/undo`, `/compress`). |
| `load_transcript(session_id)` | Load all messages from a session's SQLite transcript. |
| `rewind_session(session_id, n=1)` | Back up `n` user turns via soft-delete (keeps audit trail). Returns `{rewound_count, turns_undone, target_text}`. |
### Internal Helpers
- `_ensure_loaded()` / `_ensure_loaded_locked()` β Load `sessions.json` into `_entries` dict.
- `_save()` β Atomic write to `sessions.json` via temp file + `atomic_replace`.
- `_generate_session_key(source)` β Delegates to `build_session_key()` with config params.
- `_is_session_expired(entry)` β Policy check from entry alone (no source needed). Used by
background expiry watcher.
- `_should_reset(entry, source)` β Policy check returning `"idle"`, `"daily"`, or `None`.
### Storage Layout
```
{sessions_dir}/
sessions.json # In-memory _entries dict, persisted as JSON
Maps session_key β SessionEntry (metadata only)
{session_id}.jsonl # (Legacy, removed in spec 002)
```
The canonical transcript store is SQLite via `SessionDB` (from `hermes_state`). The
`sessions.json` file persists the `session_key β session_id` mapping and entry metadata
(flags, timestamps, token counts). If SQLite is unavailable, the store falls back to
JSONL, but this is a degradation path.
---
## 4. SessionKey Generation Rules
Session keys are deterministic strings that identify a conversation lane. They are generated
by `build_session_key(source, group_sessions_per_user, thread_sessions_per_user)`.
### Key Format
```
agent:main:{platform}:{chat_type}[:{chat_id}][:{thread_id}][:{participant_id}]
```
### DM Rules
| Scenario | Key |
|---|---|
| DM with chat_id | `agent:main:telegram:dm:12345` |
| DM with chat_id + thread | `agent:main:telegram:dm:12345:thread_678` |
| DM without chat_id, with participant_id | `agent:main:signal:dm:user_abc` |
| DM without chat_id or participant_id | `agent:main:telegram:dm` |
| WhatsApp DM (canonicalized) | `agent:main:whatsapp:dm:{canonical_number}` |
- DMs always include `chat_id` when present, isolating each private conversation.
- `thread_id` further differentiates threaded DMs within the same DM chat.
- Without `chat_id`, falls back to `user_id_alt` or `user_id` as participant_id.
- Without any identifier, all DMs on that platform collapse to one shared session.
### Group/Channel Rules
| Scenario | Key |
|---|---|
| Group chat | `agent:main:telegram:group:-10012345` |
| Group chat, per-user isolation | `agent:main:telegram:group:-10012345:user_abc` |
| Thread in group, shared | `agent:main:discord:group:12345:thread_678` |
| Thread in group, per-user | `agent:main:discord:group:12345:thread_678:user_abc` |
| Channel | `agent:main:slack:channel:C12345` |
| WhatsApp group (canonicalized) | `agent:main:whatsapp:group:{canonical_id}:{participant}` |
- `chat_id` identifies the parent group/channel.
- `thread_id` differentiates threads within that parent.
- **Per-user isolation** (append `participant_id`) is controlled by:
- `group_sessions_per_user` (default: `True`) β group/channel sessions are isolated.
- `thread_sessions_per_user` (default: `False`) β threads are **shared** by default
(Telegram forum topics, Discord threads, Slack threads all share one session per thread).
- `participant_id` = `user_id_alt` or `user_id` (in that priority).
- WhatsApp identifiers are canonicalized to handle JID/LID alias flips.
### Special Case: WhatApp
WhatsApp phone numbers go through `canonical_whatsapp_identifier()` which strips the
`@s.whatsapp.net` suffix and normalizes to E.164 format. This prevents session fragmentation
when the bridge returns different alias forms of the same phone number.
---
## 5. Multi-User Isolation Strategy
Multi-user isolation determines whether multiple users in the same chat share a conversation
or each get their own private session.
### Decision Logic (`is_shared_multi_user_session`)
```python
def is_shared_multi_user_session(source, *, group_sessions_per_user, thread_sessions_per_user):
if source.chat_type == "dm":
return False # DMs are always private
if source.thread_id:
return not thread_sessions_per_user # Threads: shared unless per-user
return not group_sessions_per_user # Groups: isolated unless shared
```
### Summary
| Chat Type | Default | Config Control |
|---|---|---|
| DM | Private (never shared) | N/A |
| Group/Channel | Per-user isolation | `group_sessions_per_user` (default: True) |
| Thread (forum, discord) | Shared (all participants see same context) | `thread_sessions_per_user` (default: False) |
### Impact on System Prompt
When `shared_multi_user_session=True`, the system prompt omits a fixed user name and instead
states: *"Multi-user {thread|session} β messages are prefixed with [sender name]. Multiple
users may participate."* Individual sender names are prefixed on each user message by the
gateway at runtime, preserving prompt caching (the system prompt doesn't change per-turn).
---
## 6. Reset Policy
Reset policies control when a session automatically loses context (gets a new `session_id`).
### Policy Modes (`SessionResetPolicy`)
| Mode | Behavior | Default Config |
|---|---|---|
| `"none"` | Never auto-reset. Context managed only by compression. | β |
| `"idle"` | Reset after N minutes of inactivity from `updated_at`. | `idle_minutes: 1440` (24h) |
| `"daily"` | Reset at a specific hour each day (local time). | `at_hour: 4` (4 AM) |
| `"both"` | Whichever triggers first β daily boundary OR idle timeout. | **(default)** |
### Policy Evaluation
```python
# Idle check
idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes)
if now > idle_deadline: return "idle"
# Daily check
today_reset = now.replace(hour=policy.at_hour, minute=0, second=0, microsecond=0)
if now.hour < policy.at_hour:
today_reset -= timedelta(days=1) # Reset hasn't happened yet today
if entry.updated_at < today_reset: return "daily"
```
### Per-Platform/Per-Type Policies
Reset policies are configurable per platform and session type via `config.get_reset_policy()`.
This allows different platforms to have different expiry rules (e.g., Telegram DMs reset
after 24h idle, but Slack groups persist indefinitely).
### Exclusions
Sessions with active background processes are **never** expired or reset. The
`has_active_processes_fn` callback checks for running processes when evaluating policies.
### Reset Effects
When a reset triggers:
1. Old session is ended in SQLite (with reason `"session_reset"`).
2. New `session_id` is generated (`YYYYMMDD_HHMMSS_<8hex>`).
3. New `SessionEntry` is created with `was_auto_reset=True` and the reset reason.
4. `reset_had_activity` is set if the old session had any turns (`total_tokens > 0`).
5. The old AIAgent cache entry is evicted on the next expiry watcher pass.
6. On the first message after reset, a context notice is injected: "Session expired due to inactivity / daily reset."
---
## 7. Restart Recovery Flow
The restart recovery system ensures that in-flight sessions are preserved across gateway
restarts, crashes, and drain timeouts. It is the solution to issue #7536.
### Startup Recovery Sequence
```
Gateway starts
β
βΌ
βββββββββββββββββββββββββββββββββ
β Check for .clean_shutdown βββ Exists? βββΊ Skip suspension (clean exit)
β marker β
βββββββββββββββββββββββββββββββββ
β Missing
βΌ
βββββββββββββββββββββββββββββββββ
β session_store βββ Marks sessions updated within
β .suspend_recently_active() β last 120 seconds as resume_pending
βββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β _suspend_stuck_loop_sessions()βββ Suspends sessions that have been
β β active across 3+ restarts
βββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β Queue inbound messages while β
β startup restore runs β
β (_startup_restore_in_progress)β
βββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β For each adapter, find β
β resume_pending sessions β β
β synthesize MessageEvent and β
β run _handle_message to let β
β the agent auto-continue β
βββββββββββββββββββββββββββββββββ
```
### suspend_recently_active(max_age_seconds=120)
Called on gateway startup when no `.clean_shutdown` marker exists (indicating a crash or
unexpected exit). For each session updated within the last 120 seconds:
- Sets `resume_pending=True`, `resume_reason="restart_interrupted"`,
`last_resume_marked_at=now`.
- Skips entries already `resume_pending=True` (no double-mark).
- Skips entries explicitly `suspended=True` (hard wipe should stay).
### Stuck-Loop Detection (`_suspend_stuck_loop_sessions`)
Counts consecutive restarts via a JSON file (`{HERMES_HOME}/restart_counts.json`). If a
session has been active across 3+ consecutive restarts, it's auto-suspended so the user
gets a clean slate.
### Drain-Timeout Marking
On graceful shutdown/restart, the drain system calls `mark_resume_pending()` for any
session that was mid-turn when the drain timeout fired. Reasons:
- `"restart_timeout"` β killed during restart drain
- `"shutdown_timeout"` β killed during shutdown drain
- `"restart_interrupted"` β crash recovery (from `suspend_recently_active`)
All three reasons are in `_AUTO_RESUME_REASONS` and eligible for startup auto-resume.
### Auto-Resume on Next Access
When `get_or_create_session()` encounters `resume_pending=True`:
1. It returns the existing entry **without** creating a new `session_id`.
2. The existing transcript is loaded intact.
3. The marking is not cleared here β it survives until the next successful turn
completes (`clear_resume_pending()` is called from the gateway after
`run_conversation()` returns a real response).
4. If the resumed turn is interrupted again, the `resume_pending` flag remains set,
and the next restart will retry. The stuck-loop counter handles terminal escalation
(3 retries β suspended).
### Clean Shutdown Marker (`.clean_shutdown`)
Written at the end of a graceful shutdown. On next startup:
- If present: skip `suspend_recently_active()` entirely. Active agents were already
drained, so no sessions are stuck.
- Then delete the marker.
This prevents unwanted auto-resets after `hermes update`, `hermes gateway restart`,
or `/restart`.
---
## 8. Message Queuing Flow
The message queuing system handles two scenarios:
1. **Interrupt follow-ups** β When a user sends multiple messages while the agent is
processing, subsequent messages are queued as single-slot pending messages.
2. **`/queue` FIFO** β Explicit `/queue` commands that must each produce their own full
agent turn, in order, without merging.
### Data Structures
```
adapter._pending_messages: Dict[session_key, MessageEvent]
βββ Single "next-up" slot per session. Overwritten on repeat sends
(burst collapse). Shared with photo-burst follow-ups.
self._queued_events: Dict[session_key, List[MessageEvent]]
βββ Overflow buffer. Each /queue invocation appends here when the
slot is occupied. Promoted one-at-a-time after each drain.
```
### Enqueue (`_enqueue_fifo`)
```
_enqueue_fifo(session_key, event, adapter)
β
βΌ
βββββββββββββββββββββββββββββββββββββββββ
β Is slot free? β
β (session_key NOT in _pending_messages)βββ Yes βββΊ Place event in slot
βββββββββββββββββββββββββββββββββββββββββ
β No
βΌ
Append to _queued_events[session_key] (overflow tail)
```
### Dequeue / Promotion (`_promote_queued_event`)
Called at the drain site after the slot was consumed. If there's an overflow item:
- When `pending_event is None` (slot was empty), return overflow head as the new event.
- When `pending_event` exists, stage overflow head in the slot for the next recursion.
- If no adapter available, push back to `_queued_events` (don't silently drop).
### Queue Depth
`_queue_depth(session_key, adapter)` returns `len(overflow) + (1 if slot occupied else 0)`.
### Clearing
Queued events for a session are cleared on `/new` and `/reset` (via `_handle_reset_command`).
### FIFO Invariant
Each `/queue` invocation produces exactly one full agent turn, in FIFO order, with no
merging. The single-slot `_pending_messages` + overflow `_queued_events` design ensures
that repeated sends during an active turn don't cause out-of-order processing.
---
## 9. Session Context Injection
`SessionContext` is built from a `SessionSource` and `GatewayConfig` and injected into the
agent's system prompt. It tells the agent:
- Where the current message came from
- What platforms are connected
- Where it can deliver scheduled task outputs
- Whether this is a shared multi-user session
### Construction (`build_session_context`)
```python
def build_session_context(source, config, session_entry=None) -> SessionContext
```
1. Collects connected platforms from config.
2. Collects home channels for each platform.
3. Determines `shared_multi_user_session` via `is_shared_multi_user_session()`.
4. Attaches session metadata (key, id, timestamps) if `session_entry` is provided.
### PII Redaction (`build_session_context_prompt`)
The dynamic system prompt section (`## Current Session Context`) can optionally redact
personally identifiable information before sending to the LLM:
- User IDs β `user_<12hex>` (SHA-256 prefix)
- Chat IDs β `<platform>:<12hex>` or just `<12hex>`
- Platforms excluded from redaction: Discord (needs raw IDs for `@mentions`),
and any plugin-registered platform not marked `pii_safe`.
Redaction applies only to the system prompt text. Routing, session keys, and adapter
operations always use the original values.
---
## 10. Background Expiry Watcher
The `_session_expiry_watcher` task runs in the gateway event loop every 300 seconds (5 min).
### Responsibilities
1. **Finalize expired sessions** β For each entry where `_is_session_expired()` returns
True and `expiry_finalized` is False:
- Invoke `on_session_finalize` plugin hooks (cleanup, notifications).
- Clean up cached AIAgent resources (close tool resources, shut down memory provider).
- Evict the cached agent entry.
- Clear per-session overrides (`_session_model_overrides`, reasoning overrides, etc.).
- Mark `expiry_finalized=True` and persist (sessions.json + state.db).
- Promote the state.db session row to `end_reason='session_reset'` via
`promote_to_session_reset()` β conditional: only live rows or rows ended with a
recoverable accidental reason (`agent_close`, `ws_orphan_reap`) are promoted, so
explicit boundaries (`compression`, `session_switch`, β¦) are never overwritten. This
durably records the reset so stale-route recovery cannot resurrect the expired
session with its full history (#61220, #61993, #63539).
2. **Sweep idle cached agents** β Calls `_sweep_idle_cached_agents()` to evict agents that
have been idle beyond `_AGENT_CACHE_IDLE_TTL_SECS` (3600s / 1h), regardless of session
reset policy. This prevents unbounded memory growth in gateways with long-lived sessions.
3. **Prune stale entries** β Calls `session_store.prune_old_entries()` hourly based on
`config.session_store_max_age_days`. Prevents `sessions.json` from growing unbounded.
### Failure Handling
- Per-session retry count: each failed finalize is retried up to 3 consecutive times.
- After 3 failures, the entry is force-marked `expiry_finalized=True` to prevent infinite
retry loops.
---
## 11. Agent Cache
The gateway maintains an LRU cache of `AIAgent` instances keyed by `session_key` to
preserve prompt caching across turns.
### Cache Properties
- **Max size:** 128 entries (`_AGENT_CACHE_MAX_SIZE`).
- **Eviction policy:** Least-recently-used (LRU via `OrderedDict`).
- **Idle TTL:** 3600s (1h) β enforced by `_session_expiry_watcher`.
- **Lock:** `_agent_cache_lock` (threading) for thread safety.
### Cache Lifecycle
```
Message arrives
β
βΌ
get_or_create_session() β session_key obtained
β
βΌ
Lookup _agent_cache[session_key]
β
βββ Hit β move_to_end(), reuse AIAgent (preserves prompt cache)
β
βββ Miss β create new AIAgent, store in cache
(if at capacity, popitem(last=False) evicts LRU entry)
β
βΌ
run_conversation() β agent processes message
β
βΌ
Session expiry watcher evicts agent when session finalizes
```
### Cleanup Flow
When a session expires:
1. `_cleanup_agent_resources(agent)` β shuts down memory provider, closes tool resources.
2. `_evict_cached_agent(key)` β removes from `_agent_cache` so the agent can be GC'd.
---
## Appendix: Key Configuration
| Config Key | Type | Default | Description |
|---|---|---|---|
| `group_sessions_per_user` | `bool` | `true` | Isolate group/channel sessions per user |
| `thread_sessions_per_user` | `bool` | `false` | Isolate thread sessions per user |
| `session_store_max_age_days` | `int` | `0` | Prune sessions older than N days (0=disabled) |
| `agent.gateway_auto_continue_freshness` | `int` | `3600` | Seconds for resume freshness window |
| `agent.gateway_timeout` | `int` | `1800` | Agent turn timeout (30 min default) |
### Reset Policy (per-platform/type, in config.yaml)
```yaml
session_reset:
mode: none # none (default) | idle | daily | both
at_hour: 4 # daily reset hour (local time)
idle_minutes: 1440 # idle timeout (24h)
notify: true # notify user on auto-reset
```
Platform-specific overrides can be set under `platforms.<name>.session_reset`.
|