test(agent-chat): audit guardrail and error handling
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
_TEMPERATURE_UNSUPPORTED_PREFIXES = (
|
||||
"codex/",
|
||||
"omni/codex/",
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user