[2026-06-21] taiga-vault: family/how-to/htpc-emulators-setup.md personal/agent/Phase 2 — Approval Reactions.md personal/creative/songs/Рыба.md personal/projects/balda/balda-valera.md personal/projects/balda/fast-rlm-integration.md personal/projects/hermes/hermes-gateway-stream-consumer-text-loss.md personal/projects/hermes/no-edit-segment-send-bug.md personal/projects/zulip-router-approval-reactions.md personal/projects/zulip-router.md
This commit is contained in:
@@ -50,6 +50,66 @@ runtime:
|
||||
|
||||
Раньше не работало — исправлено в upstream `32e33a8 fix: support hosted MCP toolsets` / `d774c78 fix: use hosted llmagent sessions`.
|
||||
|
||||
## Проблема: MCP не работают у Валеры (provider: type=openai)
|
||||
|
||||
### Коренная причина
|
||||
hostedagent (`pkg/runtime/hostedagent/`) не поддерживает MCP инструменты.
|
||||
`openAIConstructor` в `agentfactory.go` получает `resolvedMCP map[string]agentconfig.MCPServerConfig`,
|
||||
но не передаёт их в `hostedagent.Config` — у Config нет поля для MCP.
|
||||
`openAIConstructor` вызывает `newHostedAgent(hostedagent.Config{...})` без MCPServers.
|
||||
|
||||
Фикс: в `hostedagent.Config` добавить `MCPServers map[string]agentconfig.MCPServerConfig`,
|
||||
а в `openAIConstructor` передавать `toRuntimeMCPServers(resolvedMCP)`.
|
||||
|
||||
Важно: hostedagent использует OpenAI-compatible API (не ACP). MCP инструменты нужно
|
||||
интегрировать через OpenAI tool_calls — модель шлёт tool_call, код выполняет MCP вызов.
|
||||
|
||||
## Проблема: MCP не работают у Валеры — попытка фикса
|
||||
|
||||
### Что было сделано (2026-06-19)
|
||||
|
||||
#### 1. go.mod
|
||||
Добавлен `replace github.com/normahq/norma => ../norma-local` — для локальной разработки.
|
||||
|
||||
#### 2. hostedagent — добавлена поддержка MCP
|
||||
- `pkg/runtime/hostedagent/agent.go`:
|
||||
- В Config добавлено `MCPServers map[string]acpagent.MCPServerConfig`
|
||||
- Сохранено в `runtimeAgent`
|
||||
- В `run()`: после создания модели вызывается `model.SetTools(openAIToolsFromMCPServers(r.MCPServers))`
|
||||
|
||||
- `pkg/runtime/hostedagent/openai.go`:
|
||||
- В `openAIChatRequest` добавлено `Tools []openAIToolDefinition`
|
||||
- В `OpenAIModel` добавлены `tools []openAIToolDefinition + `SetTools()`
|
||||
- Добавлен `tool_calls` в парсинг ответа (`tool_calls` → content string)
|
||||
|
||||
#### 3. agentfactory — передача MCP
|
||||
- В `openAIConstructor` добавлено `MCPServers: toRuntimeMCPServers(resolvedMCP)` в вызов `newHostedAgent()`
|
||||
|
||||
#### 4. Dockerfile
|
||||
В `Dockerfile.claudio` добавлен `COPY norma-local/ ./norma-local/` — чтобы `replace` работал в контейнере.
|
||||
|
||||
#### 5. Сборка
|
||||
Образ собран и запущен как `balda-agent-valera:latest`.
|
||||
|
||||
#### 6. Диагностика
|
||||
- `mcpServerIDs` приходят корректно: `[balda obsidian fast-rlm]`
|
||||
- Добавлен stderr-лог в `agentfactory.go` в `Build()`:
|
||||
```go
|
||||
fmt.Fprintf(os.Stderr, "BALDA-DEBUG: mcpServerIDs=%v resolvedMCP=%v\n", mcpServerIDs, resolvedMCP)
|
||||
```
|
||||
- Для просмотра логов нужен `docker logs balda-agent-valera-1 2>&1 | grep BALDA-DEBUG`
|
||||
|
||||
### Почему не заработало
|
||||
Точная причина не установлена — нужно увидеть `resolvedMCP` в логах контейнера. Возможные варианты:
|
||||
- `resolveMCPServers` возвращает пустой map
|
||||
- `toRuntimeMCPServers` неправильно конвертирует
|
||||
- `SetTools` не влияет на уже созданные сообщения в сессии
|
||||
|
||||
### Что осталось
|
||||
1. Прочитать stderr из контейнера: `docker logs balda-agent-valera-1 2>&1 | grep BALDA-DEBUG`
|
||||
2. Если `resolvedMCP` пустой — исправлять цепочку resolveMCPServers
|
||||
3. Если не пустой — тестировать response с tool_calls в DeepSeek ответе
|
||||
|
||||
## Диагностика молчания
|
||||
|
||||
### 1. MCP ошибка: `failed to list MCP tools: failed to connect: Not Found`
|
||||
|
||||
@@ -113,12 +113,20 @@ mcp_servers:
|
||||
7. Идея использовать OpenRouter (`z-ai/glm-5`) как дефолт — отвергнута: используем DeepSeek как у Балды/Whale.
|
||||
8. Утверждение про "актуализированную доку" 16 июня — на хост-FS дока НЕ менялась (правки шли в контейнерную FS). Реальная актуализация через `mcp_obsidian_write_note` — 2026-06-16.
|
||||
|
||||
## Что НЕ делаем без явного подтверждения
|
||||
- Не пушить изменения в git.
|
||||
- Не правим config.yaml до согласования итогового формата секции `fast-rlm`.
|
||||
- Не рестартим Балду без согласования.
|
||||
## Текущий статус (2026-06-19)
|
||||
### ✅ Сделано
|
||||
- Шаги 1–4: образ собран, контейнер `fast-rlm-mcp` Up 2 дня
|
||||
- `fast-rlm-mcp` в сети `balda_default`, доступен из Валеры по DNS
|
||||
- MCP HTTP сервер работает, initialize/tools/list отвечают
|
||||
- Валера (`balda-agent-valera-1`) запущен после простоя — отвечает 401 на webhook, 404 на корень
|
||||
- **fast-rlm добавлен в `config.yaml` Балды** — и в `runtime.mcp_servers`, и в `balda.mcp_servers`
|
||||
- Балда рестартнута
|
||||
|
||||
### ❓ Не проверено end-to-end
|
||||
- Нужен реальный вызов `recursive_context_compress` из Валеры (через Zulip @Валера)
|
||||
- В логах старта Валеры нет явного "MCP connected: fast-rlm" — возможно коннект ленивый
|
||||
|
||||
## История
|
||||
- **2026-06-16 ~14:00**: создан после серии фабрикаций и компакций контекста. План построен с явными точками реальной проверки на каждом шаге.
|
||||
- **2026-06-16 ~16:00**: пользователь поправил — провайдер LLM = DeepSeek (как у Балды/Whale), не OpenAI/OpenRouter.
|
||||
- **2026-06-16 ~17:00**: актуализация через `mcp_obsidian_write_note` — добавлены секции "Провайдер LLM" и "Подтверждённый API", compose v1 вместо v2, сеть `balda_default` вместо `balda-agent_default`, файл `.env` в список, Deno в Dockerfile, галлюцинации 5–8.
|
||||
- **2026-06-16 ~14:00**: создан
|
||||
- **2026-06-16 ~17:00**: актуализация
|
||||
- **2026-06-19 ~03:47**: Валера запущен, fast-rlm добавлен в config.yaml и рестартнут
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
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`.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
tags:
|
||||
- hermes
|
||||
- bug
|
||||
- gateway
|
||||
- stream_consumer
|
||||
- fixed
|
||||
---
|
||||
# No-edit (webhook) segment send bug
|
||||
|
||||
**Дата:** 20.06.2026
|
||||
**Файл:** `gateway/stream_consumer.py` (lines 574, 581, 591)
|
||||
**Статус:** ✅ Исправлено, закоммичено
|
||||
|
||||
## Симптом
|
||||
|
||||
При webhook-доставке (Zulip, Telegram через no-edit) текст, который модель генерирует между tool calls (перед tool call, после возврата результата), **не отправлялся промежуточно**. Весь текст накапливался в `_accumulated` и улетал единым куском только на `got_done`. Пользователь видел только tool calls и финальный ответ.
|
||||
|
||||
## Root Cause
|
||||
|
||||
В `stream_consumer.py` в `adapter_supports_edit=False` ветке:
|
||||
|
||||
```python
|
||||
# Строка 574 — ДО (баг):
|
||||
_has_nl = "\\\\n" in self._accumulated # ищет буквальный \n (2 символа: \ + n)
|
||||
|
||||
# Строка 581 — ДО (баг):
|
||||
_should_send = _has_nl and _can_send # segment_break не отправляет без \n
|
||||
|
||||
# Строка 591 — ДО (баг):
|
||||
_nl_pos = self._accumulated.rfind("\\\\n") # ищет 2 символа вместо 1
|
||||
```
|
||||
|
||||
`"\\\\n"` в Python-файле — это **4 слеша**: Python видит `\\n` (строка из 2 символов: `\` + `n`). А `_accumulated` содержит реальные символы newline (ASCII 10, один символ). Проверка всегда возвращала `False`, поэтому:
|
||||
- `_has_nl = False` → `_should_send = False` (даже с `got_segment_break=True`)
|
||||
- Текст копился до `got_done`
|
||||
|
||||
## Фикс
|
||||
|
||||
```python
|
||||
# Строка 574 — ПОСЛЕ:
|
||||
_has_nl = "\\n" in self._accumulated # Python escape для newline
|
||||
|
||||
# Строка 581 — ПОСЛЕ:
|
||||
_should_send = _can_send # segment break/got_done всегда отправляют
|
||||
|
||||
# Строка 591 — ПОСЛЕ:
|
||||
_nl_pos = self._accumulated.rfind("\\n") # Python escape для newline
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
Новый файл: `tests/gateway/test_stream_consumer_no_edit.py`
|
||||
|
||||
Три теста:
|
||||
1. `test_no_edit_sends_text_across_segment_breaks` — текст с \n, 3 сегмента → 3 sends
|
||||
2. `test_no_edit_sends_text_without_newline_on_segment_break` — текст без \n, segment_break → отправляет
|
||||
3. `test_no_edit_multiple_segments_are_separate_messages` — 4 сегмента → 4 sends
|
||||
|
||||
Все 92 существующих теста stream_consumer проходят зелеными.
|
||||
|
||||
## Верификация
|
||||
|
||||
После фикса тест с 3x вызова терминала + промежуточные фразы — все сообщения дошли до пользователя.
|
||||
@@ -347,13 +347,23 @@ if result.get("interrupted"):
|
||||
**Изменяет:** Только сбор и отправку tool outputs после interrupt.
|
||||
**Не изменяет:** conversation_loop.py, run_agent.py, stream_consumer.py, approval.py.
|
||||
|
||||
### Баг 2: Стриминг не работает
|
||||
### Баг 2: Стриминг — partial не работали
|
||||
|
||||
Hermes не стримит ответ через webhook — агент отвечает одним блоком после завершения генерации. Пользователь не видит процесс печати.
|
||||
**Проблема (оригинал):** WebhookAdapter не имеет `SUPPORTS_MESSAGE_EDITING` — stream_consumer не знал что редактирование не поддерживается. Consumer пытался edit, падал в fallback. Курсор `▉` оставался в финальном ответе.
|
||||
|
||||
**Root cause:** Webhook-транспорт не поддерживает стриминг — ответ формируется целиком и шлётся одним POST-запросом в Zulip через роутер.
|
||||
**Фикс (коммиты `fa271d2d1`, `1ac06d593`, `88cbb77b9`):**
|
||||
1. Добавлен `adapter_supports_edit: bool = True` в `StreamConsumerConfig`
|
||||
2. В `run.py` при создании consumer читается `getattr(adapter, "SUPPORTS_MESSAGE_EDITING", True)` — для WebhookAdapter возвращает `True` (нет атрибута), поэтому добавлена ручная настройка через конфиг и проверка `cfg.adapter_supports_edit`
|
||||
3. Создана `else` ветка для no-edit (webhook) в stream_consumer — без курсора, отправка по `\n`
|
||||
4. Добавлен rate-limit: partial на `\n` не чаще чем раз в `edit_interval` (~500ms)
|
||||
5. Убран `buffer_threshold` для no-edit — partial только по `\n` + таймер, чтобы не рвать строки и не создавать дубли
|
||||
6. Gateway перезапускается только через Eagle Dashboard (`localhost:8880`)
|
||||
|
||||
**Фикс:** ? (Пока не чинили)
|
||||
**Текущее поведение:**
|
||||
- Partial отправляются только при `\n` в буфере, не чаще раза в 500ms
|
||||
- При `got_done` — всё накопленное отправляется
|
||||
- Если LLM генерирует без `\n` — ждёт `got_done` (весь ответ одним куском)
|
||||
- Всё стабильно, без дублей, без разрыва строк
|
||||
|
||||
### Баг 3 (Phase 2): Seed реакций не срабатывал из-за skip_bot_messages
|
||||
|
||||
|
||||
+149
-107
@@ -1,6 +1,6 @@
|
||||
# Zulip Router
|
||||
|
||||
_Последнее обновление: 2026-06-17 (skip_mention_forward: устранена двойная доставка при @mention)_
|
||||
_Последнее обновление: 2026-06-19 (forward_trigger, sender_email, data, stream_id — payload идентичен Zulip outgoing webhook)_
|
||||
|
||||
## Цель
|
||||
|
||||
@@ -26,17 +26,17 @@ Zulip
|
||||
|
||||
```
|
||||
Zulip Event Queue
|
||||
↓ poll (credentials Eagle из ~/.hermes/config.yaml)
|
||||
↓ poll (credentials router-bot)
|
||||
[zulip-router]
|
||||
│
|
||||
├── @mention бота от человека → POST owner-боту + ownership
|
||||
├── @mention любого бота (включая другого) → ownership, НЕ форвардить
|
||||
├── трейд с ownership → POST owner-боту
|
||||
├── новый тред без @mention → default-боту (из конфига)
|
||||
├── тред с ownership → POST owner-боту (forward_trigger из конфига)
|
||||
├── новый тред без @mention → default-боту (forward_trigger из конфига)
|
||||
└── reset / system → только владельцу треда
|
||||
```
|
||||
|
||||
**Ключевое:** credentials — Eagle/Орла (не новый бот). Роутер регистрирует свою event queue (отдельную от очереди Eagle).
|
||||
**Ключевое:** credentials — router-bot@zulip.qentra.top (отдельный бот, не Eagle).
|
||||
|
||||
## Правила маршрутизации (пошагово)
|
||||
|
||||
@@ -48,7 +48,7 @@ Zulip Event Queue
|
||||
- Если сообщение от **человека** (sender_id не из списка ботов) и содержит `@**<бот>**`:
|
||||
- Записать этого бота как владельца треда (stream+topic)
|
||||
- Проверить `skip_mention_forward` для этого бота:
|
||||
- **false** (по умолчанию) — FORWARD сообщение этому боту
|
||||
- **false** (по умолчанию) — FORWARD сообщение этому боту (mention trigger)
|
||||
- **true** — НЕ форвардить (Zulip outgoing webhook уже доставил напрямую). Ownership обновляется.
|
||||
- Если сообщение от **человека** и содержит @mention **другого бота** (из конфига, но не того, что стал бы овнером):
|
||||
- Всё равно записать этого бота как владельца треда
|
||||
@@ -57,10 +57,10 @@ Zulip Event Queue
|
||||
- Обновить ownership, но НЕ форвардить (Zulip сам доставит)
|
||||
|
||||
3. **Нет @mention, но тред уже закреплён за ботом:**
|
||||
- FORWARD владельцу треда
|
||||
- FORWARD владельцу треда (c forward_trigger из конфига, fallback "owner")
|
||||
|
||||
4. **Новый тред без @mention:**
|
||||
- FORWARD default-боту (из конфига)
|
||||
- FORWARD default-боту (c forward_trigger из конфига, fallback "default")
|
||||
|
||||
5. **reset / system-команды без @mention:**
|
||||
- FORWARD только владельцу треда
|
||||
@@ -72,51 +72,102 @@ Zulip Event Queue
|
||||
- Роутер **ставит ownership**, но **не форвардит** если в сообщении есть @mention любого бота из его конфига
|
||||
- Целевой бот получит сообщение через свой собственный outgoing webhook от Zulip
|
||||
|
||||
## Forward payload (что шлёт роутер боту)
|
||||
|
||||
Роутер шлёт payload, **идентичный** Zulip outgoing webhook:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"sender_id": 8,
|
||||
"sender_email": "admin@zulip.local",
|
||||
"stream_id": 10,
|
||||
"subject": "test",
|
||||
"content": "прием"
|
||||
},
|
||||
"data": "прием",
|
||||
"trigger": "mention",
|
||||
"token": "***",
|
||||
"bot_email": "balda-bot@zulip.qentra.top",
|
||||
"bot_full_name": "Валера"
|
||||
}
|
||||
```
|
||||
|
||||
**Поля, которых не было до 2026-06-19 (баг):**
|
||||
- `data` — без него Валера видел пустой текст и слал только Session Started (никогда не отвечал моделью)
|
||||
- `bot_email` — не обязателен, но для полной идентичности
|
||||
- `sender_email` в message — без него `isAllowedOwner()` возвращал false
|
||||
- `stream_id` в message — без него locator строился с streamID=0 (channel ID 0)
|
||||
|
||||
**Как это ловилось:** после исправления trigger=mention, Валера заходил в `handleAutoClaimMention`, но `payload.Data` был пустым → строка 1030 `if text == ""` → только Welcome, `handleMessage` не вызывался.
|
||||
|
||||
## Конфиг роутера (`~/Docker/zulip-router/config.yaml`)
|
||||
|
||||
```yaml
|
||||
zulip:
|
||||
bot_email: "router-bot@zulip.qentra.top"
|
||||
api_key: "aOYAXlBV1bZlv871fnTTgbGTBX7R4DeC" # router-bot, не eagle-bot
|
||||
server_url: "https://zulip.qentra.top"
|
||||
webhook_token: "08cd0f..." # совпадает с secret в конфиге Eagle (route eagle)
|
||||
api_key: "..."
|
||||
server_url: https://zulip.qentra.top
|
||||
webhook_token: "..."
|
||||
|
||||
bots:
|
||||
- name: "Валера"
|
||||
aliases: ["Valera"]
|
||||
bot_email: balda-bot@zulip.qentra.top
|
||||
aliases: [Valera, valera]
|
||||
webhook: "http://balda-agent-valera-1:8091/zulip/webhook"
|
||||
webhook_token: "..."
|
||||
skip_mention_forward: true
|
||||
forward_trigger: mention
|
||||
- name: "Клавдий"
|
||||
aliases: ["Klavdiy"]
|
||||
bot_email: claudio-bot@zulip.qentra.top
|
||||
aliases: [Klavdiy, klavdiy, Claudio, claudio]
|
||||
webhook: "http://claudio-agent-claudio-1:8092/zulip/webhook"
|
||||
skip_mention_forward: true
|
||||
forward_trigger: mention
|
||||
- name: "Eagle"
|
||||
bot_email: eagle-bot@zulip.qentra.top
|
||||
aliases: ["Орёл", "орёл", "eagle", "Eagle"]
|
||||
aliases: [eagle, Eagle, Орёл, орёл]
|
||||
webhook: "http://host.docker.internal:8644/webhooks/eagle"
|
||||
skip_mention_forward: true
|
||||
- name: "Кит"
|
||||
bot_email: whale-bot@zulip.qentra.top
|
||||
aliases: ["Whale", "whale", "кит"]
|
||||
aliases: [Whale, whale, кит]
|
||||
webhook: "http://host.docker.internal:8645/webhooks/whale"
|
||||
skip_mention_forward: true
|
||||
|
||||
# bot_ids — sender_id ботов в Zulip (чтобы отличать сообщения человека от бота)
|
||||
bot_ids:
|
||||
- 9 # Eagle / Орёл (Hermes)
|
||||
- 10 # Клавдий (claudio-bot)
|
||||
- 11 # Валера (balda-bot)
|
||||
- 13 # Кит (whale-bot)
|
||||
- 14 # router-bot
|
||||
- 9 # zulip-router-bot (legacy)
|
||||
- 11 # Валера (balda-bot)
|
||||
- 13 # Клавдий (claudio-bot)
|
||||
- 14 # Орёл (eagle-bot)
|
||||
- 15 # router-bot (current poller)
|
||||
- 16 # Кит (whale-bot)
|
||||
|
||||
default_bot: "Валера"
|
||||
skip_bot_messages: true # не форвардить сообщения от ботов (включая router-bot)
|
||||
skip_bot_messages: true
|
||||
|
||||
ownership:
|
||||
ttl: 24h
|
||||
persist_path: "/data/ownership.json"
|
||||
|
||||
http:
|
||||
listen: :8090
|
||||
```
|
||||
|
||||
### Поля конфига бота
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
| `name` | Отображаемое имя бота |
|
||||
| `bot_email` | Email бота в Zulip (для `bot_email` в forward payload) |
|
||||
| `aliases` | Алиасы для @mention (все регистры) |
|
||||
| `webhook` | URL POST-эндпоинта бота |
|
||||
| `webhook_token` | Токен для X-Hub-Signature-256 |
|
||||
| `skip_mention_forward` | Не дублировать @mention (Zulip уже доставил) |
|
||||
| `forward_trigger` | Значение поля `trigger` в forward payload (fallback: owner/default) |
|
||||
|
||||
`forward_trigger` — добавлен 2026-06-19. Для ботов, ожидающих `trigger=mention` чтобы создать сессию. Без него роутер слал `trigger=owner`, Валера не создавал сессию и молчал.
|
||||
|
||||
## Что меняется в balda
|
||||
|
||||
После деплоя роутера Events API polling уже отключён у обоих ботов (сделано 2026-06-16):
|
||||
@@ -131,62 +182,93 @@ zulip:
|
||||
|
||||
## Код
|
||||
|
||||
**Репозиторий:** `~/Developer/zulip-router/` — Go, ~260 LOC (план)
|
||||
**Репозиторий:** `~/Developer/zulip-router/` — Go, ~430 LOC
|
||||
|
||||
**Git:** `git init` 2026-06-16. Первый коммит: `a403562` — чистый оригинал от 14:22. `.bak` файлы — слепки конфигов до правок Кита.
|
||||
|
||||
**Deploy:** `~/Docker/zulip-router/docker-compose.yaml`
|
||||
**Git:** `git init` 2026-06-16. Последний коммит: `487f481` (2026-06-19).
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `main.go` | Poll loop, routing, dispatch |
|
||||
| `config.go` | Config struct + YAML loading + env var expansion |
|
||||
| `zulip.go` | Zulip API client (register_queue, get_events, long-poll) |
|
||||
| `zulip.go` | Zulip API client (register_queue, get_events, long-poll, get_user_email, add_reaction) |
|
||||
| `ownership.go` | Topic ownership store (RW mutex + JSON persistence) |
|
||||
| `forwarder.go` | HTTP POST к balda webhook endpoints |
|
||||
|
||||
### Структуры
|
||||
|
||||
**Message (config.go):**
|
||||
```go
|
||||
type Message struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
SenderEmail string `json:"sender_email,omitempty"`
|
||||
SenderFullName string `json:"sender_full_name,omitempty"`
|
||||
DisplayRecipient string `json:"display_recipient"`
|
||||
StreamID int64 `json:"stream_id,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
Stream string `json:"stream,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**BotCfg (config.go):**
|
||||
```go
|
||||
type BotCfg struct {
|
||||
Name string `yaml:"name"`
|
||||
BotEmail string `yaml:"bot_email"`
|
||||
Aliases []string `yaml:"aliases"`
|
||||
Webhook string `yaml:"webhook"`
|
||||
WebhookToken string `yaml:"webhook_token"`
|
||||
SkipMentionForward bool `yaml:"skip_mention_forward"`
|
||||
ForwardTrigger string `yaml:"forward_trigger"`
|
||||
}
|
||||
```
|
||||
|
||||
**forwardPayload (forwarder.go):**
|
||||
```go
|
||||
type forwardPayload struct {
|
||||
Message Message `json:"message"`
|
||||
Data string `json:"data"`
|
||||
Trigger string `json:"trigger"`
|
||||
Token string `json:"token,omitempty"`
|
||||
BotEmail string `json:"bot_email,omitempty"`
|
||||
Bot string `json:"bot_full_name,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
**Compose:** `~/Docker/zulip-router/docker-compose.yaml`
|
||||
|
||||
**Сеть:** `balda_default` (external) — чтобы видеть balda-agent-valera-1 и claudio-agent-claudio-1 по Docker DNS.
|
||||
|
||||
### Credentials: Eagle, не новый бот
|
||||
|
||||
Роутер использует credentials Eagle/Орла из `~/.hermes/config.yaml` → `platforms.zulip`.
|
||||
|
||||
Не создавать нового бота. Eagle уже имеет права на чтение всех публичных стримов.
|
||||
|
||||
### Env vars
|
||||
|
||||
Создать `~/Docker/zulip-router/.env`:
|
||||
|
||||
```env
|
||||
# Credentials Eagle/Орла — из ~/.hermes/config.yaml
|
||||
ZULIP_API_KEY=<скопировать из конфига Eagle>
|
||||
```
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
cd ~/Docker/zulip-router
|
||||
docker-compose up -d
|
||||
docker logs zulip-router --tail 20 # проверить poll loop
|
||||
|
||||
# После изменений в коде:
|
||||
cd ~/Docker/zulip-router && docker-compose build --no-cache && docker-compose up -d
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] Написать код роутера (~260 LOC)
|
||||
- [x] Создать compose `~/Docker/zulip-router/docker-compose.yaml`
|
||||
- [x] Набить `.env` с credentials Eagle (из `~/.hermes/config.yaml`)
|
||||
- [x] Набить `.env` с credentials (router-bot)
|
||||
- [x] `docker-compose up -d`
|
||||
- [x] Проверить логи: `docker logs zulip-router --tail 50`
|
||||
- [x] Добавить `all_public_streams=true` в register (роутер не получал события)
|
||||
- [x] Добавить guards: timestamp (старт роутера) + skip_bot_messages
|
||||
- [x] Протестировать @mention от человека в новом треде → правильная маршрутизация
|
||||
- [x] Устранить двойную доставку Орлу (skip_mention_forward: true, Jun 17 16:57)
|
||||
- [ ] Протестировать сообщение без @mention в закреплённом треде → ownership
|
||||
- [ ] Удалить `extractOtherMention()` патч из кода balda-ботов (больше не нужен)
|
||||
- [x] Добавить `all_public_streams=true` в register
|
||||
- [x] Добавить guards: timestamp + skip_bot_messages
|
||||
- [x] Протестировать @mention от человека → правильная маршрутизация
|
||||
- [x] Устранить двойную доставку (skip_mention_forward)
|
||||
- [x] Добавить forward_trigger, sender_email, data, stream_id (роутер слал неполный payload)
|
||||
- [ ] Протестировать сообщение без @mention в закреплённом треде
|
||||
- [ ] Удалить `extractOtherMention()` патч из кода balda-ботов
|
||||
|
||||
## Опции запуска
|
||||
|
||||
@@ -194,8 +276,8 @@ docker logs zulip-router --tail 20 # проверить poll loop
|
||||
/app/zulip-router [--config /etc/zulip-router/config.yaml] [--debug]
|
||||
```
|
||||
|
||||
Флаг `--debug` включает `slog.LevelDebug` (structured JSON-text логи).
|
||||
Удобнее: `DEBUG=true` env var (через entrypoint.sh) — подхватывается из docker-compose.
|
||||
Флаг `--debug` включает `slog.LevelDebug` (structured JSON-text логи).
|
||||
Удобнее: `DEBUG=true` env var — подхватывается из docker-compose.
|
||||
|
||||
По умолчанию `DEBUG=false` — только INFO+.
|
||||
|
||||
@@ -206,72 +288,32 @@ docker logs zulip-router --tail 20 # проверить poll loop
|
||||
Уровни:
|
||||
- **ERROR** — фатальные ошибки, падения, ошибки форварда
|
||||
- **WARN** — skip unparseable event, owner bot not found
|
||||
- **INFO** — queue registered, router ready (start timestamp), routing decision (mention/owner/default), forward success, http start
|
||||
- **INFO** — queue registered, router ready (start timestamp), routing decision, forward success, http start
|
||||
- **DEBUG** — raw event body, poll response, ownership get/set, forward request/response body, routing skip reasons, guard skips
|
||||
|
||||
Включение: `--debug` флаг или `DEBUG=true` env var.
|
||||
|
||||
## Guards (фильтры сообщений)
|
||||
|
||||
Два guard'а в `processEvent`, выполняются до любой маршрутизации:
|
||||
|
||||
### 1. Timestamp guard (всегда включён)
|
||||
|
||||
Сообщения, созданные **до старта роутера**, скипаются полностью (включая обновление ownership).
|
||||
|
||||
```go
|
||||
startTime := time.Now().Unix() // после регистрации очереди
|
||||
if ev.Timestamp < startTime {
|
||||
slog.Debug("skip: event from before router start", ...)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Лог: `"router ready, events before this timestamp will be skipped" start_timestamp=<unix>`
|
||||
на уровне INFO (всегда виден).
|
||||
|
||||
На уровне DEBUG — `"skip: event from before router start"` для каждого скипнутого события.
|
||||
|
||||
### 2. Bot sender guard (конфигурируемый)
|
||||
|
||||
Если `skip_bot_messages: true` в конфиге, все сообщения от ботов (sender_id из `bot_ids`) скипаются — не форвардятся и не обновляют ownership.
|
||||
|
||||
Лог (DEBUG): `"skip: bot message (skip_bot_messages=true)"`
|
||||
|
||||
**Зачем:** router-bot может писать сообщения в стримы для теста/уведомлений. Роутер не должен форвардить их обратно ботам.
|
||||
|
||||
## Решённые проблемы
|
||||
|
||||
### 1. Event queue умирала, long-poll зависал (FIXED 2026-06-17)
|
||||
### 1–5. Первые проблемы (см. архив)
|
||||
|
||||
**Симптом:** роутер регистрирует очередь, делает `fetchEvents` с `dont_block=false`, и **зависает навсегда**. Логи отсутствуют часами. Сообщения не обрабатываются.
|
||||
### 6. Валера не отвечал на сообщения от роутера (FIXED 2026-06-19)
|
||||
|
||||
**Фикс:** `all_public_streams=true` при регистрации очереди + обработка `BAD_EVENT_QUEUE_ID` с перерегистрацией.
|
||||
**Симптом:** @mention от Zulip outgoing webhook работает, сообщения от роутера — только `Session Started`, без ответа модели. Сообщение `channel ID 0` в системных уведомлениях.
|
||||
|
||||
### 2. Роутер не получал события (FIXED 2026-06-17)
|
||||
**Корень три проблемы в роутере:**
|
||||
|
||||
**Причина:** router-bot не был подписан на стримы. Очередь регистрировалась, но событий не получала.
|
||||
**Фикс:** `all_public_streams=true` в `registerQueue`.
|
||||
1. **Поле `data` не передавалось.** Роутер не слал `data` (текст сообщения) в payload. Валера на строке 242 читает `payload.Data` — пусто → строка 1030 `if text == ""` → только Welcome.
|
||||
|
||||
### 3. Сообщения до старта роутера обрабатывались (FIXED 2026-06-17)
|
||||
2. **Поле `sender_email` не передавалось.** В `Message` struct не было поля `SenderEmail`. Zulip Events API не отдаёт `sender_email` — роутер не мог его заполнить. Без email `isAllowedOwner()` возвращал false.
|
||||
|
||||
**Фикс:** timestamp guard — сообщения с timestamp < времени старта скипаются.
|
||||
3. **Поле `stream_id` не передавалось.** `Message` struct не имел `StreamID`. Валера на строке 1084 использует `payload.Message.StreamID` → default 0 → `channel ID 0`.
|
||||
|
||||
### 4. Сообщения от router-bot форвардились ботам (FIXED 2026-06-17)
|
||||
4. **trigger=owner вместо trigger=mention.** Валера ожидает `trigger=mention` для авто-создания сессии и регистрации owner'а. Роутер слал `trigger=owner`.
|
||||
|
||||
**Фикс:** конфигурируемый `skip_bot_messages: true` — сообщения от ботов скипаются.
|
||||
**Фиксы в коде (коммит `487f481`):**
|
||||
- `config.go`: добавлены поля `SenderEmail`, `StreamID` в Message, `ForwardTrigger` в BotCfg
|
||||
- `zulip.go`: добавлен метод `GetUserEmail(userID)` — дёргает Zulip API `/api/v1/users/{id}`
|
||||
- `main.go`: после guards, вызов `GetUserEmail` для не-ботов; при owner-routing использует `bot.ForwardTrigger` с fallback "owner"; default-routing аналогично
|
||||
- `forwarder.go`: добавлены поля `Data`, `BotEmail` в forwardPayload; заполняются при Forward()
|
||||
- `config.yaml`: `forward_trigger: mention` для Валеры и Клавдия
|
||||
|
||||
### 5. Двойная доставка @mention (FIXED 2026-06-17)
|
||||
|
||||
**Симптом:** Орёл получал одно сообщение дважды — один раз через Zulip outgoing webhook, второй — через роутер.
|
||||
|
||||
**Причина:** роутер форвардил @mention-сообщения, хотя Zulip outgoing webhook уже доставил их напрямую боту.
|
||||
|
||||
**Фикс:** добавлен флаг `skip_mention_forward: true` в конфиг каждого бота, у которого есть outgoing webhook. При @mention от человека:
|
||||
- ownership обновляется (кто владелец треда)
|
||||
- если `skip_mention_forward: true` — forward не делается (логируется `"skip forward: mention delivered via Zulip outgoing webhook"`)
|
||||
- если `skip_mention_forward: false` (по умолчанию) — поведение не меняется
|
||||
|
||||
**Код:** `config.go` → `BotCfg.SkipMentionForward bool`, `main.go` → проверка при @mention от человека.
|
||||
|
||||
**Git:** be53c1d (роутер), 3fba7c8 (Docker конфиг).
|
||||
**Итог:** роутер шлёт payload, **идентичный** Zulip outgoing webhook. Валера не видит разницы.
|
||||
|
||||
Reference in New Issue
Block a user