refactor(agent): use local JWT decoder instead of src.core.auth.jwt

Replace src.core.auth.jwt import with lightweight _jwt_decoder.py that
uses jose.jwt directly with JWT_SECRET env var. Avoids pulling AuthConfig
→ AUTH_DATABASE_URL/SECRET_KEY validators → SQLAlchemy models into agent.

Removed: AUTH_SECRET_KEY, AUTH_DATABASE_URL from agent compose env.
Removed: src/core/auth/ copies from Dockerfile.agent COPY section.
Added:   src/agent/_jwt_decoder.py — stateless JWT validation.
This commit is contained in:
2026-07-06 13:37:08 +03:00
parent b2d5aa5bf8
commit 590b09f587
7 changed files with 302 additions and 197 deletions

View File

@@ -0,0 +1,40 @@
# backend/src/agent/_jwt_decoder.py
# #region AgentChat.JwtDecoder [C:1] [TYPE Module] [SEMANTICS agent-chat,jwt,decode]
# @BRIEF Lightweight JWT decode for agent — uses JWT_SECRET env var directly, avoids
# pulling src.core.auth.jwt which requires AUTH_DATABASE_URL and ORM deps.
# @RATIONALE The agent only needs stateless JWT validation (exp, sub, signature).
# Token blacklisting is only relevant for backend auth flow — the agent
# processes one request at a time and doesn't need DB-backed revocation.
# @REJECTED Importing src.core.auth.jwt.decode_token was rejected — it drags in
# AuthConfig → AUTH_DATABASE_URL/SECRET_KEY validators → SQLAlchemy models,
# bloating the agent image with backend infrastructure it doesn't need.
import os
from jose import jwt
_DEFAULT_SECRET = os.getenv("JWT_SECRET", "")
_DEFAULT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
def decode_token(token: str) -> dict:
"""Decode and validate a JWT token using JWT_SECRET from environment.
Performs stateless validation: signature, expiration, and required claims
(exp, sub).
Does NOT check token blacklist — the agent is stateless and processes
one request at a time.
"""
return jwt.decode(
token,
_DEFAULT_SECRET,
algorithms=[_DEFAULT_ALGORITHM],
options={
"verify_signature": True,
"verify_exp": True,
"require": ["exp", "sub"],
},
)
# #endregion AgentChat.JwtDecoder

View File

