Жизненный цикл сессии

Аудитория: разработчики и мейнтейнеры шлюза Исходные файлы: gateway/session.py (~1200 строк + соседние session_*.py), gateway/run.py (~5500 строк фасада + фазы в run_*.py), gateway/config.py Последнее обновление: 2026-06-16

Обзор

Сессия — это непрерывный разговор между агентом и одним или несколькими пользователями на мессенджер-платформе. Жизненный цикл сессии определяет, когда разговор сохраняется, когда сбрасывается, как переживает перезапуски шлюза и как сообщения ставятся в очередь при параллельных операциях.

Система сессий живёт в основном в двух модулях:

  • gateway/session.py — модель данных (SessionSource, SessionEntry, SessionContext), генерация ключей (build_session_key) и основное хранилище (SessionStore).
  • gateway/run.py — фасад раннера шлюза (GatewayRunner), который встраивает сессии в конвейер обработки сообщений; сами фазы лежат в соседних run_*.py: обслуживание сессий (run_watchers.py), кэш агентов (run_agent_cache.py), восстановление после перезапуска (session_recovery.py) и очередь сообщений (run_busy.py).

1. SessionSource — дескриптор источника сообщения

SessionSource — замороженная запись о том, откуда пришло сообщение. Она прикрепляется к каждому входящему MessageEvent и используется для маршрутизации, изоляции и внедрения контекста.

Поля

ПолеТипПо умолчаниюОписание
platformPlatform(обязательное)Enum мессенджер-платформы (telegram, discord, slack, signal, whatsapp, matrix, local и т. д.).
chat_idstr(обязательное)Идентификатор чата/группы/канала на уровне платформы. Проходит через преобразование chat_id_key адаптера.
chat_nameOptional[str]NoneЧеловекочитаемое имя чата или группы.
chat_typestr"dm"Одно из "dm", "group", "channel", "thread". Управляет генерацией ключа сессии и изоляцией.
user_idOptional[str]NoneИдентификатор пользователя на платформе. Используется для авторизации и изоляции сессий по пользователям.
user_nameOptional[str]NoneОтображаемое имя автора сообщения. Внедряется в системный промпт.
thread_idOptional[str]NoneИдентификатор темы форума / треда Discord / треда Slack. Различает разговоры в тредах.
chat_topicOptional[str]NoneТема или описание канала (topic канала Discord, purpose канала Slack).
user_id_altOptional[str]NoneСтабильный альтернативный ID, специфичный для платформы (UUID в Signal, union_id в Feishu). Используется, когда user_id эфемерный.
chat_id_altOptional[str]NoneВнутренний ID группы Signal — сопоставляет идентификатор группы Signal V2 с его канонической формой.
is_botboolFalseTrue, если автор сообщения — бот или вебхук (боты Discord).
guild_idOptional[str]NoneИдентификатор области: гильдия Discord / рабочее пространство Slack / сервер Matrix.
parent_chat_idOptional[str]NoneРодительский канал, когда chat_id указывает на тред.
message_idOptional[str]NoneID сообщения-триггера. Нужен для операций pin/reply/react и внедрения ID в Discord (внедрённая пометка [Triggering message id: …] едет только в сообщении, уходящем в API; сохранённая строка пользователя хранит авторский текст).
role_authorizedboolFalseTrue, если адаптер выдал доступ по роли на платформе (а не по ID конкретного пользователя).

Основные методы

  • description (свойство: str) — человекочитаемая сводка, например "DM with Alice", "group: My Group, thread: 12345".
  • to_dict() / from_dict() — сериализация туда и обратно для хранения в sessions.json.

2. SessionEntry — запись активной сессии

SessionEntry — запись метаданных отдельной сессии; хранится в памяти и сбрасывается в {sessions_dir}/sessions.json. Каждая запись сопоставляет session_key с текущим session_id.

Поля

