test(agent-chat): audit guardrail and error handling

This commit is contained in:
2026-07-06 01:20:12 +03:00
parent 49e4ac0fe2
commit 082d6af3ab
23 changed files with 1541 additions and 354 deletions

View File

@@ -336,8 +336,18 @@ async def _format_tool_output_via_llm(
# @SIDE_EFFECT Invokes LangChain tools; modifies _pending_confirmations dict.
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
# @DATA_CONTRACT Input: (conv_id, action, user_jwt, env_id) -> Output: AsyncGenerator[str]
# @RATIONALE Fast-path resume (direct tool execution without re-entering LangGraph) chosen because the HITL checkpoint already contains all necessary context — re-running the agent would be redundant and slow.
# @REJECTED Pure streaming without checkpoint was rejected — without a persisted checkpoint, a crash after confirmation but before tool execution would lose the operation entirely with no rollback capability.
# @RATIONALE Fast-path resume (direct tool execution via _pending_confirmations dict)
# chosen because the HITL confirmation payload already contains serialised tool
# name + args — re-entering LangGraph to invoke the same tool is redundant.
# Bypasses ~1-3s of LangGraph overhead (agent init, state reconstruction, tool
# re-selection) per resume. Falls back to full LangGraph checkpoint resume when
# _pending_confirmations is empty (e.g. after container restart).
# @REJECTED ALWAYS checkpoint resume via create_agent(interrupt_before=[]) was
# rejected — adds 1-3s latency to every resume for no reliability gain when
# _pending_confirmations is populated. The full checkpoint path is preserved as
# the fallback, providing defense-in-depth for container restart scenarios.
# @REJECTED Pure streaming without checkpoint — would lose unconfirmed operations
# on crash with no rollback capability.
async def handle_resume( # noqa: C901
conversation_id: str, action: str,
user_jwt: str = "", env_id: str | None = None,

View File

@@ -13,7 +13,6 @@
import logging
import os
from typing import Optional
logger = logging.getLogger("superset_tools_app")
@@ -26,7 +25,7 @@ def _get_descriptions() -> tuple[list[str], list[str]]:
"""Return (descriptions, tool_names) from get_all_tools() docstrings.
Uses _TOOL_DESCRIPTIONS_OVERRIDES from tools.py for optional RU/EN synonyms.
"""
from src.agent.tools import get_all_tools, _TOOL_DESCRIPTIONS_OVERRIDES
from src.agent.tools import _TOOL_DESCRIPTIONS_OVERRIDES, get_all_tools
all_tools = get_all_tools()
names = []
@@ -44,8 +43,8 @@ def _get_descriptions() -> tuple[list[str], list[str]]:
# Model state — lazy-loaded on first call
# ═══════════════════════════════════════════════════════════════════
_embedding_model: Optional[object] = None
_tool_embeddings: Optional[object] = None # torch.Tensor or numpy array
_embedding_model: object | None = None
_tool_embeddings: object | None = None # torch.Tensor or numpy array
_tool_names: list[str] = []
_THRESHOLD = float(os.getenv("EMBEDDING_SIMILARITY_THRESHOLD", "0.65"))

View File

@@ -10,7 +10,6 @@
from typing import Any
_TEMPERATURE_UNSUPPORTED_PREFIXES = (
"codex/",
"omni/codex/",

View File

@@ -31,16 +31,16 @@ from jose import JWTError
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
from openai import APIConnectionError, APITimeoutError, AuthenticationError
from openai import APIConnectionError, APITimeoutError, AuthenticationError, RateLimitError
from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT
from src.agent._llm_params import chat_openai_kwargs
from src.agent._confirmation import (
_pending_confirmations,
confirmation_payload,
handle_resume,
permission_denied_payload,
)
from src.agent._llm_params import chat_openai_kwargs
from src.agent._persistence import (
extract_user_id,
generate_llm_title,
@@ -261,15 +261,6 @@ async def _check_llm_provider_health() -> str:
# #endregion AgentChat.GradioApp.LlmHealthCheck
# @ingroup AgentChat
# @BRIEF Core streaming handler — runs LangGraph agent, yields ChatMessage tokens with structured metadata.
# @PRE JWT valid, user authenticated.
# @POST Tokens streamed via yield; HITL interrupts yield confirm_required metadata.
# @SIDE_EFFECT Calls LLM, invokes tools, writes checkpoints.
# @RATIONALE Async generator pattern chosen for Gradio ChatInterface compatibility — Gradio iterates
# @REJECTED Returning a single response (non-streaming) was rejected — violates FR-003 (streaming mandate).
# #region AgentChat.GradioApp.InjectUIContext [C:2] [TYPE Function] [SEMANTICS agent-chat,context,uicontext]
# @ingroup AgentChat
# @BRIEF Add UI context block to runtime context string. Informational only, not instructions.
@@ -307,7 +298,6 @@ def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list:
if not env_id:
return tools
import types
for tool in tools:
# Only wrap tools that accept 'environment_id' parameter
@@ -388,7 +378,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
# ── Per-user lock ──
user_id = user_id_str or (extract_user_id(user_jwt_str) if user_jwt_str else "admin")
if _user_locks.get(user_id, False):
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND"}})
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND", "detail": "Другой запрос уже обрабатывается. Дождитесь завершения перед отправкой нового."}})
return
_user_locks[user_id] = True
conv_id: str | None = None
@@ -495,7 +485,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
except TimeoutError:
yield json.dumps({
"content": "❌ Ошибка: предыдущий стрим ещё не завершён",
"metadata": {"type": "error", "code": "STREAM_CLEANUP_TIMEOUT"},
"metadata": {"type": "error", "code": "STREAM_CLEANUP_TIMEOUT", "detail": "Предыдущий поток не завершился. Повторите запрос."},
})
return
# Capture tool_name BEFORE handle_resume pops the pending confirmation
@@ -640,6 +630,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
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]},
})
@@ -699,6 +690,24 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return
except RateLimitError as exc:
_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,
},
})
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return
except OutputParserException as e:
if attempt < max_attempts - 1:
text = "Respond with valid JSON only. Previous response was malformed.\n\n" + text
@@ -721,13 +730,19 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"},
)
try:
state = await agent.aget_state(config)
if getattr(state, "next", None):
yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id)
return
except Exception:
pass
# Only attempt HITL recovery if this is NOT a known LLM/API error.
# LLM errors (rate limits, connection failures) crash the graph
# before tool execution — there is no HITL checkpoint to resume.
_llm_error_patterns = ["rate limit", "quota", "429", "api key", "auth", "timeout"]
_is_llm_error = any(p in str(exc).lower() for p in _llm_error_patterns)
if not _is_llm_error:
try:
state = await agent.aget_state(config)
if getattr(state, "next", None):
yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id)
return
except Exception:
pass
yield json.dumps({
"content": f"❌ Ошибка: {exc}",
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},

View File

@@ -9,21 +9,6 @@
# @REJECTED Using only environment variables for LLM config was rejected — FastAPI API-based config allows runtime switching without restart.
# RunnableWithMessageHistory wrapper is NOT used — PostgresSaver handles history natively.
import os
import time
import httpx
import psycopg
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.prebuilt import create_react_agent
from psycopg.rows import dict_row
from src.agent._config import FASTAPI_URL, AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE
from src.agent._llm_params import chat_openai_kwargs
from src.core.logger import logger
# ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ──
# LangChain BaseTool objects carry an ``args_schema`` field that is a Pydantic
# BaseModel *class* reference (not an instance). When the OpenAI SDK recursively
@@ -34,12 +19,24 @@ from src.core.logger import logger
# PydanticSerializationError: Unable to serialize unknown type: ModelMetaclass
#
# The fix: skip model_dump for classes, only dump instances.
import inspect as _inspect
import os
import httpx
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.prebuilt import create_react_agent
import openai._utils._transform as _openai_transform
import psycopg
from psycopg.rows import dict_row
import pydantic as _pydantic
import pydantic_core as _pydantic_core
from src.agent._config import AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE, FASTAPI_URL
from src.agent._llm_params import chat_openai_kwargs
from src.core.logger import logger
_original_transform = _openai_transform._async_transform_recursive
async def _patched_transform(data, *, annotation, inner_type=None):

View File

@@ -12,7 +12,6 @@ from src.agent.context import get_user_jwt
from src.agent.tools import _redact_sensitive_fields
from src.core.logger import logger
# #region AgentChat.Middleware.LoggingMiddleware [C:3] [TYPE Function] [SEMANTICS audit,tool,logging]
# @ingroup AgentChat
# @BRIEF Log every tool-call event to assistant_audit table with user context.

View File

@@ -9,9 +9,10 @@
# when GRADIO_ALLOW_PORT_FALLBACK=true and an external proxy is updated separately.
# @REJECTED Hardcoding the port was rejected — it must be configurable for different deployment environments.
import socket
import httpx
from src.agent._config import FASTAPI_URL, SERVICE_JWT, GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, GRADIO_ALLOW_PORT_FALLBACK
from src.agent._config import FASTAPI_URL, GRADIO_ALLOW_PORT_FALLBACK, GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, SERVICE_JWT
from src.core.cot_logger import seed_trace_id
from src.core.logger import logger
@@ -80,6 +81,7 @@ def _fetch_llm_config() -> dict | None:
if __name__ == "__main__":
import asyncio
from src.agent.app import create_chat_interface
from src.agent.context import set_service_jwt
from src.agent.langgraph_setup import configure_from_api, init_checkpointer