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:
@@ -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")
|
||||||
|
|||||||
@@ -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("<", "<")
|
msg = ev["msg"].replace("<", "<")
|
||||||
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("<", "<")[:100]
|
preview = ev.get("preview", "").replace("<", "<")[: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(
|
||||||
|
|||||||
Reference in New Issue
Block a user