26 KiB
created, status, tags, title, type, updated
| created | status | tags | title | type | updated | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| 2026-06-17 | draft |
|
Zulip Router: Approval Reactions | plan | 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):
- Строка 3494:
running_agent.interrupt(event.text)— прерывает агента (interrupt = вставляет текст как новое сообщение пользователя) - Строка 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), добавить:
# /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 бота.
Изменения в роутере:
- Добавить
"reaction"вevent_types - При
reactionevent: определить кто автор сообщения (по message_id), emoji → choice, POST на webhook бота с телом{ "message": {...}, "trigger": "approve:once" } - Опционально: pre-seed реакции на approval-сообщения от ботов
Плюсы: единое место для reaction routing, не меняет Hermes код
Минусы: нужно менять роутер + всё равно нужен Вариант А для /approve текстом
Вариант В (минимальный + реакции)
Вариант А + роутер: фикс /approve в run.py, реакции через роутер.
Рекомендация
Вариант А (для /approve) и потом Варианта Б (для реакций).
Вариант А чинит /approve сейчас — одно изменение в _route_to_active_session().
Изменения в zulip-router (для реакций)
1. Добавить reaction в event_types
// main.go
eventTypes := []string{"message", "reaction"}
2. Добавить struct для реакции
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
if ev.Type == "reaction" {
processReaction(cfg, store, fwd, ev)
return
}
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).
// 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:** и ставит реакции 👍 🔒 ♾️ 👎.
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
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) не дошли до пользователя. Дропнулись полностью.
Воспроизведение (тестовый сценарий):
- Агент пишет текст → вызывает terminal() с echo → пишет текст → вызывает terminal() с echo → ... → вызывает terminal() с dangerous command
- Dangerous command триггерит approval —
GatewayApprovalHandler._await_gateway_decision()блокирует agent thread наthreading.Event.wait() - Пользователь отвечает на approval (👍 или /approve)
- Interrupt срабатывает:
running_agent.interrupt()+interrupt_gateway_approvals() - Агент выходит из цикла с
interrupted=True - Gateway берёт
result["final_response"]— это пустая строка (прервано до генерации финального ответа) - stream_consumer.finish() вызывается, но
_accumulatedпустой — ничего не отправляется - Результат: пользователь видит только approval-запрос, ни один terminal output не дошёл
Root cause — подробный data flow
Data flow в стриминг-режиме:
-
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() -
Terminal output (НЕ стримится):
_execute_tool_calls()в conversation_loop вызывает handle_function_call → terminal.execute(). Tool output НЕ проходит через stream_delta_callback. Он сохраняется только вmessages[](как role=tool) внутри агента. -
Tool progress (
run.pyline 16982): Отключён для WEBHOOK (tool_progress_enabled = ... and source.platform != Platform.WEBHOOK). Даже если бы был включён — он показывает только "terminal: "cmd..."", не вывод команды. -
WebhookAdapter не поддерживает edit_message: У WebhookAdapter НЕТ метода
edit_message(толькоsend). Stream consumer пытается edit → получает fallback →_edit_supported=False→ дальнейшие дельты буферизуются в_accumulatedи отправляются только наfinish().
Цепочка потери — пошагово:
- Агент вызывает terminal("echo текст1") — output в messages, НЕ стримится пользователю
- Агент вызывает terminal("sleep 2") — blocker, НЕ стримится
- Агент вызывает terminal("echo текст2") — output в messages, НЕ стримится
- Агент вызывает terminal("sleep 2 && rm -rf ...") — триггерит approval
_await_gateway_decision()— approval is sent to user (текст с "React 👍...")- approval посылается через
_status_adapter.send()(отдельный send, НЕ через stream consumer) - User approves → interrupt →
running_agent.interrupt()+interrupt_gateway_approvals() _interrupt_requested = True→ conversation loop прерываетсяagent.run_conversation()возвращает{final_response: "", interrupted: True, messages: [...]}- Gateway вызывает
_stream_consumer.finish() _accumulatedпуст (terminal outputs никогда не попали в стрим) → ничего не отправляется- Gateway смотрит
result["final_response"]— пусто → "no error message" - Всё потеряно
Ключевая проблема:
- 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().
# В _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 сразу после выполнения. Это требует изменений в нескольких местах.
Проблемы варианта Б:
- Terminal output — бинарный, может быть >100KB — не влезет в одно edit
- Terminal output не редактируется (в отличие от LLM текста) — каждое выполнение новая строка
- Для WebhookAdapter (нет edit_message/стриминга в принципе) — каждое terminal output будет отдельным HTTP POST
Вариант В (баланс — flush на tool boundary):
После каждого tool call, если есть что отправить пользователю — форсировать отсылку перед следующим API call к LLM. Эффект: пользователь видит накопленный стриминг к моменту блокировки на approval.
# В 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(), добавить:
# Если был 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):
- Добавлен
adapter_supports_edit: bool = TrueвStreamConsumerConfig - В
run.pyпри создании consumer читаетсяgetattr(adapter, "SUPPORTS_MESSAGE_EDITING", True)— для WebhookAdapter возвращаетTrue(нет атрибута), поэтому добавлена ручная настройка через конфиг и проверкаcfg.adapter_supports_edit - Создана
elseветка для no-edit (webhook) в stream_consumer — без курсора, отправка по\n - Добавлен rate-limit: partial на
\nне чаще чем раз вedit_interval(~500ms) - Убран
buffer_thresholdдля no-edit — partial только по\n+ таймер, чтобы не рвать строки и не создавать дубли - 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), добавить:
# /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)
- Проанализировать код и написать план
- Добавить guard в
_route_to_active_session()— если команда approve/deny, не прерывать агента и approval - Тесты: 21 passed (2.35s)
- Закоммичено:
eae71aab8—gateway/run.py(8 строк добавил) - Доки обновлены:
reset-stop.md,zulip-router-approval-reactions.md - Проверено 18.06.2026:
/approveчерез@**Кит** /approve— OK (rm -rfвыполнен с одобрения) - Проверено 18.06.2026:
/stop— работает (стоп-слово, блокирует дальнейшие действия)
Phase 1b: /reset (✅ проверено 18.06.2026)
/reset— сбрасывает контекст. Проверено 18.06.2026.
Phase 2: Реакции через роутер — реализация
- Добавить reaction-поля в
ZulipEvent(config.go) - Добавить
"reaction"в event_types и обработку в main.go - Создать MessageOwnerStore в ownership.go
- Добавить
addReaction()в zulip.go - Реализовать обработчик реакции: emoji → choice → Forward
- Pre-seed реакций на approval-сообщения
- Собрать и перезапустить роутер
- Починить seed — перенести ДО
skip_bot_messagesguard'а (seed ставился после выхода) - Образ пересобран, контейнер перезапущен ✅
- Seed реакции ставятся на approval-сообщения ✅
- Deny (👎) форвардит
approve:denyв Hermes ✅ - Закоммичено:
baf41bc✅ - Дока обновлена ✅
Cosmetics: текст approval → реакции в run.py
run.py: приsource.platform == Platform.WEBHOOKзаменяет/approveтекст на реакционные эмодзи (👍🔒♾️👎)- Для остальных платформ (CLI, Telegram, Discord и т.д.) остаётся старый текст с
/approve forwarder.goпопытка (commit82ab623) — откачена, т.к. forwarder обрабатывает Zulip→Hermes направление, а approval идёт Hermes→Zulip- Закоммичено:
81f10c306в~/.hermes/hermes-agent/