fix: 429 rate limit виден в UI, добавлена кнопка Стоп

- RetryOnRateLimitMiddleware: при 429 шлёт события rate_limit_wait/retry
  в глобальный UI-канал (set_ui_event_queue) — без него не меняет поведение
- ui.py: рендерит  rate_limit_wait и 🔄 rate_limit_retry в лог событий
- ui.py: показывает «Агент работает... Nс» если нет событий >15с
- ui.py: кнопка «Стоп» прерывает ожидание в чате и pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 16:59:10 +03:00
parent ee2c8de372
commit cba8940005
2 changed files with 117 additions and 11 deletions
+38 -3
View File
@@ -2,15 +2,34 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import queue
import time
from typing import Any from typing import Any
from langchain.agents.middleware import AgentMiddleware, AgentState from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import ToolMessage
_PAUSE = 30 # секунд ожидания при 429 _PAUSE = 30 # секунд ожидания при 429
_TRIES = 5 # максимум попыток _TRIES = 5 # максимум попыток
# Глобальный канал событий для UI (устанавливается из ui.py перед запуском агента).
# Если None — события просто не отправляются (CLI-режим).
_ui_event_queue: queue.Queue | None = None
def set_ui_event_queue(q: queue.Queue | None) -> None:
"""Вызывается из ui.py чтобы подключить очередь событий."""
global _ui_event_queue
_ui_event_queue = q
def _emit(event: dict) -> None:
if _ui_event_queue is not None:
try:
_ui_event_queue.put_nowait(event)
except Exception:
pass
def _is_429(exc: Exception) -> bool: def _is_429(exc: Exception) -> bool:
msg = str(exc) msg = str(exc)
@@ -18,7 +37,9 @@ def _is_429(exc: Exception) -> bool:
class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]): class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
"""Перехватывает 429 от любого инструмента и повторяет с паузой.""" """Перехватывает 429 от любого инструмента и повторяет с паузой.
Отправляет события rate_limit_wait / rate_limit_retry в UI-очередь.
"""
def wrap_tool_call(self, request, handler): def wrap_tool_call(self, request, handler):
for attempt in range(1, _TRIES + 1): for attempt in range(1, _TRIES + 1):
@@ -28,8 +49,12 @@ class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
if _is_429(e) and attempt < _TRIES: if _is_429(e) and attempt < _TRIES:
name = request.tool_call.get("name", "") name = request.tool_call.get("name", "")
print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...") print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...")
import time _emit({"t": "rate_limit_wait", "name": name,
"pause": _PAUSE, "attempt": attempt, "max": _TRIES,
"ts": _now()})
time.sleep(_PAUSE) time.sleep(_PAUSE)
_emit({"t": "rate_limit_retry", "name": name,
"attempt": attempt + 1, "ts": _now()})
else: else:
raise raise
@@ -41,6 +66,16 @@ class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
if _is_429(e) and attempt < _TRIES: if _is_429(e) and attempt < _TRIES:
name = request.tool_call.get("name", "") name = request.tool_call.get("name", "")
print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...") print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...")
_emit({"t": "rate_limit_wait", "name": name,
"pause": _PAUSE, "attempt": attempt, "max": _TRIES,
"ts": _now()})
await asyncio.sleep(_PAUSE) await asyncio.sleep(_PAUSE)
_emit({"t": "rate_limit_retry", "name": name,
"attempt": attempt + 1, "ts": _now()})
else: else:
raise raise
def _now() -> str:
from datetime import datetime
return datetime.now().strftime("%H:%M:%S")
+76 -5
View File
@@ -147,13 +147,21 @@ class AgentCallback(BaseCallbackHandler):
# Запуск агента в фоне # Запуск агента в фоне
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _run_agent_thread(agent, messages, config, q: queue.Queue, cb: AgentCallback): def _run_agent_thread(
agent, messages, config, q: queue.Queue, cb: AgentCallback,
stop_flag: threading.Event,
):
from src.agent.middlewares.retry_on_rate_limit import set_ui_event_queue
set_ui_event_queue(q)
async def _inner(): async def _inner():
try: try:
result = await agent.ainvoke(messages, {**config, "callbacks": [cb]}) result = await agent.ainvoke(messages, {**config, "callbacks": [cb]})
q.put({"t": "done", "result": result}) q.put({"t": "done", "result": result})
except Exception as e: except Exception as e:
q.put({"t": "fatal", "msg": str(e)}) q.put({"t": "fatal", "msg": str(e)})
finally:
set_ui_event_queue(None)
if not stop_flag.is_set():
asyncio.run(_inner()) asyncio.run(_inner())
@@ -207,6 +215,21 @@ def _render_event(ev: dict) -> str:
msg = ev["msg"].replace("<", "&lt;") msg = ev["msg"].replace("<", "&lt;")
return f'<div class="tool-result" style="border-color:#ef4444;color:#f87171">⚠ {msg}</div>' return f'<div class="tool-result" style="border-color:#ef4444;color:#f87171">⚠ {msg}</div>'
if kind == "rate_limit_wait":
name = ev.get("name", "?")
pause = ev.get("pause", 30)
attempt = ev.get("attempt", 1)
mx = ev.get("max", 5)
return (f'<div class="thinking" style="color:#f59e0b">'
f'{ts} 429 rate limit — {name} · жду {pause}с '
f'(попытка {attempt}/{mx})</div>')
if kind == "rate_limit_retry":
name = ev.get("name", "?")
attempt = ev.get("attempt", 2)
return (f'<div class="thinking" style="color:#86efac">'
f'🔄 {ts} повтор {name} (попытка {attempt})...</div>')
if kind == "llm_end": if kind == "llm_end":
preview = ev.get("preview", "").replace("<", "&lt;")[:100] preview = ev.get("preview", "").replace("<", "&lt;")[:100]
return f'<div class="thinking">✏ {ts} {preview}...</div>' return f'<div class="thinking">✏ {ts} {preview}...</div>'
@@ -287,18 +310,28 @@ with tab_chat:
evq: queue.Queue = queue.Queue() evq: queue.Queue = queue.Queue()
cb = AgentCallback(evq) cb = AgentCallback(evq)
all_events: list[dict] = [] all_events: list[dict] = []
stop_flag = threading.Event()
thread = threading.Thread( thread = threading.Thread(
target=_run_agent_thread, target=_run_agent_thread,
args=(agent, {"messages": lc_messages}, config, evq, cb), args=(agent, {"messages": lc_messages}, config, evq, cb, stop_flag),
daemon=True, daemon=True,
) )
thread.start() thread.start()
final_result = None final_result = None
fatal = None fatal = None
last_event_t = time.time()
stop_ph = st.empty()
while thread.is_alive() or not evq.empty(): while thread.is_alive() or not evq.empty():
# Кнопка "Стоп"
if stop_ph.button("🛑 Остановить", key=f"stop_{int(time.time()*1000)}"):
stop_flag.set()
fatal = "Остановлено пользователем"
break
changed = False changed = False
while not evq.empty(): while not evq.empty():
ev = evq.get_nowait() ev = evq.get_nowait()
@@ -309,8 +342,21 @@ with tab_chat:
fatal = ev["msg"] fatal = ev["msg"]
else: else:
all_events.append(ev) all_events.append(ev)
last_event_t = time.time()
changed = True changed = True
# Показываем "ожидание" если нет событий >15с
idle = time.time() - last_event_t
if idle > 15 and thread.is_alive():
status_ph.markdown(
f'<div class="thinking" style="color:#f59e0b">'
f'⏳ Агент работает... ({int(idle)}с без событий — '
f'возможно ожидание rate limit)</div>',
unsafe_allow_html=True,
)
else:
status_ph.empty()
if changed and all_events: if changed and all_events:
html = "".join(_render_event(e) for e in all_events[-60:]) html = "".join(_render_event(e) for e in all_events[-60:])
events_ph.markdown( events_ph.markdown(
@@ -318,9 +364,11 @@ with tab_chat:
f'max-height:250px;overflow-y:auto">{html}</div>', f'max-height:250px;overflow-y:auto">{html}</div>',
unsafe_allow_html=True, unsafe_allow_html=True,
) )
time.sleep(0.15) time.sleep(0.3)
stop_ph.empty()
events_ph.empty() events_ph.empty()
status_ph.empty()
st.session_state.chat_events = all_events st.session_state.chat_events = all_events
if fatal: if fatal:
@@ -379,19 +427,28 @@ with tab_pipeline:
evq2: queue.Queue = queue.Queue() evq2: queue.Queue = queue.Queue()
cb2 = AgentCallback(evq2) cb2 = AgentCallback(evq2)
all_events2: list[dict] = [] all_events2: list[dict] = []
stop_flag2 = threading.Event()
thread2 = threading.Thread( thread2 = threading.Thread(
target=_run_agent_thread, target=_run_agent_thread,
args=(agent, messages, config, evq2, cb2), args=(agent, messages, config, evq2, cb2, stop_flag2),
daemon=True, daemon=True,
) )
thread2.start() thread2.start()
final2 = None final2 = None
fatal2 = None fatal2 = None
last_event_t2 = time.time()
pipe_status_ph = st.empty()
pipe_stop_ph = st.empty()
with st.spinner(f"Агент решает {task_id[:8]}..."): with st.spinner(f"Агент решает {task_id[:8]}..."):
while thread2.is_alive() or not evq2.empty(): while thread2.is_alive() or not evq2.empty():
if pipe_stop_ph.button("🛑 Остановить", key=f"pipe_stop_{int(time.time()*1000)}"):
stop_flag2.set()
fatal2 = "Остановлено пользователем"
break
while not evq2.empty(): while not evq2.empty():
ev = evq2.get_nowait() ev = evq2.get_nowait()
if ev["t"] == "done": if ev["t"] == "done":
@@ -400,6 +457,17 @@ with tab_pipeline:
fatal2 = ev["msg"] fatal2 = ev["msg"]
else: else:
all_events2.append(ev) all_events2.append(ev)
last_event_t2 = time.time()
idle2 = time.time() - last_event_t2
if idle2 > 15 and thread2.is_alive():
pipe_status_ph.markdown(
f'<div class="thinking" style="color:#f59e0b">'
f'⏳ Агент работает... ({int(idle2)}с — возможно rate limit)</div>',
unsafe_allow_html=True,
)
else:
pipe_status_ph.empty()
if all_events2: if all_events2:
html = "".join(_render_event(e) for e in all_events2[-50:]) html = "".join(_render_event(e) for e in all_events2[-50:])
@@ -408,7 +476,10 @@ with tab_pipeline:
f'max-height:300px;overflow-y:auto">{html}</div>', f'max-height:300px;overflow-y:auto">{html}</div>',
unsafe_allow_html=True, unsafe_allow_html=True,
) )
time.sleep(0.15) time.sleep(0.3)
pipe_stop_ph.empty()
pipe_status_ph.empty()
if fatal2: if fatal2:
result_ph.markdown( result_ph.markdown(