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

254 lines
12 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-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 (для `/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` текстом (✅ сделано)
- [x] Проанализировать код и написать план
- [x] Добавить guard в `_route_to_active_session()` — если команда approve/deny, не прерывать агента и approval
- [x] Тесты: 21 passed (2.35s)
- [x] Закоммичено: `eae71aab8``gateway/run.py` (8 строк добавил)
### Phase 2: Реакции через роутер
- [ ] Добавить `"reaction"` в event_types роутера
- [ ] Добавить обработку reaction в `processEvent`
- [ ] Реализовать message_id → bot_name маппинг
- [ ] Добавить `addReaction` в zulip.go
- [ ] Добавить pre-seed реакций на approval-сообщения
- [ ] Проверить что POST с trigger="approve:once" правильно обрабатывается Hermes