[2026-06-18] taiga-vault: personal/projects/zulip-router-approval-reactions.md
This commit is contained in:
@@ -206,19 +206,146 @@ func (z *ZulipClient) addReaction(messageID int64, emojiName, emojiCode string)
|
||||
|
||||
## Баги Hermes webhook
|
||||
|
||||
### Баг 1: Сообщения дропаются если был approval interrupt
|
||||
### Баг 1: Сообщения дропаются при аппрувл-прерывании
|
||||
|
||||
Если агент запросил approval и ты ответил (даже не /approve, а любое сообщение), все сообщения которые были до ответа на approval — теряются. Агент их не получает.
|
||||
**Проверено/подтверждено 18.06.2026 тестом со sleep-командами:**
|
||||
|
||||
**Воспроизведение:**
|
||||
1. Агент шлёт approval request
|
||||
2. Ты продолжаешь писать другие сообщения в треде
|
||||
3. Ты отвечаешь на approval
|
||||
4. Агент получает только approval-ответ, всё что было между — дропнуто
|
||||
Пользователь попросил: написать текст, sleep(2), текст, sleep(2), текст, sleep(2) + rm -rf. Агент выполнил все 4 вызова в одном ответе, но после аппрувл-прерывания на последней команде (`rm -rf`) — ВСЕ предыдущие сообщения (первые 3 вывода terminal) не дошли до пользователя. Дропнулись полностью.
|
||||
|
||||
**Root cause:** `interrupt_gateway_approvals()` + `interrupt()` в `_route_to_active_session()` сбрасывает сообщения агента и восстанавливает стейт на момент 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 не дошёл
|
||||
|
||||
**Фикс:** ? (Пока не чинили)
|
||||
### 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 → **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: Стриминг не работает
|
||||
|
||||
|
||||
Reference in New Issue
Block a user