From 00bf0e1793b6871ccf9793ad66b0e548398600b5 Mon Sep 17 00:00:00 2001 From: Taiga Date: Thu, 18 Jun 2026 09:52:09 +0000 Subject: [PATCH] [2026-06-18] taiga-vault: personal/projects/zulip-router-approval-reactions.md --- .../zulip-router-approval-reactions.md | 145 ++++++++++++++++-- 1 file changed, 136 insertions(+), 9 deletions(-) diff --git a/personal/projects/zulip-router-approval-reactions.md b/personal/projects/zulip-router-approval-reactions.md index 3239106a..ecc61bfd 100644 --- a/personal/projects/zulip-router-approval-reactions.md +++ b/personal/projects/zulip-router-approval-reactions.md @@ -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: Стриминг не работает