ПолеТипПо умолчаниюОписание
session_keystr(обязательное)Детерминированный ключ, идентифицирующий полосу разговора (см. §4).
session_idstr(обязательное)Уникальный идентификатор конкретного воплощения разговора. Формат: YYYYMMDD_HHMMSS_<8hex>.
created_atdatetime(обязательное)Когда создано это воплощение сессии.
updated_atdatetime(обязательное)Метка последней активности; по ней ведётся обслуживание ресурсов.
originOptional[SessionSource]NoneИсточник, создавший сессию; используется для маршрутизации доставки.
display_nameOptional[str]NoneОтображаемое имя чата (берётся из SessionSource.chat_name).
platformOptional[Platform]NoneEnum платформы, сохраняемый для маршрутизации после перезапусков.
chat_typestr"dm"Тип чата; тоже сохраняется — для поиска политики.
input_tokensint0Накопленное число входных (промпт) токенов LLM.
output_tokensint0Накопленное число выходных (completion) токенов LLM.
cache_read_tokensint0Накопленное число токенов, прочитанных из кэша промптов.
cache_write_tokensint0Накопленное число токенов, записанных в кэш промптов.
total_tokensint0Общее число токенов за все ходы.
estimated_cost_usdfloat0.0Оценка накопленной стоимости в USD.
cost_statusstr"unknown"Метка статуса учёта стоимости.
last_prompt_tokensint0Последнее число токенов промпта, сообщённое API. Нужно для точной предварительной проверки перед сжатием.

Булевы флаги (конечный автомат)

У SessionEntry есть несколько булевых флагов, которые образуют простой конечный автомат: он определяет поведение сессии при следующем обращении.

ФлагТипПо умолчаниюОписание
was_auto_resetboolFalseСтавится, когда явная приостановка приводит к замене сессии. Также сохраняется в исторических записях.
auto_reset_reasonOptional[str]None"suspended" при явной приостановке; в старых записях могут остаться исторические причины сброса.
reset_had_activityboolFalseБыла ли активность в заменённой сессии.
is_fresh_resetboolFalseСтавится явными /new или /reset. При первом сообщении запускает повторное внедрение навыков темы/канала. Отделён от was_auto_reset, чтобы не показывать вводящие в заблуждение уведомления «сессия истекла».
expiry_finalizedboolFalseИсторическая ограда финализации, сохранённая для восстановления; таймеры её больше не пишут.
suspendedboolFalseСигнал жёсткой принудительной очистки. Ставится по /stop или при эскалации зациклившегося перезапуска (3+ последовательных неудачных перезапуска). При следующем get_or_create_session() принудительно создаёт новый session_id независимо от resume_pending.
resume_pendingboolFalseМаркер мягкого восстановления. Ставится recover_interrupted_turns() (восстановление после сбоя для помеченного хода без ответа) или таймаутом drain. При следующем обращении сохраняет существующий session_id — пользователь продолжает на том же транскрипте. Снимается после завершения следующего успешного хода.
resume_reasonOptional[str]NoneПочему поставлен маркер возобновления: "restart_timeout", "shutdown_timeout", "restart_interrupted".
last_resume_marked_atOptional[datetime]NoneМетка времени последней постановки resume_pending.

