--- 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. **Подавление финального send** — `content_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_done` → **False** (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`: ```python # Не подавлять, если адаптер не поддерживает редактирование # (webhook не может отправлять partials — только один финальный ответ) if streamed and content_delivered and adapter_supports_edit: suppress = True ``` Либо: не устанавливать `content_delivered=True` для no-edit адаптера вообще — пусть всегда проходит через нормальный send. ### Вариант B: Не слать первый \n чанк если текст пустой/минимальный ```python # Не отправлять 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: ```bash 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 ```sql 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`.