297 lines
15 KiB
Markdown
297 lines
15 KiB
Markdown
---
|
||
created: '2026-06-17'
|
||
status: draft
|
||
tags:
|
||
- zulip-router
|
||
- approval
|
||
- reactions
|
||
- hermes
|
||
- architecture
|
||
title: 'Zulip Router: Approval Reactions'
|
||
type: plan
|
||
updated: '2026-06-17'
|
||
---
|
||
# 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: Сообщения дропаются если был approval interrupt
|
||
|
||
Если агент запросил approval и ты ответил (даже не /approve, а любое сообщение), все сообщения которые были до ответа на approval — теряются. Агент их не получает.
|
||
|
||
**Воспроизведение:**
|
||
1. Агент шлёт approval request
|
||
2. Ты продолжаешь писать другие сообщения в треде
|
||
3. Ты отвечаешь на approval
|
||
4. Агент получает только approval-ответ, всё что было между — дропнуто
|
||
|
||
**Root cause:** `interrupt_gateway_approvals()` + `interrupt()` в `_route_to_active_session()` сбрасывает сообщения агента и восстанавливает стейт на момент approval. Промежуточные сообщения (между approval и ответом) не вшиты в этот стейт.
|
||
|
||
**Фикс:** ? (Пока не чинили)
|
||
|
||
### Баг 2: Стриминг не работает
|
||
|
||
Hermes не стримит ответ через webhook — агент отвечает одним блоком после завершения генерации. Пользователь не видит процесс печати.
|
||
|
||
**Root cause:** Webhook-транспорт не поддерживает стриминг — ответ формируется целиком и шлётся одним POST-запросом в Zulip через роутер.
|
||
|
||
**Фикс:** ? (Пока не чинили)
|
||
|
||
### Баг 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] Дока обновлена ✅
|