Files
obsidian-vault/personal/projects/zulip-router-approval-reactions.md
T

441 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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/`