Files
obsidian-vault/personal/projects/hermes/hermes-gateway-stream-consumer-text-loss.md
T

12 KiB
Raw Blame History

tags
tags
hermes
gateway
stream-consumer
bug

Hermes Gateway Stream Consumer — потеря текста ответов

Ключевой вывод (2026-06-19): DeepSeek не возвращает interim commentary между tool calls Причина отсутствия комментариев в Zulip между тулами — не в gateway, а в том что DeepSeek (с cache hit 95-100%) при каждом tool call возвращает только tool_calls, без content. interim_assistant_callback вызывается только когда есть текст между тулами. DeepSeek этого текста не выдаёт.

UPDATED FINDINGS: Ответы ДОХОДЯТ в Zulip. Проблема не в полной потере, а в:

  1. Дублирование текста_accumulated не очищается после _send_or_edit с got_done=True, затем got_done блок (строки 607-619) снова отправляет то же самое
  2. Подавление финального sendcontent_delivered=True устанавливается после first-send, gateway подавляет нормальную отправку (но first-send уже отправил текст, так что ответ доходит)
  3. Partial на \n отправляет пустые чанки — первый \n в начале текста триггерит send пустой строки

Что не так:

  • В no-edit ветке, got_done=True:
    1. _should_send=True_send_or_edit(accumulated) — отправляет весь текст
    2. Строка 596: if _sent_len and not got_doneFalse (got_done=True) → аккумулятор НЕ очищен
    3. Строка 607: if self._accumulated: → True (старый текст всё ещё там)
    4. Строка 618-619: _final_content_delivered = True
    5. Gateway видит content_delivered и подавляет fallback send — но текст уже отправлен

Почему пользователь видит "только tool calls":

  • В webhook режиме user может видеть tool calls мгновенно, а текстовый ответ приходит пачкой позже — потому что весь текст накапливается до got_done
  • Между partial sends и tool calls нет синхронизации — текст может прийти после следующего tool call

Симптом

При ответе на webhook-сообщение (Zulip → zulip-router → webhook POST на whale gateway → обработка → ответ) часть или весь текстовый ответ агента не доставляется в чат. Пользователь видит только tool calls, approval запросы, и иногда первые несколько символов текста. Полноценный текстовый ответ пропадает.

Детектированная проблема

Механизм

Gateway настроен со streaming для webhook:

  • display.platforms.webhook.streaming: true (из конфига)
  • adapter_supports_edit=False для webhook (задано кодом)

Stream consumer (no-edit branch) должен накапливать текст и отправлять частями по \n с rate-limit, затем на got_done — отправить остаток целиком.

Что происходит на самом деле

Паттерн из лога (все ответы):

  1. first-send: text_len=N text=[...полный текст ответа...] — stream consumer получает весь текст
  2. Suppressing normal final send ... streamed=True previewed=False content_delivered=True — gateway подавляет финальную отправку
  3. В Zulip приходит только обрезанная версия или ничего

Ключевая строка лога:

2026-06-19 17:17:20,668 INFO gateway.run: Suppressing normal final send for session ...: streamed=True previewed=False content_delivered=True

content_delivered=True устанавливается СЛИШКОМ РАНО — до того как весь текст отправлен.

Root Cause

В stream_consumer.py в no-edit branch:

  1. Первый \n в начале ответа (пустая строка перед началом текста) триггерит send через _send_or_edit
  2. Этот send отправляет \n\n (пустоту или пару символов) в webhook
  3. После отправки через webhook, stream consumer помечает content_delivered=True
  4. Когда приходит got_done — остальной огромный текст тоже отправляется, НО
  5. Gateway в run.py видит streamed=True AND content_delivered=True и ПОДАВЛЯЕТ свою нормальную финальную отправку, думая что контент уже доставлен стримером

В итоге: первый пустой/короткий \n чанк — единственное что доставляется. Весь реальный контент теряется в подавлении.

Почему раньше не было видно

Ранее гейтвей по умолчанию не имел streaming для webhook. Когда streaming включили (для фикса tool progress), стали видны эти проблемы:

  • streaming активируется
  • первый \n отправляет пустоту
  • content_delivered=True
  • got_done подавлен
  • текст пропадает

Подтверждение из лога