@@ -24,6 +24,7 @@ TITLE_MAX_LENGTH = 80
# ── Rule-based title cleaning ────────────────────────────────────
# #region AgentChat.Persistence.CleanTitle [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,title]
# @ingroup AgentChat
# @BRIEF Clean raw user text into a readable conversation title — strip file markers,
@@ -47,11 +48,11 @@ def clean_title(user_text: str) -> str: # noqa: C901
# ── Strip file upload markers (cut before first occurrence) ──
file_markers = [
"\n--- Uploaded file content ---",
"--- Uploaded file content ---", # no leading newline
"--- Uploaded file content ---", # no leading newline
"\n[PRE-FETCHED DATA",
"[PRE-FETCHED DATA", # no leading newline
"[PRE-FETCHED DATA", # no leading newline
"\n[/PRE-FETCHED DATA]",
"[/PRE-FETCHED DATA]", # no leading newline
"[/PRE-FETCHED DATA]", # no leading newline
]
cut_pos = len(text)
for marker in file_markers:
@@ -67,11 +68,11 @@ def clean_title(user_text: str) -> str: # noqa: C901
# ── Take first meaningful segment ──
# Priority: first sentence ending with .!?, else first line, else full text
sentence_end = -1
for m in re.finditer(r'[.!?]\s', text):
for m in re.finditer(r"[.!?]\s", text):
sentence_end = m.start()
break
if sentence_end > 3: # don't cut "Привет." into "Привет"
text = text[:sentence_end + 1].strip()
text = text[: sentence_end + 1].strip()
elif "\n" in text:
text = text.split("\n")[0].strip()
@@ -86,6 +87,7 @@ def clean_title(user_text: str) -> str: # noqa: C901
if text.startswith("http://") or text.startswith("https://"):
try:
from urllib.parse import urlparse
domain = urlparse(text).netloc or "ссылка"
except Exception:
domain = "ссылка"
@@ -108,6 +110,8 @@ def clean_title(user_text: str) -> str: # noqa: C901
return "Новый диалог"
return text
# #endregion AgentChat.Persistence.CleanTitle
@@ -124,6 +128,8 @@ def detect_message_state(text: str) -> str | None:
if any(m in t for m in error_markers):
return "error"
return None
# #endregion AgentChat.Persistence.DetectState
@@ -133,11 +139,14 @@ def detect_message_state(text: str) -> str | None:
# @POST Returns user_id string or "unknown".
def extract_user_id(jwt_str: str) -> str:
try:
from src.core.auth.jwt import decode_token
from src.agent._jwt_decoder import decode_token
payload = decode_token(jwt_str)
return payload.get("sub", payload.get("user_id", "unknown"))
except Exception:
return "unknown"
# #endregion AgentChat.Persistence.ExtractUserId
@@ -182,11 +191,7 @@ async def _call_llm_for_title(user_text: str) -> str | None:
if not clean_text or clean_text in ("Новый диалог",):
return None
prompt = (
f"Сгенерируй заголовок из 3-5 слов на русском для диалога. "
f"Только заголовок, без кавычек и пояснений.\n\n"
f"Диалог: {clean_text}"
)
prompt = f"Сгенерируй заголовок из 3-5 слов на русском для диалога. Только заголовок, без кавычек и пояснений.\n\nДиалог: {clean_text}"
api_key = config.get("api_key", "")
base_url = config.get("base_url", "")
@@ -214,7 +219,8 @@ async def _call_llm_for_title(user_text: str) -> str | None:
if resp.status_code != 200:
_logger.explore(
"LLM title: API error",
payload={"status": resp.status_code, "reason": resp.reason_phrase}, error=f"HTTP {resp.status_code}: {resp.text[:100]}",
payload={"status": resp.status_code, "reason": resp.reason_phrase},
error=f"HTTP {resp.status_code}: {resp.text[:100]}",
extra={"src": "AgentChat.Persistence"},
)
return None
@@ -222,7 +228,7 @@ async def _call_llm_for_title(user_text: str) -> str | None:
title = data.get("choices", [{}])[0].get("message", {}).get("content", "")
if title:
# Strip markdown/formatting
title = re.sub(r'[*_`#"\']', '', title).strip()
title = re.sub(r'[*_`#"\']', "", title).strip()
title = title[:100] # safety cap
if title:
return title
@@ -274,11 +280,14 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None:
except Exception as e:
logger.explore(
"LLM title save failed",
payload={"conv_id": conv_id}, error=str(e),
payload={"conv_id": conv_id},
error=str(e),
extra={"src": "AgentChat.Persistence"},
)
finally:
_title_locks.pop(conv_id, None)
# #endregion AgentChat.Persistence.GenerateLlmTitle
@@ -286,6 +295,7 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None:
# Prefetch dashboards
# ═══════════════════════════════════════════════════════════════════
# #region AgentChat.Persistence.PrefetchDashboards [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,prefetch]
# @ingroup AgentChat
# @BRIEF Pre-fetch dashboard data so the LLM has it in context without needing to call a tool.
@@ -296,6 +306,7 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None:
async def prefetch_dashboards(env_id: str) -> str:
try:
from src.agent.tools import FASTAPI_URL, _dual_auth_headers
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards",
@@ -326,10 +337,13 @@ async def prefetch_dashboards(env_id: str) -> str:
except Exception as e:
logger.explore(
"Prefetch dashboards failed",
payload={"env_id": env_id}, error=str(e),
payload={"env_id": env_id},
error=str(e),
extra={"src": "AgentChat.Persistence.PrefetchDashboards"},
)
return ""
# #endregion AgentChat.Persistence.PrefetchDashboards
@@ -341,6 +355,7 @@ async def prefetch_dashboards(env_id: str) -> str:
async def prefetch_databases(env_id: str) -> str:
try:
from src.agent.tools import FASTAPI_URL, _dual_auth_headers
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{FASTAPI_URL}/api/agent/superset/databases",
@@ -365,10 +380,13 @@ async def prefetch_databases(env_id: str) -> str:
except Exception as e:
logger.explore(
"Prefetch databases failed",
payload={"env_id": env_id}, error=str(e),
payload={"env_id": env_id},
error=str(e),
extra={"src": "AgentChat.Persistence.PrefetchDatabases"},
)
return ""
# #endregion AgentChat.Persistence.PrefetchDatabases
@@ -404,14 +422,16 @@ async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin"
]
if assistant_text.strip():
assistant_state = detect_message_state(assistant_text)
messages.append({
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "assistant",
"text": assistant_text.strip(),
"state": assistant_state,
"created_at": datetime.utcnow().isoformat(),
})
messages.append(
{
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "assistant",
"text": assistant_text.strip(),
"state": assistant_state,
"created_at": datetime.utcnow().isoformat(),
}
)
title = clean_title(user_text)
payload = {
@@ -431,8 +451,11 @@ async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin"
except Exception as e:
logger.explore(
"Failed to save conversation",
payload={"conv_id": conv_id}, error=str(e),
payload={"conv_id": conv_id},
error=str(e),
extra={"src": "AgentChat.Persistence"},
)
# #endregion AgentChat.Persistence.SaveConversation
# #endregion AgentChat.Persistence

View File

@@ -58,7 +58,7 @@ from src.agent.tools import (
get_all_tools,
start_tool_retry_event_buffer,
)
from src.core.auth.jwt import decode_token
from src.agent._jwt_decoder import decode_token
from src.core.cot_logger import seed_trace_id
from src.core.logger import logger
@@ -83,22 +83,26 @@ async def _build_agent_context(env_id: str | None) -> str:
parts.append(f"Current environment_id: {env_id}")
dashboards = await prefetch_dashboards(env_id)
if dashboards:
parts.extend([
"",
"[PRE-FETCHED DASHBOARDS]",
dashboards,
"[/PRE-FETCHED DASHBOARDS]",
"Use the pre-fetched dashboards directly for dashboard name/id resolution.",
])
parts.extend(
[
"",
"[PRE-FETCHED DASHBOARDS]",
dashboards,
"[/PRE-FETCHED DASHBOARDS]",
"Use the pre-fetched dashboards directly for dashboard name/id resolution.",
]
)
databases = await prefetch_databases(env_id)
if databases:
parts.extend([
"",
"[PRE-FETCHED DATABASES]",
databases,
"[/PRE-FETCHED DATABASES]",
"Use the pre-fetched databases directly for database_id resolution. Always use database_id from this list.",
])
parts.extend(
[
"",
"[PRE-FETCHED DATABASES]",
databases,
"[/PRE-FETCHED DATABASES]",
"Use the pre-fetched databases directly for database_id resolution. Always use database_id from this list.",
]
)
parts.append("[/RUNTIME CONTEXT]")
return "\n".join(parts)
@@ -127,6 +131,8 @@ async def _generate_title_best_effort(conv_id: str, visible_user_text: str) -> N
error=str(exc),
extra={"src": "AgentChat.GradioApp.Title"},
)
# #endregion AgentChat.GradioApp.TitleBestEffort
# ── Session state ───────────────────────────────────────────────
@@ -145,6 +151,7 @@ _LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts"
# ── File persistence ────────────────────────────────────────────
# #region AgentChat.GradioApp.PersistFile [C:3] [TYPE Function] [SEMANTICS agent-chat,storage,file]
# @ingroup AgentChat
# @BRIEF Copy uploaded chat file to storage under chat_uploads category.
@@ -187,6 +194,8 @@ def _persist_chat_file(file_path: str, conv_id: str) -> str | None:
extra={"src": "AgentChat.PersistFile"},
)
return rel_path
# #endregion AgentChat.GradioApp.PersistFile
# Per-conversation mutex for HITL resume (FR-026): keyed by conversation_id
_conv_locks: dict[str, asyncio.Event] = {}
@@ -204,6 +213,7 @@ _service_jwt_cache: dict[str, str] = {}
# the generator and sends yielded JSON strings as event data to the frontend.
# @REJECTED Returning a single response (non-streaming) was rejected — violates FR-003 (streaming mandate).
# #region AgentChat.GradioApp.LlmHealthCheck [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,health]
# @ingroup AgentChat
# @BRIEF Check LLM provider connectivity with in-memory cache (30s TTL).
@@ -218,16 +228,19 @@ async def _check_llm_provider_health() -> str:
try:
from src.agent.langgraph_setup import _fetch_llm_config as _get_config
config = await _get_config()
if not config or not config.get("configured"):
return _llm_status["status"]
llm = ChatOpenAI(**chat_openai_kwargs(
model=config.get("default_model", "gpt-4o-mini"),
base_url=config.get("base_url", "https://api.openai.com/v1"),
api_key=config.get("api_key", ""),
max_tokens=1,
))
llm = ChatOpenAI(
**chat_openai_kwargs(
model=config.get("default_model", "gpt-4o-mini"),
base_url=config.get("base_url", "https://api.openai.com/v1"),
api_key=config.get("api_key", ""),
max_tokens=1,
)
)
await llm.ainvoke([HumanMessage(content="ping")])
_llm_status["status"] = "ok"
_llm_status["last_error"] = ""
@@ -254,10 +267,10 @@ async def _check_llm_provider_health() -> str:
_llm_status["last_error"] = ""
_llm_status["last_check_ts"] = time.time()
return "ok"
logger.explore("LLM health check failed",
error=str(exc),
extra={"src": "AgentChat.GradioApp.LlmHealthCheck"})
logger.explore("LLM health check failed", error=str(exc), extra={"src": "AgentChat.GradioApp.LlmHealthCheck"})
return _llm_status["status"] # return cached status on unexpected errors
# #endregion AgentChat.GradioApp.LlmHealthCheck
@@ -279,6 +292,8 @@ def _inject_uicontext(runtime_context: str, uicontext: dict) -> str:
lines.append(f"Environment: {uicontext['envId']}")
lines.append("[/USER CONTEXT]")
return "\n".join(lines)
# #endregion AgentChat.GradioApp.InjectUIContext
@@ -298,14 +313,10 @@ def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list:
if not env_id:
return tools
for tool in tools:
# Only wrap tools that accept 'environment_id' parameter
sig = inspect.signature(
tool.coroutine if tool.coroutine
else (tool.func if tool.func else tool._run)
)
if 'environment_id' not in sig.parameters:
sig = inspect.signature(tool.coroutine if tool.coroutine else (tool.func if tool.func else tool._run))
if "environment_id" not in sig.parameters:
continue
# Intercept input before args_schema validation
@@ -314,12 +325,14 @@ def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list:
def _make_parse_wrapper(orig_fn, eid: str):
"""Create a wrapper that injects env_id into tool_input dict."""
@functools.wraps(orig_fn)
def wrapped(self, tool_input, tool_call_id=None):
if isinstance(tool_input, dict):
if tool_input.get('environment_id') is None:
tool_input = {**tool_input, 'environment_id': eid}
if tool_input.get("environment_id") is None:
tool_input = {**tool_input, "environment_id": eid}
return orig_fn(self, tool_input, tool_call_id)
return wrapped
tool._parse_input = _make_parse_wrapper(orig_unbound, env_id).__get__(tool, type(tool))
@@ -327,14 +340,18 @@ def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list:
# Wrap coroutine as safety net
original_coro = tool.coroutine
if original_coro:
@functools.wraps(original_coro)
async def env_aware_coro(*args, **kwargs):
if kwargs.get('environment_id') is None and env_id:
kwargs['environment_id'] = env_id
if kwargs.get("environment_id") is None and env_id:
kwargs["environment_id"] = env_id
return await original_coro(*args, **kwargs)
tool.coroutine = env_aware_coro
return tools
# #endregion AgentChat.GradioApp.EnvInjection
@@ -390,8 +407,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
is_resume = action in ("confirm", "deny")
logger.reason(
"Agent handler invoked",
payload={"user_id": user_id, "conv_id": conv_id, "action": action, "env_id": env_id,
"is_resume": is_resume, "msg_len": len(str(message))},
payload={"user_id": user_id, "conv_id": conv_id, "action": action, "env_id": env_id, "is_resume": is_resume, "msg_len": len(str(message))},
extra={"src": "AgentChat.GradioApp.Handler"},
)
@@ -406,15 +422,17 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
if len(text) > max_msg_length:
truncated = text[:max_msg_length]
last_sentence_end = max(
truncated.rfind('. '), truncated.rfind('! '), truncated.rfind('? '),
truncated.rfind('.\n'), truncated.rfind('!\n'), truncated.rfind('?\n'),
truncated.rfind('.\r'), truncated.rfind('!\r'), truncated.rfind('?\r'),
)
text = (
text[:last_sentence_end + 1] + "\n[...truncated]"
if last_sentence_end > max_msg_length * 0.8
else truncated + "\n[...truncated]"
truncated.rfind(". "),
truncated.rfind("! "),
truncated.rfind("? "),
truncated.rfind(".\n"),
truncated.rfind("!\n"),
truncated.rfind("?\n"),
truncated.rfind(".\r"),
truncated.rfind("!\r"),
truncated.rfind("?\r"),
)
text = text[: last_sentence_end + 1] + "\n[...truncated]" if last_sentence_end > max_msg_length * 0.8 else truncated + "\n[...truncated]"
visible_user_text = text
# ── File upload ──
@@ -427,10 +445,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
if file_path and os.path.exists(file_path):
file_size = os.path.getsize(file_path)
if file_size > MAX_FILE_SIZE_BYTES:
yield json.dumps({
"content": f"❌ File exceeds 10MB limit ({file_size / 1024 / 1024:.1f} MB)",
"metadata": {"type": "error", "code": "FILE_TOO_LARGE", "detail": "Max file size is 10 MB"},
})
yield json.dumps(
{
"content": f"❌ File exceeds 10MB limit ({file_size / 1024 / 1024:.1f} MB)",
"metadata": {"type": "error", "code": "FILE_TOO_LARGE", "detail": "Max file size is 10 MB"},
}
)
return
# Persist file to storage for download
if conv_id:
@@ -445,6 +465,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
try:
uicontext = json.loads(uicontext_str)
from src.agent._context import validate_uicontext
uicontext = validate_uicontext(uicontext)
logger.reason("UIContext received", payload={"objectType": uicontext.get("objectType"), "objectId": uicontext.get("objectId")}, extra={"src": "AgentChat.Handler"})
except json.JSONDecodeError:
@@ -465,14 +486,16 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
# ── Yield file metadata for frontend download link ──
if file_storage_path and file_original_name:
yield json.dumps({
"metadata": {
"type": "file_uploaded",
"file_name": file_original_name,
"file_path": file_storage_path,
"file_size": file_size,
yield json.dumps(
{
"metadata": {
"type": "file_uploaded",
"file_name": file_original_name,
"file_path": file_storage_path,
"file_size": file_size,
}
}
})
)
# ── HITL resume path ──
if action in ("confirm", "deny"):
@@ -483,10 +506,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
try:
await asyncio.wait_for(lock.wait(), timeout=2.0)
except TimeoutError:
yield json.dumps({
"content": "❌ Ошибка: предыдущий стрим ещё не завершён",
"metadata": {"type": "error", "code": "STREAM_CLEANUP_TIMEOUT", "detail": "Предыдущий поток не завершился. Повторите запрос."},
})
yield json.dumps(
{
"content": "❌ Ошибка: предыдущий стрим ещё не завершён",
"metadata": {"type": "error", "code": "STREAM_CLEANUP_TIMEOUT", "detail": "Предыдущий поток не завершился. Повторите запрос."},
}
)
return
# Capture tool_name BEFORE handle_resume pops the pending confirmation
pending_pre = _pending_confirmations.get(conv_id, {}) if conv_id else {}
@@ -507,20 +532,23 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
agent_tools = get_all_tools()
# Apply tool pipeline: RBAC → context affinity (035-agent-chat-context)
from src.agent._tool_filter import build_tool_pipeline
agent_tools = build_tool_pipeline(
agent_tools,
user_role,
uicontext.get("objectType") if uicontext else None,
)
yield json.dumps({
"content": "",
"metadata": {
"type": "pipeline_result",
"tools": [tool.name for tool in agent_tools],
"object_type": uicontext.get("objectType") if uicontext else None,
"user_role": user_role,
},
})
yield json.dumps(
{
"content": "",
"metadata": {
"type": "pipeline_result",
"tools": [tool.name for tool in agent_tools],
"object_type": uicontext.get("objectType") if uicontext else None,
"user_role": user_role,
},
}
)
# Auto-inject environment_id into tool calls when LLM omits it
agent_tools = _inject_env_id_into_tools(agent_tools, env_id)
agent = await create_agent(agent_tools, env_id)
@@ -552,43 +580,45 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
if isinstance(content, str):
token_text = content
elif isinstance(content, list):
token_text = "".join(
str(item.get("text") or item.get("content") or "")
if isinstance(item, dict) else str(item)
for item in content
)
token_text = "".join(str(item.get("text") or item.get("content") or "") if isinstance(item, dict) else str(item) for item in content)
else:
token_text = str(content)
if not token_text:
continue
emitted_any = True
assistant_parts.append(token_text)
yield json.dumps({
"content": token_text,
"metadata": {"type": "stream_token", "token": token_text},
})
yield json.dumps(
{
"content": token_text,
"metadata": {"type": "stream_token", "token": token_text},
}
)
elif kind == "on_tool_start":
tool_name = event["name"]
emitted_any = True
redacted_input = _redact_sensitive_fields(event["data"].get("input", {}))
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": redacted_input},
})
yield json.dumps(
{
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": redacted_input},
}
)
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
emitted_any = True
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
yield json.dumps(
{
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
}
)
elif kind == "on_tool_error":
tool_name = event["name"]
err = str(event["data"].get("error", "Unknown"))
emitted_any = True
if "PERMISSION_DENIED:" in err:
marker = err[err.index("PERMISSION_DENIED:"):]
marker = err[err.index("PERMISSION_DENIED:") :]
_, denied_tool, required_role, denied_role = marker.split(":", 3)
denied_role = denied_role.split()[0].strip("'\"")
yield permission_denied_payload(
@@ -599,24 +629,33 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
continue
if "timed out" in err.lower() or "timeout" in err.lower():
is_write_tool = "operation status unknown" in err.lower() or tool_name in {
"deploy_dashboard", "commit_changes", "create_branch", "run_backup",
"execute_migration", "start_maintenance", "end_maintenance",
"deploy_dashboard",
"commit_changes",
"create_branch",
"run_backup",
"execute_migration",
"start_maintenance",
"end_maintenance",
}
yield json.dumps({
"content": f"⏱️ {tool_name} — timeout",
"metadata": {
"type": "tool_timeout",
"tool": tool_name,
"timeout_seconds": 30,
"is_write_tool": is_write_tool,
"retryable": not is_write_tool,
},
})
yield json.dumps(
{
"content": f"⏱️ {tool_name} — timeout",
"metadata": {
"type": "tool_timeout",
"tool": tool_name,
"timeout_seconds": 30,
"is_write_tool": is_write_tool,
"retryable": not is_write_tool,
},
}
)
continue
yield json.dumps({
"content": f"{tool_name}{err}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": err},
})
yield json.dumps(
{
"content": f"{tool_name}{err}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": err},
}
)
state = await agent.aget_state(config)
for retry_event in drain_tool_retry_events():
@@ -627,30 +666,30 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id)
return
elif not emitted_any:
yield json.dumps({
"content": "❌ Агент завершился без ответа.",
"metadata": {"type": "error", "code": "EMPTY_AGENT_RESPONSE",
"detail": "Агент завершил обработку без ответа. Попробуйте переформулировать запрос.",
"state_next": repr(getattr(state, "next", None)),
"state_tasks": repr(getattr(state, "tasks", None))[:500]},
})
yield json.dumps(
{
"content": "❌ Агент завершился без ответа.",
"metadata": {"type": "error", "code": "EMPTY_AGENT_RESPONSE", "detail": "Агент завершил обработку без ответа. Попробуйте переформулировать запрос.", "state_next": repr(getattr(state, "next", None)), "state_tasks": repr(getattr(state, "tasks", None))[:500]},
}
)
break
except (APIConnectionError, httpx.ConnectError) as exc:
_llm_status["status"] = "unavailable"
_llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider connection failed",
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"})
yield json.dumps({
"content": "❌ LLM провайдер недоступен",
"metadata": {
"type": "error", "code": "LLM_PROVIDER_UNAVAILABLE",
"detail": "LLM провайдер недоступен. Проверьте подключение к upstream API.",
"retryable": True,
},
})
logger.explore("LLM provider connection failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
yield json.dumps(
{
"content": "❌ LLM провайдер недоступен",
"metadata": {
"type": "error",
"code": "LLM_PROVIDER_UNAVAILABLE",
"detail": "LLM провайдер недоступен. Проверьте подключение к upstream API.",
"retryable": True,
},
}
)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return
@@ -658,17 +697,18 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["status"] = "timeout"
_llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider timed out",
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"})
yield json.dumps({
"content": "❌ LLM провайдер не отвечает",
"metadata": {
"type": "error", "code": "LLM_TIMEOUT",
"detail": "LLM провайдер не отвечает. Таймаут соединения.",
"retryable": True,
},
})
logger.explore("LLM provider timed out", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
yield json.dumps(
{
"content": "❌ LLM провайдер не отвечает",
"metadata": {
"type": "error",
"code": "LLM_TIMEOUT",
"detail": "LLM провайдер не отвечает. Таймаут соединения.",
"retryable": True,
},
}
)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return
@@ -676,17 +716,18 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["status"] = "auth_error"
_llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider auth failed",
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"})
yield json.dumps({
"content": "❌ API ключ LLM отклонён",
"metadata": {
"type": "error", "code": "LLM_AUTH_ERROR",
"detail": "API ключ LLM отклонён. Проверьте credentials.",
"retryable": False,
},
})
logger.explore("LLM provider auth failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
yield json.dumps(
{
"content": "❌ API ключ LLM отклонён",
"metadata": {
"type": "error",
"code": "LLM_AUTH_ERROR",
"detail": "API ключ LLM отклонён. Проверьте credentials.",
"retryable": False,
},
}
)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return
@@ -694,17 +735,18 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["status"] = "unavailable"
_llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider rate limited",
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"})
yield json.dumps({
"content": "❌ Превышен лимит запросов к LLM. Попробуйте позже.",
"metadata": {
"type": "error", "code": "LLM_RATE_LIMITED",
"detail": "Превышена квота LLM провайдера. Повторите запрос через несколько минут.",
"retryable": True,
},
})
logger.explore("LLM provider rate limited", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
yield json.dumps(
{
"content": "❌ Превышен лимит запросов к LLM. Попробуйте позже.",
"metadata": {
"type": "error",
"code": "LLM_RATE_LIMITED",
"detail": "Превышена квота LLM провайдера. Повторите запрос через несколько минут.",
"retryable": True,
},
}
)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return
@@ -718,10 +760,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
error=str(e),
extra={"src": "AgentChat.GradioApp.Handler"},
)
yield json.dumps({
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
})
yield json.dumps(
{
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
}
)
except Exception as exc:
logger.explore(
@@ -743,10 +787,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
return
except Exception:
pass
yield json.dumps({
"content": f"❌ Ошибка: {exc}",
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},
})
yield json.dumps(
{
"content": f"❌ Ошибка: {exc}",
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},
}
)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(str(part) for part in assistant_parts))
return
@@ -764,11 +810,14 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
if conv_id and conv_id in _conv_locks:
_conv_locks[conv_id].set()
del _conv_locks[conv_id]
# #endregion AgentChat.GradioApp.Handler
# ── Gradio interface ──
# #region AgentChat.GradioApp.CreateInterface [C:2] [TYPE Function] [SEMANTICS agent-chat,gradio,interface]
# @ingroup AgentChat
# @BRIEF Create the Gradio ChatInterface with additional inputs for conv_id, action, user_id, jwt, env_id.
@@ -792,6 +841,8 @@ def create_chat_interface():
["Запусти миграцию", None, None],
],
)
# #endregion AgentChat.GradioApp.CreateInterface
@@ -800,6 +851,8 @@ def create_chat_interface():
# @BRIEF Healthcheck endpoint for Docker.
async def health():
return {"status": "ok", "uptime": os.times().elapsed if hasattr(os.times(), "elapsed") else 0}
# #endregion AgentChat.GradioApp.Health