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:
40
backend/src/agent/_jwt_decoder.py
Normal file
40
backend/src/agent/_jwt_decoder.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user