Логика переходов состояний (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          │
              └──────────────────────┘

Порядок приоритетов в get_or_create_session():

  1. suspended=True → всегда принудительный сброс (жёсткая очистка)
  2. resume_pending=True → сохранить session_id (мягкое восстановление)
  3. Триггеров нет → вернуть существующую запись (обновить updated_at)

3. SessionStore — хранилище и операции

SessionStore — основной слой хранения. Он держит словарь в памяти (_entries), сбрасываемый в sessions.json, а каноническим хранилищем метаданных сессий и транскриптов сообщений служит SQLite (SessionDB).

Конструктор

SessionStore(sessions_dir: Path, config: GatewayConfig, has_active_processes_fn=None)
  • sessions_dir — каталог, где лежит sessions.json.
  • config — экземпляр GatewayConfig с настройками маршрутизации и обслуживания.
  • has_active_processes_fn — необязательный колбэк по session_key, проверяющий, есть ли запущенные фоновые процессы. Сессии с активными процессами защищены от вычистки записей маршрутизации.

Операции (методы)

МетодОписание
get_or_create_session(source, force_new=False)Основная точка входа. Возвращает существующий SessionEntry или создаёт новый. Учитывает явную приостановку и состояние восстановления после перезапуска. Создаёт/завершает записи в SQLite.
update_session(session_key, last_prompt_tokens=None)Лёгкое обновление метаданных после взаимодействия. Обновляет updated_at, при необходимости записывает last_prompt_tokens.
reset_session(session_key, display_name=None)Явный сброс (по /new или /reset). Создаёт новый session_id, ставит is_fresh_reset=True. Завершает старую сессию в SQLite и создаёт новую.
switch_session(session_key, target_session_id, *, expected_session_id=None)Переключение на другой существующий ID сессии (по /resume). Завершает текущую сессию в SQLite, заново открывает целевую. С expected_session_id= перенацеливание становится операцией compare-and-swap: возвращает None и ничего не переключает, если ключ уже не указывает на эту сессию, — так вызывающий код, который разрешал по снимку через await (повторное закрепление при асинхронном делегировании, починка привязки темы Telegram), не перезапишет параллельный /new или /resume.
suspend_session(session_key)Помечает сессию как suspended=True (по /stop). Принудительный автосброс при следующем обращении.
mark_resume_pending(session_key, reason)Помечает сессию как resume_pending=True (по таймауту drain). При следующем обращении session_id сохраняется. НЕ перекрывает suspended=True.
clear_resume_pending(session_key)Снимает resume_pending после успешного возобновлённого хода. Вызывается из шлюза после возврата run_conversation().
recover_interrupted_turns(max_age_seconds)Восстановление после сбоя: переводит устойчивые маркеры активного хода, оставленные умершим процессом, в resume_pending=True (restart_interrupted). Сессии без маркера свой ход завершили, и их не трогают. Вызывается при старте после некорректного завершения.
prune_old_entries(max_age_days)Удаляет записи старше max_age_days (по updated_at). Пропускает записи suspended и сессии с активными процессами.
list_sessions(active_minutes=None)Возвращает все сессии, при желании отфильтрованные по недавней активности. Сортировка по updated_at по убыванию.
lookup_by_session_id(session_id)Находит активный SessionEntry по сохранённому ID сессии.
has_any_sessions()Проверяет, создавалась ли хоть одна сессия когда-либо (смотрит историю в SQLite, а не только словарь в памяти).
append_to_transcript(session_id, message, skip_db=False)Дописывает сообщение в транскрипт SQLite. skip_db=True предотвращает дублирующую запись, если агент уже сохранил сообщение.
rewrite_transcript(session_id, messages)Полная замена транскрипта сессии (используется /retry, /undo, /compress).
load_transcript(session_id)Загружает все сообщения из транскрипта сессии в SQLite.
rewind_session(session_id, n=1)Откатывает n пользовательских ходов мягким удалением (аудиторский след сохраняется); тонкая обёртка над SessionDB.rewind_user_turn (hermes_state_rewind.py) — тем же единственным откатом, что используют /undo//retry в CLI и TUI. Возвращает {rewound_count, turns_undone, target_text}.

Внутренние хелперы

  • _ensure_loaded() / _ensure_loaded_locked() — загружают sessions.json в словарь _entries.
  • _save() — атомарная запись в sessions.json через временный файл + atomic_replace.
  • _generate_session_key(source) — делегирует в build_session_key() с параметрами из конфига.

Структура хранилища

{sessions_dir}/
  sessions.json          # Словарь _entries из памяти, сохранённый как JSON
                           Сопоставляет session_key → SessionEntry (только метаданные)
  {session_id}.jsonl     # (Устаревшее, удалено в спецификации 002)

Каноническое хранилище транскриптов — SQLite через SessionDB (из hermes_state). Файл sessions.json хранит сопоставление session_key → session_id и метаданные записей (флаги, метки времени, счётчики токенов). Если SQLite недоступен, хранилище откатывается на JSONL, но это деградированный режим.


4. Правила генерации SessionKey

Ключи сессий — детерминированные строки, идентифицирующие полосу разговора. Их генерирует build_session_key(source, group_sessions_per_user, thread_sessions_per_user).

Формат ключа

agent:main:{platform}:{chat_type}[:{chat_id}][:{thread_id}][:{participant_id}]

Правила для личных сообщений (DM)

СценарийКлюч
DM с chat_idagent:main:telegram:dm:12345
DM с chat_id + тредagent:main:telegram:dm:12345:thread_678
DM без chat_id, с participant_idagent:main:signal:dm:user_abc
DM без chat_id и participant_idagent:main:telegram:dm
DM в WhatsApp (канонизированный)agent:main:whatsapp:dm:{canonical_number}
  • DM всегда включают chat_id, когда он есть, — так каждый приватный разговор изолирован.
  • thread_id дополнительно разделяет тредовые DM внутри одного DM-чата.
  • Без chat_id в качестве participant_id берётся user_id_alt или user_id.
  • Без какого-либо идентификатора все DM на этой платформе схлопываются в одну общую сессию.

Правила для групп и каналов

СценарийКлюч
Групповой чатagent:main:telegram:group:-10012345
Групповой чат, изоляция по пользователямagent:main:telegram:group:-10012345:user_abc
Тред в группе, общийagent:main:discord:group:12345:thread_678
Тред в группе, по пользователямagent:main:discord:group:12345:thread_678:user_abc
Каналagent:main:slack:channel:C12345
Группа WhatsApp (канонизированная)agent:main:whatsapp:group:{canonical_id}:{participant}
  • chat_id идентифицирует родительскую группу или канал.
  • thread_id различает треды внутри этого родителя.
  • Изоляцией по пользователям (добавление participant_id) управляют:
    • group_sessions_per_user (по умолчанию True) — сессии групп и каналов изолированы.
    • thread_sessions_per_user (по умолчанию False) — треды по умолчанию общие (темы форумов Telegram, треды Discord и Slack делят одну сессию на тред).
  • participant_id = user_id_alt или user_id (в таком порядке приоритета).
  • Идентификаторы WhatsApp канонизируются, чтобы переживать переключения алиасов JID/LID.

Особый случай: WhatsApp

Телефонные номера WhatsApp проходят через canonical_whatsapp_identifier(), которая отрезает суффикс @s.whatsapp.net и нормализует номер в формат E.164. Это предотвращает фрагментацию сессий, когда мост возвращает разные формы алиаса одного и того же номера.


5. Стратегия изоляции пользователей

Изоляция пользователей определяет, делят ли несколько пользователей одного чата общий разговор или каждый получает собственную приватную сессию.

Логика решения (is_shared_multi_user_session)

def is_shared_multi_user_session(source, *, group_sessions_per_user, thread_sessions_per_user):
    if source.chat_type == "dm":
        return False  # DM всегда приватны
    if source.thread_id:
        return not thread_sessions_per_user  # Треды: общие, если не включена изоляция по пользователям
    return not group_sessions_per_user       # Группы: изолированы, если не включён общий режим

Сводка

Тип чатаПо умолчаниюНастройка
DMПриватный (никогда не общий)Нет
Группа/каналИзоляция по пользователямgroup_sessions_per_user (по умолчанию True)
Тред (форум, discord)Общий (все участники видят один контекст)thread_sessions_per_user (по умолчанию False)

Влияние на системный промпт

При shared_multi_user_session=True системный промпт не содержит фиксированного имени пользователя, а вместо этого сообщает: «Multi-user {thread|session} — messages are prefixed with [sender name]. Multiple users may participate.» Имена отправителей шлюз подставляет префиксом к каждому пользовательскому сообщению во время выполнения — так сохраняется кэширование промптов (системный промпт не меняется от хода к ходу).


6. Явные границы разговора

Бездействие и время по часам никогда не ротируют разговор. Явную границу создают /new и /reset; длинные истории по-прежнему обслуживает сжатие контекста. Устаревшая конфигурация таймеров игнорируется.

Явная приостановка по-прежнему создаёт границу при следующем входящем ходе. Восстановление уважает явные и исторические финализированные границы, а не открывает их заново. Вытеснение только по ресурсам и сбор осиротевших WebSocket-соединений оставляют разговоры возобновляемыми.


7. Восстановление после перезапуска

Система восстановления после перезапуска гарантирует, что сессии с незавершёнными ходами переживут перезапуски шлюза, сбои и таймауты drain. Это решение issue #7536.

Последовательность восстановления при старте

Gateway starts
       │
       ▼
┌───────────────────────────────┐
│ Check for .clean_shutdown     │── Exists? ──► Skip suspension (clean exit)
│ marker                        │
└───────────────────────────────┘
       │ Missing
       ▼
┌───────────────────────────────┐
│ _recover_unclean_sessions()   │── Marked turn with a persisted reply
│                               │   → delivery ledger (sent, marked);
│                               │   marked turn without one →
│                               │   resume_pending (once)
└───────────────────────────────┘
       │
       ▼
┌───────────────────────────────┐
│ _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       │
└───────────────────────────────┘

Восстановление после сбоя (_recover_unclean_sessions)

Вызывается при старте шлюза, если нет маркера .clean_shutdown (сбой или неожиданный выход). Работает только с устойчивыми маркерами активного хода и никогда — по давности активности: чат, который просто был активен незадолго до сбоя, свой ход завершил, и повторного ответа не получит.

Маркер ставится, когда ход начинается, и держится, пока финальный ответ не попадёт в журнал доставки (адаптер снимает его сразу после record_delivery_obligation) или пока не останется ничего недоставленного (потоковый ответ, подавленный или пустой ответ). Поэтому маркер, оставшийся к старту, означает одно из двух:

  • Ответ сохранён, но в журнал не попал. Сохранённый в транскрипте ответ записывается в журнал как строка без владельца, а маркер снимается; проход при загрузке доставляет его один раз с пометкой «Recovered reply». Ход заново не генерируется. Ответ оценивается так же, как его оценила бы живая доставка: голый маркер молчания ([SILENT], NO_REPLY, …) во внутреннем ходе или ответ на диагностическое пробуждение, которое правила чата глушат, ничего не требуют (маркер снимается, ничего не отправляется и не возобновляется). Голый маркер молчания в ходе человека превращается в то же уведомление «returned only a silence marker», которое отправляет живой путь.
  • Ответ не сохранён. recover_interrupted_turns() ставит resume_pending=True, resume_reason="restart_interrupted", и ход один раз автоматически возобновляется.

Время начала маркера хранится как UTC с часовым поясом и сравнивается в секундах эпохи, поэтому перезапуск в другом локальном поясе (переход на летнее время, TZ контейнера против юнита) не отбрасывает свежий маркер как устаревший и не принимает ответ прошлого хода за ответ текущего. Маркер, записанный старой сборкой (наивное локальное время), читается как локальное время хоста.

Ход, который уже есть в журнале, заново доставляет проход по журналу, и он же снимает resume_pending для этой сессии, так что ход никогда не будет и доставлен, и отвечен повторно.

Обнаружение зацикленных перезапусков (_suspend_stuck_loop_sessions)

Считает последовательные перезапуски через JSON-файл ({HERMES_HOME}/restart_counts.json). Если сессия оставалась активной через 3+ перезапуска подряд, она автоматически приостанавливается, чтобы пользователь получил чистый лист.

Пометка по таймауту drain

При штатном завершении или перезапуске система drain вызывает mark_resume_pending() для каждой сессии, которая была посреди хода, когда сработал таймаут drain. Причины:

  • "restart_timeout" — убита во время drain при перезапуске
  • "shutdown_timeout" — убита во время drain при завершении
  • "restart_interrupted" — восстановление после сбоя для помеченного хода без ответа (из recover_interrupted_turns)

Все три причины входят в _AUTO_RESUME_REASONS и дают право на автовозобновление при старте.

Автовозобновление при следующем обращении

Когда get_or_create_session() встречает resume_pending=True:

  1. Она возвращает существующую запись, не создавая новый session_id.
  2. Существующий транскрипт загружается целиком.
  3. Пометка здесь не снимается — она живёт до завершения следующего успешного хода (clear_resume_pending() вызывается из шлюза после того, как run_conversation() вернёт настоящий ответ).
  4. Если возобновлённый ход снова прервётся, флаг resume_pending остаётся, и следующий перезапуск повторит попытку. Терминальной эскалацией занимается счётчик зацикленных перезапусков (3 попытки → suspended).

Маркер чистого завершения (.clean_shutdown)

Пишется в конце штатного завершения. При следующем старте:

  • Если он есть: восстановление после сбоя полностью пропускается, а осиротевшие маркеры ходов отбрасываются. Активные агенты уже были корректно завершены (drain), так что застрявших сессий нет.
  • Затем маркер удаляется.

Это предотвращает нежелательные автосбросы после hermes update, hermes gateway restart или /restart.


8. Очередь сообщений

Система очередей сообщений обрабатывает два сценария:

  1. Дополнения во время работы — когда пользователь шлёт несколько сообщений, пока агент обрабатывает запрос, последующие сообщения ставятся в очередь как одиночный слот ожидания.
  2. FIFO по /queue — явные команды /queue, каждая из которых должна дать собственный полный ход агента, по порядку, без слияния.

Структуры данных

adapter._pending_messages: Dict[session_key, MessageEvent]
    └── Один слот «следующее» на сессию. Перезаписывается при повторных
        отправках (схлопывание серии). Общий с дополнениями к серии фото.

self._queued_events: Dict[session_key, List[MessageEvent]]
    └── Буфер переполнения. Каждый вызов /queue дописывает сюда, когда
        слот занят. Продвигается по одному после каждого drain.

Постановка в очередь (_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)

Извлечение / продвижение (_promote_queued_event)

Вызывается в точке drain после того, как слот был потреблён. Если в буфере переполнения есть элемент:

  • Когда pending_event is None (слот был пуст), голова буфера возвращается как новое событие.
  • Когда pending_event есть, голова буфера ставится в слот для следующей рекурсии.
  • Если адаптер недоступен, элемент возвращается обратно в _queued_events (без тихой потери).

Глубина очереди

_queue_depth(session_key, adapter) возвращает len(overflow) + (1 if slot occupied else 0).

Очистка

События в очереди сессии очищаются по /new и /reset (через _handle_reset_command). /stop сбрасывает одноместное продолжение, которое пользователь отправил во время прерванного хода. Внутреннее пробуждение, припаркованное в любом из хранилищ (уведомление о завершении асинхронного делегирования, notify+wake от kanban/cron), переживает все три команды: _interrupt_and_clear_session оставляет его в слоте (вытаскивая из переполнения, если слот занимало отброшенное продолжение от человека), так что проход после команды сразу его запускает, а не оставляет сессию простаивать до следующего сообщения пользователя. Может ли ещё выполниться пробуждение, привязанное к сессии, которую только что закрыл /new, решается в момент обработки (_resolve_async_delegation_session, fail-closed).

Обе команды к тому же завершают фоновые делегирования сессии (tools.async_delegation. interrupt_for_session, выбор по ключу маршрутизации и по устойчивому id сессии породившего): _interrupt_and_clear_session рассылает остановку для пути занятой сессии, а _handle_stop_command делает то же для простаивающей сессии, чей диспетчеризующий ход уже закончился (и отвечает «Stopped», а не «No active task to stop»). Собственное жёсткое прерывание хода до этих единиц не доходит — при диспетчеризации их отцепляют от _active_children, — так что без рассылки они доработали бы до конца и разбудили бы чат через несколько минут. Каждая остановленная единица всё равно штатно завершается и возвращается уведомлением о завершении со status="interrupted" и частичным выводом дочернего агента. /new и /reset уже делали это в _handle_reset_command; более ранний вызов общего помощника там идемпотентен (жёсткое прерывание, запрошенное дважды, — это одна остановка).

Инвариант FIFO

Каждый вызов /queue даёт ровно один полный ход агента, в порядке FIFO, без слияния. Схема «одиночный слот _pending_messages + буфер переполнения _queued_events» гарантирует, что повторные отправки во время активного хода не нарушат порядок обработки.


9. Внедрение контекста сессии

SessionContext строится из SessionSource и GatewayConfig и внедряется в системный промпт агента. Он сообщает агенту:

  • откуда пришло текущее сообщение;
  • какие платформы подключены;
  • куда можно доставить результаты запланированных задач;
  • является ли сессия общей многопользовательской.

Построение (build_session_context)

def build_session_context(source, config, session_entry=None) -> SessionContext
  1. Собирает подключённые платформы из конфига.
  2. Собирает домашние каналы для каждой платформы.
  3. Определяет shared_multi_user_session через is_shared_multi_user_session().
  4. Прикрепляет метаданные сессии (ключ, id, метки времени), если передан session_entry.

Маскирование PII (build_session_context_prompt)

Динамическая секция системного промпта (## Current Session Context) может по желанию маскировать персональные данные перед отправкой в LLM:

  • ID пользователей → user_<12hex> (префикс SHA-256)
  • ID чатов → <platform>:<12hex> или просто <12hex>
  • Платформы, исключённые из маскирования: Discord (нужны сырые ID для @mentions) и любая платформа, зарегистрированная плагином и не помеченная как pii_safe.

Маскирование касается только текста системного промпта. Маршрутизация, ключи сессий и операции адаптеров всегда используют исходные значения.


10. Фоновое обслуживание

_session_housekeeping_watcher периодически выметает простаивающих кэшированных агентов, сбрасывает записи кэша при нехватке памяти и раз в час вычищает старые записи маршрутизации. Из-за бездействия или времени суток он транскрипт никогда не завершает.

Вытеснение по TTL, LRU и давлению памяти сначала фиксирует живой транскрипт в провайдерах памяти, затем мягко освобождает клиентов. Активные ходы остаются под защитой; ресурсы терминала, браузера и фоновых процессов мягкое освобождение переживают. Вычистка записей маршрутизации сохраняет канонический транскрипт в SQLite, а живые процессы защищают свои записи маршрутизации от вычистки. Исторические флаги expiry_finalized остаются оградами для восстановления, но таймерный наблюдатель их больше не пишет.


11. Кэш агентов

Шлюз держит LRU-кэш экземпляров AIAgent с ключом session_key, чтобы сохранять кэширование промптов между ходами.

Свойства кэша

  • Максимальный размер: 128 записей (agent.agent_cache.max_size, по умолчанию _AGENT_CACHE_MAX_SIZE).
  • Политика вытеснения: давно не использовавшиеся (LRU через OrderedDict).
  • TTL простоя: 3600 с (1 ч) — agent.agent_cache.idle_ttl_secs, применяет _session_housekeeping_watcher.
  • Бюджет памяти: agent.agent_cache.memory_high_mb (по умолчанию auto) — см. ниже.
  • Блокировка: _agent_cache_lock (threading) для потокобезопасности.

Вытеснение при нехватке памяти

Кэшированный агент удерживает _session_messages — полный живой транскрипт вместе с выводом инструментов, а это десятки МБ на сессии со 100+ вызовами инструментов. Лимит числа записей и TTL простоя этого не видят: шлюз, обслуживающий много чатов, держит в памяти каждый «тёплый» транскрипт (агенты, сделавшие ход в пределах TTL, никогда не выметаются как простаивающие), так что RSS растёт, пока cgroup не начнёт троттлить, а SIGTERM уже не успеет сбросить данные в пределах stop-таймаута systemd (#80764).

Клапаном служит _sweep_agent_cache_under_pressure(). На каждом тике наблюдателя она сравнивает анонимную память с memory_high_mb — берётся anon из memory.stat самой cgroup, если шлюз работает под лимитом cgroup (это область, на которую списывается бюджет, поэтому потомки в том же юните, например ядра execute_code, тоже считаются; #110549), иначе анонимный RSS самого процесса. При превышении бюджета она вытесняет LRU-агентов через тот же мягкий путь, что и ограничитель по числу записей (_commit_then_release_soft), а затем вызывает malloc_trim, чтобы освобождённые арены действительно вернулись ОС. Вытесненные сессии восстанавливают транскрипт из сохранённой сессии при следующем ходе.

Три класса сессий никогда не сбрасываются:

  • агенты посреди хода (их клиенты и песочницы заняты);
  • protect_recent самых недавно использованных сессий (их кэш промптов ценнее всего);
  • любая сессия, чей живой транскрипт ещё не дошёл до диска целиком — transcript_persistence_caught_up() сравнивает _last_flushed_db_idx с len(_session_messages); на то же расхождение реагирует защита от порчи записи FTS, когда сохраняет живую историю вместо отстающего транскрипта.

memory_high_mb: auto выводит бюджет из лимита cgroup, под которым работает шлюз (memory.high, затем memory.max, затем cgroup v1), а без лимита берёт общий объём RAM. Задайте число, чтобы зафиксировать бюджет, или 0/off, чтобы отключить этот проход целиком. Хелперы лежат в gateway/agent_cache_pressure.py.

Жизненный цикл кэша

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
    │
    ▼
Housekeeping soft-releases idle agents without ending transcripts

Порядок очистки

Вытеснение по ресурсам убирает кэшированного агента и фиксирует память перед мягким освобождением клиентов. Полный демонтаж _cleanup_agent_resources(agent) приберегается для настоящих границ разговора и завершения работы.


Приложение: ключи конфигурации

Ключ конфигаТипПо умолчаниюОписание
group_sessions_per_userbooltrueИзолировать сессии групп/каналов по пользователям
thread_sessions_per_userboolfalseИзолировать сессии тредов по пользователям
session_store_max_age_daysint0Вычищать сессии старше N дней (0 = отключено)
agent.gateway_auto_continue_freshnessint3600Окно свежести для возобновления, в секундах
agent.gateway_timeoutint1800Таймаут хода агента (по умолчанию 30 мин)
agent.agent_cache.max_sizeint128Лимит LRU-записей кэшированных AIAgent
agent.agent_cache.idle_ttl_secsint3600Вытеснять агентов, простаивающих дольше этого
agent.agent_cache.memory_high_mbint/strautoБюджет анонимного RSS, выше которого сбрасываются LRU-транскрипты
agent.agent_cache.max_evictions_per_passint16Максимум сессий, сбрасываемых за один проход при нехватке памяти
agent.agent_cache.protect_recentint8Число MRU-сессий, которых проход при нехватке памяти не трогает

База состояния и восстановление FTS

Канонический транскрипт живёт в таблицах sessions и messages. Таблицы FTS5 и их триггеры синхронизации — производные индексы, которые можно отсоединить и перестроить, не удаляя канонических сообщений. Ограниченный режим отказа в работе и явная процедура восстановления описаны в Восстановление базы состояния.

Время жизни разговора

Настройки сброса по бездействию или по расписанию на день не поддерживаются. Явные /new и /reset, компактизация, приостановка и восстановление после сбоя сохраняют свои отдельные роли в жизненном цикле.

перевод и редактура:

обновлено

⌕ ESC