Все ответы после последнего рестарта (17:13:06, 17:16:55):

  • 17:13:44 → first-send text_len=44 → Suppressing ... content_delivered=TrueТы не видел моего сообщения
  • 17:14:09 → first-send text_len=71 → Suppressing ... content_delivered=True → Видел обрывок
  • 17:14:22 → first-send text_len=223 → Suppressing ... content_delivered=True → Видел
  • 17:15:13 → first-send text_len=335 → Suppressing ... content_delivered=True → Видел
  • 17:16:30 → first-send text_len=439 → Suppressing ... content_delivered=True → Видел
  • 17:17:20 → first-send text_len=356 → Suppressing ... content_delivered=TrueОбрезано
  • 17:18:16 → first-send text_len=785 → Suppressing ... content_delivered=TrueНе дошло
  • 17:19:02 → first-send text_len=780 → Suppressing ... content_delivered=TrueНе дошло
  • 17:21:10 → first-send text_len=1314 → Suppressing ... content_delivered=TrueНе дошло

Из лога: все большие ответы (>300 символов) подавляются. Маленькие (<200 символов) проходят.

Сравнение Zulip DB с логом

Последние сообщения Кит в треде (из Zulip DB):

  • msg 70116: " Command approved" — это approval ответ, маленький
  • msg 70115-70107: tool calls (терминал, read_file)
  • msg 70106: read_file

Ни одного текстового ответа больше 100 символов! Все они были подавлены гейтвеем.

Фикс

Два варианта:

Вариант A: Не подавлять финальную отправку для webhook (no-edit адаптер)

В run.py, где проверяется content_delivered:

# Не подавлять, если адаптер не поддерживает редактирование
# (webhook не может отправлять partials — только один финальный ответ)
if streamed and content_delivered and adapter_supports_edit:
    suppress = True

Либо: не устанавливать content_delivered=True для no-edit адаптера вообще — пусть всегда проходит через нормальный send.

Вариант B: Не слать первый \n чанк если текст пустой/минимальный

# Не отправлять partial если это просто \n без реального контента
if _has_nl and len(self._accumulated.strip()) < 3:
    # Пропустить — это просто форматирование, не настоящий контент
    continue

Но вариант A надёжнее — он не полагается на эвристику.

DeepSeek commentary — руководство по диагностике (2026-06-19)

Когда текст агента не доходит между tool calls

ПЕРВОЕ что проверять: какой провайдер используется.

DeepSeek (особенно с cache hit 95-100%) не генерирует .content между tool calls — он возвращает только tool_calls. Gateway вызывает interim_assistant_callback ТОЛЬКО когда в ответе есть .content. Если content пустой — commentary не шлётся.

Это особенность модели, не баг gateway.

Где смотреть в логе

Лог: ~/.hermes/hermes-whale/logs/gateway.log

Искать размер ответа у каждого API call:

grep -n 'Received' ~/.hermes/hermes-whale/logs/gateway.log | tail -20

Пример — commentary отправлен (out > 0):

Received 31 additional tokens (total 31, out=31) in session ...

Пример — только tool call (out < 200, cache 100%):

Received 153 tokens (total 3787, out=153, 100% cache) ...
Received 4 tokens (total 7811, out=4, 100% cache) ...

Эвристика:

  • out > 300 — commentary + tool calls (есть текст)
  • out < 200 + 100% cache — чистый tool call, commentary не было

Как проверить в Zulip DB

SELECT id, content, sender_id FROM zulip_message
WHERE subject = 'Название треда'
ORDER BY id DESC LIMIT 20;

Сопоставить: сколько API calls → сколько сообщений в Zulip.

Где смотреть в коде

Файл Что искать
gateway/run.py:~16960 interim_assistant_callback — вызывается только если .content is not None
gateway/run.py:~16997 source.platform != Platform.WEBHOOK — был гард для webhook (исправлен)
gateway/stream_consumer.py _send_or_edit и _has_nl — отправка commentary (исправлено подавление)
gateway/adapters/webhook.py _delivery_info инстанс-переменная — терялась при рестарте (исправлена)

Сводка исправлений (2026-06-19)

Файл Исправление
gateway/run.py:16997 Убран source.platform != Platform.WEBHOOK — interim messages разрешены для webhook
gateway/stream_consumer.py Commentary отправляется независимо от \n в no-edit ветке
gateway/adapters/webhook.py delivery_info восстанавливается из route config при рестарте

Важно: все эти фиксы не влияют на DeepSeek commentary — DeepSeek не шлёт .content между tool calls независимо от gateway.

Если проблема появится с другим провайдером (Claude, GPT) — надо смотреть interim_assistant_callback в run.py.