--- created: '2026-06-17' status: draft tags: - zulip-router - approval - reactions - hermes - architecture title: 'Zulip Router: Approval Reactions' type: plan updated: '2026-06-18' --- # Zulip Router: Approval Reactions ## Проблема Eagle (и Кит) не могут использовать reaction-based approval для dangerous commands. ### Root cause (3 слоя) **Слой 1 — отсутствие `send_exec_approval` в WebhookAdapter:** `WebhookAdapter` не имеет метода `send_exec_approval`. Когда приходит dangerous command, gateway видит что у адаптера нет этого метода (проверка `getattr(type(adapter), "send_exec_approval", None)` на строке 18000 run.py) и падает на текстовый fallback с "Reply `/approve`". Реакции не проставляются. **Слой 2 — `/approve` через роутер обрывает approval вместо того чтобы подтвердить:** Когда пользователь шлёт `/approve` (без @mention), оно приходит через роутер как обычный POST на webhook. В `GatewayMessageHandler._route_to_active_session()` (run.py, строка ~3480): 1. **Строка 3494:** `running_agent.interrupt(event.text)` — прерывает агента (interrupt = вставляет текст как новое сообщение пользователя) 2. **Строка 3504:** `interrupt_gateway_approvals(session_key)` — прерывает ожидающий approval (choice = "interrupted") Только **после** interrupt (строки 7819-7826 в `_message_handler`) проверяется "а не `/approve` ли это?" и вызывается `_handle_approve_command`. Но approval уже прерван — `has_blocking_approval(session_key)` возвращает False, и `/approve` отвечает "no pending approvals". **Слой 3 — реакции не обрабатываются:** `_handle_reaction_event` в `zulip.py` вызывается только из `_poll_once`. У Eagle `ZULIP_POLLING_DISABLED=true` — поллинг выключен. zulip-router регистрирует event_types = ["message"] — реакции не получает. ### Диагноз (лаконично) `_route_to_active_session` interrupt + interrupt_gateway_approvals убивает approval ДО того как `_message_handler` успевает вызвать `_handle_approve_command`. `/approve` приходит как текст → interrupt → approval прерван → `/approve` отвечает "нет ожидающих approval" → ран оборван. ## Архитектура решения ### Вариант А (минимальный фикс в run.py) Добавить проверку на `/approve`/`/deny` в `_route_to_active_session()` **ДО** interrupt-логики. Если пришла команда аппрува — не прерывать агента, а сразу идти в `_handle_approve_command`. **Изменение:** в `_route_to_active_session()`, до строки 3492 (interrupt), добавить: ```python # /approve и /deny не должны прерывать approval и агента cmd = event.get_command() if cmd in {"approve", "deny"}: return True # пропустить interrupt, approval обработается в _message_handler ``` **Плюсы:** минимальное изменение, чинит `/approve` без @mention через роутер **Минусы:** не чинит реакции, не добавляет pre-seed реакции ### Вариант Б (через роутер) zulip-router уже поллит Zulip Events API — добавить "reaction" в event_types, обрабатывать реакции и слать POST `/approve` или `/deny` на webhook бота. **Изменения в роутере:** 1. Добавить `"reaction"` в `event_types` 2. При `reaction` event: определить кто автор сообщения (по message_id), emoji → choice, POST на webhook бота с телом `{ "message": {...}, "trigger": "approve:once" }` 3. Опционально: pre-seed реакции на approval-сообщения от ботов **Плюсы:** единое место для reaction routing, не меняет Hermes код **Минусы:** нужно менять роутер + всё равно нужен Вариант А для `/approve` текстом ### Вариант В (минимальный + реакции) Вариант А + роутер: фикс `/approve` в run.py, реакции через роутер. ## Рекомендация **Вариант А (для `/approve`)** и потом **Варианта Б (для реакций)**. Вариант А чинит `/approve` сейчас — одно изменение в `_route_to_active_session()`. ## Изменения в zulip-router (для реакций) ### 1. Добавить `reaction` в event_types ```go // main.go eventTypes := []string{"message", "reaction"} ``` ### 2. Добавить struct для реакции ```go type ZulipEvent struct { ID int64 `json:"id"` Type string `json:"type"` Timestamp int64 `json:"timestamp"` Flags []string `json:"flags,omitempty"` Message *Message `json:"message,omitempty"` // Reaction fields (when type == "reaction") Op string `json:"op,omitempty"` // "add" или "remove" UserID int64 `json:"user_id,omitempty"` MessageID int64 `json:"message_id,omitempty"` EmojiName string `json:"emoji_name,omitempty"` EmojiCode string `json:"emoji_code,omitempty"` } ``` ### 3. Обработка reaction в processEvent ```go if ev.Type == "reaction" { processReaction(cfg, store, fwd, ev) return } ``` ```go func processReaction(cfg, store, fwd, ev): if ev.Op != "add" → return if int64SliceContains(cfg.BotIDs, ev.UserID) → return // emoji → choice choice := "" switch ev.EmojiName { case "+1", "thumbs_up", "white_check_mark": choice = "once" case "lock", "locked": choice = "session" case "infinity": choice = "always" case "-1", "thumbs_down", "cross_mark": choice = "deny" } if choice == "" → return // найти владельца сообщения // нужно хранить message_id → bot_name ownerName, ok := store.getMessageOwner(ev.MessageID) if !ok → return // найти бота bot := cfg.findBot(ownerName) if bot == nil → return // отправить POST с trigger = "approve:" + choice fwd.Forward(bot, message, "approve:"+choice) ``` ### 4. Message owner store Нужно хранить кто написал сообщение (bot). ```go // ownership.go — добавить type MessageOwnerStore struct { mu sync.RWMutex entries map[int64]messageOwner // message_id → owner info } type messageOwner struct { BotName string `json:"bot_name"` Timestamp time.Time `json:"timestamp"` } ``` Заполнять при `processEvent` для сообщений от ботов. ### 5. Pre-seed реакций (опционально) Роутер находит сообщения ботов с маркером `⚠️ **Command Approval Required**` или `⚠️ **Dangerous command requires approval:**` и ставит реакции 👍 🔒 ♾️ 👎. ```go func seedApprovalReactions(z *ZulipClient, msg *Message) { if !isApprovalMessage(msg.Content) { return } reactions := []struct{name, code string}{ {"thumbs_up", "1f44d"}, {"locked", "1f512"}, {"infinity", "267e"}, {"thumbs_down", "1f44e"}, } for _, r := range reactions { z.addReaction(msg.ID, r.name, r.code) } } ``` ### 6. addReaction в zulip.go ```go func (z *ZulipClient) addReaction(messageID int64, emojiName, emojiCode string) error { form := url.Values{} form.Set("emoji_name", emojiName) form.Set("emoji_code", emojiCode) form.Set("reaction_type", "unicode_emoji") req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/messages/%d/reactions", z.Server, messageID), strings.NewReader(form.Encode())) req.SetBasicAuth(z.Email, z.APIKey) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := z.HTTP.Do(req) // ... } ``` ## Баги Hermes webhook ### Баг 1: Сообщения дропаются при аппрувл-прерывании **Проверено/подтверждено 18.06.2026 тестом со sleep-командами:** Пользователь попросил: написать текст, sleep(2), текст, sleep(2), текст, sleep(2) + rm -rf. Агент выполнил все 4 вызова в одном ответе, но после аппрувл-прерывания на последней команде (`rm -rf`) — ВСЕ предыдущие сообщения (первые 3 вывода terminal) не дошли до пользователя. Дропнулись полностью. **Воспроизведение (тестовый сценарий):** 1. Агент пишет текст → вызывает terminal() с echo → пишет текст → вызывает terminal() с echo → ... → вызывает terminal() с dangerous command 2. Dangerous command триггерит approval — `GatewayApprovalHandler._await_gateway_decision()` блокирует agent thread на `threading.Event.wait()` 3. Пользователь отвечает на approval (👍 или /approve) 4. Interrupt срабатывает: `running_agent.interrupt()` + `interrupt_gateway_approvals()` 5. Агент выходит из цикла с `interrupted=True` 6. Gateway берёт `result["final_response"]` — это пустая строка (прервано до генерации финального ответа) 7. stream_consumer.finish() вызывается, но `_accumulated` пустой — ничего не отправляется 8. Результат: пользователь видит только approval-запрос, ни один terminal output не дошёл ### Root cause — подробный data flow **Data flow в стриминг-режиме:** 1. **LLM текстовые деливеры:** Агент в `conversation_loop.py` вызывает `interruptible_streaming_api_call()` в `chat_completion_helpers.py`. Каждый чанк delta.content → `agent._fire_stream_delta(content)` → `agent.stream_delta_callback(content)` → `_stream_consumer.on_delta(content)` → queue → async task `_send_or_edit()` 2. **Terminal output (НЕ стримится):** `_execute_tool_calls()` в conversation_loop вызывает handle_function_call → terminal.execute(). Tool output **НЕ проходит через stream_delta_callback**. Он сохраняется только в `messages[]` (как role=tool) внутри агента. 3. **Tool progress** (`run.py` line 16982): Отключён для WEBHOOK (`tool_progress_enabled = ... and source.platform != Platform.WEBHOOK`). Даже если бы был включён — он показывает только "terminal: \"cmd...\"", не вывод команды. 4. **WebhookAdapter не поддерживает edit_message:** У WebhookAdapter НЕТ метода `edit_message` (только `send`). Stream consumer пытается edit → получает fallback → `_edit_supported=False` → дальнейшие дельты буферизуются в `_accumulated` и отправляются только на `finish()`. **Цепочка потери — пошагово:** 1. Агент вызывает terminal("echo текст1") — output в messages, НЕ стримится пользователю 2. Агент вызывает terminal("sleep 2") — blocker, НЕ стримится 3. Агент вызывает terminal("echo текст2") — output в messages, НЕ стримится 4. Агент вызывает terminal("sleep 2 && rm -rf ...") — триггерит approval 5. `_await_gateway_decision()` — approval is sent to user (текст с "React 👍...") 6. approval посылается через `_status_adapter.send()` (отдельный send, НЕ через stream consumer) 7. User approves → **interrupt** → `running_agent.interrupt()` + `interrupt_gateway_approvals()` 8. `_interrupt_requested = True` → conversation loop прерывается 9. `agent.run_conversation()` возвращает `{final_response: "", interrupted: True, messages: [...]}` 10. Gateway вызывает `_stream_consumer.finish()` 11. `_accumulated` пуст (terminal outputs никогда не попали в стрим) → ничего не отправляется 12. Gateway смотрит `result["final_response"]` — пусто → "no error message" 13. Всё потеряно **Ключевая проблема:** - LLM text stримится, terminal output — нет - Terminal output живёт только в `messages[]`, не попадает ни в stream consumer, ни в final_response - При interrupt final_response пустая → нечего отправлять - Даже если бы стриминг работал идеально (с edit_message на webhookе) — terminal outputs всё равно бы потерялись, т.к. они никогда не проходят через stream_delta_callback ### План фикса **Вариант А (рекомендуемый — минимальные изменения в gateway):** Изменить `_run_agent` в `gateway/run.py` — после `run_conversation()` при interrupt собрать все tool outputs из `result["messages"]` и отправить их пользователю через `_stream_consumer.on_delta()` или `adapter.send()`. ```python # В _run_agent, после agent.run_conversation(), перед stream_consumer.finish(): if result.get("interrupted") and _stream_consumer: # Собрать terminal outputs из messages и отправить for msg in result.get("messages", []): if msg.get("role") == "tool" and msg.get("content"): _stream_consumer.on_delta(f"```\n{msg['content']}\n```\n") _stream_consumer.finish() ``` **Плюсы:** ~10 строк, минимальный риск, чинит потерю при любом interrupt (не только approval) **Минусы:** Нет real-time доставки (output показывается только после interrupt/завершения) **Вариант Б (comprehensive — terminal output стримится в реальном времени):** terminal output проходит через stream_delta_callback сразу после выполнения. Это требует изменений в нескольких местах. **Проблемы варианта Б:** 1. Terminal output — бинарный, может быть >100KB — не влезет в одно edit 2. Terminal output не редактируется (в отличие от LLM текста) — каждое выполнение новая строка 3. Для WebhookAdapter (нет edit_message/стриминга в принципе) — каждое terminal output будет отдельным HTTP POST **Вариант В (баланс — flush на tool boundary):** После каждого tool call, если есть что отправить пользователю — форсировать отсылку перед следующим API call к LLM. Эффект: пользователь видит накопленный стриминг к моменту блокировки на approval. ```python # В conversation_loop.py, после _execute_tool_calls(): agent._fire_stream_delta(f"🖥️ Terminal output:\n```\n{tool_result}\n```\n") ``` **Проблемы варианта В:** - Засоряет стрим LLM текста техническими деталями - Может быть огромным (>1MB terminal output) **Вариант Г (рекомендуется к реализации — пассивная доставка через stream consumer / adapter.send при interrupt):** Самый безопасный: при interrupt gateway сам собирает все tool outputs из `messages` и отправляет их связным текстом. Не требует изменений в conversation_loop или run_agent. **Детали реализации варианта Г:** В `_run_agent()` в `gateway/run.py`, в блоке после `agent.run_conversation()` и `_stream_consumer.finish()`, добавить: ```python # Если был interrupt, собрать неотправленные tool outputs if result.get("interrupted"): tool_outputs = [] for msg in result.get("messages", []): if msg.get("role") == "tool" and msg.get("content", "").strip(): tool_name = msg.get("name", "tool") content = msg["content"] # Обрезать слишком длинные output (>10KB) if len(content) > 10240: content = content[:10240] + f"\n... ({len(content)} bytes total)" tool_outputs.append(f"**{tool_name}:**\n```\n{content}\n```") if tool_outputs: # Отправить через adapter.send() — это обходной путь, не через stream await _status_adapter.send( _status_chat_id, "\n\n".join(tool_outputs), metadata=_status_thread_metadata, ) ``` **Плюсы:** - Чинит потерю для любого interrupt (не только approval) - Не трогает conversation loop — безопасно - Terminal output не засоряет LLM стрим - Можно обрезать слишком большие output **Минусы:** - Не real-time — output показывается только после прерывания - Дублирование если уже было отправлено (но на webhook без edit_message это редкий случай) ### Recommendation Вариант Г — минимум изменений, максимум safety. Вся логика в одном месте — `_run_agent()` в `run.py`. **Файл:** `gateway/run.py` **Область:** блок ~line 18240, после `_stream_consumer.finish()`, перед return. **Изменяет:** Только сбор и отправку tool outputs после interrupt. **Не изменяет:** conversation_loop.py, run_agent.py, stream_consumer.py, approval.py. ### Баг 2: Стриминг — partial не работали **Проблема (оригинал):** WebhookAdapter не имеет `SUPPORTS_MESSAGE_EDITING` — stream_consumer не знал что редактирование не поддерживается. Consumer пытался edit, падал в fallback. Курсор `▉` оставался в финальном ответе. **Фикс (коммиты `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 `isApprovalMessage` и `seedApprovalReactions` в коде Phase 2 стояли ПОСЛЕ guard-а `if senderIsBot && cfg.SkipBotMessages`. Approval-сообщения шлются от бота → `SkipBotMessages = true` → processEvent выходил раньше, чем seed ставился. **Фикс 18.06.2026:** Перенёс seed-логику ДО guard'а `SkipBotMessages` — seedApprovalReactions вызывается сразу после определения senderIsBot, до любой фильтрации. ## Изменения в Hermes (для `/approve` текстом) ### run.py — _route_to_active_session Перед interrupt-секцией (строка 3492), добавить: ```python # /approve и /deny не должны прерывать approval # Они обрабатываются в _message_handler (строка 7819) if event.get_command() in {"approve", "deny"}: return True ``` ## Слеш-команды не обрабатываются Hermes Слеш-команды (`/reset`, `/stop`, `/approve`, `/deny` и любые другие `/...`) передаются агенту как обычный текст промпта. Hermes **не перехватывает** их на уровне платформы или роутера. | Команда | Реальность | |---------|------------| | `/reset` | Не сбрасывает контекст — агент видит это как текст и должен сам остановиться / начать заново | | `/stop` | Не останавливает агента — агент видит это как текст и должен сам прекратить действия | | `/approve` | Тоже идёт агенту — но есть отдельная проблема с interrupt (см. Root cause, слой 2) | | Любая `/команда` | Идёт агенту в промпт как обычное сообщение | **Root cause:** Hermes — агентный фреймворк, а не чат-платформа. У него нет встроенного парсера слеш-команд. Вся логика — на агенте. ## Системные оповещения о действиях агента не приходят Hermes **не отправляет пользователю нотификации** о действиях агента. Когда агент пишет/читает/изменяет файлы, запускает команды, вызывает инструменты (MCP, skills, terminal, и т.д.) — пользователь об этом **не узнаёт**, если агент сам не напишет об этом в чат. **Способ узнать:** только дождаться ответа агента в чат или спросить «что сделано». ## TODO ### Phase 1: `/approve` текстом (✅ сделано — проверено 18.06.2026) - [x] Проанализировать код и написать план - [x] Добавить guard в `_route_to_active_session()` — если команда approve/deny, не прерывать агента и approval - [x] Тесты: 21 passed (2.35s) - [x] Закоммичено: `eae71aab8` — `gateway/run.py` (8 строк добавил) - [x] Доки обновлены: `reset-stop.md`, `zulip-router-approval-reactions.md` - [x] Проверено 18.06.2026: `/approve` через `@**Кит** /approve` — OK (`rm -rf` выполнен с одобрения) - [x] Проверено 18.06.2026: `/stop` — работает (стоп-слово, блокирует дальнейшие действия) ### Phase 1b: `/reset` (✅ проверено 18.06.2026) - [x] `/reset` — сбрасывает контекст. Проверено 18.06.2026. ### Phase 2: Реакции через роутер — реализация - [x] Добавить reaction-поля в `ZulipEvent` (config.go) - [x] Добавить `"reaction"` в event_types и обработку в main.go - [x] Создать MessageOwnerStore в ownership.go - [x] Добавить `addReaction()` в zulip.go - [x] Реализовать обработчик реакции: emoji → choice → Forward - [x] Pre-seed реакций на approval-сообщения - [x] Собрать и перезапустить роутер - [x] Починить seed — перенести ДО `skip_bot_messages` guard'а (seed ставился после выхода) - [x] Образ пересобран, контейнер перезапущен ✅ - [x] Seed реакции ставятся на approval-сообщения ✅ - [x] Deny (👎) форвардит `approve:deny` в Hermes ✅ - [x] Закоммичено: `baf41bc` ✅ - [x] Дока обновлена ✅ ### Cosmetics: текст approval → реакции в run.py - `run.py`: при `source.platform == Platform.WEBHOOK` заменяет `/approve` текст на реакционные эмодзи (👍🔒♾️👎) - Для остальных платформ (CLI, Telegram, Discord и т.д.) остаётся старый текст с `/approve` - `forwarder.go` попытка (commit `82ab623`) — откачена, т.к. forwarder обрабатывает Zulip→Hermes направление, а approval идёт Hermes→Zulip - Закоммичено: `81f10c306` в `~/.hermes/hermes-agent/`