[2026-06-18] eagle: personal/projects/zulip-router-approval-reactions.md

This commit is contained in:
Alexey Martemyanov
2026-06-18 15:30:20 +06:00
parent ef970aa9aa
commit b241eb8de5
@@ -206,28 +206,146 @@ func (z *ZulipClient) addReaction(messageID int64, emojiName, emojiCode string)
## Баги Hermes webhook
### Баг 1: Сообщения дропаются если был approval interrupt
### Баг 1: Сообщения дропаются при аппрувл-прерывании
**Проверено/подтверждено 18.06.2026 тестом со sleep-командами:**
Пользователь попросил: написать текст, sleep(2), текст, sleep(2), текст, sleep(2) + rm -rf. Агент выполнил все 4 вызова в одном ответе, но после аппрувл-прерывания на последней команде (`rm -rf`) — ВСЕ предыдущие сообщения (первые 3 вывода terminal) не дошли до пользователя. Дропнулись полностью.
**Механизм потери:** При аппрувл-прерывании генерация стопается, pending-сообщения из стрима не отправляются получателю. Hermes формирует approval-запрос (без буферизации неотправленного контента), пользователь видит только approval, всё что асинхронно настримилось ранее — теряется.
**Воспроизведение (тестовый сценарий):**
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 не дошёл
**Воспроизведение:**
1. Агент шлёт approval request
### Root cause — подробный data flow
Если агент запросил approval и ты ответил (даже не /approve, а любое сообщение), все сообщения которые были до ответа на approval — теряются. Агент их не получает.
**Data flow в стриминг-режиме:**
**Воспроизведение:**
1. Агент шлёт approval request
2. Ты продолжаешь писать другие сообщения в треде
3. Ты отвечаешь на approval
4. Агент получает только approval-ответ, всё что было между — дропнуто
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()`
**Root cause:** `interrupt_gateway_approvals()` + `interrupt()` в `_route_to_active_session()` сбрасывает сообщения агента и восстанавливает стейт на момент approval. Промежуточные сообщения (между approval и ответом) не вшиты в этот стейт.
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: Стриминг не работает