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

26 KiB
Raw Permalink Blame History

created, status, tags, title, type, updated
created status tags title type updated
2026-06-17 draft
zulip-router
approval
reactions
hermes
architecture
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):

  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), добавить:

# /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

// 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) не дошли до пользователя. Дропнулись полностью.

Воспроизведение (тестовый сценарий):

  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 → interruptrunning_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().

# В _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.

# В 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):

  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), добавить:

# /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)
  • Закоммичено: eae71aab8gateway/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_messages guard'а (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 попытка (commit 82ab623) — откачена, т.к. forwarder обрабатывает Zulip→Hermes направление, а approval идёт Hermes→Zulip
  • Закоммичено: 81f10c306 в ~/.hermes/hermes-agent/