refactor(agent): extract agent+shared into standalone packages with full GRACE semantic markup

- Move agent code from backend/src/agent/ to agent/src/ss_tools/agent/
- Extract shared stdlib-only utilities to shared/src/ss_tools/shared/
- Add #region/#endregion contracts to all ~140 functions (INV_1 compliance)
- Update docker files, entrypoint, build scripts for new package layout
- Backend now imports ss_tools.shared._llm_health (no gradio/langchain deps)
- Add specs for 036-039 feature plans
This commit is contained in:
2026-07-07 15:18:24 +03:00
parent ce368429f7
commit b95df37cd5
75 changed files with 2711 additions and 1861 deletions

View File

@@ -1,43 +0,0 @@
# Agent runtime: Gradio + LangGraph agent (no embedding model by default)
anyio>=4.12.0
certifi>=2025.11.12
h11>=0.16.0
httpcore>=1.0.9
httpx>=0.28.1
idna>=3.11
sniffio>=1.3.0
pydantic>=2.7,<3
pydantic-settings
pydantic_core>=2.41.0
typing_extensions>=4.15.0
# Gradio UI
gradio>=5.50.0,<6
python-multipart>=0.0.21
aiofiles>=24.1.0
# LangChain agent framework
langchain-core>=0.3
langchain-openai>=0.3
langgraph>=0.2
langgraph-checkpoint-postgres
# OpenAI SDK (explicit — needed by langchain-openai)
openai>=1.0.0
# Document parsing (uploaded files)
pdfplumber
openpyxl
# DB for LangGraph checkpoint persistence (langgraph-checkpoint-postgres uses psycopg v3)
psycopg2-binary
psycopg>=3.1
# Retry/utility
tenacity>=8.0.0
requests>=2.32.0
# JWT token validation (used by src.core.auth.jwt.decode_token)
python-jose[cryptography]

View File

@@ -1,4 +0,0 @@
# Embedding model for semantic tool routing (large ML deps: torch, transformers, etc.)
# This file is OPTIONAL — install only if semantic routing is needed.
# See _embedding_router.py: graceful fallback when this package is absent.
sentence-transformers>=3.0

View File

@@ -1,12 +1,11 @@
# Full development requirements — aggregate of role-specific files.
# For production Docker images, use the role-specific files:
# Full development requirements — backend only.
# For production Docker images, use:
# - requirements-backend.txt (FastAPI backend)
# - requirements-agent.txt (Gradio agent, no embedding)
# - requirements-embeddings.txt (optional semantic routing ML deps)
# For development, also install:
# - agent/requirements.txt (Gradio agent, separate project)
# - shared/ (pip install -e shared/) (shared utilities)
-r requirements-backend.txt
-r requirements-agent.txt
-r requirements-embeddings.txt
# Dev/test only — NOT included in any production image
ruff>=0.11.0

View File

@@ -1,5 +0,0 @@
# backend/src/agent/__init__.py
# #region AgentChat [C:3] [TYPE Module] [SEMANTICS agent-chat]
# @defgroup AgentChat LangGraph-based Gradio agent — streaming chat with HITL guardrails.
# @LAYER Application
# #endregion AgentChat

View File

@@ -1,30 +0,0 @@
# backend/src/agent/_config.py
# #region AgentChat.Config [C:2] [TYPE Module] [SEMANTICS agent-chat,config,env]
# @ingroup AgentChat
# @BRIEF Centralized env-var reads for agent services. Read once, import everywhere.
# @RATIONALE FASTAPI_URL, SERVICE_JWT, GRADIO_* were read from os.getenv in 4+
# separate files. Consolidating here eliminates redundant env-reads and
# ensures consistent defaults across the agent module.
import os
# ── FastAPI backend URL ──────────────────────────────────────────
FASTAPI_URL: str = os.getenv("FASTAPI_URL", "http://localhost:8000")
# ── Service-to-service JWT (agent → FastAPI auth) ────────────────
SERVICE_JWT: str = os.getenv("SERVICE_JWT", "")
# ── Gradio server ────────────────────────────────────────────────
GRADIO_SERVER_NAME: str = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
GRADIO_SERVER_PORT: int = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
GRADIO_ALLOW_PORT_FALLBACK: bool = os.getenv("GRADIO_ALLOW_PORT_FALLBACK", "").strip().lower() in {"1", "true", "yes"}
# ── File storage ─────────────────────────────────────────────────
STORAGE_ROOT: str = os.getenv("STORAGE_ROOT", "/app/storage")
# ── Prefetch ─────────────────────────────────────────────────────
AGENT_PREFETCH_DASHBOARD_LIMIT: int = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25"))
# ── HITL (Human-in-the-Loop) ────────────────────────────────────
AGENT_CONFIRM_TOOLS: bool = os.getenv("AGENT_CONFIRM_TOOLS", "").strip().lower() in ("true", "1", "yes")
AGENT_INTERRUPT_BEFORE: str = os.getenv("AGENT_INTERRUPT_BEFORE", "")
# #endregion AgentChat.Config

View File

@@ -1,468 +0,0 @@
# backend/src/agent/_confirmation.py
# #region AgentChat.Confirmation [C:3] [TYPE Module] [SEMANTICS agent-chat,hitl,confirmation,resume]
# @defgroup AgentChat HITL confirmation contract builder and resume handler.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.ToolResolver]
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE Extracting confirmation logic into a dedicated module prevents the handler
# from exceeding 400 lines and centralises risk classification in one place.
from collections.abc import AsyncGenerator
import json
from typing import Any
from langchain_openai import ChatOpenAI
from src.agent._llm_params import chat_openai_kwargs
from src.agent._tool_resolver import (
extract_tool_call_from_state,
find_tool,
normalize_tool_args,
)
from src.agent.langgraph_setup import create_agent
from src.agent.tools import get_all_tools
_pending_confirmations: dict[str, dict[str, Any]] = {}
# #region AgentChat.Confirmation.Contract [C:2] [TYPE Function] [SEMANTICS agent-chat,hitl,contract]
# @ingroup AgentChat
# @BRIEF Build confirmation contract dict — risk level, prompt, operation metadata.
# @POST Returns dict with operation, risk, risk_level, prompt, requires_confirmation keys.
def build_confirmation_contract(tool_name: str | None) -> dict[str, Any]:
"""Build confirmation contract — risk classification heuristic.
LLM handles intent; tools are classified by name prefix for HITL UX."""
operation = tool_name or "unknown_action"
# Guard heuristic: deploy_*, execute_*, create_*, run_*, commit_*, start_*, end_*
_guarded_prefixes = ("deploy", "execute", "create", "run", "commit", "start", "end")
if any(operation.startswith(p) for p in _guarded_prefixes):
risk_level = "guarded"
risk = "write"
prompt = "Подтвердить изменение данных?"
else:
risk_level = "safe"
risk = "read"
prompt = "Разрешить чтение данных?"
return {
"operation": operation,
"risk": risk,
"risk_level": risk_level,
"prompt": prompt,
"requires_confirmation": True,
}
# #endregion AgentChat.Confirmation.Contract
# #region AgentChat.Confirmation.GuardV2 [C:4] [TYPE Function] [SEMANTICS agent-chat,hitl,confirmation,guardrails]
# @ingroup AgentChat
# @BRIEF Build extended confirmation contract — three-axis risk with env context.
# @POST Returns dict with risk, risk_level, dangerous, env_context.
# @RATIONALE Three-axis (tool_risk x env_risk x permission) replaces prefix-only heuristic.
# @REJECTED Single-axis prefix-only — cannot distinguish prod vs staging.
def _resolve_env_tier(tool_args: dict, target_env: str | None) -> str | None:
"""Resolve environment context and normalize to tier label."""
env_context = target_env
if tool_args.get("env_id"):
env_context = tool_args["env_id"]
elif tool_args.get("environment_id"):
env_context = tool_args["environment_id"]
if not env_context:
return None
lowered = str(env_context).lower()
if "prod" in lowered:
return "prod"
if "stag" in lowered or "test" in lowered:
return "staging"
if "dev" in lowered or "local" in lowered:
return "dev"
return None
def _build_v2_prompt(risk_level: str, env_tier: str | None) -> str:
"""Build user-facing prompt from risk level and env tier."""
if risk_level == "dangerous":
return "⚠️ Опасная операция! Это действие НЕОБРАТИМО."
if risk_level == "guarded" and env_tier == "prod":
return "⚠️ Изменение данных в PRODUCTION! Подтвердите действие."
if risk_level == "guarded":
return "Подтвердите изменение данных."
return "Разрешить чтение данных?"
def build_confirmation_contract_v2(
tool_name: str | None,
tool_args: dict | None = None,
user_role: str = "viewer",
target_env: str | None = None,
) -> dict[str, Any]:
"""Build extended confirmation contract — three-axis risk classification."""
operation = tool_name or "unknown_action"
tool_args = tool_args or {}
# 1. Tool risk (prefix-based)
_dangerous_ops = {"delete"}
_guarded_prefixes = ("deploy", "execute", "create", "run", "commit", "start", "end")
if any(operation.startswith(p) for p in _dangerous_ops):
risk_level = "dangerous"
risk = "write"
elif any(operation.startswith(p) for p in _guarded_prefixes):
risk_level = "guarded"
risk = "write"
else:
risk_level = "safe"
risk = "read"
# 2. Env context — resolve from tool_args first, then fallback
env_tier = _resolve_env_tier(tool_args, target_env)
# 3. Permission check
from src.agent._tool_filter import enforce_tool_permission
permission_granted = enforce_tool_permission(operation, user_role)
# Build alternatives for denied ops
alternatives = None
required_role = None
if not permission_granted:
from src.agent._tool_filter import _TOOL_PERMISSIONS
required_roles = _TOOL_PERMISSIONS.get(operation, ["admin"])
required_role = required_roles[0] if required_roles else "admin"
if risk_level != "safe":
alternatives = [
{"action": "get_health_summary", "prompt": "Запросить отчет о состоянии системы"}, # noqa: RUF001
{"action": "search_dashboards", "prompt": "Найти дашборды"},
]
# 4. Build prompt
prompt = _build_v2_prompt(risk_level, env_tier)
return {
"operation": operation,
"risk": risk,
"risk_level": risk_level,
"dangerous": risk_level == "dangerous",
"env_context": env_tier,
"permission_granted": permission_granted,
"required_role": required_role,
"alternatives": alternatives,
"prompt": prompt,
"requires_confirmation": True,
}
# #endregion AgentChat.Confirmation.GuardV2
# #region AgentChat.Confirmation.PermissionDenied [C:3] [TYPE Function] [SEMANTICS agent-chat,security,permission-denied,sse]
# @ingroup AgentChat
# @BRIEF Yield permission_denied SSE event — bypasses HITL checkpoint.
# @POST Returns JSON string with type="permission_denied", tool_name, required_role, user_role, alternatives.
# @RATIONALE Security: forbidden calls must NOT enter guarded HITL checkpoint.
# @REJECTED Emitting confirm_required with permission_granted=false — enters checkpoint for known-forbidden call.
def permission_denied_payload(
tool_name: str,
required_role: str = "admin",
user_role: str = "viewer",
alternatives: list[dict] | None = None,
) -> str:
"""Yield permission_denied SSE — bypasses HITL checkpoint entirely."""
return json.dumps({
"content": f"⛔ Недостаточно прав для {tool_name}",
"metadata": {
"type": "permission_denied",
"tool_name": tool_name,
"required_role": required_role,
"user_role": user_role,
"alternatives": alternatives or [],
},
})
# #endregion AgentChat.Confirmation.PermissionDenied
# #region AgentChat.Confirmation.MetadataForTool [C:3] [TYPE Function] [SEMANTICS agent-chat,hitl,metadata]
# @ingroup AgentChat
# @BRIEF Generate confirmation metadata dict for a specific tool name + args.
# @POST Returns metadata dict with type, thread_id, prompt, tool_name, tool_args, risk fields.
def confirmation_metadata_for_tool(
conv_id: str,
tool_name: str | None,
tool_args: dict[str, Any] | None = None,
user_role: str = "viewer",
target_env: str | None = None,
) -> dict[str, Any]:
contract = build_confirmation_contract_v2(tool_name, tool_args, user_role, target_env)
return {
"type": "confirm_required",
"thread_id": conv_id,
"prompt": contract["prompt"],
"tool_name": contract["operation"],
"tool_args": tool_args or {},
"risk": contract["risk"],
"risk_level": contract["risk_level"],
"requires_confirmation": contract["requires_confirmation"],
"dangerous": contract.get("dangerous", False),
"env_context": contract.get("env_context"),
"permission_granted": contract.get("permission_granted", True),
"required_role": contract.get("required_role"),
"alternatives": contract.get("alternatives"),
"intent": {
"operation": contract["operation"],
"risk": contract["risk"],
"risk_level": contract["risk_level"],
"requires_confirmation": contract["requires_confirmation"],
},
}
# #endregion AgentChat.Confirmation.MetadataForTool
# #region AgentChat.Confirmation.Metadata [C:3] [TYPE Function] [SEMANTICS agent-chat,hitl,metadata]
# @ingroup AgentChat
# @BRIEF Generate confirmation metadata from LangGraph state + user text.
# @POST Returns metadata dict (delegates to MetadataForTool after extraction).
def confirmation_metadata(
conv_id: str,
state,
user_text: str,
user_role: str | None = None,
target_env: str | None = None,
) -> dict[str, Any]:
tool_name, tool_args = extract_tool_call_from_state(state, user_text)
# Resolve user_role from state if not explicitly provided
if user_role is None:
user_role = state.values.get("user_role", "viewer") if hasattr(state, "values") else "viewer"
# Resolve target_env from state if not explicitly provided
if target_env is None:
target_env = state.values.get("env_id") if hasattr(state, "values") else None
return confirmation_metadata_for_tool(conv_id, tool_name, tool_args, user_role, target_env)
# #endregion AgentChat.Confirmation.Metadata
# #region AgentChat.Confirmation.Payload [C:2] [TYPE Function] [SEMANTICS agent-chat,hitl,payload]
# @ingroup AgentChat
# @BRIEF Serialise confirmation into a JSON payload string for the Gradio event stream.
# @POST Returns JSON string with content + metadata.
def confirmation_payload(
conv_id: str,
state,
user_text: str,
user_role: str | None = None,
target_env: str | None = None,
) -> str:
return json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": confirmation_metadata(conv_id, state, user_text, user_role, target_env),
})
# #endregion AgentChat.Confirmation.Payload
# #region AgentChat.Confirmation.FormatOutput [C:3] [TYPE Function] [SEMANTICS agent-chat,hitl,llm,formatting]
# @ingroup AgentChat
# @BRIEF Format tool output via LLM for a natural-language response, with fallback to
# prettified JSON. Yields streaming tokens.
# @POST Yields stream_token events with formatted text.
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
# @RATIONALE Fast-path confirmation bypasses the LangGraph agent — the tool result is
# raw JSON. This function adds an LLM formatting layer so the user sees a
# readable response instead of raw data. Falls back to rule-based formatting
# when LLM is unavailable.
# @REJECTED Yielding raw JSON directly was rejected — users expect LLM-styled answers,
# not machine-readable data dumps.
async def _format_tool_output_via_llm(
tool_name: str, output: str,
) -> AsyncGenerator[str]:
from src.agent.langgraph_setup import _fetch_llm_config
from src.core.logger import logger
text = output.strip()
if not text:
yield json.dumps({
"content": "_(нет данных)_",
"metadata": {"type": "stream_token", "token": "_(нет данных)_"},
})
return
# ── Try LLM formatting ──
config = await _fetch_llm_config()
if config and config.get("configured"):
try:
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["api_key"],
max_tokens=1024,
))
prompt = (
f"Tool '{tool_name}' returned this data:\n\n{text}\n\n"
"Summarize this data in a concise, human-readable format. "
"Use bullet points or a short paragraph. "
"Keep it brief — under 5 sentences. "
"Answer in Russian unless the data is in English."
)
async for chunk in llm.astream(prompt):
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
return
except Exception as exc:
logger.explore(
"LLM formatting failed, falling back to prettified output",
payload={"tool": tool_name}, error=str(exc),
extra={"src": "AgentChat.Confirmation.FormatOutput"},
)
# ── Fallback: prettified JSON or raw text ──
try:
data = json.loads(text)
pretty = json.dumps(data, indent=2, ensure_ascii=False)
yield json.dumps({
"content": pretty,
"metadata": {"type": "stream_token", "token": pretty},
})
except (json.JSONDecodeError, ValueError):
yield json.dumps({
"content": text,
"metadata": {"type": "stream_token", "token": text},
})
# #endregion AgentChat.Confirmation.FormatOutput
# #region AgentChat.Confirmation.HandleResume [C:4] [TYPE Function] [SEMANTICS agent-chat,hitl,resume,streaming]
# @ingroup AgentChat
# @BRIEF Resume from HITL checkpoint — execute confirmed tool or abort on deny.
# @PRE conversation_id is valid. action is "confirm" or "deny".
# @POST Streams confirm_resolved, tool_start, tool_end/tool_error events via yield.
# @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 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,
) -> AsyncGenerator[str]:
from src.agent.context import set_user_jwt
from src.core.logger import logger
set_user_jwt(user_jwt)
pending = _pending_confirmations.pop(conversation_id, None)
if pending is not None:
if action == "deny":
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
return
if action == "confirm":
logger.reason(
"Fast-path confirmation resume",
payload={"tool": pending.get("tool_name"), "conv_id": conversation_id},
extra={"src": "AgentChat.Confirmation"},
)
tool_name = str(pending.get("tool_name") or "unknown_action")
tool_args = normalize_tool_args(pending.get("tool_args"))
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
})
tool_obj = find_tool(tool_name)
if tool_obj is None:
error = f"Unknown tool: {tool_name}"
logger.explore(
"Unknown tool in resume",
payload={"tool": tool_name}, error=error,
extra={"src": "AgentChat.Confirmation"},
)
yield json.dumps({
"content": f"{tool_name}{error}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": error},
})
return
try:
output = await tool_obj.ainvoke(tool_args)
except Exception as exc:
logger.explore(
"Tool invocation failed in resume",
payload={"tool": tool_name}, error=str(exc),
extra={"src": "AgentChat.Confirmation"},
)
yield json.dumps({
"content": f"{tool_name}{exc}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(exc)},
})
return
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
# Format tool output via LLM for a human-readable response
async for chunk in _format_tool_output_via_llm(tool_name, str(output)):
yield chunk
logger.reflect(
"Fast-path confirmation completed",
payload={"tool": tool_name},
extra={"src": "AgentChat.Confirmation"},
)
return
logger.reason(
"LangGraph checkpoint resume",
payload={"conv_id": conversation_id, "action": action},
extra={"src": "AgentChat.Confirmation"},
)
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
if action == "confirm":
config = {"configurable": {"thread_id": conversation_id}}
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
async for event in agent.astream_events(None, config=config, version="v2"):
kind = event.get("event")
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif action == "deny":
logger.reflect(
"Checkpoint resume denied",
payload={"conv_id": conversation_id},
extra={"src": "AgentChat.Confirmation"},
)
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
# #endregion AgentChat.Confirmation.HandleResume
# #endregion AgentChat.Confirmation

View File

@@ -1,162 +0,0 @@
# backend/src/agent/_context.py
# #region AgentChat.Context.Validate [C:2] [TYPE Module] [SEMANTICS agent-chat,context,validate,security]
# @defgroup AgentChat UIContext validation and prompt-injection protection.
# @LAYER Service
import json
ALLOWED_OBJECT_TYPES: frozenset = frozenset({"dashboard", "dataset", "migration"})
class UIContextValidationError(ValueError):
"""Raised when a UIContext payload fails validation.
Provides a descriptive message identifying the specific field and
reason for rejection. Never exposes raw payload values beyond the
field value itself — prevents information leakage into logs.
"""
pass
# ── Internal helpers ─────────────────────────────────────────────────────────
# Each encapsulates one field-level check. Keeps validate_uicontext()
# cyclomatic complexity ≤ 2 (only the ``None`` guard).
# #region AgentChat.Context.Validate.CheckObjectType [C:1] [TYPE Function]
def _check_object_type(value: str | None) -> None:
"""Validate ``objectType`` — one of ALLOWED_OBJECT_TYPES or None."""
if value is not None and value not in ALLOWED_OBJECT_TYPES:
raise UIContextValidationError(
f"UIContext: invalid objectType '{value}'"
f" — must be one of {ALLOWED_OBJECT_TYPES}"
)
# #endregion AgentChat.Context.Validate.CheckObjectType
# #region AgentChat.Context.Validate.CheckObjectId [C:1] [TYPE Function]
def _check_object_id(value: str | None) -> None:
"""Validate ``objectId`` — numeric string or None."""
if value is not None and not (isinstance(value, str) and value.isdigit()):
raise UIContextValidationError(
f"UIContext: invalid objectId '{value}'"
)
# #endregion AgentChat.Context.Validate.CheckObjectId
# #region AgentChat.Context.Validate.CheckObjectName [C:1] [TYPE Function]
def _check_object_name(value: str | None) -> None:
"""Validate ``objectName`` — string ≤ 256 chars or None."""
if value is None:
return
if not isinstance(value, str):
raise UIContextValidationError(
f"UIContext: invalid objectName '{value}'"
)
if len(value) > 256:
raise UIContextValidationError(
"UIContext: objectName exceeds 256 characters"
)
# #endregion AgentChat.Context.Validate.CheckObjectName
# #region AgentChat.Context.Validate.CheckEnvId [C:1] [TYPE Function]
def _check_env_id(value: str | None) -> None:
"""Validate ``envId`` — string or None."""
if value is not None and not isinstance(value, str):
raise UIContextValidationError(
f"UIContext: invalid envId '{value}'"
)
# #endregion AgentChat.Context.Validate.CheckEnvId
# #region AgentChat.Context.Validate.CheckRoute [C:1] [TYPE Function]
def _check_route(value: str) -> None:
"""Validate ``route`` — string, ≤ 512 chars."""
if not isinstance(value, str):
raise UIContextValidationError(
f"UIContext: invalid route '{value}' — must be a string"
)
if len(value) > 512:
raise UIContextValidationError(
"UIContext: route exceeds 512 characters"
)
# #endregion AgentChat.Context.Validate.CheckRoute
# #region AgentChat.Context.Validate.CheckContextVersion [C:1] [TYPE Function]
def _check_context_version(value: int) -> None:
"""Validate ``contextVersion`` — must equal 1."""
if not isinstance(value, int) or value != 1:
raise UIContextValidationError(
f"UIContext: unsupported contextVersion {value}"
)
# #endregion AgentChat.Context.Validate.CheckContextVersion
# #region AgentChat.Context.Validate.CheckPayloadSize [C:1] [TYPE Function]
def _check_payload_size(payload: dict) -> None:
"""Validate serialised JSON payload ≤ 4096 bytes.
Serialises with ``sort_keys=True`` for deterministic sizing.
"""
serialized = json.dumps(payload, sort_keys=True)
if len(serialized.encode("utf-8")) >= 4096:
raise UIContextValidationError(
"UIContext: payload exceeds 4 KB"
)
# #endregion AgentChat.Context.Validate.CheckPayloadSize
# ── Public API ───────────────────────────────────────────────────────────────
# #region AgentChat.Context.Validate.Payload [C:2] [TYPE Function] [SEMANTICS agent-chat,context,validate]
# @ingroup AgentChat
# @BRIEF Validate UIContext payload — enum checks, size limits, field lengths.
# @POST Returns validated dict or raises UIContextValidationError.
# @RATIONALE Trust boundary — UIContext comes from user-controlled URL params.
# Validation prevents prompt injection and malformed context.
def validate_uicontext(payload: dict) -> dict:
"""Validate a UIContext payload against field-level constraints.
Checks performed in order:
1. ``objectType`` — one of {"dashboard", "dataset", "migration"} or None.
2. ``objectId`` — numeric string or None.
3. ``objectName`` — string ≤ 256 chars or None.
4. ``envId`` — string or None.
5. ``route`` — string ≤ 512 chars.
6. ``contextVersion`` — int, must equal 1.
7. Serialised JSON payload ≤ 4096 bytes.
Parameters
----------
payload : dict or None
Raw UIContext dictionary. ``None`` returns ``{}`` immediately.
Returns
-------
dict
The validated payload (unchanged).
Raises
------
UIContextValidationError
If any constraint is violated. The message identifies the field
and reason without leaking internal state.
"""
if payload is None:
return {}
_check_object_type(payload.get("objectType"))
_check_object_id(payload.get("objectId"))
_check_object_name(payload.get("objectName"))
_check_env_id(payload.get("envId"))
_check_route(payload.get("route"))
_check_context_version(payload.get("contextVersion"))
_check_payload_size(payload)
return payload
# #endregion AgentChat.Context.Validate.Payload
# #endregion AgentChat.Context.Validate

View File

@@ -1,154 +0,0 @@
# backend/src/agent/_embedding_router.py
# #region AgentChat.EmbeddingRouter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,embedding,fallback]
# @defgroup AgentChat Embedding-based tool router — fallback when keyword matching yields <3 tools.
# @LAYER Service
# @BRIEF Lazy-loaded embedding model for cosine similarity between user query and tool descriptions.
# @RATIONALE Tool descriptions are auto-generated from @tool docstrings (enforced by LangChain).
# Optional _TOOL_DESCRIPTIONS_OVERRIDES in tools.py provides RU/EN synonyms for
# tools where the docstring alone isn't descriptive enough. This eliminates the
# hardcoded _TOOL_DESCRIPTIONS dict as a separate source of truth.
# @REJECTED Hardcoded _TOOL_DESCRIPTIONS dict — drifts out of sync with get_all_tools().
# @INVARIANT Descriptions are derived from get_all_tools() docstrings — always 1:1 with tools.
# @INVARIANT embedding_top_k() returns empty list (never raises) when model unavailable.
import logging
import os
logger = logging.getLogger("superset_tools_app")
# ═══════════════════════════════════════════════════════════════════
# Tool description — auto-generated from @tool docstrings.
# Override via _TOOL_DESCRIPTIONS_OVERRIDES in tools.py for RU/EN synonyms.
# ═══════════════════════════════════════════════════════════════════
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 _TOOL_DESCRIPTIONS_OVERRIDES, get_all_tools
all_tools = get_all_tools()
names = []
descriptions = []
for tool_obj in all_tools:
name = tool_obj.name
names.append(name)
desc = _TOOL_DESCRIPTIONS_OVERRIDES.get(name) or (tool_obj.description or "").strip()
if not desc:
desc = name # fallback: use tool name as description
descriptions.append(desc)
return descriptions, names
# ═══════════════════════════════════════════════════════════════════
# Model state — lazy-loaded on first call
# ═══════════════════════════════════════════════════════════════════
_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"))
_TOP_K = int(os.getenv("EMBEDDING_TOP_K", "5"))
_MODEL_NAME = os.getenv(
"EMBEDDING_MODEL",
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
)
def _load_model() -> bool:
"""Lazy-load the embedding model and pre-embed tool descriptions.
Returns True on success, False on any failure (missing package, download error,
OOM, etc.). On failure, the router degrades gracefully — embedding_top_k()
returns an empty list and the caller falls back to keyword-only results.
"""
global _embedding_model, _tool_embeddings, _tool_names
if _embedding_model is not None:
return True
try:
from sentence_transformers import SentenceTransformer
except ImportError:
logger.warning(
"sentence-transformers not installed — embedding router disabled. "
"Install with: pip install sentence-transformers"
)
return False
try:
logger.info("Loading embedding model: %s", _MODEL_NAME)
_embedding_model = SentenceTransformer(_MODEL_NAME)
descriptions, _tool_names[:] = _get_descriptions()
_tool_embeddings = _embedding_model.encode(
descriptions,
convert_to_tensor=True,
show_progress_bar=False,
)
logger.info(
"Embedding model loaded. Tools: %d, model: %s",
len(_tool_names), _MODEL_NAME,
)
return True
except Exception as exc:
logger.warning(
"Failed to load embedding model '%s': %s — embedding router disabled",
_MODEL_NAME, exc,
)
_embedding_model = None
return False
def embedding_top_k(query: str) -> list[str]:
"""Return top-K tool names above cosine similarity threshold.
Args:
query: Raw user query text (any language).
Returns:
List of tool name strings ordered by descending similarity.
Empty list if model unavailable, no descriptions match, or an error occurs.
"""
if not query or not query.strip():
return []
if not _load_model():
return []
try:
from sentence_transformers.util import cos_sim
query_embedding = _embedding_model.encode(
query,
convert_to_tensor=True,
show_progress_bar=False,
)
similarities = cos_sim(query_embedding, _tool_embeddings)[0]
results: list[str] = []
for idx in similarities.argsort(descending=True)[:_TOP_K]:
score = float(similarities[idx])
if score >= _THRESHOLD:
results.append(_tool_names[idx])
if results:
logger.debug(
"Embedding router: query='%s'%d tools above %.2f: %s",
query[:80], len(results), _THRESHOLD, results,
)
return results
except Exception as exc:
logger.warning("Embedding router failed for query '%s': %s", query[:80], exc)
return []
def embedding_is_available() -> bool:
"""Check if embedding model is loaded and ready (non-blocking)."""
return _load_model()
# #endregion AgentChat.EmbeddingRouter

View File

@@ -1,50 +0,0 @@
# 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 AUTH_SECRET_KEY env var, 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.
# @INVARIANT AUTH_SECRET_KEY is the ONLY accepted JWT signing key. JWT_SECRET is
# unsupported and triggers a migration message if present without
# AUTH_SECRET_KEY.
# @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.
# @REJECTED Fallback from JWT_SECRET to AUTH_SECRET_KEY was rejected — ambiguous
# naming caused operator confusion and secret drift between environments.
import os
from jose import JWTError, jwt
def decode_token(token: str) -> dict:
"""Decode and validate a JWT token using AUTH_SECRET_KEY from environment.
Performs stateless validation: signature, expiration, and required claims
(exp, sub). Does NOT check token blacklist or audience/issuer.
Raises JWTError with migration guidance if JWT_SECRET is set but
AUTH_SECRET_KEY is missing (old deployments must rename the variable).
"""
secret = os.getenv("AUTH_SECRET_KEY", "")
jwt_secret_legacy = os.getenv("JWT_SECRET", "")
if not secret:
if jwt_secret_legacy:
raise JWTError("JWT_SECRET is no longer supported. Rename JWT_SECRET to AUTH_SECRET_KEY in your .env / docker-compose and restart the agent.")
raise JWTError("AUTH_SECRET_KEY environment variable is not set")
return jwt.decode(
token,
secret,
algorithms=[os.getenv("JWT_ALGORITHM", "HS256")],
options={
"verify_signature": True,
"verify_exp": True,
"verify_aud": False,
"require": ["exp", "sub"],
},
)
# #endregion AgentChat.JwtDecoder

View File

@@ -1,132 +0,0 @@
# backend/src/agent/_llm_health.py
# #region AgentChat.LlmHealth [C:3] [TYPE Module] [SEMANTICS agent-chat,llm,health,status]
# @ingroup AgentChat
# @BRIEF LLM provider health check with in-memory cache (30s TTL).
# Uses openai+httpx only (no langchain) — works in both backend and agent containers.
# @LAYER Infrastructure
# @RELATION CALLED_BY -> [api/routes/agent_status.py]
# @RELATION CALLED_BY -> [agent/app.py]
# @RATIONALE agent/app.py imports gradio at module level. The backend container
# does not have gradio installed. The /api/agent/llm-status endpoint needs
# _check_llm_provider_health but must not trigger gradio import.
# Additionally, langchain_openai/langchain_core are only in the agent container
# (requirements-agent.txt), not the backend (requirements-backend.txt).
# This module uses only openai+httpx (available in both containers).
# @POST Returns status string: 'ok' | 'unavailable' | 'timeout' | 'auth_error'.
# @SIDE_EFFECT Makes a probe request to the LLM provider; caches result in module memory.
import os
import time
from typing import Any
import httpx
from openai import (
APIConnectionError,
APITimeoutError,
AuthenticationError,
RateLimitError,
)
from src.core.logger import logger
# ── LLM provider health cache ─────────────────────────────────────────
_llm_status: dict[str, Any] = {
"status": "ok",
"last_error": "",
"last_check_ts": 0.0,
}
_LLM_CHECK_CACHE_TTL = 30 # seconds between health checks
_LLM_LAST_ERROR_KEY = "last_llm_error"
_LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts"
# #region AgentChat.LlmHealth.Check [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,health,check]
# @ingroup AgentChat
# @BRIEF Check LLM provider connectivity with in-memory cache (30s TTL).
# @POST Returns status string: 'ok' | 'unavailable' | 'timeout' | 'auth_error'.
# @SIDE_EFFECT Makes a probe request to the LLM provider; caches result in module memory.
# @RATIONALE Prevents sending every user request into a dead LLM backend.
# @REJECTED langchain_openai.ChatOpenAI was rejected — not installed in backend
# container. AsyncOpenAI from the openai package is available in both containers.
async def _check_llm_provider_health() -> str:
"""Check LLM provider connectivity. Cached for _LLM_CHECK_CACHE_TTL seconds."""
now = time.time()
if now - _llm_status["last_check_ts"] < _LLM_CHECK_CACHE_TTL:
return _llm_status["status"]
# Fetch LLM config from backend's own API (same as agent container does)
try:
fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000")
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{fastapi_url}/api/agent/llm-config")
if resp.status_code != 200:
_llm_status["status"] = "unavailable"
_llm_status["last_error"] = f"LLM config endpoint returned {resp.status_code}"
_llm_status["last_check_ts"] = time.time()
return "unavailable"
config = resp.json()
if not config or not config.get("configured"):
_llm_status["status"] = "unavailable"
_llm_status["last_error"] = "No LLM provider configured"
_llm_status["last_check_ts"] = time.time()
return "unavailable"
except Exception as exc:
_llm_status["status"] = "unavailable"
_llm_status["last_error"] = f"Failed to fetch LLM config: {exc}"
_llm_status["last_check_ts"] = time.time()
return "unavailable"
# Probe LLM API using AsyncOpenAI (available in both backend and agent containers)
try:
from openai import AsyncOpenAI
from src.core.ssl import system_ssl_context
api = AsyncOpenAI(
api_key=config.get("api_key", ""),
base_url=config.get("base_url", "https://api.openai.com/v1"),
http_client=httpx.AsyncClient(
verify=system_ssl_context(),
timeout=10,
),
)
await api.chat.completions.create(
model=config.get("default_model", "gpt-4o-mini"),
max_tokens=1,
messages=[{"role": "user", "content": "ping"}],
)
_llm_status["status"] = "ok"
_llm_status["last_error"] = ""
_llm_status["last_check_ts"] = time.time()
return "ok"
except (APIConnectionError, httpx.ConnectError):
_llm_status["status"] = "unavailable"
_llm_status["last_error"] = "Connection refused"
_llm_status["last_check_ts"] = time.time()
return "unavailable"
except (APITimeoutError, httpx.ReadTimeout):
_llm_status["status"] = "timeout"
_llm_status["last_error"] = "Request timed out"
_llm_status["last_check_ts"] = time.time()
return "timeout"
except AuthenticationError:
_llm_status["status"] = "auth_error"
_llm_status["last_error"] = "Invalid API key"
_llm_status["last_check_ts"] = time.time()
return "auth_error"
except RateLimitError:
_llm_status["status"] = "unavailable"
_llm_status["last_error"] = "Rate limited"
_llm_status["last_check_ts"] = time.time()
return "unavailable"
except Exception as exc:
if "usage_metadata" in str(exc) or "total_tokens" in str(exc):
_llm_status["status"] = "ok"
_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.LlmHealth.Check"})
return _llm_status["status"] # return cached status on unexpected errors
# #endregion AgentChat.LlmHealth.Check
# #endregion AgentChat.LlmHealth

View File

@@ -1,69 +0,0 @@
# backend/src/agent/_llm_params.py
# #region AgentChat.LlmParams [C:3] [TYPE Module] [SEMANTICS agent-chat,llm,openai,compatibility]
# @defgroup AgentChat Shared LLM parameter compatibility helpers.
# @LAYER Service
# @BRIEF Build provider-safe ChatOpenAI kwargs and raw OpenAI payloads.
# @POST Unsupported sampling parameters are omitted for reasoning/codex models.
# @RATIONALE Some OpenAI-compatible gateways reject temperature for reasoning/codex
# models. Centralising the guard prevents resume, health-check, and title
# generation paths from diverging.
from typing import Any
_TEMPERATURE_UNSUPPORTED_PREFIXES = (
"codex/",
"omni/codex/",
"gpt-5",
"o1",
"o3",
"o4",
)
def _canonical_model_name(model: str | None) -> str:
"""Return provider-stripped, lowercase model name for compatibility checks."""
name = (model or "").strip().lower()
if name.startswith(("codex/", "omni/codex/")):
return name
if "/" in name:
return name.rsplit("/", 1)[-1]
return name
def supports_temperature(model: str | None) -> bool:
"""Return False for model families whose APIs reject temperature."""
name = _canonical_model_name(model)
return not any(name.startswith(prefix) for prefix in _TEMPERATURE_UNSUPPORTED_PREFIXES)
def chat_openai_kwargs(
*,
model: str,
base_url: str | None,
api_key: str,
max_tokens: int,
temperature: float = 0,
) -> dict[str, Any]:
"""Build ChatOpenAI kwargs without unsupported temperature for reasoning models."""
kwargs: dict[str, Any] = {
"model": model,
"base_url": base_url,
"api_key": api_key,
"max_tokens": max_tokens,
}
if supports_temperature(model):
kwargs["temperature"] = temperature
return kwargs
def add_temperature_if_supported(
payload: dict[str, Any],
*,
model: str | None,
temperature: float = 0,
) -> dict[str, Any]:
"""Mutate and return payload with temperature only when the model supports it."""
if supports_temperature(model):
payload["temperature"] = temperature
return payload
# #endregion AgentChat.LlmParams

View File

@@ -1,461 +0,0 @@
# backend/src/agent/_persistence.py
# #region AgentChat.Persistence [C:3] [TYPE Module] [SEMANTICS agent-chat,persistence,save,prefetch,title]
# @defgroup AgentChat Conversation persistence helpers — save, clean titles, LLM title generation, prefetch.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Context]
# @RATIONALE Persistence logic is extracted to keep the handler under 400 lines and avoid
# mixing HTTP concerns with streaming logic.
import asyncio
from datetime import datetime
import os
import re
from typing import Any
import uuid
import httpx
from src.agent._config import AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT, FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
from src.agent._llm_params import add_temperature_if_supported
from src.core.logger import logger
SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save"
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,
# pre-fetch blocks, JSON/CSV, URLs; truncate to 80 chars at word boundary.
# @POST Returns cleaned title string; "Новый диалог" for empty/unparseable input.
# @RATIONALE Raw user_text often contains file content, pre-fetched data, or pasted JSON/CSV
# that produces unreadable titles. Rule-based cleaning is instant (no LLM latency)
# and handles 90% of cases. LLM titling is a best-effort async layer on top.
# @REJECTED Truncating raw text without cleaning was rejected — produces titles like
# "--- Uploaded file content --- id,name,status 1,Dashboard A..."
def clean_title(user_text: str) -> str: # noqa: C901
if not user_text or not user_text.strip():
return "Новый диалог"
text = user_text.strip()
# ── Already a HITL title? Skip cleaning ──
if text.startswith("") or text.startswith("⏹️ "):
return text[:TITLE_MAX_LENGTH]
# ── Strip file upload markers (cut before first occurrence) ──
file_markers = [
"\n--- Uploaded file content ---",
"--- Uploaded file content ---", # no leading newline
"\n[PRE-FETCHED DATA",
"[PRE-FETCHED DATA", # no leading newline
"\n[/PRE-FETCHED DATA]",
"[/PRE-FETCHED DATA]", # no leading newline
]
cut_pos = len(text)
for marker in file_markers:
pos = text.find(marker)
if pos != -1 and pos < cut_pos:
cut_pos = pos
if cut_pos < len(text):
text = text[:cut_pos].strip()
if not text:
return "Новый диалог"
# ── 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):
sentence_end = m.start()
break
if sentence_end > 3: # don't cut "Привет." into "Привет"
text = text[: sentence_end + 1].strip()
elif "\n" in text:
text = text.split("\n")[0].strip()
if not text:
return "Новый диалог"
# ── Detect structured data (JSON/CSV/URL) ──
if text.startswith("{") or text.startswith("["):
prefix = "Данные: "
inner = text[1:57].strip().rstrip(",")
return prefix + inner + ("" if len(text) > 60 else "")
if text.startswith("http://") or text.startswith("https://"):
try:
from urllib.parse import urlparse
domain = urlparse(text).netloc or "ссылка"
except Exception:
domain = "ссылка"
return domain
# ── Detect code (starts with def/class/import) ──
if any(text.startswith(kw) for kw in ("def ", "class ", "import ", "from ")):
first_line = text.split("\n")[0].strip()
return first_line[:TITLE_MAX_LENGTH]
# ── Hard truncate at word boundary ──
if len(text) > TITLE_MAX_LENGTH:
cut = text.rfind(" ", 0, TITLE_MAX_LENGTH)
if cut == -1:
cut = TITLE_MAX_LENGTH - 1
text = text[:cut].rstrip(".,;:!?") + ""
# ── Fallback for empty/whitespace ──
if not text.strip():
return "Новый диалог"
return text
# #endregion AgentChat.Persistence.CleanTitle
# #region AgentChat.Persistence.DetectState [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,error-detection]
# @ingroup AgentChat
# @BRIEF Detect error/cancelled state from assistant message text.
# @POST Returns "error", "cancelled", or None.
def detect_message_state(text: str) -> str | None:
t = text.lower() if text else ""
error_markers = ["недоступен", "unavailable", "ошибка", "error", "произошла", "try again"]
cancel_markers = ["отменен", "cancelled", "отклонен", "denied"]
if any(m in t for m in cancel_markers):
return "cancelled"
if any(m in t for m in error_markers):
return "error"
return None
# #endregion AgentChat.Persistence.DetectState
# #region AgentChat.Persistence.ExtractUserId [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,auth]
# @ingroup AgentChat
# @BRIEF Extract user ID from JWT payload.
# @POST Returns user_id string or "unknown".
def extract_user_id(jwt_str: str) -> str:
try:
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
# ═══════════════════════════════════════════════════════════════════
# LLM title generation (best-effort, async)
# ═══════════════════════════════════════════════════════════════════
# Per-conversation lock to prevent concurrent title generation
_title_locks: dict[str, asyncio.Lock] = {}
async def _get_llm_config() -> dict[str, Any] | None:
"""Fetch LLM provider config from FastAPI for title generation."""
try:
fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000")
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{fastapi_url}/api/agent/llm-config", headers=headers)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
async def _call_llm_for_title(user_text: str) -> str | None:
"""Call LLM to generate a 3-5 word Russian title. Returns title or None on failure."""
from src.core.logger import logger as _logger
try:
config = await _get_llm_config()
if not config or not config.get("configured"):
_logger.explore("LLM title: no provider configured", extra={"src": "AgentChat.Persistence"})
return None
clean_text = clean_title(user_text)[:200]
# Skip LLM for trivial titles
if not clean_text or clean_text in ("Новый диалог",):
return None
prompt = f"Сгенерируй заголовок из 3-5 слов на русском для диалога. Только заголовок, без кавычек и пояснений.\n\nДиалог: {clean_text}"
api_key = config.get("api_key", "")
base_url = config.get("base_url", "")
model = config.get("default_model", "gpt-4o-mini")
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 15,
}
add_temperature_if_supported(payload, model=model)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
# Normalise base_url: strip trailing /v1 to avoid double /v1
base = base_url.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
api_url = base + "/v1/chat/completions"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(api_url, json=payload, headers=headers)
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]}",
extra={"src": "AgentChat.Persistence"},
)
return None
data = resp.json()
title = data.get("choices", [{}])[0].get("message", {}).get("content", "")
if title:
# Strip markdown/formatting
title = re.sub(r'[*_`#"\']', "", title).strip()
title = title[:100] # safety cap
if title:
return title
except Exception as e:
_logger.explore("LLM title generation failed", error=str(e), extra={"src": "AgentChat.Persistence"})
return None
# #region AgentChat.Persistence.GenerateLlmTitle [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,llm,title]
# @ingroup AgentChat
# @BRIEF Async best-effort LLM title generation — patches conversation title via REST.
# @PRE conversation_id exists. LLM provider configured (best-effort, skips otherwise).
# @POST Conversation title updated via PATCH if LLM succeeds; no-op on failure.
# @SIDE_EFFECT HTTP PATCH to FastAPI save endpoint; may call external LLM API.
# @RATIONALE LLM-generated titles are more readable than rule-based ones (e.g. "CSV-файл:
# Анализ дашбордов" vs "Проанализируй CSV"). This is a non-blocking best-effort
# layer — the rule-based title is already saved, LLM just improves it.
async def generate_llm_title(conv_id: str, user_text: str) -> None:
if not conv_id or not user_text:
return
# Per-conversation dedup lock
lock = _title_locks.setdefault(conv_id, asyncio.Lock())
if lock.locked():
return # already generating for this conversation
async with lock:
title = await _call_llm_for_title(user_text)
if not title:
return
# Patch the title via the same save endpoint
try:
headers = {"Content-Type": "application/json"}
if _SERVICE_JWT:
headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
payload = {
"conversation_id": conv_id,
"title": title,
"user_id": "admin",
"messages": [],
}
async with httpx.AsyncClient(timeout=5) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
logger.reflect(
"LLM title updated",
payload={"conv_id": conv_id, "title": title[:40]},
extra={"src": "AgentChat.Persistence"},
)
except Exception as e:
logger.explore(
"LLM title save failed",
payload={"conv_id": conv_id},
error=str(e),
extra={"src": "AgentChat.Persistence"},
)
finally:
_title_locks.pop(conv_id, None)
# #endregion AgentChat.Persistence.GenerateLlmTitle
# ═══════════════════════════════════════════════════════════════════
# 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.
# @POST Returns formatted dashboard list string, or empty string on failure.
# @SIDE_EFFECT Makes HTTP GET to FastAPI /api/dashboards.
# @RATIONALE Some LLMs (gemma) don't call tools even when instructed. Pre-fetch ensures
# dashboard data is available in context without requiring a tool call.
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",
params={"q": "", "env_id": env_id or ""},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = _PREFETCH_LIMIT
total = len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
except Exception as e:
logger.explore(
"Prefetch dashboards failed",
payload={"env_id": env_id},
error=str(e),
extra={"src": "AgentChat.Persistence.PrefetchDashboards"},
)
return ""
# #endregion AgentChat.Persistence.PrefetchDashboards
# #region AgentChat.Persistence.PrefetchDatabases [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,prefetch,databases]
# @ingroup AgentChat
# @BRIEF Pre-fetch all databases for an environment into a formatted string for runtime context.
# @SIDE_EFFECT HTTP GET to FastAPI /api/agent/superset/databases.
# @RATIONALE Позволяет LLM видеть database_id для каждой БД, не вызывая отдельный инструмент.
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",
params={"environment_id": env_id or ""},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
databases = resp.json()
if not databases:
return "No databases found."
lines = ["Available databases (use database_id for SQL tools):"]
for db in databases:
db_id = db.get("id", "?")
db_name = db.get("database_name", db.get("name", "?"))
db_engine = db.get("backend", db.get("engine", ""))
if db_engine:
lines.append(f" • DB #{db_id}: {db_name} ({db_engine})")
else:
lines.append(f" • DB #{db_id}: {db_name}")
return "\n".join(lines)
except Exception as e:
logger.explore(
"Prefetch databases failed",
payload={"env_id": env_id},
error=str(e),
extra={"src": "AgentChat.Persistence.PrefetchDatabases"},
)
return ""
# #endregion AgentChat.Persistence.PrefetchDatabases
# #region AgentChat.Persistence.SaveConversation [C:4] [TYPE Function] [SEMANTICS agent-chat,persistence,save]
# @ingroup AgentChat
# @BRIEF Save conversation to DB via FastAPI REST. Cleans title via clean_title().
# @PRE conversation_id is valid. FASTAPI_URL reachable.
# @POST Conversation + messages saved via POST /api/agent/conversations/save.
# @SIDE_EFFECT HTTP POST to FastAPI; writes to AgentConversation and AgentMessage tables.
# @DATA_CONTRACT Input: (conv_id, user_text, user_id, assistant_text) -> Output: None (side-effect only)
# @RELATION DISPATCHES -> [AgentChat.Api.Conversations]
# @RATIONALE Anonymous Gradio sessions (anon_ prefix) default to "admin" because the agent runs in an internal network behind an auth proxy — all users within the network are trusted.
# @REJECTED Requiring explicit authentication for Gradio was rejected — the agent is designed for internal-network use where the auth proxy handles auth; adding a separate auth layer would create unnecessary friction and duplicate the proxy's responsibility.
async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
try:
headers = {"Content-Type": "application/json"}
if _SERVICE_JWT:
headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
# Normalize user_id: anonymous Gradio sessions use "anon_" prefix
if not user_id or user_id.startswith("anon_"):
user_id = "admin"
messages: list[dict[str, Any]] = [
{
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "user",
"text": user_text.strip(),
"state": None,
"created_at": datetime.utcnow().isoformat(),
},
]
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(),
}
)
title = clean_title(user_text)
payload = {
"conversation_id": conv_id,
"title": title,
"user_id": user_id,
"messages": messages,
}
async with httpx.AsyncClient(timeout=10) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
logger.reflect(
"Conversation saved",
payload={"conv_id": conv_id, "msg_count": len(messages), "title": title[:40]},
extra={"src": "AgentChat.Persistence.SaveConversation"},
)
except Exception as e:
logger.explore(
"Failed to save conversation",
payload={"conv_id": conv_id},
error=str(e),
extra={"src": "AgentChat.Persistence"},
)
# #endregion AgentChat.Persistence.SaveConversation
# #endregion AgentChat.Persistence

View File

@@ -1,224 +0,0 @@
# backend/src/agent/_tool_filter.py
# #region AgentChat.ToolFilter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,filter,context]
# @defgroup AgentChat Context-aware tool filtering + RBAC enforcement.
# @BRIEF Provides a two-stage tool selection pipeline: RBAC role check followed by
# context affinity filtering, plus an invocation-time RBAC guard.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RELATION DEPENDS_ON -> [LoggerModule]
# @INVARIANT show_capabilities is ALWAYS included in the filtered pipeline output.
# @INVARIANT enforce_tool_permission is a pure boolean guard — no SSE logging, no side effects.
# @RATIONALE Ordered pipeline ensures RBAC is enforced before context UX filtering,
# preventing role-restricted tools from appearing even in per-context
# suggestions. Invocation guard adds a second enforcement layer at call time
# for defense-in-depth.
# @REJECTED Embedding-based context filtering — postponed to post-MVP. Current approach
# uses a static affinity map which is simpler, deterministic, and sufficient
# for the initial release.
# @DATA_CONTRACT build_tool_pipeline returns a list — never mutates the input list.
# @DATA_CONTRACT enforce_tool_permission returns bool for any string input.
# ---
from typing import Any
from src.core.logger import logger
# ── Context affinity mapping ───────────────────────────────────────
# Maps object_type keys to the set of tool names relevant in that context.
# Tools not in the set are excluded when the corresponding object_type
# is active. If object_type is None or not present, no context filtering
# is applied.
_CONTEXT_TOOL_AFFINITY: dict[str, set[str]] = {
"dashboard": {
"superset_list_databases",
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"run_llm_validation",
"run_llm_documentation",
"execute_migration",
"create_branch",
"commit_changes",
},
"dataset": {
"superset_list_databases",
"superset_explore_database",
"superset_format_sql",
"superset_audit_permissions",
"superset_execute_sql",
"superset_create_dataset",
"search_dashboards",
"get_task_status",
"list_environments",
},
"migration": {
"superset_list_databases",
"execute_migration",
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"list_environments",
},
}
# ── RBAC permission mapping ────────────────────────────────────────
# Maps tool names to the list of roles allowed to invoke them.
# Tools NOT in this dict are allowed for ALL roles.
_TOOL_PERMISSIONS: dict[str, list[str]] = {
"deploy_dashboard": ["admin"],
"commit_changes": ["admin"],
"create_branch": ["admin"],
"run_backup": ["admin"],
"execute_migration": ["admin"],
"start_maintenance": ["admin"],
"end_maintenance": ["admin"],
}
# ── Tools that must always survive all filtering stages ────────────
# These tools provide introspection / capabilities and must never be
# hidden from the user regardless of role or context.
_MANDATORY_TOOLS: set[str] = {
"show_capabilities",
}
# #region AgentChat.ToolFilter.Pipeline [C:4] [TYPE Function] [SEMANTICS agent-chat,tools,filter,pipeline]
# @ingroup AgentChat
# @BRIEF Two-stage tool filtering: RBAC role check -> context affinity.
# @param tools Iterable of LangChain BaseTool objects (or any object with a .name attribute).
# @param user_role Current user's role string (e.g. "admin", "editor", "viewer").
# @param object_type Optional context object type key ("dashboard", "dataset", "migration", or None).
# @POST Returns filtered list of tool objects. show_capabilities is always included.
# @SIDE_EFFECT Each exclusion is logged via logger.reason with src="AgentChat.ToolFilter".
# @RATIONALE Ordered pipeline ensures RBAC enforced before context UX filtering.
# @REJECTED Embedding-based filtering — postponed to post-MVP.
def build_tool_pipeline(
tools: list[Any],
user_role: str,
object_type: str | None = None,
) -> list[Any]:
"""Filter tools through RBAC then context affinity, always keeping mandatory tools.
Stage 1 — RBAC: Remove tools the user's role is not permitted to use.
Stage 2 — Context: Keep only tools relevant to the current object_type, if specified.
Parameters
----------
tools:
List of LangChain BaseTool instances (must have .name attribute).
user_role:
Role identifier for permission checks (e.g. "admin", "editor").
object_type:
Optional context key that selects a subset of tools. Pass None or an
unmapped key to skip context filtering.
Returns
-------
list[Any]:
Filtered list of tool objects, preserving the original order.
"""
filtered: list[Any] = []
for tool in tools:
name: str = tool.name
# Stage 1: RBAC — skip if role is not permitted
if name in _TOOL_PERMISSIONS:
allowed_roles: list[str] = _TOOL_PERMISSIONS[name]
if user_role not in allowed_roles:
logger.reason(
"Tool excluded by RBAC",
payload={
"tool": name,
"reason": f"role '{user_role}' not in allowed roles {allowed_roles}",
},
extra={"src": "AgentChat.ToolFilter"},
)
continue # skip this tool
# Stage 2: Context affinity — skip if not relevant to current object_type
if (
object_type is not None
and object_type in _CONTEXT_TOOL_AFFINITY
and name not in _CONTEXT_TOOL_AFFINITY[object_type]
and name not in _MANDATORY_TOOLS
):
logger.reason(
"Tool excluded by context",
payload={
"tool": name,
"reason": f"not in context affinity set for object_type '{object_type}'",
},
extra={"src": "AgentChat.ToolFilter"},
)
continue # skip this tool
filtered.append(tool)
# Enforce mandatory tools — ensure show_capabilities is always present
seen_names: set[str] = {t.name for t in filtered}
missing_mandatory: set[str] = _MANDATORY_TOOLS - seen_names
if missing_mandatory:
# Re-scan the original list for any missing mandatory tools
for tool in tools:
if tool.name in missing_mandatory:
filtered.append(tool)
logger.reason(
"Mandatory tool re-included after filtering",
payload={"tool": tool.name},
extra={"src": "AgentChat.ToolFilter"},
)
return filtered
# #endregion AgentChat.ToolFilter.Pipeline
# #region AgentChat.ToolFilter.InvocationGuard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,security,guard]
# @ingroup AgentChat
# @BRIEF Invocation-time RBAC enforcement — pure boolean guard, no SSE logging.
# @param tool_name Name of the tool being invoked.
# @param user_role Current user's role string.
# @POST Returns True if allowed, False if role insufficient.
# @SIDE_EFFECT None — pure boolean guard. The caller (agent_handler) uses the result to
# decide whether to yield permission_denied SSE.
# @RATIONALE Two-layer enforcement: prompt-level filter (build_tool_pipeline) + invocation
# guard. The invocation guard is defense-in-depth against stale client state
# or bypass attempts.
def enforce_tool_permission(tool_name: str, user_role: str) -> bool:
"""Check whether *user_role* is permitted to invoke *tool_name*.
This is an invocation-time guard called JUST BEFORE executing a tool.
It does NOT log, yield SSE, or produce side effects — it is a pure
boolean predicate. The caller uses the result to decide the response
(e.g. yield a ``permission_denied`` SSE event).
Parameters
----------
tool_name:
The name (string identifier) of the tool to check.
user_role:
The role string of the current user.
Returns
-------
bool
``True`` if the tool may be invoked, ``False`` if the role is
insufficient. Unknown tool names always return ``True`` (the
caller handles errors for unknown tools).
"""
if tool_name in _TOOL_PERMISSIONS:
allowed_roles: list[str] = _TOOL_PERMISSIONS[tool_name]
return user_role in allowed_roles
# Unknown tool or tool without explicit permissions — allowed by default
return True
# #endregion AgentChat.ToolFilter.InvocationGuard
# #endregion AgentChat.ToolFilter

View File

@@ -1,108 +0,0 @@
# backend/src/agent/_tool_resolver.py
# #region AgentChat.ToolResolver [C:2] [TYPE Module] [SEMANTICS agent-chat,tools,resolution]
# @defgroup AgentChat Tool resolution helpers for the LangGraph agent.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE Centralised tool resolution prevents duplication of tool-name matching logic.
# Deterministic intent matching (infer_tool_from_text, fast_confirmation_tool,
# keyword lists, negation guard, classification sets) removed — LLM handles
# all intent detection through LangGraph tool-calling. Only utility helpers
# (tool call coercion, args normalization) remain.
# @REJECTED Deterministic intent matching — fragile substring collisions, maintenance burden
# of 24 keyword lists across 3 files, negation blindness in fast-track, and
# inability to handle synonyms ("панели"≠"дашборды") or typos ("дашборд").
# @REJECTED Fast-track confirmation — bypasses LLM, causing negation blindness.
# @REJECTED Tool risk classification sets — LLM decides which tools to call;
# LangGraph interrupt_before handles HITL for dangerous tools at graph level.
from typing import Any
# ── Graph nodes — used by confirmation subsystem to distinguish tools from infrastructure ──
_GRAPH_NODE_NAMES = {"agent", "tools", "__start__", "__end__"}
# #region AgentChat.ToolResolver.KnownNames [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,catalog]
# @ingroup AgentChat
# @BRIEF Return registered LangChain tool names.
# @POST Returns set of tool name strings; falls back to empty set on failure.
def known_agent_tool_names() -> set[str]:
try:
from src.agent.tools import get_all_tools
return {str(tool_obj.name) for tool_obj in get_all_tools() if getattr(tool_obj, "name", None)}
except Exception:
return set()
# #endregion AgentChat.ToolResolver.KnownNames
# #region AgentChat.ToolResolver.NormalizeArgs [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,args]
# @ingroup AgentChat
# @BRIEF Normalize raw tool arguments into a plain dict regardless of input format.
# @POST Returns dict (empty dict for None/unparseable input).
def normalize_tool_args(raw_args: Any) -> dict[str, Any]:
if raw_args is None:
return {}
if isinstance(raw_args, dict):
return raw_args
if hasattr(raw_args, "model_dump"):
dumped = raw_args.model_dump()
return dumped if isinstance(dumped, dict) else {}
if hasattr(raw_args, "dict"):
dumped = raw_args.dict()
return dumped if isinstance(dumped, dict) else {}
return {}
# #endregion AgentChat.ToolResolver.NormalizeArgs
# #region AgentChat.ToolResolver.CoerceToolCall [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,coerce]
# @ingroup AgentChat
# @BRIEF Extract (tool_name, tool_args) tuple from a dict or object tool call.
# @POST Returns (name, args) tuple; name may be None if unparseable.
def coerce_tool_call(tool_call: Any) -> tuple[str | None, dict[str, Any]]:
if isinstance(tool_call, dict):
return (
tool_call.get("name") or tool_call.get("tool") or tool_call.get("id"),
normalize_tool_args(tool_call.get("args") or tool_call.get("input")),
)
return (
getattr(tool_call, "name", None),
normalize_tool_args(getattr(tool_call, "args", None)),
)
# #endregion AgentChat.ToolResolver.CoerceToolCall
# #region AgentChat.ToolResolver.ExtractFromState [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,checkpoint]
# @ingroup AgentChat
# @BRIEF Extract pending tool name and args from the LangGraph checkpoint.
# @POST Returns (tool_name, args) tuple; (None, {}) if nothing found.
# @RATIONALE LLM handles intent — no fallback to keyword inference.
def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None, dict[str, Any]]:
known_tools = known_agent_tool_names()
try:
messages = (state.values.get("messages") if hasattr(state, "values") else []) or []
for msg in reversed(messages[-5:]):
if hasattr(msg, "tool_calls") and msg.tool_calls:
tool_name, tool_args = coerce_tool_call(msg.tool_calls[0])
if tool_name:
return (str(tool_name), tool_args)
except Exception:
pass
if getattr(state, "next", None):
node_or_tool = str(state.next[0])
if node_or_tool in known_tools and node_or_tool not in _GRAPH_NODE_NAMES:
return (node_or_tool, {})
return (None, {})
# #endregion AgentChat.ToolResolver.ExtractFromState
# #region AgentChat.ToolResolver.FindTool [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,lookup]
# @ingroup AgentChat
# @BRIEF Find a registered LangChain tool by name.
# @POST Returns tool object or None if not found.
def find_tool(tool_name: str):
from src.agent.tools import get_all_tools
return next((tool_obj for tool_obj in get_all_tools() if getattr(tool_obj, "name", None) == tool_name), None)
# #endregion AgentChat.ToolResolver.FindTool
# #endregion AgentChat.ToolResolver

View File

@@ -1,812 +0,0 @@
# backend/src/agent/app.py
# #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app]
# @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt().
# @PRE AUTH_SECRET_KEY env var set. Shared with FastAPI for stateless validation.
# @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware.
# @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL.
# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser]
# @RELATION DEPENDS_ON -> [AgentChat.ToolResolver]
# @RELATION DEPENDS_ON -> [AgentChat.Confirmation]
# @RELATION DEPENDS_ON -> [AgentChat.Persistence]
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
# @RATIONALE Gradio ChatInterface chosen for its built-in streaming, file upload, and multimodal support — avoids custom WebSocket implementation for agent chat.
# @REJECTED Custom React chat frontend rejected — Gradio provides free authentication, session management, and mobile-responsive UI out of the box.
import asyncio
from collections.abc import AsyncGenerator
from datetime import datetime
import functools
import inspect
import json
import os
from pathlib import Path
import shutil
import time
from typing import Any
import uuid
import gradio as gr
import httpx
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, RateLimitError
from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT
from src.agent._confirmation import (
_pending_confirmations,
confirmation_payload,
handle_resume,
permission_denied_payload,
)
from src.agent._jwt_decoder import decode_token
from src.agent._llm_health import (
_LLM_CHECK_CACHE_TTL,
_LLM_LAST_ERROR_KEY,
_LLM_LAST_ERROR_TS_KEY,
_check_llm_provider_health,
_llm_status,
)
from src.agent._llm_params import chat_openai_kwargs
from src.agent._persistence import (
extract_user_id,
generate_llm_title,
prefetch_dashboards,
prefetch_databases,
save_conversation,
)
from src.agent.context import set_user_jwt, set_user_role
from src.agent.document_parser import parse_upload
from src.agent.langgraph_setup import create_agent
from src.agent.middleware import log_tool_event
from src.agent.tools import (
_redact_sensitive_fields,
drain_tool_retry_events,
get_all_tools,
start_tool_retry_event_buffer,
)
from src.core.cot_logger import seed_trace_id
from src.core.logger import logger
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
TITLE_GENERATION_TIMEOUT_S = float(os.getenv("AGENT_TITLE_GENERATION_TIMEOUT_S", "0.25"))
ENABLE_LLM_TITLE_GENERATION = os.getenv("AGENT_ENABLE_LLM_TITLES", "").lower() in {"1", "true", "yes"}
def _now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
async def _build_agent_context(env_id: str | None) -> str:
"""Build hidden runtime context for the LLM without storing it as user text."""
parts = [
"[RUNTIME CONTEXT]",
f"Current datetime: {_now_iso()}",
"If the user asks to start/run something without an explicit start time, use Current datetime as start_time.",
"If the user gives a duration such as '15 minutes' or '2 hours', compute end_time from Current datetime.",
]
if env_id:
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.",
]
)
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.append("[/RUNTIME CONTEXT]")
return "\n".join(parts)
# #region AgentChat.GradioApp.TitleBestEffort [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,title]
# @ingroup AgentChat
# @BRIEF Run LLM title generation with a bounded timeout so request loops close cleanly.
async def _generate_title_best_effort(conv_id: str, visible_user_text: str) -> None:
if not ENABLE_LLM_TITLE_GENERATION:
return
try:
await asyncio.wait_for(
generate_llm_title(conv_id, visible_user_text),
timeout=TITLE_GENERATION_TIMEOUT_S,
)
except TimeoutError:
logger.reason(
"LLM title generation deferred by timeout",
payload={"conv_id": conv_id, "timeout_s": TITLE_GENERATION_TIMEOUT_S},
extra={"src": "AgentChat.GradioApp.Title"},
)
except Exception as exc:
logger.explore(
"LLM title generation failed",
payload={"conv_id": conv_id},
error=str(exc),
extra={"src": "AgentChat.GradioApp.Title"},
)
# #endregion AgentChat.GradioApp.TitleBestEffort
# ── Session state ───────────────────────────────────────────────
# In-memory per-user lock (keyed by user_id)
_user_locks: dict[str, bool] = {}
# ── LLM provider health cache ─────────────────────────────────---
# _llm_status, _LLM_CHECK_CACHE_TTL, _LLM_LAST_ERROR_KEY, _LLM_LAST_ERROR_TS_KEY,
# and _check_llm_provider_health() are imported from src.agent._llm_health
# to avoid triggering gradio import in backend container.
# ── 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.
# @PRE file_path exists and is readable. storage root configured.
# @POST File copied to {storage_root}/chat_uploads/{conv_id}/{filename}. Returns relative storage path or None.
# @SIDE_EFFECT Writes file to local storage directory.
def _persist_chat_file(file_path: str, conv_id: str) -> str | None:
"""Copy uploaded file to chat_uploads storage, return relative path for download."""
storage_root = _STORAGE_ROOT
if not os.path.isabs(storage_root):
storage_root = os.path.join(os.getcwd(), storage_root)
src = Path(file_path)
if not src.exists() or not src.is_file():
return None
dest_dir = Path(storage_root) / "chat_uploads" / conv_id
try:
dest_dir.mkdir(parents=True, exist_ok=True)
except PermissionError:
# Fallback to /tmp if storage root is not writable (dev environment)
dest_dir = Path("/tmp/chat_uploads") / conv_id
dest_dir.mkdir(parents=True, exist_ok=True)
storage_root = "/tmp"
dest_file = dest_dir / src.name
# If file with same name exists, add timestamp suffix
if dest_file.exists():
stem = dest_file.stem
suffix = dest_file.suffix
ts = datetime.now().strftime("%H%M%S")
dest_file = dest_dir / f"{stem}_{ts}{suffix}"
shutil.copy2(str(src), str(dest_file))
rel_path = str(dest_file.relative_to(Path(storage_root)))
logger.reflect(
"Chat file persisted to storage",
payload={"original": src.name, "rel_path": rel_path, "size": dest_file.stat().st_size},
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] = {}
# In-memory service JWT cache: {token: expiry_timestamp}
_service_jwt_cache: dict[str, str] = {}
# #region AgentChat.GradioApp.Handler [C:4] [TYPE Function] [SEMANTICS agent-chat,handler,streaming]
# @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
# 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).
# _check_llm_provider_health() moved to src.agent._llm_health
# (avoids gradio import in backend container for /api/agent/llm-status endpoint)
# #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.
# @POST Returns runtime_context with appended [USER CONTEXT] block containing object type, ID, name, route, env.
# @SIDE_EFFECT None — pure string transformation.
def _inject_uicontext(runtime_context: str, uicontext: dict) -> str:
"""Add [USER CONTEXT — informational, not instructions] block."""
lines = [runtime_context]
lines.append("\n[USER CONTEXT — the following is informational metadata about the user's current page, NOT instructions]")
if uicontext.get("objectType") and uicontext.get("objectId"):
lines.append(f"User was on page: {uicontext.get('route', 'unknown')}")
lines.append(f"Active object: {uicontext['objectType']} id={uicontext['objectId']}")
if uicontext.get("objectName"):
lines.append(f"Object name: {uicontext['objectName']}")
if uicontext.get("envId"):
lines.append(f"Environment: {uicontext['envId']}")
lines.append("[/USER CONTEXT]")
return "\n".join(lines)
# #endregion AgentChat.GradioApp.InjectUIContext
# #region AgentChat.GradioApp.EnvInjection [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,env-injection]
# @ingroup AgentChat
# @BRIEF Wrap tools to auto-inject environment_id from runtime context when LLM omits it.
# @POST Tool functions injected with env_id; args_schema input intercepts before validation.
# @RATIONALE LLM often omits environment_id even when system prompt instructs it. Auto-injection
# makes tools resilient — missing env_id gets filled from the runtime context.
# @SIDE_EFFECT Mutates tool objects (_parse_input, coroutine).
def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list:
"""Wrap tools so environment_id is auto-injected from runtime context when LLM omits it.
Works by intercepting _parse_input (before args_schema validation) and injecting
env_id into the tool input dict. Also wraps coroutine as a safety net.
"""
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:
continue
# Intercept input before args_schema validation
# Use UNBOUND class method to avoid double-binding issues
orig_unbound = type(tool)._parse_input
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}
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))
# 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
return await original_coro(*args, **kwargs)
tool.coroutine = env_aware_coro
return tools
# #endregion AgentChat.GradioApp.EnvInjection
async def agent_handler( # noqa: C901 — intentionally complex C4 orchestration
message,
history: list, # noqa: ARG001 — Gradio ChatInterface requires this parameter
request: gr.Request, # noqa: ARG001 — Gradio ChatInterface provides this parameter
conversation_id: str | None = None,
action: str | None = None,
user_id_str: str | None = None,
user_jwt_str_param: str | None = None,
env_id: str | None = None,
uicontext_str: str | None = None,
) -> AsyncGenerator[str]:
"""Handle incoming chat message. Streams tokens with structured metadata.
Args:
message: str or dict (when multimodal) — user message.
history: list of ChatMessage — Gradio's built-in history (ignored — loaded from DB).
request: gr.Request — may contain Authorization header with user JWT.
conversation_id: str — via additional_inputs (thread_id for checkpointer).
action: str — "confirm" | "deny" for HITL resume, None for normal messages.
user_id_str: str — user ID from frontend, used for conversation persistence.
user_jwt_str_param: str — user JWT from frontend for tool auth.
env_id: str — selected environment ID from top-bar selector.
uicontext_str: str — JSON string of UI context from frontend (object type, id, route, etc.).
"""
# ── Auth: user JWT passed from frontend via additional_input —─
user_jwt_str = user_jwt_str_param or ""
token_payload: dict[str, Any] = {}
if user_jwt_str:
try:
token_payload = decode_token(user_jwt_str)
except JWTError:
user_jwt_str = ""
set_user_jwt(user_jwt_str)
user_role = token_payload.get("role") or token_payload.get("user_role") or "viewer"
set_user_role(user_role)
# ── 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", "detail": "Другой запрос уже обрабатывается. Дождитесь завершения перед отправкой нового."}})
return
_user_locks[user_id] = True
conv_id: str | None = None
try:
# ── Resolve conversation ID early (needed for file persistence) ──
conv_id = conversation_id or str(uuid.uuid4())
_trace_id = seed_trace_id()
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))},
extra={"src": "AgentChat.GradioApp.Handler"},
)
# ── Parse message ──
text = message.get("text", "") if isinstance(message, dict) else str(message)
files = message.get("files", []) if isinstance(message, dict) else []
if not text.strip() and not files:
return
# ── Truncate long messages ──
max_msg_length = 100_000
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]"
visible_user_text = text
# ── File upload ──
file_storage_path: str | None = None
file_original_name: str | None = None
file_size: int = 0
if files:
file_obj = files[0]
file_path = file_obj if isinstance(file_obj, str) else getattr(file_obj, "name", None)
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"},
}
)
return
# Persist file to storage for download
if conv_id:
file_storage_path = _persist_chat_file(file_path, conv_id)
file_original_name = Path(file_path).name
parsed = parse_upload(file_obj)
text = f"{text}\n\n--- Uploaded file content ---\n{parsed}"
# Parse and validate uicontext (035-agent-chat-context)
uicontext = None
if uicontext_str:
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:
logger.explore("Invalid uicontext JSON", error="JSONDecodeError", extra={"src": "AgentChat.Handler"})
except ValueError as e:
logger.explore("UIContext validation failed", error=str(e), extra={"src": "AgentChat.Handler"})
uicontext = None
runtime_context = await _build_agent_context(env_id)
# Inject uicontext into runtime context
if uicontext:
runtime_context = _inject_uicontext(runtime_context, uicontext)
agent_text = f"{visible_user_text}\n\n{runtime_context}"
if text != visible_user_text:
agent_text = f"{text}\n\n{runtime_context}"
# ── 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,
}
}
)
# ── HITL resume path ──
if action in ("confirm", "deny"):
conv_id = conversation_id
if conv_id:
lock = _conv_locks.get(conv_id)
if lock is not None:
try:
await asyncio.wait_for(lock.wait(), timeout=2.0)
except TimeoutError:
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 {}
tool_name = pending_pre.get("tool_name", "") if pending_pre else ""
async for chunk in handle_resume(conv_id, action, user_jwt_str, env_id):
yield chunk
# Build descriptive title from captured tool_name
title = f"{'' if action == 'confirm' else '⏹️'} {tool_name or 'Операция'}" if tool_name else f"HITL: {action}"
await save_conversation(conv_id or str(uuid.uuid4()), title, user_id)
return
# ── Normal send path ──
conv_id = conversation_id or str(uuid.uuid4())
_conv_locks[conv_id] = asyncio.Event()
# All tools exposed — LLM handles intent detection via LangGraph tool-calling.
# Embedding-based tool selection (top-K) replaces keyword matching if model available.
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,
},
}
)
# 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)
config = {"configurable": {"thread_id": conv_id}}
assistant_parts: list[str] = []
max_attempts = 2
start_tool_retry_event_buffer()
try:
for attempt in range(max_attempts):
try:
emitted_any = False
async for event in agent.astream_events(
{"messages": [HumanMessage(content=agent_text)]},
config=config,
version="v2",
):
for retry_event in drain_tool_retry_events():
emitted_any = True
yield json.dumps(retry_event)
kind = event.get("event")
if kind in ("on_tool_start", "on_tool_end", "on_tool_error"):
await log_tool_event(event, conv_id)
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
content = chunk.content
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)
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},
}
)
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},
}
)
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]}},
}
)
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:") :]
_, denied_tool, required_role, denied_role = marker.split(":", 3)
denied_role = denied_role.split()[0].strip("'\"")
yield permission_denied_payload(
denied_tool,
required_role=required_role,
user_role=denied_role,
)
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",
}
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},
}
)
state = await agent.aget_state(config)
for retry_event in drain_tool_retry_events():
emitted_any = True
yield json.dumps(retry_event)
if getattr(state, "next", None):
emitted_any = True
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]},
}
)
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,
},
}
)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return
except (APITimeoutError, httpx.ReadTimeout) as exc:
_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,
},
}
)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return
except AuthenticationError as exc:
_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,
},
}
)
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
continue
logger.explore(
"LLM malformed output",
payload={"conv_id": conv_id, "attempt": attempt},
error=str(e),
extra={"src": "AgentChat.GradioApp.Handler"},
)
yield json.dumps(
{
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
}
)
except Exception as exc:
logger.explore(
"Agent handler failed",
payload={"conv_id": conv_id, "user_id": user_id},
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"},
)
# 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)},
}
)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(str(part) for part in assistant_parts))
return
assistant_text = "".join(str(part) for part in assistant_parts)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text=assistant_text)
await _generate_title_best_effort(conv_id, visible_user_text)
logger.reflect(
"Agent handler completed",
payload={"conv_id": conv_id, "assistant_len": len(assistant_text)},
extra={"src": "AgentChat.GradioApp.Handler"},
)
finally:
_user_locks[user_id] = False
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.
# @POST Returns gr.ChatInterface instance.
def create_chat_interface():
return gr.ChatInterface(
fn=agent_handler,
type="messages",
multimodal=True,
additional_inputs=[
gr.Textbox(label="conversation_id", visible=False),
gr.Textbox(label="action", visible=False),
gr.Textbox(label="user_id_str", visible=False),
gr.Textbox(label="user_jwt_str_param", visible=False),
gr.Textbox(label="env_id", visible=False),
gr.Textbox(label="uicontext_str", visible=False),
],
examples=[
["Покажи дашборды", None, None],
["Статус системы", None, None],
["Запусти миграцию", None, None],
],
)
# #endregion AgentChat.GradioApp.CreateInterface
# #region AgentChat.GradioApp.Health [C:1] [TYPE Function] [SEMANTICS agent-chat,healthcheck]
# @ingroup AgentChat
# @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
if __name__ == "__main__":
demo = create_chat_interface()
demo.launch(
server_name=GRADIO_SERVER_NAME,
server_port=GRADIO_SERVER_PORT,
)
# #endregion AgentChat.GradioApp

View File

@@ -1,40 +0,0 @@
# backend/src/agent/context.py
# #region AgentChat.Context [C:3] [TYPE Module] [SEMANTICS agent-chat,context,auth]
# @defgroup AgentChat JWT context propagation for LangGraph tools.
# @RATIONALE LangGraph tool execution may run in a different async context,
# preventing ContextVar from propagating. Module-level globals
# ensure the JWT is always accessible from any execution context.
# @NOTE NOT thread-safe — each Gradio agent process handles one request at a time
# (enforced by _user_locks in app.py).
_user_jwt: str = ""
_service_jwt: str = ""
_user_role: str = "viewer"
def set_user_jwt(jwt: str) -> None:
global _user_jwt
_user_jwt = jwt
def get_user_jwt() -> str:
return _user_jwt
def set_user_role(role: str) -> None:
global _user_role
_user_role = role or "viewer"
def get_user_role() -> str:
return _user_role
def set_service_jwt(jwt: str) -> None:
global _service_jwt
_service_jwt = jwt
def get_service_jwt() -> str:
return _service_jwt
# #endregion AgentChat.Context

View File

@@ -1,129 +0,0 @@
# backend/src/agent/document_parser.py
# #region AgentChat.Document.Parser [C:3] [TYPE Module] [SEMANTICS agent-chat,document,parser]
# @defgroup AgentChat Parse PDF and XLSX files into text/structured data.
# @RELATION DEPENDS_ON -> [EXT:pdfplumber]
# @RELATION DEPENDS_ON -> [EXT:openpyxl]
# @PRE File exists, valid format, ≤10MB.
# @POST Returns extracted text (PDF) or structured dict (XLSX).
from pathlib import Path
class ParseError(Exception):
"""Raised when document parsing fails."""
def parse_pdf(file_path: str) -> str:
"""Extract text from PDF using pdfplumber (primary) with PyPDF2 fallback."""
try:
import pdfplumber
except ImportError:
raise ParseError("pdfplumber not installed") from None
try:
with pdfplumber.open(file_path) as pdf:
pages = []
for page in pdf.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n\n".join(pages) if pages else ""
except Exception as e:
# Fallback to PyPDF2
try:
import PyPDF2
with open(file_path, "rb") as f:
reader = PyPDF2.PdfReader(f)
return "\n\n".join(p.extract_text() for p in reader.pages if p.extract_text())
except Exception:
raise ParseError(f"Failed to parse PDF: {e}") from None
def parse_xlsx(file_path: str) -> str:
"""Extract structured data from XLSX — sheet names + cell data."""
try:
import openpyxl
except ImportError:
raise ParseError("openpyxl not installed") from None
try:
wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
parts = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
rows = []
for row in ws.iter_rows(values_only=True):
cells = [str(c) if c is not None else "" for c in row]
rows.append("\t".join(cells))
parts.append(f"=== Sheet: {sheet_name} ===\n" + "\n".join(rows))
return "\n\n".join(parts)
except Exception as e:
raise ParseError(f"Failed to parse XLSX: {e}") from e
def _detect_format_by_magic(path: str) -> str | None:
"""Detect file format by reading magic bytes. Returns extension or None."""
try:
with open(path, "rb") as f:
header = f.read(8)
except OSError:
return None
if header[:4] == b"%PDF":
return ".pdf"
if header[:4] == b"PK\x03\x04":
# ZIP-based: XLSX, DOCX, etc. Try XLSX first.
return ".xlsx"
if header[:1] in (b"{", b"["):
return ".json"
# CSV often starts with text characters; safe fallback
return None
def parse_upload(file_data) -> str:
"""Parse an uploaded file based on its extension with magic-byte fallback.
Args:
file_data: str (file path) or dict with "name" and "path"/"file_path" keys.
"""
if isinstance(file_data, str):
path = file_data
name = Path(path).name
else:
name = file_data.get("name") or file_data.get("orig_name", "")
path = file_data.get("path") or file_data.get("file_path", "")
# Gradio sometimes sends files without 'name' key — fall back to path stem
if not name and path:
name = Path(path).name
ext = Path(name).suffix.lower()
# Double fallback: if name has no extension, try the physical file path
if not ext and path:
ext = Path(path).suffix.lower()
# Triple fallback: detect by magic bytes (Gradio ChatInterface loses filename)
if not ext and path:
ext = _detect_format_by_magic(path)
if ext == ".pdf":
return parse_pdf(path)
elif ext in (".xlsx", ".xls"):
return parse_xlsx(path)
elif ext in (".json", ".csv", ".txt"):
with open(path, encoding="utf-8", errors="replace") as f:
return f.read(100_000) # truncate at ~100k chars
elif ext is None:
# Magic bytes detection returned None — unknown binary, try as text
try:
with open(path, encoding="utf-8", errors="replace") as f:
return f.read(100_000)
except Exception as e:
raise ParseError(
f"Could not detect file format for '{name}'. "
f"Supported: PDF, XLSX, JSON, CSV, TXT"
) from e
else:
raise ParseError(
f"Unsupported format: '{ext}' (file: {name}). "
f"Supported: PDF, XLSX, JSON, CSV, TXT"
)
# #endregion AgentChat.Document.Parser

View File

@@ -1,222 +0,0 @@
# backend/src/agent/langgraph_setup.py
# #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent]
# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver.
# @PRE LLM provider configured via backend API /api/agent/llm-config.
# @POST Compiled StateGraph ready for astream_events().
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call.
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE LangGraph create_react_agent provides built-in tool calling + checkpointing + interrupt/resume.
# @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.
# ── 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
# transforms the request body, it hits ``isinstance(data, pydantic.BaseModel)``
# which is True for both instances AND classes. It then calls ``model_dump()``
# on the class, which fails with:
#
# 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):
if isinstance(data, _pydantic.BaseModel):
if _inspect.isclass(data):
# BaseModel CLASS (not instance) — skip model_dump
return data
# BaseModel INSTANCE — intercept PydanticSerializationError
try:
return await _original_transform(data, annotation=annotation, inner_type=inner_type)
except _pydantic_core.PydanticSerializationError:
print(f"[PATCH] Caught PydanticSerializationError on {type(data).__name__}")
# Fallback: dump with exclude of type-ref fields
serializable = {}
for field_name in data.model_fields_set:
val = getattr(data, field_name)
if isinstance(val, type) and issubclass(val, _pydantic.BaseModel):
serializable[field_name] = val.model_json_schema()
else:
serializable[field_name] = val
return serializable
return await _original_transform(data, annotation=annotation, inner_type=inner_type)
_openai_transform._async_transform_recursive = _patched_transform
# ── Postgres checkpointer (FR-004/FR-012/FR-027) ──
_CHECKPOINTER: AsyncPostgresSaver | None = None
_CHECKPOINTER_INIT = False
_CHECKPOINTER_CONN = None
async def init_checkpointer() -> None:
"""Initialize the PostgreSQL checkpointer (FR-004/FR-012/FR-027).
Called once at agent startup. Creates a persistent psycopg async connection
and passes it to AsyncPostgresSaver. Runs .setup() to create checkpoint tables.
Connection stays open for the lifetime of the agent process.
"""
global _CHECKPOINTER, _CHECKPOINTER_INIT, _CHECKPOINTER_CONN
if _CHECKPOINTER_INIT:
return
db_url = os.getenv("DATABASE_URL")
# Convert SQLAlchemy-style URL to psycopg format
pg_url = db_url.replace("postgresql+psycopg2://", "postgres://").replace("postgresql://", "postgres://")
_CHECKPOINTER_CONN = await psycopg.AsyncConnection.connect(pg_url, autocommit=True, row_factory=dict_row)
_CHECKPOINTER = AsyncPostgresSaver(_CHECKPOINTER_CONN)
await _CHECKPOINTER.setup()
_CHECKPOINTER_INIT = True
# ── LLM config (no cache — fetched on each create_agent call) ──
_llm_config: dict | None = None
def configure_from_api(llm_config: dict) -> None:
"""Update LLM config from FastAPI response. Called at startup."""
global _llm_config
_llm_config = llm_config
async def _fetch_llm_config() -> dict | None:
"""Fetch LLM config from FastAPI.
Called on every create_agent() to pick up Admin UI changes immediately.
Falls back to cached config if fetch fails.
"""
global _llm_config
try:
fastapi_url = FASTAPI_URL
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{fastapi_url}/api/agent/llm-config")
if resp.status_code == 200:
config = resp.json()
if config.get("configured"):
_llm_config = config
return config
except Exception as e:
logger.explore("Failed to fetch LLM config from FastAPI", error=str(e),
extra={"src": "AgentChat.LangGraph.Setup"})
return _llm_config
def _interrupt_before_from_env() -> list[str]:
"""Return LangGraph node names that must pause for HITL confirmation."""
if AGENT_CONFIRM_TOOLS:
return ["tools"]
raw = _INTERRUPT_BEFORE
if not raw:
return []
return [name.strip() for name in raw.split(",") if name.strip()]
async def create_agent(
tools: list,
env_id: str | None = None,
interrupt_before: list[str] | None = None,
):
"""Create the LangGraph agent with PostgreSQL checkpointer and message history.
LLM configuration source:
llm_config from FastAPI /api/agent/llm-config (fetched on every call).
If backend has no configured provider, agent raises an error.
Returns a compiled StateGraph ready for astream_events().
interrupt_before is set from AGENT_CONFIRM_TOOLS (or AGENT_INTERRUPT_BEFORE env var)
to enable HITL guardrails — when pending tools are detected, the graph pauses before
executing them and yields confirm_required metadata to the frontend.
Checkpointer is AsyncPostgresSaver (survives container restarts).
"""
# Fetch fresh LLM config from FastAPI on every call
config = await _fetch_llm_config()
if config and config.get("configured"):
api_key = config["api_key"]
base_url = config.get("base_url")
model = config.get("default_model")
config_source = "FastAPI"
else:
raise RuntimeError(
"No LLM provider configured in backend. "
"Configure one via Settings → AI Providers in the web UI."
)
logger.reason(
"Creating LangGraph agent",
payload={"model": model, "config_source": config_source, "tools_count": len(tools), "env_id": env_id},
extra={"src": "AgentChat.LangGraph.Setup"},
)
llm = ChatOpenAI(**chat_openai_kwargs(
model=model,
base_url=base_url,
api_key=api_key,
max_tokens=2048,
))
# System prompt — env_id injected deterministically, not in user message
prompt = (
"You are a Superset Tools assistant. You have access to tools for searching "
"dashboards, managing maintenance, running migrations and backups, "
"executing SQL and exploring databases, auditing permissions, "
"managing Git operations (branch/commit/deploy), running LLM validation "
"and documentation, creating and copying dashboards and datasets, "
"and checking system health, environments, and task status. "
"You handle all intent detection — multi-intent queries, negations (\"don't run\"), "
"synonyms (\"панели\" = \"дашборды\"), and typos are your responsibility. "
"Call the right tool(s) for the job. If data is already provided in context, "
"use it directly rather than calling redundant tools. "
"For maintenance requests, use the RUNTIME CONTEXT current datetime when the user says "
"\"start\", \"run\", \"now\", \"запусти\", or \"сейчас\" without an explicit start time. "
"Convert user durations into end_time. Do not ask for ISO datetime in that case. "
"If a user asks for dashboard maintenance, resolve the dashboard from provided context or tools, "
"then infer affected tables when possible; ask for table names only after resolution fails."
)
if env_id:
prompt += f"\n\nCurrent environment: '{env_id}'. When calling tools that accept env_id, use this value."
# Checkpointer — AsyncPostgresSaver. Fallback to InMemory if Postgres init failed
if _CHECKPOINTER is not None:
checkpointer = _CHECKPOINTER
else:
checkpointer = InMemorySaver()
logger.explore(
"Postgres checkpointer unavailable, falling back to InMemorySaver",
error="_CHECKPOINTER is None — checkpoints will be lost on restart",
extra={"src": "AgentChat.LangGraph.Setup"},
)
graph = create_react_agent(
model=llm,
tools=tools,
prompt=prompt,
version="v2",
checkpointer=checkpointer,
interrupt_before=_interrupt_before_from_env() if interrupt_before is None else interrupt_before,
)
logger.reflect(
"LangGraph agent created",
payload={"model": model, "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)},
extra={"src": "AgentChat.LangGraph.Setup"},
)
return graph
# #endregion AgentChat.LangGraph.Setup

View File

@@ -1,59 +0,0 @@
# backend/src/agent/middleware.py
# #region AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS agent-chat,middleware,logging,audit]
# @defgroup AgentChat Audit logging and confirmation risk middleware for LangGraph agent.
# @BRIEF LoggingMiddleware writes tool-call events to assistant_audit table.
# @RELATION DEPENDS_ON -> [AssistantAuditRecord]
# @RATIONALE FR-024: All agent interactions must be logged for auditability.
# @REJECTED ConfirmationRiskMiddleware rejected — LangGraph interrupt_before=DANGEROUS_TOOLS handles HITL natively.
from datetime import UTC, datetime
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.
# @PRE agent event has 'event' key with type on_tool_start/on_tool_end/on_tool_error.
# @POST Audit record written to assistant_audit table (async, non-blocking).
# @SIDE_EFFECT Writes to assistant_audit table via FastAPI REST call.
# @RELATION DISPATCHES -> [get_assistant_audit]
async def log_tool_event(event: dict, conversation_id: str) -> None:
"""Log a tool-call event to the audit trail.
Args:
event: LangGraph event dict with 'event', 'name', and 'data' keys.
conversation_id: Current conversation thread ID.
"""
kind = event.get("event", "")
tool_name = event.get("name", "unknown")
user_jwt = get_user_jwt()
audit_payload = {
"event_type": kind,
"tool": tool_name,
"conversation_id": conversation_id,
"user_jwt_present": bool(user_jwt),
"timestamp": datetime.now(UTC).isoformat(),
}
if "data" in event:
data = event["data"]
if kind == "on_tool_start":
raw_input = data.get("input", "")
audit_payload["input"] = str(_redact_sensitive_fields(raw_input))[:500]
elif kind == "on_tool_error":
audit_payload["error"] = str(data.get("error", ""))[:500]
logger.reason(
"Tool audit event",
payload=audit_payload,
extra={"src": "AgentChat.Middleware.LoggingMiddleware"},
)
# TODO: Async write to assistant_audit table via REST call to FastAPI
# This is intentionally fire-and-forget — audit failures must not block tool execution
# #endregion AgentChat.Middleware.LoggingMiddleware
# #endregion AgentChat.Middleware

View File

@@ -1,132 +0,0 @@
# backend/src/agent/run.py
# #region AgentChat.Run [C:3] [TYPE Module] [SEMANTICS agent-chat,entrypoint,startup]
# @ingroup AgentChat
# @BRIEF Entrypoint for Gradio agent backend. Fetches LLM config from FastAPI on startup.
# @PRE FastAPI backend reachable at FASTAPI_URL. Service JWT available for auth.
# @POST Gradio agent running on configured port.
# @SIDE_EFFECT Binds to a TCP port via Gradio launch.
# @RATIONALE Gradio port must match the frontend proxy target. Optional fallback is available only
# 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, 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
def _find_free_port(start_port: int, max_attempts: int = 100) -> int:
"""Find a free TCP port starting from start_port, scanning up to max_attempts ports."""
for port in range(start_port, start_port + max_attempts):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("", port))
return port
except OSError:
continue
raise OSError(f"No free port found in range {start_port}-{start_port + max_attempts - 1}")
def _fetch_llm_config() -> dict | None:
"""Fetch active LLM provider config from FastAPI with retry.
Retries up to 30s (6 × 5s) to wait for FastAPI to be ready.
Falls back to env vars if FastAPI is unreachable or returns no active provider.
"""
import time
service_token = SERVICE_JWT
headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
for attempt in range(6):
try:
resp = httpx.get(f"{FASTAPI_URL}/api/agent/llm-config", headers=headers, timeout=5)
resp.raise_for_status()
config = resp.json()
if config.get("configured"):
logger.reason(
"LLM config fetched from FastAPI",
payload={"provider_type": config.get("provider_type"), "model": config.get("default_model")},
extra={"src": "AgentChat.Run.FetchLlmConfig"},
)
return config
logger.explore(
"FastAPI returned no active LLM provider",
payload={"reason": config.get("reason")},
error="No configured LLM provider",
extra={"src": "AgentChat.Run.FetchLlmConfig"},
)
except Exception as e:
if attempt < 5:
logger.reason(
f"Waiting for FastAPI (attempt {attempt + 1}/6)",
payload={"error": str(e)},
extra={"src": "AgentChat.Run.FetchLlmConfig"},
)
time.sleep(5)
else:
logger.explore(
"Failed to fetch LLM config after 6 attempts",
error=str(e),
extra={"src": "AgentChat.Run.FetchLlmConfig"},
)
logger.explore(
"Falling back to env vars for LLM config",
error="FastAPI unreachable",
extra={"src": "AgentChat.Run.FetchLlmConfig"},
)
return 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
seed_trace_id() # Seed trace for agent startup lifecycle
# Propagate SERVICE_JWT to ContextVar for tool calls
if SERVICE_JWT:
set_service_jwt(SERVICE_JWT)
# Fetch LLM config from FastAPI at startup
llm_config = _fetch_llm_config()
if llm_config:
configure_from_api(llm_config)
# Initialize PostgreSQL checkpointer (FR-004/FR-012/FR-027)
asyncio.run(init_checkpointer())
# Bind the configured port. Falling back silently breaks the Vite/nginx proxy target.
configured_port = GRADIO_SERVER_PORT
allow_port_fallback = GRADIO_ALLOW_PORT_FALLBACK
if allow_port_fallback:
try:
port = _find_free_port(configured_port)
if port != configured_port:
logger.explore(
"Port in use, falling back",
payload={"configured_port": configured_port, "actual_port": port},
error=f"Port {configured_port} is in use",
extra={"src": "AgentChat.Run.PortBinding"},
)
except OSError as e:
logger.explore(
"Failed to find a free port",
error=str(e),
extra={"src": "AgentChat.Run.PortBinding"},
)
raise
else:
port = configured_port
demo = create_chat_interface()
demo.launch(
server_name=GRADIO_SERVER_NAME,
server_port=port,
)
# #endregion AgentChat.Run

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ __all__ = [
"admin",
"admin_api_keys",
"agent_conversations",
"agent_status",
"agent_superset",
"agent_superset_explore",
"assistant",

View File

@@ -2,7 +2,7 @@
# #region Api.Agent.Status [C:2] [TYPE Module] [SEMANTICS api,agent,llm,status,health]
# @BRIEF Agent LLM provider health status endpoint — used by frontend for provider availability indicator.
# @RATIONALE Frontend performs health check at mount and auto-retries every 30s if provider unavailable.
# @RELATION DEPENDS_ON -> [AgentChat.GradioApp]
# @RELATION DEPENDS_ON -> [ss_tools.shared._llm_health]
from fastapi import APIRouter
router = APIRouter(prefix="/api/agent", tags=["agent-status"])
@@ -13,10 +13,14 @@ router = APIRouter(prefix="/api/agent", tags=["agent-status"])
# @BRIEF Return cached LLM provider health status (or trigger probe if cache expired).
# @POST Returns {"status": "ok"|"unavailable"|"timeout"|"auth_error",
# "last_error": str, "retry_after_s": int}
# @RATIONALE Uses ss_tools.shared._llm_health instead of src.agent._llm_health
# because the agent code has been moved to a separate project. The shared
# module uses only openai+httpx (no gradio, no langchain), so it works in
# both backend and agent containers.
@router.get("/llm-status")
async def get_llm_status():
"""Get cached LLM provider health status. Probes provider if cache expired."""
from src.agent._llm_health import _check_llm_provider_health, _llm_status
from ss_tools.shared._llm_health import _check_llm_provider_health, _llm_status
status = await _check_llm_provider_health()
return {
"status": status,

View File

@@ -1,490 +0,0 @@
# backend/tests/agent/test_confirmation.py
# #region Test.AgentChat.Confirmation [C:3] [TYPE Module] [SEMANTICS test,agent,hitl,confirmation,integration]
# @BRIEF Integration tests for _confirmation.py — handle_resume fast-path, LLM formatting,
# build_confirmation_contract, metadata, and title race-condition coverage.
# @RELATION BINDS_TO -> [AgentChat.Confirmation]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import json
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ── Helpers ─────────────────────────────────────────────────────────
def _json_chunks(chunks: list[str]) -> list[dict]:
"""Parse all JSON chunk strings into dicts."""
return [json.loads(c) for c in chunks]
async def _collect(generator):
"""Collect all items from an async generator into a list."""
return [item async for item in generator]
def _make_fake_llm_chunk(content: str):
"""Create a fake ChatOpenAI chunk with .content."""
chunk = MagicMock()
chunk.content = content
return chunk
# ── Fixtures ────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def clear_pending():
"""Clear _pending_confirmations before each test."""
from src.agent._confirmation import _pending_confirmations
_pending_confirmations.clear()
# ═══════════════════════════════════════════════════════════════════
# build_confirmation_contract
# ═══════════════════════════════════════════════════════════════════
# #region test_build_confirmation_contract [C:2] [TYPE Class]
# @BRIEF Test build_confirmation_contract for all risk levels.
class TestBuildConfirmationContract:
def test_safe_tool_returns_read_contract(self):
from src.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("list_environments")
assert c["risk"] == "read"
assert c["risk_level"] == "safe"
assert c["prompt"] == "Разрешить чтение данных?"
assert c["requires_confirmation"] is True
def test_dangerous_tool_returns_write_contract(self):
from src.agent._confirmation import build_confirmation_contract
# deploy_dashboard starts with "deploy" → guarded risk level in v1 contract
c = build_confirmation_contract("deploy_dashboard")
assert c["risk"] == "write"
assert c["risk_level"] == "guarded"
assert c["prompt"] == "Подтвердить изменение данных?"
def test_guarded_tool_returns_write_contract(self):
from src.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("start_maintenance")
assert c["risk"] == "write"
assert c["risk_level"] == "guarded"
assert c["prompt"] == "Подтвердить изменение данных?"
def test_unknown_tool_returns_unknown_contract(self):
from src.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("some_unknown_tool")
assert c["risk"] == "read"
assert c["risk_level"] == "safe"
assert c["prompt"] == "Разрешить чтение данных?"
def test_none_tool_name_returns_unknown(self):
from src.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract(None)
assert c["operation"] == "unknown_action"
assert c["risk"] == "read"
assert c["risk_level"] == "safe"
# #endregion test_build_confirmation_contract
# ═══════════════════════════════════════════════════════════════════
# confirmation_metadata_for_tool
# ═══════════════════════════════════════════════════════════════════
# #region test_confirmation_metadata_for_tool [C:2] [TYPE Class]
# @BRIEF Test confirmation_metadata_for_tool output shape.
class TestConfirmationMetadataForTool:
def test_includes_all_required_fields(self):
from src.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-42", "list_environments", {"env_id": "prod"})
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-42"
assert meta["tool_name"] == "list_environments"
assert meta["tool_args"] == {"env_id": "prod"}
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
assert "intent" in meta
assert meta["intent"]["operation"] == "list_environments"
def test_empty_args_defaults_to_empty_dict(self):
from src.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-1", "list_environments", None)
assert meta["tool_args"] == {}
def test_no_args_passed_defaults_to_empty_dict(self):
from src.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-1", "list_environments")
assert meta["tool_args"] == {}
# #endregion test_confirmation_metadata_for_tool
# ═══════════════════════════════════════════════════════════════════
# _format_tool_output_via_llm
# ═══════════════════════════════════════════════════════════════════
# #region test_format_tool_output [C:3] [TYPE Class]
# @BRIEF Integration tests for _format_tool_output_via_llm — LLM path and fallbacks.
class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_empty_output_yields_placeholder(self):
from src.agent._confirmation import _format_tool_output_via_llm
chunks = await _collect(_format_tool_output_via_llm("test_tool", ""))
data = _json_chunks(chunks)
assert len(data) == 1
assert "нет данных" in data[0]["content"]
@pytest.mark.asyncio
async def test_whitespace_only_yields_placeholder(self):
from src.agent._confirmation import _format_tool_output_via_llm
chunks = await _collect(_format_tool_output_via_llm("test_tool", " "))
data = _json_chunks(chunks)
assert len(data) == 1
assert "нет данных" in data[0]["content"]
@pytest.mark.asyncio
async def test_llm_formatting_streams_tokens(self):
"""When LLM config exists, output is streamed through ChatOpenAI."""
from src.agent._confirmation import _format_tool_output_via_llm
fake_llm = MagicMock()
fake_llm.astream = MagicMock(return_value=_make_async_iter([
_make_fake_llm_chunk("Доступно"),
_make_fake_llm_chunk(" три"),
_make_fake_llm_chunk(" окружения."),
]))
with patch("src.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
patch("src.agent._confirmation.ChatOpenAI", return_value=fake_llm):
mock_cfg.return_value = {
"configured": True,
"api_key": "sk-test",
"default_model": "gpt-4o-mini",
"base_url": "https://api.test.com/v1",
}
chunks = await _collect(
_format_tool_output_via_llm("list_environments", '[{"id":"ss-dev"}]')
)
data = _json_chunks(chunks)
assert len(data) == 3
assert data[0]["content"] == "Доступно"
assert data[1]["content"] == " три"
assert data[2]["content"] == " окружения."
for d in data:
assert d["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_fallback_when_llm_unavailable(self):
"""When fetch_llm_config returns None, fall back to prettified JSON."""
from src.agent._confirmation import _format_tool_output_via_llm
raw = '[{"id":"ss-dev","name":"Dev"}]'
with patch("src.agent.langgraph_setup._fetch_llm_config", return_value=None):
chunks = await _collect(_format_tool_output_via_llm("list_environments", raw))
data = _json_chunks(chunks)
assert len(data) == 1
# Should be prettified JSON (indent=2)
assert ' "' in data[0]["content"]
assert "ss-dev" in data[0]["content"]
assert data[0]["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_fallback_when_config_not_configured(self):
"""When config exists but configured=False, fall back to prettified JSON."""
from src.agent._confirmation import _format_tool_output_via_llm
raw = '{"key": "value"}'
with patch("src.agent.langgraph_setup._fetch_llm_config",
return_value={"configured": False}):
chunks = await _collect(_format_tool_output_via_llm("some_tool", raw))
data = _json_chunks(chunks)
assert len(data) == 1
assert '"key"' in data[0]["content"]
@pytest.mark.asyncio
async def test_fallback_on_llm_exception(self):
"""When ChatOpenAI raises, fall back to prettified JSON."""
from src.agent._confirmation import _format_tool_output_via_llm
raw = '[{"a": 1}]'
with patch("src.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
patch("src.agent._confirmation.ChatOpenAI") as mock_llm_cls:
mock_cfg.return_value = {
"configured": True,
"api_key": "sk-test",
"default_model": "gpt-4o-mini",
}
mock_llm_cls.side_effect = RuntimeError("LLM connection refused")
chunks = await _collect(_format_tool_output_via_llm("test_tool", raw))
data = _json_chunks(chunks)
assert len(data) == 1
assert "a" in data[0]["content"]
@pytest.mark.asyncio
async def test_non_json_output_passed_through_raw(self):
"""Non-JSON output (plain text) is yielded as-is in fallback."""
from src.agent._confirmation import _format_tool_output_via_llm
raw = "Operation completed successfully"
with patch("src.agent.langgraph_setup._fetch_llm_config", return_value=None):
chunks = await _collect(_format_tool_output_via_llm("test_tool", raw))
data = _json_chunks(chunks)
assert len(data) == 1
assert data[0]["content"] == raw
# #endregion test_format_tool_output
# ═══════════════════════════════════════════════════════════════════
# #region test_handle_resume_integration [C:3] [TYPE Class]
# @BRIEF Integration tests for handle_resume — fast-path confirm/deny, error paths,
# LLM formatting integration, and title race-condition coverage.
class TestHandleResumeIntegration:
@pytest.mark.asyncio
async def test_deny_yields_cancelled(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
_pending_confirmations["conv-deny"] = {
"tool_name": "list_environments",
"tool_args": {},
}
chunks = await _collect(handle_resume("conv-deny", "deny"))
data = _json_chunks(chunks)
assert len(data) == 1
assert data[0]["metadata"]["type"] == "confirm_resolved"
assert data[0]["metadata"]["result"] == "denied"
assert "отменена" in data[0]["content"].lower()
# Pending is popped
assert "conv-deny" not in _pending_confirmations
@pytest.mark.asyncio
async def test_confirm_executes_tool_and_formats_via_llm(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
tool = MagicMock()
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev","name":"Dev"}]')
_pending_confirmations["conv-confirm"] = {
"tool_name": "list_environments",
"tool_args": {},
}
async def collect():
with patch("src.agent._confirmation.find_tool", return_value=tool), \
patch("src.agent._confirmation._format_tool_output_via_llm") as mock_fmt:
async def _fake_fmt(tool_name, output):
yield json.dumps({
"content": f"SUMMARY: {tool_name} -> {output[:20]}",
"metadata": {"type": "stream_token", "token": "X"},
})
mock_fmt.side_effect = _fake_fmt
chunks = [c async for c in handle_resume("conv-confirm", "confirm")]
return chunks
chunks = await collect()
data = _json_chunks(chunks)
types = [d["metadata"]["type"] for d in data]
assert "confirm_resolved" in types
assert "tool_start" in types
assert "tool_end" in types
assert "stream_token" in types
# Check tool_end has the output summary
tool_end = [d for d in data if d["metadata"]["type"] == "tool_end"]
assert len(tool_end) == 1
assert tool_end[0]["metadata"]["output"]["result"].startswith('[{"id":"ss-dev"')
# Check LLM formatting chunk
stream_tokens = [d for d in data if d["metadata"]["type"] == "stream_token"]
assert len(stream_tokens) >= 1
assert "SUMMARY" in stream_tokens[0]["content"]
# Pending is popped
assert "conv-confirm" not in _pending_confirmations
@pytest.mark.asyncio
async def test_confirm_unknown_tool_yields_error(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
_pending_confirmations["conv-unknown"] = {
"tool_name": "nonexistent_tool_xyz",
"tool_args": {},
}
with patch("src.agent._confirmation.find_tool", return_value=None):
chunks = await _collect(handle_resume("conv-unknown", "confirm"))
data = _json_chunks(chunks)
types = [d["metadata"]["type"] for d in data]
assert "confirm_resolved" in types
assert "tool_start" in types
assert "tool_error" in types
assert "stream_token" not in types, "No LLM output on tool error"
error_chunk = [d for d in data if d["metadata"]["type"] == "tool_error"]
assert len(error_chunk) == 1
assert "Unknown tool" in error_chunk[0]["metadata"]["error"]
@pytest.mark.asyncio
async def test_confirm_tool_invocation_failure_yields_error(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
tool = MagicMock()
tool.ainvoke = AsyncMock(side_effect=RuntimeError("API timeout"))
_pending_confirmations["conv-fail"] = {
"tool_name": "list_environments",
"tool_args": {},
}
with patch("src.agent._confirmation.find_tool", return_value=tool):
chunks = await _collect(handle_resume("conv-fail", "confirm"))
data = _json_chunks(chunks)
types = [d["metadata"]["type"] for d in data]
assert "tool_error" in types
assert "tool_end" not in types, "No tool_end on failure"
assert "stream_token" not in types, "No LLM output on failure"
error_chunk = [d for d in data if d["metadata"]["type"] == "tool_error"]
assert len(error_chunk) == 1
assert "API timeout" in error_chunk[0]["metadata"]["error"]
@pytest.mark.asyncio
async def test_confirm_with_no_pending_falls_to_langgraph(self):
"""When no pending confirmation exists, handle_resume should fall through
to the LangGraph checkpoint resume path."""
from src.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
patch("src.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-no-pending", "confirm"))
data = _json_chunks(chunks)
assert len(data) == 1
assert data[0]["metadata"]["type"] == "confirm_resolved"
assert data[0]["metadata"]["result"] == "confirmed"
@pytest.mark.asyncio
async def test_deny_with_no_pending_returns_denied(self):
"""When no pending confirmation exists, deny also goes through
the LangGraph checkpoint path."""
from src.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
patch("src.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-no-pending", "deny"))
data = _json_chunks(chunks)
assert len(data) == 1
assert data[0]["metadata"]["type"] == "confirm_resolved"
assert data[0]["metadata"]["result"] == "denied"
@pytest.mark.asyncio
async def test_confirm_streams_langgraph_events_when_no_pending(self):
"""When no pending and LangGraph agent streams events, they are forwarded."""
from src.agent._confirmation import handle_resume
mock_chunk = MagicMock()
mock_chunk.content = "Hello from checkpoint"
mock_agent = MagicMock()
mock_agent.astream_events = MagicMock(return_value=_make_async_iter([
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
{"event": "on_tool_start", "name": "some_tool", "data": {"input": {}}},
{"event": "on_tool_end", "name": "some_tool", "data": {"output": "ok"}},
]))
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
patch("src.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-stream", "confirm"))
data = _json_chunks(chunks)
types = [d["metadata"]["type"] for d in data]
assert "confirm_resolved" in types
assert "stream_token" in types
assert "tool_start" in types
assert "tool_end" in types
# #endregion test_handle_resume_integration
# ═══════════════════════════════════════════════════════════════════
# Title race-condition coverage
# ═══════════════════════════════════════════════════════════════════
# #region test_title_race_condition [C:2] [TYPE Class]
# @BRIEF Verify that agent_handler captures tool_name BEFORE handle_resume pops it,
# so the conversation title is descriptive (e.g. "✅ list_environments"), not
# the fallback "HITL: confirm".
class TestTitleRaceCondition:
@pytest.mark.asyncio
async def test_tool_name_read_before_pop(self):
"""Simulate the exact flow from agent_handler: capture tool_name from
_pending_confirmations BEFORE calling handle_resume (which pops it)."""
from src.agent._confirmation import _pending_confirmations
conv_id = "title-race-test"
_pending_confirmations[conv_id] = {
"tool_name": "list_environments",
"tool_args": {},
}
# Step 1: Capture BEFORE pop (this is what the fix does)
pending_pre = _pending_confirmations.get(conv_id, {})
tool_name = pending_pre.get("tool_name", "") if pending_pre else ""
assert tool_name == "list_environments", (
"tool_name must be readable BEFORE handle_resume pops it"
)
# Step 2: Pop (simulating handle_resume's internal pop)
_pending_confirmations.pop(conv_id, None)
# Step 3: After pop, accessing _pending_confirmations would give empty
pending_post = _pending_confirmations.get(conv_id, {})
assert pending_post == {}, "After pop, pending should be gone"
# But tool_name was already captured — title should be "✅ list_environments"
title = f"{tool_name}" if tool_name else "HITL: confirm"
assert title == "✅ list_environments", (
f"Title should use captured tool_name, got: {title}"
)
@pytest.mark.asyncio
async def test_race_condition_without_fix_would_fallback(self):
"""Demonstrate the bug: if tool_name is read AFTER pop, it's lost."""
from src.agent._confirmation import _pending_confirmations
conv_id = "race-bug-test"
_pending_confirmations[conv_id] = {
"tool_name": "get_health_summary",
"tool_args": {},
}
# Pop first (bad order — this is what the old code did)
_pending_confirmations.pop(conv_id, None)
# THEN try to read tool_name (too late!)
pending_post = _pending_confirmations.get(conv_id, {})
tool_name = pending_post.get("tool_name", "") if pending_post else ""
assert tool_name == "", "tool_name should be empty after pop — this IS the bug"
title = f"{tool_name}" if tool_name else "HITL: confirm"
assert title == "HITL: confirm", "Without the fix, title falls back to generic"
# #endregion test_title_race_condition
# ── Helper for async iter ─────────────────────────────────────────
def _make_async_iter(items):
"""Create an async iterator from a list."""
class AsyncIter:
def __init__(self, items):
self._iter = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
return AsyncIter(items)
# #endregion Test.AgentChat.Confirmation

View File

@@ -1,561 +0,0 @@
# #region Test.Agent.SupersetTools [C:3] [TYPE Module] [SEMANTICS test,agent,tools,superset,sql,audit,dashboard,dataset]
# @BRIEF Integration tests for new Superset agent tools: SQL, explore DB, audit, create/copy dashboard, create dataset, format SQL.
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @RELATION BINDS_TO -> [AgentChat.ToolResolver]
# @TEST_EDGE: safe_sql -> superset_execute_sql with SELECT passes
# @TEST_EDGE: dangerous_sql -> superset_execute_sql with DROP returns error
# @TEST_EDGE: external_fail -> HTTP error returns error string
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import httpx
# ── Helpers ──
def _mock_httpx_response(status_code=200, json_data=None, text=""):
"""Build a mock httpx.Response with given status, JSON, and text."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.text = text
resp.json = MagicMock(return_value=json_data or {})
return resp
# ═══════════════════════════════════════════════════════════════════
# Tool name registration
# ═══════════════════════════════════════════════════════════════════
class TestToolRegistration:
"""Test that new tools are registered in get_all_tools() and classification sets."""
def test_all_new_tools_registered(self):
"""All 7 new tools appear in get_all_tools()."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
expected = {
"superset_execute_sql",
"superset_explore_database",
"superset_audit_permissions",
"superset_create_dashboard",
"superset_copy_dashboard",
"superset_create_dataset",
"superset_format_sql",
}
assert expected.issubset(tool_names), f"Missing: {expected - tool_names}"
@pytest.mark.skip(reason="_SAFE_AGENT_TOOLS removed during 035 refactoring — needs test rewrite for new tool registry API")
def test_tool_resolver_sets_contain_new_tools(self):
"""Classification sets in _tool_resolver.py contain the new tools."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent._tool_resolver import (
_SAFE_AGENT_TOOLS,
_GUARDED_AGENT_TOOLS,
_FAST_CONFIRM_TOOLS,
)
assert "superset_execute_sql" in _SAFE_AGENT_TOOLS
assert "superset_explore_database" in _SAFE_AGENT_TOOLS
assert "superset_audit_permissions" in _SAFE_AGENT_TOOLS
assert "superset_format_sql" in _SAFE_AGENT_TOOLS
assert "superset_create_dashboard" in _GUARDED_AGENT_TOOLS
assert "superset_copy_dashboard" in _GUARDED_AGENT_TOOLS
assert "superset_create_dataset" in _GUARDED_AGENT_TOOLS
assert "superset_explore_database" in _FAST_CONFIRM_TOOLS
assert "superset_audit_permissions" in _FAST_CONFIRM_TOOLS
assert "superset_format_sql" in _FAST_CONFIRM_TOOLS
# ═══════════════════════════════════════════════════════════════════
# Intent matching (get_tools_for_query)
# ═══════════════════════════════════════════════════════════════════
class TestIntentMatching:
"""Test get_tools_for_query matches intent keywords for new tools."""
pytestmark = pytest.mark.skip(reason="get_tools_for_query removed during 035 refactoring — needs test rewrite for new tool pipeline API")
def _get_for_query(self, query):
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_tools_for_query
return {t.name for t in get_tools_for_query(query)}
def test_sql_intent(self):
tools = self._get_for_query("run a SQL query on users")
assert "superset_execute_sql" in tools
def test_schema_intent(self):
tools = self._get_for_query("list all schemas in the database")
assert "superset_explore_database" in tools
def test_table_intent(self):
tools = self._get_for_query("show me the tables in public schema")
assert "superset_explore_database" in tools
def test_audit_intent(self):
tools = self._get_for_query("audit user permissions access rights")
assert "superset_audit_permissions" in tools
def test_create_dashboard_intent(self):
tools = self._get_for_query("create a new dashboard called Sales")
assert "superset_create_dashboard" in tools
def test_copy_dashboard_intent(self):
tools = self._get_for_query("copy dashboard 42")
assert "superset_copy_dashboard" in tools
def test_create_dataset_intent(self):
tools = self._get_for_query("create a new dataset for orders table")
assert "superset_create_dataset" in tools
def test_format_sql_intent(self):
# The intent matcher looks for exact phrase "format sql" in text
tools = self._get_for_query("please format sql for me")
assert "superset_format_sql" in tools
def test_no_match_returns_defaults(self):
tools = self._get_for_query("hello how are you")
assert "show_capabilities" in tools
assert "search_dashboards" in tools
# ═══════════════════════════════════════════════════════════════════
# superset_execute_sql
# ═══════════════════════════════════════════════════════════════════
class TestSupersetExecuteSql:
"""Test superset_execute_sql agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_safe_sql_execution(self):
"""Safe SELECT returns API result."""
mock_resp = _mock_httpx_response(200, text='{"status": "success", "data": []}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
})
assert "success" in result
@pytest.mark.asyncio
async def test_dangerous_sql_returns_error(self):
"""Dangerous SQL returns error from API (guard is server-side)."""
mock_resp = _mock_httpx_response(200, text='{"error": "Dangerous SQL rejected"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "DROP TABLE users",
})
assert "Dangerous" in result
@pytest.mark.asyncio
async def test_optional_schema(self):
"""Schema parameter is passed when provided."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_execute_sql
await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
"query_schema": "public",
})
assert "schema" in mock_post.call_args[1]["params"]
@pytest.mark.asyncio
async def test_http_error(self):
"""HTTP error returns error string."""
mock_resp = _mock_httpx_response(500, text="Internal Server Error")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
})
assert "Error 500" in result
# ═══════════════════════════════════════════════════════════════════
# superset_explore_database
# ═══════════════════════════════════════════════════════════════════
class TestSupersetExploreDatabase:
"""Test superset_explore_database agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_list_schemas(self):
"""Action 'schemas' GETs /api/agent/superset/databases/{id}/schemas."""
mock_resp = _mock_httpx_response(200, text='["public", "analytics"]')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "schemas",
})
assert "public" in result
@pytest.mark.asyncio
async def test_list_tables(self):
"""Action 'tables' GETs /api/agent/superset/databases/{id}/tables."""
mock_resp = _mock_httpx_response(200, text="table1, table2")
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "tables",
"schema_name": "public",
})
assert "table" in result
@pytest.mark.asyncio
async def test_table_metadata(self):
"""Action 'table_metadata' GETs metadata endpoint."""
mock_resp = _mock_httpx_response(200, text='{"columns": []}')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "table_metadata",
"table_name": "users",
})
assert "columns" in result
@pytest.mark.asyncio
async def test_select_star(self):
"""Action 'select_star' GETs select_star endpoint."""
mock_resp = _mock_httpx_response(200, text='"SELECT * FROM users"')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "select_star",
"table_name": "users",
})
assert "SELECT" in result
@pytest.mark.asyncio
async def test_tables_missing_schema_returns_error(self):
"""Missing schema_name for 'tables' returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "tables",
})
assert "Error" in result
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_table_metadata_missing_table_name_returns_error(self):
"""Missing table_name for 'table_metadata' returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "table_metadata",
})
assert "Error" in result
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_unknown_action_returns_error(self):
"""Unknown action returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "invalid_action",
})
assert "unknown action" in result.lower()
mock_get.assert_not_called()
# ═══════════════════════════════════════════════════════════════════
# superset_audit_permissions
# ═══════════════════════════════════════════════════════════════════
class TestSupersetAuditPermissions:
"""Test superset_audit_permissions agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_basic_audit(self):
"""Permissions audit GETs /api/agent/superset/audit/permissions."""
mock_resp = _mock_httpx_response(200, text='{"users": [], "total_users": 0}')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_audit_permissions
result = await superset_audit_permissions.ainvoke({
"environment_id": "prod",
})
assert "total_users" in result
@pytest.mark.asyncio
async def test_audit_with_filters(self):
"""Username filter and include_admin are passed as query params."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = mock_resp
from src.agent.tools import superset_audit_permissions
await superset_audit_permissions.ainvoke({
"environment_id": "prod",
"username_filter": "alice",
"include_admin": True,
})
call_params = mock_get.call_args[1]["params"]
assert call_params["username_filter"] == "alice"
assert call_params["include_admin"] == "true"
@pytest.mark.asyncio
async def test_audit_without_filters(self):
"""Without filters, no additional params beyond environment_id."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = mock_resp
from src.agent.tools import superset_audit_permissions
await superset_audit_permissions.ainvoke({
"environment_id": "prod",
})
call_params = mock_get.call_args[1]["params"]
assert "username_filter" not in call_params
assert "include_admin" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_create_dashboard
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCreateDashboard:
"""Test superset_create_dashboard agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_create_dashboard(self):
"""Creates dashboard via POST to /api/agent/superset/dashboards."""
mock_resp = _mock_httpx_response(201, text='{"id": 1, "dashboard_title": "Sales"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_create_dashboard
result = await superset_create_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_title": "Sales",
"slug": "sales",
"published": True,
})
assert "Sales" in result
@pytest.mark.asyncio
async def test_create_dashboard_minimal(self):
"""Minimal create with just title."""
mock_resp = _mock_httpx_response(201, text='{"id": 2}')
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_create_dashboard
await superset_create_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_title": "Minimal",
})
call_params = mock_post.call_args[1]["params"]
assert call_params["dashboard_title"] == "Minimal"
assert call_params["published"] == "false"
# ═══════════════════════════════════════════════════════════════════
# superset_copy_dashboard
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCopyDashboard:
"""Test superset_copy_dashboard agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_copy_dashboard(self):
"""Copies dashboard via POST to /api/agent/superset/dashboards/{id}/copy."""
mock_resp = _mock_httpx_response(201, text='{"id": 2, "result": "copied"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_copy_dashboard
result = await superset_copy_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_id": 1,
"dashboard_title": "Copy of Sales",
})
assert "copied" in result
@pytest.mark.asyncio
async def test_copy_dashboard_without_title(self):
"""Copy without optional title."""
mock_resp = _mock_httpx_response(201, text="{}")
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_copy_dashboard
await superset_copy_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_id": 1,
})
call_params = mock_post.call_args[1]["params"]
assert "dashboard_title" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_create_dataset
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCreateDataset:
"""Test superset_create_dataset agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_create_dataset(self):
"""Creates dataset via POST to /api/agent/superset/datasets."""
mock_resp = _mock_httpx_response(201, text='{"id": 5, "table_name": "orders"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_create_dataset
result = await superset_create_dataset.ainvoke({
"environment_id": "prod",
"table_name": "orders",
"database": 1,
"schema_name": "public",
})
assert "orders" in result
@pytest.mark.asyncio
async def test_create_dataset_minimal(self):
"""Minimal create without schema."""
mock_resp = _mock_httpx_response(201, text='{"id": 6}')
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_create_dataset
await superset_create_dataset.ainvoke({
"environment_id": "prod",
"table_name": "users",
"database": 1,
})
call_params = mock_post.call_args[1]["params"]
assert "schema_name" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_format_sql
# ═══════════════════════════════════════════════════════════════════
class TestSupersetFormatSql:
"""Test superset_format_sql agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_format_sql(self):
"""Formats SQL via POST to /api/agent/superset/sqllab/format."""
mock_resp = _mock_httpx_response(200, text="SELECT\n 1\nFROM\n users\n")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_format_sql
result = await superset_format_sql.ainvoke({
"environment_id": "prod",
"sql": "SELECT 1 FROM users",
})
assert "SELECT" in result
@pytest.mark.asyncio
async def test_format_sql_error(self):
"""HTTP error returns error string."""
mock_resp = _mock_httpx_response(500, text="Internal Error")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_format_sql
result = await superset_format_sql.ainvoke({
"environment_id": "prod",
"sql": "INVALID",
})
assert "Error 500" in result
# ═══════════════════════════════════════════════════════════════════
# Tool Resolver inference
# ═══════════════════════════════════════════════════════════════════
class TestToolResolverInference:
"""Test _tool_resolver.py inference with new Superset tool patterns."""
pytestmark = pytest.mark.skip(reason="infer_tool_from_text removed during 035 refactoring — needs test rewrite for new resolver API")
def test_infer_from_text_sql(self):
"""SQL-related query infers superset_execute_sql."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent._tool_resolver import infer_tool_from_text
# The resolver doesn't currently have SQL inference — verify fallback
# but ensure it's extended if needed in the future
result = infer_tool_from_text("sql select query users")
# No specific SQL inference yet — returns None or base tool
# This test documents current behavior; update if resolver is extended
assert result is None or isinstance(result, str)
# #endregion Test.Agent.SupersetTools

View File

@@ -1,129 +0,0 @@
# backend/tests/agent/test_title_cleaning.py
# #region Test.Agent.Persistence.CleanTitle [C:2] [TYPE Module] [SEMANTICS test,agent,persistence,title]
# @BRIEF Unit tests for rule-based conversation title cleaning — all edge cases.
# @RELATION BINDS_TO -> [AgentChat.Persistence.CleanTitle]
# @TEST_EDGE: empty_input -> "Новый диалог"
# @TEST_EDGE: whitespace_only -> "Новый диалог"
# @TEST_EDGE: file_markers -> stripped before first marker
# @TEST_EDGE: prefetch_markers -> stripped before first marker
# @TEST_EDGE: hitl_title -> preserved as-is
# @TEST_EDGE: json_input -> "Данные: {...}"
# @TEST_EDGE: url_input -> domain extracted
# @TEST_EDGE: long_text -> truncated at 80 with word boundary
# @TEST_EDGE: code_input -> first line preserved
import pytest
from src.agent._persistence import clean_title
class TestCleanTitle:
"""Rule-based title cleaning — all edge cases from the spec matrix."""
# ── Empty / whitespace ──
def test_empty_string(self):
assert clean_title("") == "Новый диалог"
def test_whitespace_only(self):
assert clean_title(" \n \t ") == "Новый диалог"
def test_none_input(self):
assert clean_title(None) == "Новый диалог"
# ── Short / normal ──
def test_short_greeting(self):
assert clean_title("Привет") == "Привет"
def test_emoji_only(self):
assert clean_title("🔥") == "🔥"
def test_normal_russian(self):
assert clean_title("Покажи доступные дашборды") == "Покажи доступные дашборды"
def test_normal_english(self):
assert clean_title("Show me all dashboards in prod") == "Show me all dashboards in prod"
# ── File markers ──
def test_file_marker_stripped(self):
result = clean_title("Покажи дашборды\n--- Uploaded file content ---\nid,name\n1,A")
assert result == "Покажи дашборды"
def test_only_file_marker(self):
result = clean_title("--- Uploaded file content ---\nid,name\n1,A")
assert result == "Новый диалог"
# ── Pre-fetch markers ──
def test_prefetch_marker_stripped(self):
result = clean_title("Мигрируй 42\n[PRE-FETCHED DATA]\nAvailable dashboards...")
assert result == "Мигрируй 42"
def test_prefetch_end_marker_stripped(self):
result = clean_title("Поиск\n[/PRE-FETCHED DATA]\nextra")
assert result == "Поиск"
# ── HITL titles (preserved) ──
def test_hitl_confirm_preserved(self):
assert clean_title("✅ get_health_summary") == "✅ get_health_summary"
def test_hitl_deny_preserved(self):
assert clean_title("⏹️ show_capabilities") == "⏹️ show_capabilities"
# ── JSON / CSV / URL detection ──
def test_json_input(self):
result = clean_title('{"id": 1, "name": "Dashboard A"}')
assert result.startswith("Данные: ")
def test_csv_input(self):
result = clean_title("id,name,status\n1,Dashboard A,active")
# CSV under 80 chars, first line kept as-is
assert result == "id,name,status"
def test_url_input(self):
result = clean_title("https://superset.example.com/dashboard/42")
# Should extract domain
assert "superset.example.com" in result
# ── Sentence cut ──
def test_cut_at_first_sentence(self):
result = clean_title(
"Чтобы проанализировать систему, мне нужно проверить health. "
"Для начала покажи список дашбордов."
)
assert "Чтобы проанализировать систему, мне нужно проверить health." in result
assert "Для начала" not in result
def test_cut_at_exclamation(self):
result = clean_title("Срочно! Проверь статус системы.")
assert result == "Срочно!"
def test_cut_at_question(self):
result = clean_title("Как работает миграция? Нужно подробное описание.")
assert result == "Как работает миграция?"
# ── Long text truncation ──
def test_long_text_truncated(self):
long_text = "Это очень длинное сообщение про миграцию дашбордов из окружения dev в prod с заменой конфигов и фиксом кросс-фильтров"
result = clean_title(long_text)
assert len(result) <= 80
assert result.endswith("")
def test_long_text_no_spaces(self):
result = clean_title("A" * 200)
assert len(result) <= 80
# ── Code detection ──
def test_code_def(self):
result = clean_title("def migrate(): pass\n\nMore text here")
assert result == "def migrate(): pass"
def test_code_import(self):
result = clean_title("import os\nimport sys\n\nShow dashboards")
assert result == "import os"
# ── Already clean ──
def test_already_clean_title(self):
assert clean_title("CSV-файл: Анализ дашбордов") == "CSV-файл: Анализ дашбордов"
# ── Edge: no dot at end ──
def test_single_sentence_no_punctuation(self):
result = clean_title("Проверь статус системы ss-dev")
assert result == "Проверь статус системы ss-dev"
# #endregion Test.Agent.Persistence.CleanTitle

View File

@@ -1,241 +0,0 @@
# backend/tests/test_agent/test_agent_confirmation_v2.py
# #region Test.Agent.ConfirmationV2 [C:3] [TYPE Module] [SEMANTICS test,agent-chat,confirmation,guardrails,hitl]
# @BRIEF Contract tests for confirmation_metadata_for_tool and build_confirmation_contract_v2 — three-axis risk, env resolution, permission denied.
# @RELATION BINDS_TO -> [AgentChat.Confirmation]
# @TEST_FIXTURE: deploy_prod -> guarded + prod env_context
# @TEST_FIXTURE: deploy_staging -> guarded + staging env_context
# @TEST_FIXTURE: deploy_dev -> guarded + dev env_context
# @TEST_FIXTURE: delete_any -> dangerous risk_level
# @TEST_FIXTURE: read_only -> safe risk_level
# @TEST_FIXTURE: analyst_deploy -> permission_granted=false
# @TEST_FIXTURE: env_resolution -> tool_args.env_id > target_env > None
from src.agent._confirmation import (
_resolve_env_tier,
build_confirmation_contract_v2,
confirmation_metadata_for_tool,
permission_denied_payload,
)
# ── _resolve_env_tier ───────────────────────────────────────────────
# #region test_env_resolution_from_tool_args [C:2] [TYPE Function]
def test_resolve_env_tier_from_tool_args_env_id():
"""env_id in tool_args takes highest priority."""
assert _resolve_env_tier({"env_id": "prod-01"}, None) == "prod"
assert _resolve_env_tier({"env_id": "prod-01"}, "staging") == "prod" # tool_args wins
# #endregion test_env_resolution_from_tool_args
# #region test_env_resolution_from_environment_id [C:2] [TYPE Function]
def test_resolve_env_tier_from_environment_id():
"""environment_id in tool_args (alternative key) works."""
assert _resolve_env_tier({"environment_id": "staging-v2"}, None) == "staging"
# #endregion test_env_resolution_from_environment_id
# #region test_env_resolution_from_target_env [C:2] [TYPE Function]
def test_resolve_env_tier_from_target_env():
"""target_env fallback when tool_args has no env."""
assert _resolve_env_tier({}, "ss-dev") == "dev"
assert _resolve_env_tier({"query": "test"}, "prod-v2") == "prod"
# #endregion test_env_resolution_from_target_env
# #region test_env_resolution_null_when_no_env [C:2] [TYPE Function]
def test_resolve_env_tier_null_when_no_env():
"""When neither tool_args nor target_env provides env, return None."""
assert _resolve_env_tier({}, None) is None
assert _resolve_env_tier({"dashboard_id": 42}, None) is None
# #endregion test_env_resolution_null_when_no_env
# #region test_env_resolution_ambiguous_names [C:2] [TYPE Function]
def test_resolve_env_tier_ambiguous_names():
"""Environment names containing stag/test/local/dev are correctly tiered."""
assert _resolve_env_tier({"env_id": "autotest"}, None) == "staging" # "test" in autotest
assert _resolve_env_tier({"env_id": "localhost"}, None) == "dev" # "local" in localhost
# #endregion test_env_resolution_ambiguous_names
# ── build_confirmation_contract_v2 ───────────────────────────────────
# #region test_deploy_to_prod_is_guarded_with_prod_context [C:2] [TYPE Function]
def test_deploy_to_prod_is_guarded_with_prod_context():
"""Deploy to production → guarded risk + prod env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "prod-01"}, "admin", "prod-01",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["dangerous"] is False
assert contract["env_context"] == "prod"
assert contract["permission_granted"] is True
# #endregion test_deploy_to_prod_is_guarded_with_prod_context
# #region test_deploy_to_staging_is_guarded_with_staging_context [C:2] [TYPE Function]
def test_deploy_to_staging_is_guarded_with_staging_context():
"""Deploy to staging → guarded risk + staging env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "staging-v2"}, "admin", "staging-v2",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["env_context"] == "staging"
# #endregion test_deploy_to_staging_is_guarded_with_staging_context
# #region test_deploy_to_dev_is_guarded_with_dev_context [C:2] [TYPE Function]
def test_deploy_to_dev_is_guarded_with_dev_context():
"""Deploy to dev → guarded risk + dev env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "my-dev-env"}, "admin", "my-dev-env",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["env_context"] == "dev"
# #endregion test_deploy_to_dev_is_guarded_with_dev_context
# #region test_delete_operation_is_dangerous [C:2] [TYPE Function]
def test_delete_operation_is_dangerous():
"""Delete-prefixed tools should be classified as dangerous."""
contract = build_confirmation_contract_v2(
"delete_dashboard", {}, "admin",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "dangerous"
assert contract["dangerous"] is True
# #endregion test_delete_operation_is_dangerous
# #region test_read_only_tool_is_safe [C:2] [TYPE Function]
def test_read_only_tool_is_safe():
"""Search/list/get tools should be classified as safe/read."""
contract = build_confirmation_contract_v2(
"search_dashboards", {"env_id": "prod"}, "admin", "prod",
)
assert contract["risk"] == "read"
assert contract["risk_level"] == "safe"
assert contract["dangerous"] is False
# #endregion test_read_only_tool_is_safe
# #region test_viewer_gets_permission_denied [C:2] [TYPE Function]
def test_viewer_permission_denied_for_write_tools():
"""Viewer role should be denied permission for write tools."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "prod"}, "viewer", "prod",
)
assert contract["permission_granted"] is False
assert contract["required_role"] == "admin"
assert contract["alternatives"] is not None
assert len(contract["alternatives"]) >= 1
# #endregion test_viewer_gets_permission_denied
# #region test_analyst_permission_denied_specific_tools [C:2] [TYPE Function]
def test_analyst_permission_denied_for_admin_tools():
"""Analyst/editor role should be denied for admin-only tools."""
for tool_name in ("deploy_dashboard", "execute_migration", "run_backup"):
contract = build_confirmation_contract_v2(tool_name, {}, "analyst")
assert contract["permission_granted"] is False, f"{tool_name} should be denied for analyst"
# #endregion test_analyst_permission_denied_specific_tools
# #region test_admin_write_tool_permission_granted [C:2] [TYPE Function]
def test_admin_write_tool_permission_granted():
"""Admin role should always have permission_granted=True for guarded tools."""
write_tools = ["deploy_dashboard", "commit_changes", "execute_migration"]
for tool_name in write_tools:
contract = build_confirmation_contract_v2(tool_name, {}, "admin")
assert contract["permission_granted"] is True, f"{tool_name} should be allowed for admin"
# #endregion test_admin_write_tool_permission_granted
# ── confirmation_metadata_for_tool ────────────────────────────────────
# #region test_metadata_for_tool_contains_required_fields [C:2] [TYPE Function]
def test_metadata_for_tool_contains_all_required_fields():
"""confirmation_metadata_for_tool should produce a complete metadata dict."""
meta = confirmation_metadata_for_tool(
"conv-123", "deploy_dashboard",
{"env_id": "prod-01"}, "admin", "prod-01",
)
required_fields = [
"type", "thread_id", "prompt", "tool_name", "tool_args",
"risk", "risk_level", "requires_confirmation",
"dangerous", "env_context",
]
for field in required_fields:
assert field in meta, f"Missing field '{field}' in metadata"
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-123"
assert meta["tool_name"] == "deploy_dashboard"
assert meta["risk"] == "write"
assert meta["risk_level"] == "guarded"
assert meta["requires_confirmation"] is True
# #endregion test_metadata_for_tool_contains_required_fields
# ── permission_denied_payload ─────────────────────────────────────────
# #region test_permission_denied_payload_structure [C:2] [TYPE Function]
def test_permission_denied_payload_structure():
"""permission_denied_payload should produce valid JSON with correct type."""
import json
payload_str = permission_denied_payload("deploy_dashboard", "admin", "viewer")
payload = json.loads(payload_str)
assert payload["content"] == "⛔ Недостаточно прав для deploy_dashboard"
assert payload["metadata"]["type"] == "permission_denied"
assert payload["metadata"]["tool_name"] == "deploy_dashboard"
assert payload["metadata"]["required_role"] == "admin"
assert payload["metadata"]["user_role"] == "viewer"
assert payload["metadata"]["alternatives"] == []
# #endregion test_permission_denied_payload_structure
# #region test_permission_denied_payload_with_alternatives [C:2] [TYPE Function]
def test_permission_denied_payload_with_alternatives():
"""Alternatives list should be preserved in the payload."""
import json
alternatives = [{"action": "get_health_summary", "prompt": "Check system health"}]
payload_str = permission_denied_payload(
"deploy_dashboard", "admin", "viewer", alternatives,
)
payload = json.loads(payload_str)
assert payload["metadata"]["alternatives"] == alternatives
# #endregion test_permission_denied_payload_with_alternatives
# ── Unknown/null tool ─────────────────────────────────────────────────
# #region test_null_tool_name_handled [C:2] [TYPE Function]
def test_null_tool_name_handled_gracefully():
"""Null tool_name should produce 'unknown_action' fallback."""
contract = build_confirmation_contract_v2(None)
assert contract["operation"] == "unknown_action"
assert contract["risk_level"] == "safe"
# #endregion test_null_tool_name_handled
# #region test_execute_migration_is_guarded [C:2] [TYPE Function]
def test_execute_migration_is_guarded():
"""execute_migration should be classified as guarded (write)."""
contract = build_confirmation_contract_v2("execute_migration", {})
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
# #endregion test_execute_migration_is_guarded
# #region test_commit_changes_is_guarded [C:2] [TYPE Function]
def test_commit_changes_is_guarded():
"""commit_changes should be classified as guarded (write)."""
contract = build_confirmation_contract_v2("commit_changes", {})
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
# #endregion test_commit_changes_is_guarded
# #endregion Test.Agent.ConfirmationV2

View File

@@ -1,218 +0,0 @@
# backend/tests/test_agent/test_agent_context.py
# #region Test.Agent.Context [C:3] [TYPE Module] [SEMANTICS test,agent-chat,context,validation,uicontext]
# @BRIEF Contract tests for UIContext validation — enum checks, size limits, field lengths, boundary values.
# @RELATION BINDS_TO -> [AgentChat.Context.Validate]
# @TEST_FIXTURE: null -> returns {}
# @TEST_FIXTURE: dashboard+id+name -> passes
# @TEST_FIXTURE: malformed_objectType -> raises UIContextValidationError
# @TEST_FIXTURE: objectName>256 -> raises
# @TEST_FIXTURE: oversized>4KB -> raises
# @TEST_FIXTURE: contextVersion!=1 -> raises
# @TEST_FIXTURE: dataset_context -> passes
# @TEST_FIXTURE: migration_context -> passes
# @TEST_FIXTURE: non_numeric_objectId -> raises
# @TEST_FIXTURE: empty_route -> passes
import pytest
from src.agent._context import UIContextValidationError, validate_uicontext
# #region test_null_payload_returns_empty [C:2] [TYPE Function]
def test_null_payload_returns_empty_dict():
"""Null payloads should safely return an empty dict."""
assert validate_uicontext(None) == {}
# #endregion test_null_payload_returns_empty
# #region test_valid_dashboard_context_passes [C:2] [TYPE Function]
def test_valid_dashboard_context_passes():
"""Full dashboard UIContext with all fields should validate cleanly."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "Energy Report",
"envId": "ss-dev",
"route": "/dashboards/42",
"contextVersion": 1,
}
result = validate_uicontext(payload)
assert result == payload
# #endregion test_valid_dashboard_context_passes
# #region test_valid_dataset_context_passes [C:2] [TYPE Function]
def test_valid_dataset_context_passes():
"""Dataset UIContext should pass validation."""
payload = {
"objectType": "dataset",
"objectId": "15",
"objectName": None,
"envId": "staging",
"route": "/datasets/15",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_valid_dataset_context_passes
# #region test_valid_migration_context_passes [C:2] [TYPE Function]
def test_valid_migration_context_passes():
"""Migration UIContext should pass validation."""
payload = {
"objectType": "migration",
"objectId": None,
"objectName": None,
"envId": None,
"route": "/migrations",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_valid_migration_context_passes
# #region test_invalid_object_type_raises [C:2] [TYPE Function]
def test_invalid_object_type_raises_validation_error():
"""Unknown objectType values should be rejected."""
payload = {
"objectType": "chart",
"objectId": "42",
"envId": "ss-dev",
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectType"):
validate_uicontext(payload)
# #endregion test_invalid_object_type_raises
# #region test_invalid_object_id_raises [C:2] [TYPE Function]
def test_non_numeric_object_id_raises():
"""ObjectId must be a numeric string or None."""
payload = {
"objectType": "dashboard",
"objectId": "abc-not-a-number",
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectId"):
validate_uicontext(payload)
# #endregion test_invalid_object_id_raises
# #region test_object_name_exceeds_limit_raises [C:2] [TYPE Function]
def test_object_name_exceeds_256_chars_raises():
"""ObjectName longer than 256 characters should be rejected."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "A" * 257,
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectName"):
validate_uicontext(payload)
# #endregion test_object_name_exceeds_limit_raises
# #region test_object_name_at_boundary_passes [C:2] [TYPE Function]
def test_object_name_at_256_chars_passes():
"""ObjectName at exactly 256 characters should pass."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "A" * 256,
"route": "/dashboards/42",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_object_name_at_boundary_passes
# #region test_invalid_context_version_raises [C:2] [TYPE Function]
def test_invalid_context_version_raises():
"""Only contextVersion=1 is currently supported."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
"contextVersion": 2,
}
with pytest.raises(UIContextValidationError, match="contextVersion"):
validate_uicontext(payload)
# #endregion test_invalid_context_version_raises
# #region test_missing_context_version_raises [C:2] [TYPE Function]
def test_missing_context_version_raises():
"""ContextVersion should always be present and equal 1."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
}
with pytest.raises(UIContextValidationError):
validate_uicontext(payload)
# #endregion test_missing_context_version_raises
# #region test_payload_exceeds_4kb_raises [C:2] [TYPE Function]
def test_payload_exceeds_4kb_raises():
"""Payloads larger than 4 KB should be rejected to prevent prompt injection."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
"contextVersion": 1,
# Create a field that pushes the serialized JSON over 4096 bytes
"padding": "X" * 4096,
}
with pytest.raises(UIContextValidationError, match="exceeds 4 KB"):
validate_uicontext(payload)
# #endregion test_payload_exceeds_4kb_raises
# #region test_route_exceeds_512_chars_raises [C:2] [TYPE Function]
def test_route_exceeds_512_chars_raises():
"""Route length should be capped at 512 characters."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/" + "a" * 512,
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="route"):
validate_uicontext(payload)
# #endregion test_route_exceeds_512_chars_raises
# #region test_env_id_none_and_string_accepted [C:2] [TYPE Function]
def test_env_id_none_and_string_accepted():
"""envId should accept None and string values."""
assert validate_uicontext({
"objectType": "dashboard", "objectId": "42",
"envId": None, "route": "/dashboards/42", "contextVersion": 1,
})["envId"] is None
assert validate_uicontext({
"objectType": "dashboard", "objectId": "42",
"envId": "ss-dev", "route": "/dashboards/42", "contextVersion": 1,
})["envId"] == "ss-dev"
# #endregion test_env_id_none_and_string_accepted
# #region test_without_object_type_passes [C:2] [TYPE Function]
def test_no_object_type_with_env_passes():
"""Context without objectType (general mode) should pass validation."""
payload = {
"objectType": None,
"objectId": None,
"envId": "ss-dev",
"route": "/settings",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_without_object_type_passes
# #endregion Test.Agent.Context

View File

@@ -1,274 +0,0 @@
# #region TestAgentChat.Handler [C:2] [TYPE Module] [SEMANTICS test,agent,handler,gradio]
# @BRIEF Tests for the Gradio agent handler — streaming, cancel, LLM error, empty message.
# @RELATION BINDS_TO -> [AgentChat.GradioApp.Handler]
import os
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
# Set AUTH_SECRET_KEY and LLM_API_KEY for tests (match conftest)
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
os.environ["OPENAI_API_KEY"] = "sk-test-key"
os.environ["LLM_API_KEY"] = "sk-test-key"
os.environ["LLM_MODEL"] = "gpt-4o"
os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture(autouse=True)
def mock_save_conversation():
with patch("src.agent.app.save_conversation", new_callable=AsyncMock):
yield
def _make_test_jwt(user_id: str = "test-user") -> str:
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
def _empty_agent_state():
state = MagicMock(spec_set=["next"])
state.next = ()
return state
# #region TestAgentChat.Handler.EmptyMessage [C:2] [TYPE Function] [SEMANTICS test,handler,empty]
# @BRIEF Empty message returns immediately without calling LangGraph.
# @TEST_EDGE empty_text, empty_with_files_none
@pytest.mark.anyio
async def test_handler_empty_message_returns_immediately():
"""An empty message should return immediately without calling the graph."""
from src.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
# Patch create_agent to avoid OpenAI init
with patch("src.agent.langgraph_setup.create_agent"):
# Empty message
message = {"text": "", "files": None}
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
# Empty text passes auth but should not start a graph
assert len(results) == 0, "Empty message should yield no chunks"
# create_agent should NOT be called for empty messages
# (it gets called currently — future optimization)
# mock_create.assert_not_called() # TODO: optimize to skip graph for empty msg
# #endregion TestAgentChat.Handler.EmptyMessage
# #region TestAgentChat.Handler.AuthGraceful [C:2] [TYPE Function] [SEMANTICS test,handler,auth]
# @BRIEF Missing or invalid JWT does NOT reject — Gradio handler forwards to graph (auth at tool layer).
# @TEST_EDGE missing_auth, invalid_token
# @RATIONALE Per design, @gradio/client does not forward Authorization headers, so the Gradio handler
# does NOT enforce JWT. Missing/invalid JWT falls back to anonymous context.
# Tool-level auth is enforced via SERVICE_JWT + X-User-JWT dual identity pattern.
@pytest.mark.anyio
async def test_handler_missing_auth_continues_gracefully():
"""Missing authorization header does NOT yield UNAUTHORIZED — handler continues."""
from src.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
mock_request.headers = {}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
# Patch create_agent to prevent LLM call — handler should proceed without JWT
with patch("src.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
"""Empty async generator — yields nothing."""
return
yield # make it a generator
mock_graph.astream_events = _empty_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
# No UNAUTHORIZED error — handler proceeds with empty user_jwt
for r in results:
import json
parsed = json.loads(r) if isinstance(r, str) else r
meta = parsed.get("metadata", {})
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject missing auth — JWT optional at Gradio layer"
@pytest.mark.anyio
async def test_handler_invalid_jwt_continues_gracefully():
"""Invalid JWT does NOT yield UNAUTHORIZED — handler continues with fallback context."""
from src.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
mock_request.headers = {"authorization": "Bearer invalid-token"}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
with patch("src.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
return
yield
mock_graph.astream_events = _empty_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
for r in results:
import json
parsed = json.loads(r) if isinstance(r, str) else r
meta = parsed.get("metadata", {})
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject invalid JWT — invalid token ignored at Gradio layer"
# #endregion TestAgentChat.Handler.AuthError
# #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming]
# @BRIEF Handler yields stream_token chunks when LangGraph streams events.
@pytest.mark.anyio
async def test_handler_yields_stream_tokens():
"""Handler yields stream_token metadata when graph emits token events."""
from src.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
# Patch create_agent to return a mock that streams events
with patch("src.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _mock_stream(*_args, **_kwargs):
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content=" world")}}
mock_graph.astream_events = _mock_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", None):
results.append(chunk)
assert len(results) > 0, "Should yield at least one chunk"
import json
stream_tokens = [r for r in results if json.loads(r)["metadata"]["type"] == "stream_token"]
assert len(stream_tokens) > 0, "Should yield stream_token metadata"
# #endregion TestAgentChat.Handler.Streaming
# #region TestAgentChat.Handler.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,handler,resume]
# @BRIEF Handler detects action=confirm and resumes via Command(resume=...).
@pytest.mark.anyio
async def test_handler_resume_confirm():
"""When action='confirm', handler resumes via Command(resume=...)."""
from src.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "confirm", "files": None}
# Patch create_agent in _confirmation because handle_resume (now in _confirmation.py)
# imports create_agent directly from langgraph_setup
with patch("src.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
async def _empty_stream(*_args, **_kwargs):
return
yield
mock_graph.astream_events = _empty_stream
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "confirm"):
results.append(chunk)
# Should yield confirm_resolved metadata
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["type"] == "confirm_resolved"
assert parsed["metadata"]["result"] == "confirmed"
assert mock_create.call_args.kwargs["interrupt_before"] == []
# #endregion TestAgentChat.Handler.ResumeConfirm
# #region TestAgentChat.Handler.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,handler,deny]
@pytest.mark.anyio
async def test_handler_resume_deny():
"""When action='deny', handler yields confirm_resolved with denied."""
from src.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "deny", "files": None}
with patch("src.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "deny"):
results.append(chunk)
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["type"] == "confirm_resolved"
assert parsed["metadata"]["result"] == "denied"
# #endregion TestAgentChat.Handler.ResumeDeny
# #endregion TestAgentChat.Handler

View File

@@ -1,255 +0,0 @@
# backend/tests/test_agent/test_agent_tool_filter.py
# #region Test.Agent.ToolFilter [C:3] [TYPE Module] [SEMANTICS test,agent-chat,tools,filter,pipeline,rbac]
# @BRIEF Contract tests for build_tool_pipeline and enforce_tool_permission — two-layer RBAC + context affinity.
# @RELATION BINDS_TO -> [AgentChat.ToolFilter]
# @TEST_FIXTURE: null_object_type -> all RBAC-allowed tools pass
# @TEST_FIXTURE: dashboard+admin -> all dashboard tools + capabilities kept
# @TEST_FIXTURE: dashboard+analyst -> admin-only tools removed from dashboard
# @TEST_FIXTURE: dataset+admin -> all dataset tools + capabilities kept
# @TEST_FIXTURE: dataset+viewer -> admin-only tools removed from dataset
# @TEST_FIXTURE: migration+admin -> all migration tools + capabilities kept
# @TEST_FIXTURE: unknown_object_type -> graceful fallback to full list
# @TEST_FIXTURE: invocation_guard_admin -> deploy_dashboard allowed for admin
# @TEST_FIXTURE: invocation_guard_viewer -> deploy_dashboard rejected for viewer
# @TEST_FIXTURE: unknown_tool_invocation -> always allowed
# @TEST_FIXTURE: show_capabilities_always_included -> mandatory tool
# @TEST_FIXTURE: idempotent -> build_tool_pipeline never mutates input list
from types import SimpleNamespace
from src.agent._tool_filter import (
_CONTEXT_TOOL_AFFINITY,
_TOOL_PERMISSIONS,
build_tool_pipeline,
enforce_tool_permission,
)
# #region _tools_helper [C:1] [TYPE Function] [SEMANTICS test,helper]
# @BRIEF Create SimpleNamespace-based tool mimics with .name attribute for pipeline tests.
def _tools(names: list[str]) -> list[SimpleNamespace]:
return [SimpleNamespace(name=name) for name in names]
# #endregion _tools_helper
# ── build_tool_pipeline ──────────────────────────────────────────────
# #region test_null_object_type_returns_all_rbac_allowed [C:2] [TYPE Function]
def test_null_object_type_returns_all_rbac_allowed_tools():
"""With no object_type, all tools pass except those blocked by RBAC (viewer)."""
tools = _tools(["search_dashboards", "deploy_dashboard", "show_capabilities"])
# Admin: all tools pass
result_admin = [t.name for t in build_tool_pipeline(tools, "admin", None)]
assert "deploy_dashboard" in result_admin
assert "search_dashboards" in result_admin
assert "show_capabilities" in result_admin
# Viewer: deploy_dashboard blocked by RBAC
result_viewer = [t.name for t in build_tool_pipeline(tools, "viewer", None)]
assert "deploy_dashboard" not in result_viewer
assert "search_dashboards" in result_viewer
assert "show_capabilities" in result_viewer
# #endregion test_null_object_type_returns_all_rbac_allowed
# #region test_dashboard_context_admin [C:2] [TYPE Function]
def test_dashboard_context_admin_keeps_affinity_tools():
"""Dashboard context + admin role: keep all dashboard tools + capabilities."""
tools = _tools([
"search_dashboards", "get_health_summary", "deploy_dashboard",
"run_llm_validation", "run_llm_documentation", "execute_migration",
"create_branch", "commit_changes", "show_capabilities",
# Non-dashboard tools (should be excluded)
"run_backup", "superset_execute_sql", "list_environments",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "dashboard")]
assert "search_dashboards" in result
assert "get_health_summary" in result
assert "deploy_dashboard" in result
assert "show_capabilities" in result
assert "run_backup" not in result
assert "superset_execute_sql" not in result
# #endregion test_dashboard_context_admin
# #region test_dashboard_context_viewer [C:2] [TYPE Function]
def test_dashboard_context_viewer_removes_admin_only():
"""Dashboard context + viewer: admin-only tools removed even from dashboard affinity."""
tools = _tools([
"search_dashboards", "deploy_dashboard", "execute_migration",
"commit_changes", "run_llm_validation", "show_capabilities",
])
result = [t.name for t in build_tool_pipeline(tools, "viewer", "dashboard")]
assert "search_dashboards" in result
assert "run_llm_validation" in result
assert "show_capabilities" in result
assert "deploy_dashboard" not in result
assert "execute_migration" not in result
assert "commit_changes" not in result
# #endregion test_dashboard_context_viewer
# #region test_dataset_context_admin [C:2] [TYPE Function]
def test_dataset_context_admin_keeps_dataset_tools():
"""Dataset context + admin: keep dataset affinity tools, exclude non-dataset."""
tools = _tools([
"superset_explore_database", "superset_format_sql", "superset_execute_sql",
"superset_audit_permissions", "superset_create_dataset",
"search_dashboards", "get_task_status", "list_environments",
"show_capabilities",
# Non-dataset tools
"deploy_dashboard", "run_backup", "start_maintenance",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "dataset")]
assert "superset_explore_database" in result
assert "superset_execute_sql" in result
assert "superset_format_sql" in result
assert "show_capabilities" in result
assert "deploy_dashboard" not in result
assert "run_backup" not in result
# #endregion test_dataset_context_admin
# #region test_dataset_context_viewer [C:2] [TYPE Function]
def test_dataset_context_viewer_removes_admin_tools():
"""Dataset context + viewer: admin-only tools in dataset affinity removed."""
tools = _tools([
"superset_explore_database", "superset_execute_sql",
"search_dashboards", "show_capabilities",
"start_maintenance",
])
result = [t.name for t in build_tool_pipeline(tools, "viewer", "dataset")]
assert "superset_explore_database" in result
assert "superset_execute_sql" in result
assert "show_capabilities" in result
assert "start_maintenance" not in result
# #endregion test_dataset_context_viewer
# #region test_migration_context_admin [C:2] [TYPE Function]
def test_migration_context_admin_keeps_migration_tools():
"""Migration context + admin: keep migration affinity tools, exclude others."""
tools = _tools([
"execute_migration", "search_dashboards", "get_health_summary",
"deploy_dashboard", "list_environments", "show_capabilities",
# Non-migration tools
"run_backup", "superset_execute_sql",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "migration")]
assert "execute_migration" in result
assert "search_dashboards" in result
assert "show_capabilities" in result
assert "run_backup" not in result
assert "superset_execute_sql" not in result
# #endregion test_migration_context_admin
# #region test_unknown_object_type_falls_back [C:2] [TYPE Function]
def test_unknown_object_type_falls_back_to_full_list():
"""Unknown object_type should not filter — behaves like null context."""
tools = _tools([
"search_dashboards", "deploy_dashboard", "run_backup",
"superset_execute_sql", "show_capabilities",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "unknown_type")]
# All RBAC-allowed tools pass (admin sees all)
assert "search_dashboards" in result
assert "deploy_dashboard" in result
assert "run_backup" in result
assert "superset_execute_sql" in result
assert "show_capabilities" in result
# #endregion test_unknown_object_type_falls_back
# #region test_show_capabilities_always_included [C:2] [TYPE Function]
def test_show_capabilities_always_included():
"""show_capabilities must survive all filtering stages regardless of context."""
tools = _tools(["show_capabilities"])
# Must appear in any context+role combination
for obj_type in (None, "dashboard", "dataset", "migration"):
for role in ("admin", "editor", "viewer"):
result = [t.name for t in build_tool_pipeline(tools, role, obj_type)]
assert "show_capabilities" in result, (
f"show_capabilities missing for role={role}, object_type={obj_type}"
)
# #endregion test_show_capabilities_always_included
# #region test_pipeline_is_idempotent [C:2] [TYPE Function]
def test_pipeline_does_not_mutate_input_list():
"""build_tool_pipeline must return a new list and not mutate the input."""
tools = _tools(["search_dashboards", "show_capabilities"])
original_ids = [id(t) for t in tools]
_ = build_tool_pipeline(tools, "admin", "dashboard")
# Input list unchanged
assert [id(t) for t in tools] == original_ids
assert len(tools) == 2
# #endregion test_pipeline_is_idempotent
# ── enforce_tool_permission ──────────────────────────────────────────
# #region test_invocation_guard_admin_allowed [C:2] [TYPE Function]
def test_enforce_tool_permission_admin_allowed():
"""Admin role should be allowed to invoke all restricted tools."""
restricted = ["deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance"]
for tool_name in restricted:
assert enforce_tool_permission(tool_name, "admin") is True, (
f"Admin should be allowed to invoke '{tool_name}'"
)
# #endregion test_invocation_guard_admin_allowed
# #region test_invocation_guard_viewer_denied [C:2] [TYPE Function]
def test_enforce_tool_permission_viewer_denied():
"""Viewer role should be denied for all restricted tools."""
restricted = ["deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance"]
for tool_name in restricted:
assert enforce_tool_permission(tool_name, "viewer") is False, (
f"Viewer should NOT be allowed to invoke '{tool_name}'"
)
# #endregion test_invocation_guard_viewer_denied
# #region test_invocation_guard_unknown_tool_allowed [C:2] [TYPE Function]
def test_enforce_tool_permission_unknown_tool_always_allowed():
"""Unknown tools (not in _TOOL_PERMISSIONS) should always be allowed."""
assert enforce_tool_permission("search_dashboards", "viewer") is True
assert enforce_tool_permission("show_capabilities", "viewer") is True
assert enforce_tool_permission("nonexistent_tool", "viewer") is True
# #endregion test_invocation_guard_unknown_tool_allowed
# #region test_context_affinity_coverage [C:2] [TYPE Function]
def test_context_affinity_maps_exist_for_all_expected_types():
"""Verify that all three known object types have affinity mappings."""
assert "dashboard" in _CONTEXT_TOOL_AFFINITY
assert "dataset" in _CONTEXT_TOOL_AFFINITY
assert "migration" in _CONTEXT_TOOL_AFFINITY
# Each mapping should have at least 3 tools
for obj_type in ("dashboard", "dataset", "migration"):
assert len(_CONTEXT_TOOL_AFFINITY[obj_type]) >= 3, (
f"Context affinity for '{obj_type}' should have ≥3 tools"
)
# #endregion test_context_affinity_coverage
# #region test_rbac_maps_exist_for_write_tools [C:2] [TYPE Function]
def test_rbac_permissions_for_write_tools():
"""Verify all admin-only tools are explicitly listed in _TOOL_PERMISSIONS."""
expected_admin_tools = [
"deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance",
]
for tool_name in expected_admin_tools:
assert tool_name in _TOOL_PERMISSIONS, (
f"'{tool_name}' should be in _TOOL_PERMISSIONS"
)
assert "admin" in _TOOL_PERMISSIONS[tool_name]
# #endregion test_rbac_permissions_for_write_tools
# #endregion Test.Agent.ToolFilter

View File

@@ -1,106 +0,0 @@
# #region TestAgentChat.ApiFixtures [C:2] [TYPE Module] [SEMANTICS test,agent,fixtures,api]
# @BRIEF Materialize API fixtures from JSON — verify fixture structure matches expected API contracts.
# @RELATION BINDS_TO -> [AgentChat.Api.Conversations]
import json
from pathlib import Path
import pytest
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "specs" / "033-gradio-agent-chat" / "fixtures" / "api"
# #region TestAgentChat.ApiFixtures.Load [C:2] [TYPE Function] [SEMANTICS test,fixture,load]
# @BRIEF All 9 API fixture files load without parse errors.
def test_all_api_fixtures_load():
"""Verify all API fixture files exist and load as valid JSON."""
expected_fixtures = [
"conversations_list_valid.json",
"conversations_list_empty.json",
"conversations_list_missing_auth.json",
"conversations_list_invalid_page.json",
"conversations_list_external_fail.json",
"history_valid.json",
"history_not_found.json",
"service_token_valid.json",
"service_token_invalid_secret.json",
]
for name in expected_fixtures:
path = FIXTURES_DIR / name
assert path.exists(), f"Fixture not found: {name}"
with open(path) as f:
data = json.load(f)
assert "fixture_id" in data, f"{name}: missing fixture_id"
assert "verifies" in data, f"{name}: missing verifies"
assert "input" in data, f"{name}: missing input"
assert "expected" in data, f"{name}: missing expected"
# #endregion TestAgentChat.ApiFixtures.Load
# #region TestAgentChat.ApiFixtures.ConversationsList [C:2] [TYPE Function] [SEMANTICS test,fixture,conversations]
# @BRIEF Conversations list fixtures have correct structure: valid returns 200+items, empty returns 200+[].
def test_conversations_list_valid():
"""FX_AgentChat.Conversations.ListValid → 200 with items array."""
with open(FIXTURES_DIR / "conversations_list_valid.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListValid"
assert fixture["expected"]["status"] == 200
assert "items" in fixture["expected"]["body"]
assert fixture["input"]["method"] == "GET"
def test_conversations_list_empty():
"""FX_AgentChat.Conversations.ListEmpty → 200 with empty items."""
with open(FIXTURES_DIR / "conversations_list_empty.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListEmpty"
assert fixture["expected"]["status"] == 200
assert fixture["expected"]["body"]["items"] == []
def test_conversations_list_missing_auth():
"""FX_AgentChat.Conversations.ListMissingAuth → 401."""
with open(FIXTURES_DIR / "conversations_list_missing_auth.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListMissingAuth"
assert fixture["expected"]["status"] == 401
# #endregion TestAgentChat.ApiFixtures.ConversationsList
# #region TestAgentChat.ApiFixtures.History [C:2] [TYPE Function] [SEMANTICS test,fixture,history]
# @BRIEF History fixtures have correct structure.
def test_history_valid():
"""FX_AgentChat.History.Valid → 200 with messages."""
with open(FIXTURES_DIR / "history_valid.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.History.Valid"
assert fixture["expected"]["status"] == 200
assert "items" in fixture["expected"]["body"]
def test_history_not_found():
"""FX_AgentChat.History.NotFound → 404."""
with open(FIXTURES_DIR / "history_not_found.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.History.NotFound"
assert fixture["expected"]["status"] == 404
# #endregion TestAgentChat.ApiFixtures.History
# #region TestAgentChat.ApiFixtures.ServiceToken [C:2] [TYPE Function] [SEMANTICS test,fixture,service-token]
# @BRIEF Service token fixtures.
def test_service_token_valid():
"""FX_AgentChat.ServiceToken.Valid → 200 with token."""
with open(FIXTURES_DIR / "service_token_valid.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.ServiceToken.Valid"
assert fixture["expected"]["status"] == 200
def test_service_token_invalid():
"""FX_AgentChat.ServiceToken.InvalidSecret → 401."""
with open(FIXTURES_DIR / "service_token_invalid_secret.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.ServiceToken.InvalidSecret"
assert fixture["expected"]["status"] == 401
# #endregion TestAgentChat.ApiFixtures.ServiceToken
# #endregion TestAgentChat.ApiFixtures

View File

@@ -1,682 +0,0 @@
# #region Test.AgentChat.GradioApp [C:3] [TYPE Module] [SEMANTICS test,agent,gradio,chat]
# @BRIEF Tests for agent/app.py — agent_handler, handle_resume, extract_user_id, save_conversation, create_chat_interface, health.
# @RELATION BINDS_TO -> [AgentChat.GradioApp]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import asyncio
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
def _make_async_iter(items):
"""Create an async iterator from a list."""
class AsyncIter:
def __init__(self, items):
self._iter = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration from None
return AsyncIter(items)
def _skip_pipeline(results: list[str]) -> list[str]:
"""Filter out pipeline_result events emitted before the agent stream loop."""
return [r for r in results if json.loads(r).get("metadata", {}).get("type") != "pipeline_result"]
def _make_agent_mock(stream_events=None, raise_on_call=False):
"""Create a properly mocked agent for async iteration."""
agent = MagicMock()
if raise_on_call:
agent.astream_events = MagicMock(side_effect=stream_events)
elif stream_events is not None:
agent.astream_events = MagicMock(return_value=_make_async_iter(stream_events))
else:
agent.astream_events = MagicMock(return_value=_make_async_iter([]))
# aget_state must be awaitable — handler calls `await agent.aget_state(config)` after stream loop
state_mock = MagicMock(spec_set=["next"])
state_mock.next = ()
agent.aget_state = AsyncMock(return_value=state_mock)
return agent
@pytest.fixture(autouse=True)
def clear_locks():
"""Clear _user_locks before each test."""
from src.agent.app import _user_locks
_user_locks.clear()
@pytest.fixture
def mock_request():
req = MagicMock()
req.headers = {"authorization": ""}
return req
# #region test_extract_user_id [C:2] [TYPE Function]
# @BRIEF Test extract_user_id for various JWT payloads.
class TestExtractUserId:
def test_extracts_sub(self):
from src.agent._persistence import extract_user_id
with patch("src.agent._jwt_decoder.decode_token", return_value={"sub": "user-1"}):
assert extract_user_id("fake-jwt") == "user-1"
def test_extracts_user_id_fallback(self):
from src.agent._persistence import extract_user_id
with patch("src.agent._jwt_decoder.decode_token", return_value={"user_id": "user-2"}):
assert extract_user_id("fake-jwt") == "user-2"
def test_returns_unknown_on_exception(self):
from src.agent._persistence import extract_user_id
with patch("src.agent._jwt_decoder.decode_token", side_effect=Exception("bad token")):
assert extract_user_id("bad") == "unknown"
def test_returns_unknown_on_empty(self):
from src.agent._persistence import extract_user_id
assert extract_user_id("") == "unknown"
# #endregion test_extract_user_id
# #region test_confirmation_metadata [C:2] [TYPE Function]
# @BRIEF Test backend HITL confirmation contract exposed to the frontend.
class TestConfirmationMetadata:
def test_extracts_tool_call_and_read_contract(self):
from src.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [{"name": "list_environments", "args": {"env_id": "prod"}}]
state = MagicMock()
state.values = {"messages": [msg]}
state.next = ("tools",)
meta = confirmation_metadata("conv-1", state, "Какие есть окружения?")
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-1"
assert meta["prompt"] == "Разрешить чтение данных?"
assert meta["tool_name"] == "list_environments"
assert meta["tool_args"] == {"env_id": "prod"}
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
assert meta["requires_confirmation"] is True
assert meta["intent"]["operation"] == "list_environments"
def test_infers_tool_from_text_when_state_only_has_graph_node(self):
from src.agent._confirmation import confirmation_metadata
state = MagicMock()
state.values = {"messages": []}
state.next = ("tools",)
meta = confirmation_metadata("conv-2", state, "Покажи окружения")
# extract_tool_call_from_state no longer infers from text — LLM handles intent.
# State has no tool_calls, and "tools" node is in _GRAPH_NODE_NAMES → filtered.
assert meta["tool_name"] == "unknown_action"
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
def test_write_contract_for_guarded_tool(self):
from src.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [
{
"name": "start_maintenance",
"args": {"environment_id": "prod", "tables": ["sales"]},
}
]
state = MagicMock()
state.values = {"messages": [msg]}
state.next = ("tools",)
meta = confirmation_metadata("conv-3", state, "Запусти maintenance")
assert meta["tool_name"] == "start_maintenance"
assert meta["risk"] == "write"
assert meta["risk_level"] == "guarded"
assert meta["prompt"] == "⚠️ Изменение данных в PRODUCTION! Подтвердите действие."
# v2 fields — permission check runs against user_role in state; test defaults to "viewer"
# start_maintenance requires "admin", so permission_granted will be False here
assert meta["dangerous"] is False
assert meta["env_context"] == "prod"
assert meta["permission_granted"] is False
assert meta["required_role"] == "admin"
def test_unknown_contract_does_not_expose_graph_node_as_tool(self):
from src.agent._confirmation import confirmation_metadata
state = MagicMock()
state.values = {"messages": []}
state.next = ("tools",)
meta = confirmation_metadata("conv-4", state, "Непонятное действие")
assert meta["tool_name"] == "unknown_action"
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
def test_fast_resume_deny_closes_without_langgraph(self):
from src.agent._confirmation import _pending_confirmations, handle_resume
_pending_confirmations["conv-fast-deny"] = {
"tool_name": "list_environments",
"tool_args": {},
}
async def collect():
return [chunk async for chunk in handle_resume("conv-fast-deny", "deny")]
chunks = asyncio.run(collect())
data = json.loads(chunks[0])
assert data["metadata"] == {"type": "confirm_resolved", "result": "denied"}
def test_fast_resume_confirm_executes_tool_directly(self):
from src.agent._confirmation import _pending_confirmations, handle_resume
tool = MagicMock()
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev"}]')
_pending_confirmations["conv-fast-confirm"] = {
"tool_name": "list_environments",
"tool_args": {},
}
async def collect():
with patch("src.agent._confirmation.find_tool", return_value=tool), patch("src.agent._confirmation._format_tool_output_via_llm") as mock_format:
# Make the mock format helper yield nothing — don't need LLM in unit test
async def _empty_format(*_args, **_kwargs):
return
yield # pragma: no cover — async generator requires at least one yield
mock_format.side_effect = _empty_format
return [chunk async for chunk in handle_resume("conv-fast-confirm", "confirm")]
chunks = asyncio.run(collect())
metadata_types = [json.loads(chunk)["metadata"]["type"] for chunk in chunks]
assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"]
# #endregion test_confirmation_metadata
# #region test_agent_handler [C:2] [TYPE Function]
# @BRIEF Test agent_handler for various scenarios.
class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_concurrent_send(self, mock_request):
from src.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no user_id_str or JWT is provided
_user_locks["admin"] = True
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "CONCURRENT_SEND"
@pytest.mark.asyncio
async def test_handles_file_too_large(self, mock_request):
from src.agent.app import MAX_FILE_SIZE_BYTES, agent_handler
message = {"text": "analyze", "files": ["fake_path"]}
with patch("os.path.exists", return_value=True), patch("os.path.getsize", return_value=MAX_FILE_SIZE_BYTES + 1):
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "FILE_TOO_LARGE"
@pytest.mark.asyncio
async def test_handles_hitl_confirm(self, mock_request):
from src.agent.app import agent_handler
# handle_resume is imported into app.py from _confirmation
with patch("src.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}})])
with patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("confirm", [], mock_request, "conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["type"] == "confirm_resolved"
@pytest.mark.asyncio
async def test_handles_hitl_deny(self, mock_request):
from src.agent.app import agent_handler
with patch("src.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([json.dumps({"content": "denied", "metadata": {"type": "confirm_resolved", "result": "denied"}})])
with patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("deny", [], mock_request, "conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "denied"
@pytest.mark.asyncio
async def test_normal_send_yields_tokens(self, mock_request):
from src.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
mock_event_stream = [
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
{"event": "on_chain_end", "interrupt": {}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
token_data = json.loads(filtered[0])
assert token_data["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_normal_send_hides_runtime_context_from_saved_history(self, mock_request):
from src.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "ok"
agent = _make_agent_mock(
[
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
]
)
save_mock = AsyncMock()
with (
patch("src.agent.app.create_agent", return_value=agent),
patch("src.agent.app.get_all_tools", return_value=[]),
patch("src.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")),
patch("src.agent.app.generate_llm_title", AsyncMock()),
patch("src.agent.app.save_conversation", save_mock),
):
results = [
r
async for r in agent_handler(
"Запусти обслуживание дашборда USA на 15 минут",
[],
mock_request,
"conv-runtime",
None,
None,
None,
"ss-dev",
)
]
# pipeline_result + stream_token = 2 events
assert len(results) == 2
messages_arg = agent.astream_events.call_args.args[0]["messages"]
llm_text = messages_arg[0].content
assert "[RUNTIME CONTEXT]" in llm_text
assert "Current datetime:" in llm_text
assert "Available dashboards" in llm_text
save_mock.assert_called_once()
assert save_mock.call_args.args[1] == "Запусти обслуживание дашборда USA на 15 минут"
@pytest.mark.asyncio
async def test_tool_events_yielded(self, mock_request):
from src.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_start", "name": "test_tool", "data": {"input": {}}},
{"event": "on_tool_end", "name": "test_tool", "data": {"output": "done"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()), patch("src.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 2
assert json.loads(filtered[0])["metadata"]["type"] == "tool_start"
assert json.loads(filtered[1])["metadata"]["type"] == "tool_end"
@pytest.mark.asyncio
async def test_tool_error_event(self, mock_request):
from src.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_error", "name": "test_tool", "data": {"error": "something failed"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()), patch("src.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert json.loads(filtered[0])["metadata"]["type"] == "tool_error"
@pytest.mark.asyncio
async def test_output_parser_exception_retry(self, mock_request):
from langchain_core.exceptions import OutputParserException
from src.agent.app import agent_handler
mock_stream_after = _make_async_iter([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}])
call_count = [0]
def mock_astream(*_args, **_kwargs):
call_count[0] += 1
if call_count[0] == 1:
raise OutputParserException("bad output")
return mock_stream_after
agent = MagicMock()
agent.astream_events = mock_astream
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
@pytest.mark.asyncio
async def test_output_parser_exception_final_failure(self, mock_request):
from langchain_core.exceptions import OutputParserException
from src.agent.app import agent_handler
call_count = [0]
def mock_astream(*_args, **_kwargs):
call_count[0] += 1
raise OutputParserException(f"bad {call_count[0]}")
agent = MagicMock()
agent.astream_events = mock_astream
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "LLM_MALFORMED_OUTPUT"
@pytest.mark.asyncio
async def test_non_llm_error_saves_conversation(self, mock_request):
from src.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("API connection error")
agent = MagicMock()
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
# Handler catches RuntimeError, yields a pipeline result first, then error chunk
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "PROCESSING_ERROR"
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_rate_limit_error_yields_code(self, mock_request):
"""RateLimitError (429) yields LLM_RATE_LIMITED, not guardrails card."""
from openai import RateLimitError
from src.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RateLimitError(
"429 quota exceeded",
response=MagicMock(status_code=429),
body={"error": {"message": "quota exceeded"}},
)
agent = MagicMock()
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "LLM_RATE_LIMITED"
assert "Превышен лимит" in data["content"]
# save_conversation should be called even on error
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_generic_exception_with_llm_keyword_yields_error_not_guardrails(self, mock_request):
"""Generic exception containing 'quota' keyword yields error, not confirmation."""
from src.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("All codex accounts reached configured quota threshold")
agent = MagicMock()
agent.astream_events = mock_astream
# Even though aget_state may return a truthy 'next', the error
# contains LLM keywords and should skip the HITL recovery path.
agent.aget_state = AsyncMock(return_value=MagicMock(next=("tools",)))
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
# Must be PROCESSING_ERROR (not confirm_required)
assert data["metadata"]["code"] == "PROCESSING_ERROR"
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_jwt_passes_gracefully(self):
from jose import JWTError
from src.agent.app import agent_handler
req = MagicMock()
req.headers = {"authorization": "Bearer invalid.jwt"}
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.decode_token", side_effect=JWTError("invalid")), patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], req, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@pytest.mark.asyncio
async def test_valid_user_jwt_with_audience_is_kept_for_tool_auth(self, mock_request):
from src.agent.app import agent_handler
from src.agent.context import get_user_jwt
from src.core.auth.jwt import create_access_token
token = create_access_token({"sub": "admin", "scopes": ["Admin"]})
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert get_user_jwt() == token
# #endregion test_agent_handler
# #region test_handle_resume [C:2] [TYPE Function]
# @BRIEF Test handle_resume LangGraph checkpoint resume (no pending confirmation).
class TestHandleResume:
@pytest.mark.asyncio
async def test_confirm_checkpoint_resume(self):
from src.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "confirmed"
@pytest.mark.asyncio
async def test_deny_checkpoint_resume(self):
from src.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "denied"
# #endregion test_handle_resume
# #region test_save_conversation [C:2] [TYPE Function]
# @BRIEF Test save_conversation with various scenarios.
class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_success(self):
from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "test message", "user-1")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_with_service_jwt(self):
from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client, patch("src.agent._persistence.os.getenv", return_value="service-token"):
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "hello", "admin")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_failure_logged(self):
from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
# Should not raise
await save_conversation("conv-1", "msg", "u1")
@pytest.mark.asyncio
async def test_save_empty_title(self):
from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = client_instance
await save_conversation("conv-1", " ", "user-1")
call_kwargs = client_instance.post.call_args[1]
# clean_title(" ") returns "Новый диалог" (Russian for "New conversation")
assert call_kwargs["json"]["title"] == "Новый диалог"
# #endregion test_save_conversation
# #region test_create_chat_interface [C:2] [TYPE Function]
# @BRIEF Test create_chat_interface returns a gr.ChatInterface.
class TestCreateChatInterface:
def test_returns_chat_interface(self):
from src.agent.app import create_chat_interface
with patch("src.agent.app.gr.ChatInterface") as mock_ci:
result = create_chat_interface()
assert result is mock_ci.return_value
# #endregion test_create_chat_interface
# #region test_health [C:2] [TYPE Function]
# @BRIEF Test health endpoint returns status ok.
class TestHealth:
@pytest.mark.asyncio
async def test_health_returns_ok(self):
from src.agent.app import health
result = await health()
assert result["status"] == "ok"
# #endregion test_health
# #region test_file_upload_parsing [C:2] [TYPE Function]
# @BRIEF Test file upload branch — parse_upload called for valid small files.
class TestFileUploadParsing:
@pytest.mark.asyncio
async def test_handler_parses_uploaded_file(self, mock_request, tmp_path):
"""Valid small file triggers parse_upload and continues to stream."""
from src.agent.app import agent_handler
test_file = tmp_path / "test.txt"
test_file.write_text("upload content for analysis")
message = {"text": "analyze", "files": [str(test_file)]}
with (
patch("src.agent.app.create_agent") as mock_create,
patch("src.agent.app.get_all_tools", return_value=[]),
patch("src.agent.app.save_conversation", AsyncMock()),
patch("src.agent.app.log_tool_event", AsyncMock()),
patch("src.agent.app._persist_chat_file", return_value="chat_uploads/test.txt"),
):
agent = _make_agent_mock([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}}])
mock_create.return_value = agent
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
# Now: file_uploaded, pipeline_result, stream_token (3 events)
assert len(results) >= 3
# First result is file upload metadata, third (after pipeline) is stream token
file_data = json.loads(results[0])
assert file_data["metadata"]["type"] == "file_uploaded"
token_data = json.loads(results[2])
assert token_data["metadata"]["type"] == "stream_token"
# #endregion test_file_upload_parsing
# #region test_app_main_block [C:2] [TYPE Function]
# @BRIEF Test if __name__ == '__main__' block in app.py.
class TestAppMainBlock:
def test_app_main_block(self):
"""if __name__ == '__main__' creates ChatInterface and launches."""
import importlib.util
from pathlib import Path
app_path = Path(__file__).parent.parent.parent / "src" / "agent" / "app.py"
spec = importlib.util.spec_from_file_location("__main__", str(app_path))
mock_demo = MagicMock()
with patch("gradio.ChatInterface") as mock_ci:
mock_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
mock_demo.launch.assert_called_once()
# #endregion test_app_main_block
# #endregion Test.AgentChat.GradioApp

View File

@@ -1,148 +0,0 @@
# #region TestAgentChat.Confirmations [C:3] [TYPE Module] [SEMANTICS test,agent,confirmation,hitl]
# @BRIEF Supplementary HITL confirmation flow tests — concurrent send, unknown action, model edge cases.
# @RELATION BINDS_TO -> [AgentChat.GradioApp.Handler]
# @TEST_EDGE: concurrent_send -> handler yields CONCURRENT_SEND error
# @TEST_EDGE: confirm_without_conversation_id -> handler still processes confirm
# @NOTE Resume confirm/deny handler tests are in test_agent_handler.py (test_handler_resume_confirm,
# test_handler_resume_deny). Model-level state machine tests are in AgentChatModel.test.ts.
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import pytest
import jwt
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
os.environ["OPENAI_API_KEY"] = "sk-test-key"
os.environ["LLM_API_KEY"] = "sk-test-key"
os.environ["LLM_MODEL"] = "gpt-4o"
os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
def _make_test_jwt(user_id: str = "test-user") -> str:
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
@pytest.fixture(autouse=True)
def mock_save_conversation():
with patch("src.agent.app.save_conversation", new_callable=AsyncMock):
yield
# #region TestAgentChat.Confirmations.Concurrent [C:2] [TYPE Function] [SEMANTICS test,confirmation,concurrent]
# @BRIEF Concurrent send lock prevents multiple simultaneous sends from same user.
@pytest.mark.asyncio
async def test_concurrent_send_lock():
"""Handler rejects concurrent sends from same user with CONCURRENT_SEND error."""
from src.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no JWT and no user_id_str are provided
_user_locks["admin"] = True
history: list = []
mock_request = MagicMock()
mock_request.headers = {}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", None):
results.append(chunk)
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["code"] == "CONCURRENT_SEND"
# Clean up
_user_locks.pop("admin", None)
# #endregion TestAgentChat.Confirmations.Concurrent
# #region TestAgentChat.Confirmations.UnknownAction [C:2] [TYPE Function] [SEMANTICS test,confirmation,unknown]
# @BRIEF Non-confirm/deny action values are treated as normal messages.
@pytest.mark.asyncio
async def test_handler_unknown_action_treated_as_normal():
"""Handler with unknown action string proceeds as normal message send."""
from src.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
with patch("src.agent.app.create_agent") as mock_create:
mock_graph = MagicMock()
async def _mock_stream(*_args, **_kwargs):
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
mock_graph.astream_events = _mock_stream
state = MagicMock(spec_set=["next"])
state.next = ()
mock_graph.aget_state = AsyncMock(return_value=state)
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "unknown_action"):
results.append(chunk)
# Should produce stream tokens, not confirm_resolved
assert len(results) > 0
import json
meta_types = [json.loads(r)["metadata"]["type"] for r in results]
assert "stream_token" in meta_types
assert "confirm_resolved" not in meta_types
# #endregion TestAgentChat.Confirmations.UnknownAction
# #region TestAgentChat.Confirmations.ExpiredState [C:2] [TYPE Function] [SEMANTICS test,confirmation,expired]
# @BRIEF Stale confirmations — checkpoint no longer available.
@pytest.mark.asyncio
async def test_handler_confirm_no_graph():
"""Handler with action='confirm' but create_agent raises handles it gracefully."""
from src.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "confirm", "files": None}
with patch("src.agent._confirmation.create_agent") as mock_create:
mock_create.side_effect = Exception("Graph creation failed")
try:
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "confirm"):
results.append(chunk)
# Exception may propagate or be caught — either is acceptable
except Exception:
pass
# #endregion TestAgentChat.Confirmations.ExpiredState
# #endregion TestAgentChat.Confirmations

View File

@@ -1,303 +0,0 @@
# #region TestAgentChat.DocumentParser [C:2] [TYPE Module] [SEMANTICS test,agent,document,parser]
# @BRIEF Tests for document parser — PDF, XLSX, unsupported formats, empty files, errors.
# @RELATION BINDS_TO -> [AgentChat.Document.Parser]
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from src.agent.document_parser import parse_pdf, parse_xlsx, parse_upload, ParseError
# #region TestAgentChat.DocumentParser.PDF [C:2] [TYPE Function] [SEMANTICS test,parser,pdf]
# @BRIEF PDF parsing: extracts text from valid PDF, handles empty and encrypted PDFs gracefully.
def test_parse_pdf_valid():
"""Parse a valid PDF and verify text extraction."""
# Create a minimal valid PDF
pdf_content = (
b"%PDF-1.4\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
b"4 0 obj<</Length 44>>stream\n"
b"BT /F1 12 Tf 100 700 Td (Hello PDF) Tj ET\n"
b"endstream\nendobj\n"
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
b"xref\n"
b"0 6\n"
b"trailer<</Size 6/Root 1 0 R>>\n"
b"startxref\n"
b"169\n"
b"%%EOF"
)
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(pdf_content)
tmp_path = f.name
try:
result = parse_pdf(tmp_path)
assert isinstance(result, str)
assert "Hello" in result
finally:
os.unlink(tmp_path)
def test_parse_pdf_empty():
"""Empty/non-existent PDF file raises ParseError."""
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(b"")
tmp_path = f.name
try:
with pytest.raises(ParseError):
parse_pdf(tmp_path)
finally:
os.unlink(tmp_path)
def test_parse_pdf_nonexistent():
"""Non-existent PDF file raises ParseError."""
with pytest.raises((ParseError, FileNotFoundError)):
parse_pdf("/tmp/nonexistent_file_12345.pdf")
# #endregion TestAgentChat.DocumentParser.PDF
# #region TestAgentChat.DocumentParser.XLSX [C:2] [TYPE Function] [SEMANTICS test,parser,xlsx]
# @BRIEF XLSX parsing: extracts sheet names and cell data.
def test_parse_xlsx_valid():
"""Parse a valid XLSX and verify sheet+cell extraction."""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Sheet1"
ws["A1"] = "Name"
ws["B1"] = "Value"
ws["A2"] = "Test"
ws["B2"] = 42
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
wb.save(f.name)
tmp_path = f.name
try:
result = parse_xlsx(tmp_path)
assert isinstance(result, str)
assert "Sheet1" in result
assert "Name" in result
assert "Value" in result
assert "Test" in result
assert "42" in result
finally:
os.unlink(tmp_path)
def test_parse_xlsx_empty_sheet():
"""XLSX with empty sheet returns headers but no data rows."""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "EmptySheet"
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
wb.save(f.name)
tmp_path = f.name
try:
result = parse_xlsx(tmp_path)
assert isinstance(result, str)
assert "EmptySheet" in result
finally:
os.unlink(tmp_path)
def test_parse_xlsx_not_excel():
"""Non-XLSX file raises ParseError."""
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
f.write(b"not an excel file")
tmp_path = f.name
try:
with pytest.raises(ParseError):
parse_xlsx(tmp_path)
finally:
os.unlink(tmp_path)
# #endregion TestAgentChat.DocumentParser.XLSX
# #region TestAgentChat.DocumentParser.ParseUpload [C:2] [TYPE Function] [SEMANTICS test,parser,upload]
# @BRIEF parse_upload dispatches to correct parser based on extension. Unsupported → error.
def test_parse_upload_txt():
"""Parse a .txt file returns its content."""
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
f.write("Hello, world!")
tmp_path = f.name
try:
result = parse_upload(tmp_path)
assert "Hello" in result
finally:
os.unlink(tmp_path)
def test_parse_upload_json():
"""Parse a .json file returns its text."""
with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
f.write('{"key": "value"}')
tmp_path = f.name
try:
result = parse_upload(tmp_path)
assert "key" in result
finally:
os.unlink(tmp_path)
def test_parse_upload_unsupported():
"""Unsupported format raises ParseError."""
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as f:
f.write(b"binary")
tmp_path = f.name
try:
with pytest.raises(ParseError, match="Unsupported format"):
parse_upload(tmp_path)
finally:
os.unlink(tmp_path)
def test_parse_upload_dict():
"""parse_upload accepts dict with name+path (Gradio file format)."""
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
f.write("Dict test")
tmp_path = f.name
try:
result = parse_upload({"name": "test.txt", "path": tmp_path})
assert "Dict test" in result
finally:
os.unlink(tmp_path)
def test_parse_upload_file_path_fallback():
"""parse_upload falls back to file_path key if path is missing."""
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
f.write("Fallback key test")
tmp_path = f.name
try:
result = parse_upload({"name": "test.txt", "file_path": tmp_path})
assert "Fallback" in result
finally:
os.unlink(tmp_path)
# #endregion TestAgentChat.DocumentParser.ParseUpload
# #region TestAgentChat.DocumentParser.ImportErrors [C:2] [TYPE Function] [SEMANTICS test,parser,import,error]
# @BRIEF Test ImportError paths in parse_pdf and parse_xlsx.
def test_parse_pdf_import_error():
"""pdfplumber import fails → ParseError."""
import builtins
real_import = builtins.__import__
def raise_on_pdfplumber(name, globals=None, locals=None, fromlist=(), level=0):
if name == 'pdfplumber':
raise ImportError(f"No module named pdfplumber", name=name)
return real_import(name, globals, locals, fromlist, level)
with patch('builtins.__import__', side_effect=raise_on_pdfplumber):
with pytest.raises(ParseError, match="pdfplumber not installed"):
parse_pdf("/tmp/dummy.pdf")
def test_parse_xlsx_import_error():
"""openpyxl import fails → ParseError."""
import builtins
real_import = builtins.__import__
def raise_on_openpyxl(name, globals=None, locals=None, fromlist=(), level=0):
if name == 'openpyxl':
raise ImportError(f"No module named openpyxl", name=name)
return real_import(name, globals, locals, fromlist, level)
with patch('builtins.__import__', side_effect=raise_on_openpyxl):
with pytest.raises(ParseError, match="openpyxl not installed"):
parse_xlsx("/tmp/dummy.xlsx")
# #endregion TestAgentChat.DocumentParser.ImportErrors
# #region TestAgentChat.DocumentParser.PyPDF2Fallback [C:2] [TYPE Function] [SEMANTICS test,parser,pypdf2,fallback]
# @BRIEF When pdfplumber fails, PyPDF2 is used as fallback path.
def test_parse_pdf_pypdf2_fallback():
"""pdfplumber failure triggers PyPDF2 fallback."""
# Build mock PyPDF2 module in sys.modules
mock_pypdf2 = MagicMock()
mock_reader = MagicMock()
mock_page = MagicMock()
mock_page.extract_text.return_value = "Fallback PyPDF2 text"
mock_reader.pages = [mock_page]
mock_pypdf2.PdfReader = MagicMock(return_value=mock_reader)
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(b"dummy pdf content")
tmp_path = f.name
try:
# Patch pdfplumber.open directly (the real module) — NOT document_parser.pdfplumber
# because pdfplumber is imported inside the function body, not at module level.
with patch('pdfplumber.open', side_effect=Exception("pdfplumber error")), \
patch.dict('sys.modules', {'PyPDF2': mock_pypdf2}):
result = parse_pdf(tmp_path)
assert isinstance(result, str)
assert "PyPDF2" in result
mock_pypdf2.PdfReader.assert_called_once()
finally:
os.unlink(tmp_path)
# #endregion TestAgentChat.DocumentParser.PyPDF2Fallback
# #region TestAgentChat.DocumentParser.ParseUploadBranches [C:2] [TYPE Function] [SEMANTICS test,parser,upload,branches]
# @BRIEF Test parse_upload dispatches to correct parser for PDF, XLSX, XLS.
def test_parse_upload_pdf_branch():
"""parse_upload with .pdf dispatches to parse_pdf."""
import src.agent.document_parser as dp
with patch.object(dp, 'parse_pdf', return_value="pdf result") as mock_parse:
result = parse_upload({"name": "report.pdf", "path": "/tmp/report.pdf"})
assert result == "pdf result"
mock_parse.assert_called_once_with("/tmp/report.pdf")
def test_parse_upload_xlsx_branch():
"""parse_upload with .xlsx dispatches to parse_xlsx."""
import src.agent.document_parser as dp
with patch.object(dp, 'parse_xlsx', return_value="xlsx result") as mock_parse:
result = parse_upload({"name": "data.xlsx", "path": "/tmp/data.xlsx"})
assert result == "xlsx result"
mock_parse.assert_called_once_with("/tmp/data.xlsx")
def test_parse_upload_xls_branch():
"""parse_upload with .xls (legacy) also dispatches to parse_xlsx."""
import src.agent.document_parser as dp
with patch.object(dp, 'parse_xlsx', return_value="xls result") as mock_parse:
result = parse_upload({"name": "legacy.xls", "path": "/tmp/legacy.xls"})
assert result == "xls result"
mock_parse.assert_called_once_with("/tmp/legacy.xls")
# #endregion TestAgentChat.DocumentParser.ParseUploadBranches
# #endregion TestAgentChat.DocumentParser

View File

@@ -1,82 +0,0 @@
# #region Test.Agent.Feature035 [C:3] [TYPE Module] [SEMANTICS test,agent-chat,context,tools,rbac]
# @BRIEF Contract tests for feature 035 context filtering, invocation RBAC, and tool response summarisation.
# @RELATION BINDS_TO -> [AgentChat.ToolFilter]
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @TEST_EDGE: dashboard_context_admin -> Dashboard affinity keeps dashboard tools plus mandatory capabilities.
# @TEST_EDGE: dashboard_context_viewer -> RBAC removes admin-only dashboard tools.
# @TEST_EDGE: invocation_guard_denied -> Mutating tool rejects before HTTP side effect.
import pytest
from types import SimpleNamespace
from src.agent._tool_filter import build_tool_pipeline
from src.agent.context import set_user_role
from src.agent.tools import _guard_tool_permission, _summarise_response
def _tools(names: list[str]) -> list[SimpleNamespace]:
return [SimpleNamespace(name=name) for name in names]
# #region test_dashboard_context_filters_tools [C:2] [TYPE Function]
# @BRIEF Dashboard context keeps only dashboard-affinity tools and mandatory capabilities.
def test_dashboard_context_filters_tools():
tools = _tools([
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"superset_execute_sql",
"run_backup",
"show_capabilities",
])
result = [tool.name for tool in build_tool_pipeline(tools, "admin", "dashboard")]
assert result == [
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"show_capabilities",
]
# #endregion test_dashboard_context_filters_tools
# #region test_dashboard_context_viewer_removes_admin_tools [C:2] [TYPE Function]
# @BRIEF Viewer role removes admin-only tools even when they are dashboard-affinity tools.
def test_dashboard_context_viewer_removes_admin_tools():
tools = _tools([
"search_dashboards",
"deploy_dashboard",
"execute_migration",
"show_capabilities",
])
result = [tool.name for tool in build_tool_pipeline(tools, "viewer", "dashboard")]
assert result == ["search_dashboards", "show_capabilities"]
# #endregion test_dashboard_context_viewer_removes_admin_tools
# #region test_invocation_guard_blocks_mutating_tool [C:2] [TYPE Function]
# @BRIEF Invocation guard rejects admin-only tools for non-admin role before side effects.
def test_invocation_guard_blocks_mutating_tool():
set_user_role("viewer")
with pytest.raises(PermissionError, match="PERMISSION_DENIED:deploy_dashboard:admin:viewer"):
_guard_tool_permission("deploy_dashboard")
# #endregion test_invocation_guard_blocks_mutating_tool
# #region test_summarise_response_preserves_json_array_shape [C:2] [TYPE Function]
# @BRIEF Large JSON arrays are summarised as top-N plus total count, not cut mid-structure.
def test_summarise_response_preserves_json_array_shape():
text = "[" + ",".join(f'{{"id":{idx},"name":"dashboard-{idx}"}}' for idx in range(20)) + "]"
summary = _summarise_response(text, limit=100)
assert summary.startswith("Found 20 items:")
assert "dashboard-0" in summary
assert "15 more items" in summary
# #endregion test_summarise_response_preserves_json_array_shape
# #endregion Test.Agent.Feature035

View File

@@ -1,134 +0,0 @@
# #region Test.IntentKeyword.Edges [C:2] [TYPE Module] [SEMANTICS test,metadata,invariants]
# @BRIEF Metadata invariant tests for agent tools — tool catalog consistency, docstring coverage.
# Keyword matching tests removed (LLM handles intent detection via LangGraph tool-calling).
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @TEST_EDGE: all_tools_registered -> get_all_tools() returns consistent list
# @TEST_EDGE: docstrings_present -> every tool has a non-empty docstring
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, patch
# ═══════════════════════════════════════════════════════════════════
# A — Tool catalog consistency
# ═══════════════════════════════════════════════════════════════════
class TestToolCatalog:
"""Verify tool catalog is internally consistent."""
def test_get_all_tools_returns_all_24(self):
"""get_all_tools() returns at least 24 tools."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
assert len(tools) >= 24, f"Expected ≥24 tools, got {len(tools)}: {tool_names}"
# Core tools must be present
assert "show_capabilities" in tool_names
assert "search_dashboards" in tool_names
assert "get_health_summary" in tool_names
assert "start_maintenance" in tool_names
def test_tool_names_are_unique(self):
"""No duplicate tool names in get_all_tools()."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
tools = get_all_tools()
names = [t.name for t in tools]
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
def test_every_tool_has_docstring(self):
"""Every tool MUST have a docstring — enforced by LangChain but verified here."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
for t in get_all_tools():
desc = (t.description or "").strip()
assert desc, f"Tool '{t.name}' has empty description. LangChain @tool requires a docstring."
# ═══════════════════════════════════════════════════════════════════
# B — Embedding router metadata
# ═══════════════════════════════════════════════════════════════════
class TestEmbeddingMetadata:
"""Verify embedding router is consistent with tool catalog."""
def test_embedding_top_k_returns_empty_for_empty_query(self):
"""Empty query → empty result."""
try:
from src.agent._embedding_router import embedding_top_k
except ImportError:
pytest.skip("_embedding_router module not importable")
assert embedding_top_k("") == []
def test_embedding_is_available_returns_bool(self):
"""embedding_is_available returns bool, does not raise."""
try:
from src.agent._embedding_router import embedding_is_available
except ImportError:
pytest.skip("_embedding_router module not importable")
result = embedding_is_available()
assert isinstance(result, bool)
def test_get_descriptions_matches_all_tools(self):
"""_get_descriptions() covers every tool in get_all_tools() 1:1."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
try:
from src.agent._embedding_router import _get_descriptions
except ImportError:
pytest.skip("_embedding_router module not importable")
descs, names = _get_descriptions()
tool_names = {t.name for t in get_all_tools()}
assert set(names) == tool_names, (
f"Mismatch: descriptions={set(names) - tool_names}, tools={tool_names - set(names)}"
)
assert len(descs) == len(tool_names), f"Expected {len(tool_names)} descriptions, got {len(descs)}"
# ═══════════════════════════════════════════════════════════════════
# C — Tool resolver utilities (kept functions)
# ═══════════════════════════════════════════════════════════════════
class TestToolResolverUtils:
"""Utility functions in _tool_resolver still work."""
def test_normalize_tool_args_handles_dict(self):
from src.agent._tool_resolver import normalize_tool_args
assert normalize_tool_args({"key": "value"}) == {"key": "value"}
def test_normalize_tool_args_handles_none(self):
from src.agent._tool_resolver import normalize_tool_args
assert normalize_tool_args(None) == {}
def test_coerce_tool_call_from_dict(self):
from src.agent._tool_resolver import coerce_tool_call
name, args = coerce_tool_call({"name": "test_tool", "args": {"x": 1}})
assert name == "test_tool"
assert args == {"x": 1}
def test_extract_tool_call_from_state_empty(self):
from src.agent._tool_resolver import extract_tool_call_from_state
from unittest.mock import MagicMock
state = MagicMock()
state.values = {"messages": []}
state.next = None
name, args = extract_tool_call_from_state(state)
assert name is None
assert args == {}
def test_known_agent_tool_names(self):
from src.agent._tool_resolver import known_agent_tool_names
names = known_agent_tool_names()
assert "show_capabilities" in names
assert "search_dashboards" in names
assert len(names) >= 24
# #endregion Test.IntentKeyword.Edges

View File

@@ -1,446 +0,0 @@
# #region TestAgentChat.Tools [C:3] [TYPE Module] [SEMANTICS test,agent,tools,langchain]
# @BRIEF Tests for LangChain @tool functions — dual-identity auth, HTTP calls, tool wrapping.
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @TEST_EDGE: tool_rest_call -> tool calls FastAPI with dual-identity headers
# @TEST_EDGE: tool_http_failure -> tool returns error JSON gracefully
# @TEST_EDGE: get_all_tools -> returns expected tool list
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, Mock, patch
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import pytest
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
os.environ["FASTAPI_URL"] = "http://test-backend:8000"
os.environ["SERVICE_JWT"] = "test-service-jwt"
os.environ["OPENAI_API_KEY"] = "sk-test-key"
@pytest.fixture
def anyio_backend():
return "asyncio"
# #region TestAgentChat.Tools.DualAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth]
# @BRIEF Dual-identity auth headers built from ContextVar and env vars.
@pytest.mark.anyio
async def test_tool_dual_auth_headers():
"""Tools should build auth headers from ContextVar when set."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import search_dashboards
# Set JWTs in context
set_user_jwt("user-jwt-token")
set_service_jwt("service-jwt-token")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
await search_dashboards.ainvoke({"query": "test"})
# Verify the HTTP request included dual-identity headers
call_kwargs = mock_instance.get.call_args
assert call_kwargs is not None, "HTTP GET should have been called"
_, kwargs = call_kwargs
headers = kwargs.get("headers", {})
assert "Authorization" in headers, "Should include Authorization header"
assert headers["Authorization"] == "Bearer service-jwt-token"
assert headers["X-User-JWT"] == "user-jwt-token"
# #endregion TestAgentChat.Tools.DualAuth
# #region TestAgentChat.Tools.FallbackAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth,fallback]
# @BRIEF Dual-identity auth falls back to env var when ContextVar is not set.
@pytest.mark.anyio
async def test_tool_auth_fallback_to_env():
"""Tools should fall back to SERVICE_JWT env var when ContextVar is empty."""
from src.agent.context import set_service_jwt, set_user_jwt
import src.agent.tools as tools_mod
from src.agent.tools import search_dashboards
# Clear ContextVars
set_user_jwt("")
set_service_jwt("")
os.environ["SERVICE_JWT"] = "env-service-token"
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
# Since tool uses os.getenv at call time, the env var will be read
await search_dashboards.ainvoke({"query": "test"})
call_kwargs = mock_instance.get.call_args
assert call_kwargs is not None
_, kwargs = call_kwargs
headers = kwargs.get("headers", {})
# Should use env var
assert "Authorization" in headers
# #endregion TestAgentChat.Tools.FallbackAuth
# #region TestAgentChat.Tools.HttpFailure [C:2] [TYPE Function] [SEMANTICS test,tools,failure]
# @BRIEF Tool handles HTTP failure gracefully (returns error text, not exception).
@pytest.mark.anyio
async def test_tool_http_exception_handling():
"""Tool should propagate HTTP exception as error text."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import search_dashboards
set_user_jwt("test-jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.side_effect = Exception("Connection refused")
# Should propagate the exception (caller handles error)
with pytest.raises((Exception,)):
await search_dashboards.ainvoke({"query": "test"})
# #endregion TestAgentChat.Tools.HttpFailure
# #region TestAgentChat.Tools.GetAll [C:2] [TYPE Function] [SEMANTICS test,tools,registry]
# @BRIEF get_all_tools returns the expected list of tool functions.
def test_get_all_tools_returns_expected_list():
"""get_all_tools() should return search_dashboards, get_health_summary, etc."""
from src.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = [t.name for t in tools]
expected = {
"show_capabilities",
"search_dashboards",
"get_health_summary",
"list_environments",
"get_task_status",
"list_llm_providers",
"get_llm_status",
"create_branch",
"commit_changes",
"deploy_dashboard",
"execute_migration",
"run_backup",
"run_llm_validation",
"run_llm_documentation",
"list_maintenance_events",
"start_maintenance",
"end_maintenance",
}
assert expected.issubset(set(tool_names))
def test_get_all_tools_args_schema():
"""Tools with args_schema should expose required fields."""
from src.agent.tools import get_all_tools
tools = get_all_tools()
search_tool = next(t for t in tools if t.name == "search_dashboards")
health_tool = next(t for t in tools if t.name == "get_health_summary")
assert search_tool.args_schema is not None, "search_dashboards should have args_schema"
schema_fields = search_tool.args_schema.model_fields
assert "query" in schema_fields, "search_dashboards should have 'query' field"
assert schema_fields["query"].is_required(), "query should be required"
assert health_tool.args_schema is not None, "get_health_summary should have args_schema"
assert "env_id" in health_tool.args_schema.model_fields
# ═══════════════════════════════════════════════════════════════════
# Tool get_all — full catalog (replaces get_tools_for_query)
# ═══════════════════════════════════════════════════════════════════
def test_get_all_tools_returns_24_tools():
"""get_all_tools() returns full catalog — all 24 tools registered."""
from src.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
# Core tools (always present)
assert "show_capabilities" in tool_names
assert "search_dashboards" in tool_names
assert "get_health_summary" in tool_names
# Regression: minimum count — must have all 24
assert len(tools) >= 24, f"Expected ≥24 tools, got {len(tools)}: {tool_names}"
# #endregion TestAgentChat.Tools.GetAll
# #region TestAgentChat.Tools.ToolContracts [C:2] [TYPE Function] [SEMANTICS test,tools,contract]
# @BRIEF Tool contracts match @POST and @PRE declared in contracts/modules.md.
@pytest.mark.anyio
async def test_search_dashboards_correct_url():
"""search_dashboards calls GET /api/dashboards with query params."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
await search_dashboards.ainvoke({"query": "dashboard-name", "env_id": "prod"})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/dashboards" in url
# #endregion TestAgentChat.Tools.ToolContracts
# #region TestAgentChat.Tools.HealthSummary [C:2] [TYPE Function] [SEMANTICS test,tools,health]
# @BRIEF get_health_summary calls the correct FastAPI endpoint.
@pytest.mark.anyio
async def test_get_health_summary_calls_correct_url():
"""get_health_summary should call GET /api/health/summary."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import get_health_summary
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '{"status": "ok"}'
await get_health_summary.ainvoke({"env_id": "ss-dev"})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/health/summary" in url
assert kwargs.get("params") == {"environment_id": "ss-dev"}
# #endregion TestAgentChat.Tools.HealthSummary
# #region TestAgentChat.Tools.ListEnvironments [C:2] [TYPE Function] [SEMANTICS test,tools,environments]
# @BRIEF list_environments calls the correct FastAPI endpoint.
@pytest.mark.anyio
async def test_list_environments_calls_correct_url():
"""list_environments should call GET /api/settings/environments."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import list_environments
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '["prod", "dev"]'
await list_environments.ainvoke({})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/settings/environments" in url
@pytest.mark.anyio
async def test_list_environments_redacts_sensitive_fields():
"""list_environments must not expose backend secrets to chat output."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import list_environments
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]'
result = await list_environments.ainvoke({})
assert "secret-pass" not in result
assert "secret-key" not in result
assert "secret-token" not in result
assert result.count("[redacted]") == 3
# #endregion TestAgentChat.Tools.ListEnvironments
# #region TestAgentChat.Tools.TaskStatus [C:2] [TYPE Function] [SEMANTICS test,tools,task]
# @BRIEF get_task_status calls the correct FastAPI endpoint with task_id.
@pytest.mark.anyio
async def test_get_task_status_calls_correct_url():
"""get_task_status should call GET /api/tasks/{task_id}."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import get_task_status
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '{"status": "running"}'
await get_task_status.ainvoke({"task_id": "task-123"})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/tasks/task-123" in url
# #endregion TestAgentChat.Tools.TaskStatus
@pytest.mark.anyio
async def test_run_backup_posts_task_payload():
"""run_backup should create a superset-backup task through /api/tasks."""
from src.agent.context import set_service_jwt, set_user_jwt, set_user_role
from src.agent.tools import run_backup
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
set_user_role("admin")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.post.return_value = Mock(status_code=201, text='{"id": "task-1"}')
await run_backup.ainvoke({"environment_id": "prod", "dashboard_id": 10})
call_args = mock_instance.post.call_args
assert call_args is not None
args, kwargs = call_args
assert "api/tasks" in args[0]
assert kwargs["json"] == {
"plugin_id": "superset-backup",
"params": {"environment_id": "prod", "dashboard_ids": [10]},
}
@pytest.mark.anyio
async def test_deploy_dashboard_posts_git_endpoint():
"""deploy_dashboard should call the native Git deploy API."""
from src.agent.context import set_service_jwt, set_user_jwt, set_user_role
from src.agent.tools import deploy_dashboard
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
set_user_role("admin")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.post.return_value = Mock(status_code=200, text='{"status": "success"}')
await deploy_dashboard.ainvoke({"dashboard_ref": "42", "environment_id": "prod"})
call_args = mock_instance.post.call_args
assert call_args is not None
args, kwargs = call_args
assert "api/git/repositories/42/deploy" in args[0]
assert kwargs["json"] == {"environment_id": "prod"}
# #region TestAgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS test,tools,auth,headers]
# @BRIEF _dual_auth_headers builds proper headers from ContextVars.
def test_dual_auth_headers_with_both_jwts():
"""_dual_auth_headers uses service auth plus user identity when both are set."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
set_service_jwt("svc-token")
set_user_jwt("user-token")
headers = _dual_auth_headers()
assert headers.get("Authorization") == "Bearer svc-token"
assert headers.get("X-User-JWT") == "user-token"
def test_dual_auth_headers_no_user_jwt():
"""_dual_auth_headers falls back to service Authorization when no user JWT."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
set_service_jwt("svc-token")
set_user_jwt("")
headers = _dual_auth_headers()
assert headers.get("Authorization") == "Bearer svc-token"
def test_dual_auth_headers_no_jwts():
"""_dual_auth_headers falls back to _SERVICE_JWT when context vars are empty."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
set_service_jwt("")
set_user_jwt("")
headers = _dual_auth_headers()
# Context vars are empty, _SERVICE_JWT is module-level constant from _config
# (set at import time based on os.environ)
assert "Authorization" in headers
assert headers["Authorization"].startswith("Bearer ")
# #endregion TestAgentChat.Tools.DualAuthHeaders
# #endregion TestAgentChat.Tools

View File

@@ -1,183 +0,0 @@
# #region Test.AgentChat.LangGraph.Setup [C:3] [TYPE Module] [SEMANTICS test,agent,langgraph,setup]
# @BRIEF Tests for agent/langgraph_setup.py — configure_from_api, create_agent.
# @RELATION BINDS_TO -> [AgentChat.LangGraph.Setup]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def anyio_backend():
return "asyncio"
# #region test_configure_from_api [C:2] [TYPE Function]
# @BRIEF Test configure_from_api updates global config.
class TestConfigureFromApi:
def test_sets_llm_config(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({"configured": True, "api_key": "sk-test", "default_model": "gpt-4o"})
assert ls._llm_config is not None
assert ls._llm_config["configured"] is True
# Reset for other tests
ls.configure_from_api(None)
ls._llm_config = None
def test_overwrites_previous_config(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({"configured": True, "api_key": "sk-1"})
ls.configure_from_api({"configured": False})
assert ls._llm_config["configured"] is False
ls._llm_config = None
# #endregion test_configure_from_api
# #region test_create_agent [C:2] [TYPE Function]
# @BRIEF Test create_agent with various LLM config states.
class TestCreateAgent:
@pytest.mark.anyio
async def test_creates_agent_with_api_config(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({
"configured": True,
"api_key": "sk-api-config",
"base_url": "https://custom.api.com/v1",
"default_model": "gpt-4o-mini",
})
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
result = await ls.create_agent([MagicMock()])
assert result is mock_create.return_value
call_kwargs = mock_llm.call_args[1]
assert call_kwargs["api_key"] == "sk-api-config"
assert call_kwargs["base_url"] == "https://custom.api.com/v1"
assert call_kwargs["model"] == "gpt-4o-mini"
ls._llm_config = None
@pytest.mark.anyio
async def test_raises_error_when_no_llm_configured(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None # Reset
with patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)):
with pytest.raises(RuntimeError, match="No LLM provider configured in backend"):
await ls.create_agent([])
ls._llm_config = None
@pytest.mark.anyio
async def test_creates_agent_with_partial_api_config(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-key-only",
})
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
result = await ls.create_agent([])
assert result is mock_create.return_value
call_kwargs = mock_llm.call_args[1]
assert call_kwargs["api_key"] == "sk-key-only"
assert call_kwargs["base_url"] is None
assert call_kwargs["model"] is None
ls._llm_config = None
@pytest.mark.anyio
async def test_uses_inmemory_saver(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
"base_url": "",
"default_model": "gpt-4o-mini",
})
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
await ls.create_agent([])
call_kwargs = mock_create.call_args[1]
from langgraph.checkpoint.memory import InMemorySaver
assert isinstance(call_kwargs["checkpointer"], InMemorySaver)
ls._llm_config = None
@pytest.mark.anyio
async def test_uses_empty_interrupt_list_by_default(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == []
ls._llm_config = None
@pytest.mark.anyio
async def test_confirm_tools_env_interrupts_before_tools_node(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
ls._llm_config = None
@pytest.mark.anyio
async def test_uses_env_configured_interrupt_nodes(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup._INTERRUPT_BEFORE", "tools"):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
ls._llm_config = None
@pytest.mark.anyio
async def test_interrupt_override_bypasses_env_guardrail(self):
import src.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
mock_create.return_value = MagicMock()
await ls.create_agent([], interrupt_before=[])
assert mock_create.call_args[1]["interrupt_before"] == []
ls._llm_config = None
# #endregion test_create_agent
# #endregion Test.AgentChat.LangGraph.Setup

View File

@@ -1,88 +0,0 @@
# #region Test.AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS test,agent,middleware,audit]
# @BRIEF Tests for agent/middleware.py — log_tool_event.
# @RELATION BINDS_TO -> [AgentChat.Middleware]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import MagicMock, patch
import pytest
# #region test_log_tool_event [C:2] [TYPE Function]
# @BRIEF Test log_tool_event for various event types.
class TestLogToolEvent:
@pytest.mark.asyncio
async def test_logs_tool_start(self):
from src.agent.middleware import log_tool_event
event = {
"event": "on_tool_start",
"name": "migrate",
"data": {"input": {"dashboard_id": "42"}},
}
with patch("src.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
# No exception = success
@pytest.mark.asyncio
async def test_logs_tool_end(self):
from src.agent.middleware import log_tool_event
event = {
"event": "on_tool_end",
"name": "migrate",
"data": {"output": "success"},
}
with patch("src.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_logs_tool_error(self):
from src.agent.middleware import log_tool_event
event = {
"event": "on_tool_error",
"name": "migrate",
"data": {"error": "Connection failed"},
}
with patch("src.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_logs_without_user_jwt(self):
from src.agent.middleware import log_tool_event
event = {
"event": "on_tool_start",
"name": "test_tool",
"data": {"input": {}},
}
with patch("src.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_handles_missing_data_key(self):
from src.agent.middleware import log_tool_event
event = {"event": "on_tool_start", "name": "test_tool"}
with patch("src.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_handles_unknown_event_kind(self):
from src.agent.middleware import log_tool_event
event = {"event": "on_custom_event", "name": "custom"}
with patch("src.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_truncates_long_input(self):
from src.agent.middleware import log_tool_event
long_input = "x" * 1000
event = {
"event": "on_tool_start",
"name": "big_tool",
"data": {"input": long_input},
}
with patch("src.agent.middleware.get_user_jwt", return_value="token"):
await log_tool_event(event, "conv-1")
# #endregion test_log_tool_event
# #endregion Test.AgentChat.Middleware

View File

@@ -1,130 +0,0 @@
# #region TestAgentChat.ModelFixtures [C:2] [TYPE Module] [SEMANTICS test,agent,fixtures,model]
# @BRIEF Materialize model fixtures from JSON — verify fixture structure matches expected.
# @RELATION BINDS_TO -> [AgentChat.Model]
import json
from pathlib import Path
import pytest
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "specs" / "033-gradio-agent-chat" / "fixtures" / "model"
# #region TestAgentChat.ModelFixtures.SendMessageValid [C:2] [TYPE Function] [SEMANTICS test,fixture,send]
# @BRIEF FX_AgentChat.Model.SendMessage.Valid — verify fixture structure.
def test_fixture_send_message_valid():
"""Load send_message_valid fixture and verify structural integrity."""
fixture_path = FIXTURES_DIR / "send_message_valid.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.SendMessage.Valid"
assert fixture["verifies"] == "AgentChat.Model"
assert fixture["invariant"] == "StreamingStateGatesInput"
assert fixture["input"]["action"] == "sendMessage"
assert fixture["input"]["state_before"]["streamingState"] == "idle"
assert fixture["expected"]["state_after"]["streamingState"] == "streaming"
assert fixture["expected"]["state_after"]["isInputLocked"] is True
assert fixture["expected"]["state_after"]["error"] is None
# #endregion TestAgentChat.ModelFixtures.SendMessageValid
# #region TestAgentChat.ModelFixtures.CancelGeneration [C:2] [TYPE Function] [SEMANTICS test,fixture,cancel]
# @BRIEF FX_AgentChat.Model.CancelGeneration — verify cancel fixture structure.
def test_fixture_cancel_generation():
"""Load cancel_generation fixture and verify structural integrity."""
fixture_path = FIXTURES_DIR / "cancel_generation.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.CancelGeneration"
assert fixture["edge"] == "cancel_during_streaming"
assert fixture["input"]["action"] == "cancelGeneration"
expected = fixture["expected"]["state_after"]
assert expected["streamingState"] == "idle"
assert expected["isInputLocked"] is False
assert "partialText" in expected
# #endregion TestAgentChat.ModelFixtures.CancelGeneration
# #region TestAgentChat.ModelFixtures.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,fixture,confirm]
# @BRIEF FX_AgentChat.Model.ResumeConfirm — verify resume confirm fixture.
def test_fixture_resume_confirm():
"""Load resume_confirm fixture and verify structural integrity."""
fixture_path = FIXTURES_DIR / "resume_confirm.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.ResumeConfirm"
assert fixture["edge"] == "confirm_from_awaiting"
assert fixture["input"]["action"] == "resumeConfirm"
assert fixture["input"]["args"] == ["confirm"]
expected = fixture["expected"]["state_after"]
assert expected["streamingState"] == "streaming"
assert expected["pendingThreadId"] is None
# #endregion TestAgentChat.ModelFixtures.ResumeConfirm
# #region TestAgentChat.ModelFixtures.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,fixture,deny]
# @BRIEF FX_AgentChat.Model.ResumeDeny — verify resume deny fixture.
def test_fixture_resume_deny():
"""Load resume_deny fixture and verify structural integrity."""
fixture_path = FIXTURES_DIR / "resume_deny.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.ResumeDeny"
assert fixture["edge"] == "deny_from_awaiting"
assert fixture["input"]["action"] == "resumeConfirm"
assert fixture["input"]["args"] == ["deny"]
expected = fixture["expected"]["state_after"]
assert expected["streamingState"] == "idle"
assert expected["pendingThreadId"] is None
# #endregion TestAgentChat.ModelFixtures.ResumeDeny
# #region TestAgentChat.ModelFixtures.RejectedWebSocket [C:2] [TYPE Function] [SEMANTICS test,fixture,rejected]
# @BRIEF FX_AgentChat.Model.RejectedPath — verify no WebSocket resurrection.
def test_fixture_rejected_websocket():
"""Verify no WebSocket resurrection in AgentChatModel."""
fixture_path = FIXTURES_DIR / "rejected_websocket.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.RejectedPath"
assert fixture["invariant"] == "NoWebSocketImports"
assert fixture["edge"] == "rejected_path"
assert "No 'WebSocket'" in fixture["expected"]["assertions"][0]
# Read the actual model source
model_path = Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "src" / "lib" / "models" / "AgentChatModel.svelte.ts"
source = model_path.read_text()
# Verify no WebSocket imports (not just the word — it's in comments as @REJECTED)
assertions = fixture["expected"]["assertions"]
for assertion in assertions:
if "WebSocket" in assertion:
# Check for actual WebSocket import (import WebSocket, from ... import ... WebSocket)
for line in source.split("\n"):
stripped = line.strip()
if stripped.startswith("import") and "WebSocket" in stripped:
pytest.fail(f"FAIL: {assertion} — found WebSocket import: {stripped}")
if stripped.startswith("from") and "WebSocket" in stripped:
pytest.fail(f"FAIL: {assertion} — found WebSocket import: {stripped}")
if "tabRole" in assertion:
assert "tabRole" not in source, f"FAIL: {assertion}"
if "follower_notify" in assertion:
assert "follower_notify" not in source, f"FAIL: {assertion}"
if "takeoverSession" in assertion:
assert "takeoverSession" not in source, f"FAIL: {assertion}"
# #endregion TestAgentChat.ModelFixtures.RejectedWebSocket
# #endregion TestAgentChat.ModelFixtures

View File

@@ -1,252 +0,0 @@
# #region Test.AgentChat.Run [C:3] [TYPE Module] [SEMANTICS test,agent,run,entrypoint]
# @BRIEF Tests for agent/run.py — _find_free_port and _fetch_llm_config.
# @RELATION BINDS_TO -> [AgentChat.Run]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import socket
from unittest.mock import MagicMock, patch
import pytest
# #region test_find_free_port [C:2] [TYPE Function]
# @BRIEF Test _find_free_port for port scanning behavior.
class TestFindFreePort:
def test_returns_free_port(self):
from src.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
result = _find_free_port(8000, 10)
assert result == 8000
mock_instance.bind.assert_called_once_with(("", 8000))
def test_skips_busy_ports(self):
from src.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
# Ports 8000-8002 busy, 8003 free
mock_instance.bind.side_effect = [
OSError("Address in use"), # 8000
OSError("Address in use"), # 8001
OSError("Address in use"), # 8002
None, # 8003 — success
]
result = _find_free_port(8000, 10)
assert result == 8003
assert mock_instance.bind.call_count == 4
def test_raises_when_all_busy(self):
from src.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
mock_instance.bind.side_effect = OSError("Address in use")
with pytest.raises(OSError, match="No free port found"):
_find_free_port(8000, 3)
assert mock_instance.bind.call_count == 3
# #endregion test_find_free_port
# #region test_fetch_llm_config [C:2] [TYPE Function]
# @BRIEF Test _fetch_llm_config with retry and fallback behavior.
class TestFetchLlmConfig:
def test_returns_config_on_success(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"configured": True, "provider_type": "openai", "default_model": "gpt-4o"}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is not None
assert result["configured"] is True
def test_returns_none_when_not_configured(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"configured": False, "reason": "no provider"}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is None
def test_retries_on_failure(self):
from src.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_get.side_effect = Exception("Connection refused")
result = _fetch_llm_config()
assert result is None
assert mock_get.call_count == 6
def test_retries_then_returns_config(self):
from src.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_get.side_effect = [
Exception("Timeout"), # Attempt 1
Exception("Timeout"), # Attempt 2
MagicMock(json=lambda: {"configured": True, "provider_type": "openai"}), # Attempt 3
]
result = _fetch_llm_config()
assert result is not None
assert result["configured"] is True
def test_returns_none_after_max_retries_with_http_error(self):
from src.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is None
assert mock_get.call_count == 6
def test_uses_service_token_header(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get, \
patch("src.agent.run.SERVICE_JWT", "test-token"):
mock_response = MagicMock()
mock_response.json.return_value = {"configured": True}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is not None
# Verify Authorization header was sent
call_kwargs = mock_get.call_args[1]
assert call_kwargs["headers"].get("Authorization") == "Bearer test-token"
# #endregion test_fetch_llm_config
# #region test_main_block [C:2] [TYPE Function]
# @BRIEF Test if __name__ == '__main__' block — service JWT, LLM config, port fallback, OSError.
class TestMainBlock:
"""Test the if __name__ == '__main__' entry point block via importlib.util fresh module."""
def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False,
port_bind_sequence=None, port_always_fail=False):
"""Execute run.py as __main__ with given mocking configuration."""
import importlib
import importlib.util
import os
from pathlib import Path
run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py"
spec = importlib.util.spec_from_file_location("__main__", str(run_path))
# Apply env overrides
env_overrides = env_overrides or {}
for k, v in env_overrides.items():
monkeypatch.setenv(k, v)
# Reload _config to pick up env var changes (module is cached otherwise)
import src.agent._config as agent_config
importlib.reload(agent_config)
svc_jwt = env_overrides.get("SERVICE_JWT", os.environ.get("SERVICE_JWT", ""))
gradio_port = int(env_overrides.get("GRADIO_SERVER_PORT", os.environ.get("GRADIO_SERVER_PORT", "7860")))
gradio_fallback = env_overrides.get("GRADIO_ALLOW_PORT_FALLBACK", os.environ.get("GRADIO_ALLOW_PORT_FALLBACK", "false")).lower() in ("1", "true", "yes")
with patch('httpx.get') as mock_httpx_get, \
patch('socket.socket') as mock_socket_cls, \
patch('asyncio.run') as mock_asyncio_run, \
patch('src.agent.app.create_chat_interface') as mock_create_ci, \
patch('src.agent.context.set_service_jwt') as mock_set_jwt, \
patch('src.agent.langgraph_setup.configure_from_api') as mock_configure, \
patch('src.agent.langgraph_setup.init_checkpointer'), \
patch('src.agent.run.SERVICE_JWT', svc_jwt), \
patch('src.agent.run.GRADIO_SERVER_PORT', gradio_port), \
patch('src.agent.run.GRADIO_ALLOW_PORT_FALLBACK', gradio_fallback):
mock_asyncio_run.side_effect = lambda coro: coro.close() if hasattr(coro, "close") else None
# httpx for _fetch_llm_config
mock_resp = MagicMock()
if llm_configured:
mock_resp.json.return_value = {
"configured": True, "provider_type": "openai",
"default_model": "gpt-4o", "api_key": "sk-test",
}
else:
mock_resp.json.return_value = {"configured": False}
mock_httpx_get.return_value = mock_resp
# socket for _find_free_port
mock_sock = MagicMock()
mock_sock.__enter__.return_value = mock_sock
mock_socket_cls.return_value = mock_sock
if port_always_fail:
mock_sock.bind.side_effect = OSError("all ports busy")
elif port_bind_sequence is not None:
mock_sock.bind.side_effect = port_bind_sequence
else:
mock_sock.bind.side_effect = [None] # first bind succeeds
mock_demo = MagicMock()
mock_create_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return {
'set_jwt': mock_set_jwt,
'configure': mock_configure,
'demo': mock_demo,
}
def test_main_block_basic(self, monkeypatch):
"""Main block with default env, no SERVICE_JWT, no LLM config."""
# Ensure SERVICE_JWT is NOT set (some tests leak it via os.environ)
monkeypatch.delenv("SERVICE_JWT", raising=False)
result = self._run_as_main(monkeypatch,
env_overrides={"GRADIO_SERVER_PORT": "27860"})
result['set_jwt'].assert_not_called()
result['configure'].assert_not_called()
result['demo'].launch.assert_called_once()
def test_main_block_with_service_jwt(self, monkeypatch):
"""Main block sets service JWT via ContextVar."""
result = self._run_as_main(monkeypatch, env_overrides={
"SERVICE_JWT": "test-service-token",
"GRADIO_SERVER_PORT": "27861",
})
result['set_jwt'].assert_called_once_with("test-service-token")
def test_main_block_with_llm_config(self, monkeypatch):
"""Main block calls configure_from_api when LLM config is active."""
result = self._run_as_main(monkeypatch,
env_overrides={"GRADIO_SERVER_PORT": "27862"},
llm_configured=True)
result['configure'].assert_called_once()
def test_main_block_port_fallback(self, monkeypatch):
"""Port in use triggers fallback warning (logged but continues)."""
# Ports 27863, 27864 busy → 27865 free
with patch('src.agent.run.logger') as mock_logger:
result = self._run_as_main(monkeypatch,
env_overrides={
"GRADIO_SERVER_PORT": "27863",
"GRADIO_ALLOW_PORT_FALLBACK": "true",
},
port_bind_sequence=[OSError("in use"), OSError("in use"), None])
result['demo'].launch.assert_called_once()
def test_main_block_port_oserror(self, monkeypatch):
"""OSError during port finding raises in main block."""
with patch('src.agent.run.logger') as mock_logger:
with pytest.raises(OSError):
self._run_as_main(monkeypatch,
env_overrides={
"GRADIO_SERVER_PORT": "27866",
"GRADIO_ALLOW_PORT_FALLBACK": "true",
},
port_always_fail=True)
# #endregion test_main_block
# #endregion Test.AgentChat.Run

View File

@@ -1,178 +0,0 @@
# #region Test.AgentChat.ToolRetry [C:3] [TYPE Module] [SEMANTICS test,agent,tools,retry]
# @BRIEF Contract tests for _retry_read_tool — fixed-delay retry on transient errors.
# @RELATION BINDS_TO -> [AgentChat.Tools.Retry]
# @TEST_EDGE: first_attempt_502 -> Auto-retries once, succeeds.
# @TEST_EDGE: both_attempts_502 -> Raises original error.
# @TEST_EDGE: write_tool_502 -> No retry, raises immediately.
# @TEST_EDGE: connect_error -> Retries on ConnectError too.
# @TEST_EDGE: read_timeout -> Retries on ReadTimeout too.
import pytest
from unittest.mock import AsyncMock, patch
import httpx
from src.agent.tools import (
_execute_with_timeout,
_retry_read_tool,
drain_tool_retry_events,
start_tool_retry_event_buffer,
)
# ── Shared fixtures ──────────────────────────────────────────────────
def _make_502_error() -> httpx.HTTPStatusError:
"""Build a synthetic 502 HTTPStatusError for consistent test usage."""
request = httpx.Request("GET", "http://test.local/api/test")
response = httpx.Response(502, request=request)
return httpx.HTTPStatusError("Bad Gateway", request=request, response=response)
def _make_connect_error() -> httpx.ConnectError:
"""Build a synthetic ConnectError for transient-connectivity tests."""
return httpx.ConnectError("Connection refused")
def _make_read_timeout() -> httpx.ReadTimeout:
"""Build a synthetic ReadTimeout for transient-timeout tests."""
return httpx.ReadTimeout("Read timed out")
# ── Tests ────────────────────────────────────────────────────────────
class TestRetryReadTool:
"""Contract tests for _retry_read_tool — the fixed-delay retry wrapper."""
# #region test_first_attempt_502_retries_once [C:2] [TYPE Function]
# @BRIEF First attempt raises 502 → retries once → second attempt succeeds.
async def test_first_attempt_502_retries_once(self):
"""Prove @TEST_EDGE first_attempt_502: one retry + 1s delay → success."""
expected = {"status": "ok", "data": [1, 2, 3]}
error_502 = _make_502_error()
mock_fn = AsyncMock(side_effect=[error_502, expected])
start_tool_retry_event_buffer()
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
result = await _retry_read_tool("read_dashboards", mock_fn)
assert result == expected
assert mock_fn.call_count == 2
mock_sleep.assert_awaited_once_with(1)
assert drain_tool_retry_events() == [{
"content": "🔁 read_dashboards retry 1/2",
"metadata": {
"type": "tool_retry",
"tool": "read_dashboards",
"attempt": 1,
"max_attempts": 2,
},
}]
# #endregion test_first_attempt_502_retries_once
# #region test_both_attempts_502_raises [C:2] [TYPE Function]
# @BRIEF Both attempts raise 502 → exhaust retries → raises original error.
async def test_both_attempts_502_raises(self):
"""Prove @TEST_EDGE both_attempts_502: max 2 attempts, then raise."""
error_502 = _make_502_error()
mock_fn = AsyncMock(side_effect=[error_502, error_502])
with patch("asyncio.sleep", new_callable=AsyncMock), pytest.raises(httpx.HTTPStatusError) as exc_info:
await _retry_read_tool("read_dashboards", mock_fn)
assert exc_info.value is error_502
assert mock_fn.call_count == 2
# #endregion test_both_attempts_502_raises
# #region test_connect_error_retried [C:2] [TYPE Function]
# @BRIEF ConnectError is also retried — not just HTTP status errors.
async def test_connect_error_retried(self):
"""Prove ConnectError triggers the retry path."""
conn_err = _make_connect_error()
expected = "recovered_after_connect_error"
mock_fn = AsyncMock(side_effect=[conn_err, expected])
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await _retry_read_tool("read_some_tool", mock_fn)
assert result == expected
assert mock_fn.call_count == 2
# #endregion test_connect_error_retried
# #region test_read_timeout_retried [C:2] [TYPE Function]
# @BRIEF ReadTimeout is also retried — transient I/O timeouts are recoverable.
async def test_read_timeout_retried(self):
"""Prove ReadTimeout triggers the retry path."""
timeout_err = _make_read_timeout()
expected = "recovered_after_timeout"
mock_fn = AsyncMock(side_effect=[timeout_err, expected])
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await _retry_read_tool("read_big_dataset", mock_fn)
assert result == expected
assert mock_fn.call_count == 2
# #endregion test_read_timeout_retried
# #region test_retry_skips_delay_on_success [C:2] [TYPE Function]
# @BRIEF When first attempt succeeds, no sleep occurs at all.
async def test_retry_skips_delay_on_success(self):
"""Prove that the happy path never sleeps — sleep is only for retries."""
expected = {"result": "immediate"}
mock_fn = AsyncMock(return_value=expected)
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
result = await _retry_read_tool("fast_tool", mock_fn)
assert result == expected
assert mock_fn.call_count == 1
mock_sleep.assert_not_awaited()
# #endregion test_retry_skips_delay_on_success
# #region test_non_http_error_not_retried [C:2] [TYPE Function]
# @BRIEF Non-HTTP errors (e.g. ValueError) propagate immediately — no retry.
async def test_non_http_error_not_retried(self):
"""Prove that only the three specific httpx exception types are retried."""
non_http_err = ValueError("something broken in business logic")
mock_fn = AsyncMock(side_effect=non_http_err)
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, pytest.raises(ValueError) as exc_info:
await _retry_read_tool("broken_tool", mock_fn)
assert exc_info.value is non_http_err
assert mock_fn.call_count == 1
mock_sleep.assert_not_awaited()
# #endregion test_non_http_error_not_retried
class TestWriteToolNoRetry:
"""Prove that write tools bypass _retry_read_tool entirely."""
# #region test_write_tool_502_no_retry [C:2] [TYPE Function]
# @BRIEF Write tool (is_write=True) gets 502 → no retry, raises immediately.
async def test_write_tool_502_raises_immediately(self):
"""Prove @TEST_EDGE write_tool_502: _execute_with_timeout does NOT retry writes.
_post() calls _execute_with_timeout with is_write=True and the raw _request
function — never _retry_read_tool. This test ensures that layer propagates
errors immediately without any retry loop.
"""
error_502 = _make_502_error()
write_op = AsyncMock(side_effect=error_502)
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, pytest.raises(httpx.HTTPStatusError) as exc_info:
await _execute_with_timeout(
"create_dashboard",
write_op,
is_write=True,
timeout_s=5,
)
assert exc_info.value is error_502
assert write_op.call_count == 1
# Critical invariant: no sleep = no retry loop entered
mock_sleep.assert_not_awaited()
# #endregion test_write_tool_502_no_retry
# #endregion Test.AgentChat.ToolRetry

View File

@@ -1,73 +0,0 @@
# #region Test.AgentChat.ToolSummarise [C:3] [TYPE Module] [SEMANTICS test,agent,tools,summarise]
# @BRIEF Contract tests for _summarise_response — structured truncation.
# @RELATION BINDS_TO -> [AgentChat.Tools.Summarise]
# @TEST_EDGE: json_array_50_items -> Large JSON arrays summarised as top-5 + count.
# @TEST_EDGE: short_text_passthrough -> Text ≤ limit returned unchanged.
# @TEST_EDGE: json_object_keys_sample -> Large JSON objects summarised as keys + sample.
# @TEST_EDGE: non_json_sentence_boundary -> Non-JSON text truncated at sentence boundary.
import json
from src.agent.tools import _summarise_response
# #region test_summarise_json_array_50_items [C:2] [TYPE Function]
# @BRIEF JSON array with 50 items → top-5 summary with remaining count.
def test_summarise_json_array_50_items():
"""Large JSON arrays summarise with top-5 items and remaining count."""
items = [{"id": i, "name": f"item-{i}"} for i in range(50)]
text = json.dumps(items)
summary = _summarise_response(text, limit=200)
assert summary.startswith("Found 50 items:")
assert "item-0" in summary
assert "item-4" in summary
assert "... and 45 more items." in summary
# #endregion test_summarise_json_array_50_items
# #region test_summarise_short_text_passthrough [C:2] [TYPE Function]
# @BRIEF Text ≤ limit is returned unchanged (no truncation, no JSON parse overhead visible).
def test_summarise_short_text_passthrough():
"""Text within limit is returned unchanged."""
text = "This is a short response."
result = _summarise_response(text, limit=100)
assert result == text
# #endregion test_summarise_short_text_passthrough
# #region test_summarise_json_object_keys_sample [C:2] [TYPE Function]
# @BRIEF Large JSON object → key list + sample values.
def test_summarise_json_object_keys_sample():
"""Large JSON objects are summarised with keys and sample values."""
data = {f"key_{i}": f"value_{i}" * 50 for i in range(20)}
text = json.dumps(data)
summary = _summarise_response(text, limit=100)
assert summary.startswith("Result keys: ")
assert "Sample: " in summary
# #endregion test_summarise_json_object_keys_sample
# #region test_summarise_non_json_sentence_boundary [C:2] [TYPE Function]
# @BRIEF Non-JSON long text truncated at last sentence boundary before limit.
def test_summarise_non_json_sentence_boundary():
"""Non-JSON text truncates at last sentence boundary with trailing ellipsis."""
text = ("This is sentence one. " * 100)
summary = _summarise_response(text, limit=500)
# Must end with ellipsis
assert summary.endswith("...")
# Must be shorter than or equal to limit + 3 (ellipsis)
assert len(summary) <= 500
# Must retain at least one sentence boundary before the ellipsis
assert ". " in summary[:-3]
# #endregion test_summarise_non_json_sentence_boundary
# #endregion Test.AgentChat.ToolSummarise

View File

@@ -1,87 +0,0 @@
# #region Test.AgentChat.ToolTimeout [C:3] [TYPE Module] [SEMANTICS test,agent,tools,timeout]
# @BRIEF Contract tests for _execute_with_timeout — configurable timeout wrapper.
# @RELATION BINDS_TO -> [AgentChat.Tools.Timeout]
# @TEST_EDGE: complete_under_timeout -> Tool completes normally, returns result
# @TEST_EDGE: read_tool_timeout -> Read tool exceeds timeout, raises TimeoutError
# @TEST_EDGE: write_tool_timeout -> Write tool exceeds timeout, raises TimeoutError
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
# ── Tests ───────────────────────────────────────────────────────────
# #region test_completes_under_timeout [C:2] [TYPE Function]
# @BRIEF GIVEN a tool that returns quickly WHEN _execute_with_timeout is called with a 30s timeout THEN the result is returned normally.
@pytest.mark.asyncio
async def test_completes_under_timeout():
"""Tool returns its result before the timeout expires."""
from src.agent.tools import _execute_with_timeout
expected = {"status": "ok", "data": [1, 2, 3]}
fast_fn = AsyncMock(return_value=expected)
with patch("src.agent.tools.logger.explore") as mock_explore:
result = await _execute_with_timeout("fast_tool", fast_fn, is_write=False, timeout_s=30)
assert result == expected
fast_fn.assert_called_once()
mock_explore.assert_not_called()
# #endregion test_completes_under_timeout
# #region test_read_tool_timeout [C:2] [TYPE Function]
# @BRIEF GIVEN a read tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised and logger.explore is invoked.
@pytest.mark.asyncio
async def test_read_tool_timeout():
"""Read tool that sleeps longer than timeout_s must raise TimeoutError."""
from src.agent.tools import _execute_with_timeout
async def slow_read():
await asyncio.sleep(0.3)
return "never_reached"
with patch("src.agent.tools.logger.explore") as mock_explore:
with pytest.raises(TimeoutError):
await _execute_with_timeout("slow_read", slow_read, is_write=False, timeout_s=0.05)
mock_explore.assert_called_once()
call_args = mock_explore.call_args
assert call_args[0][0] == "Tool timeout"
payload = call_args[1]["payload"]
assert payload["tool"] == "slow_read"
assert payload["timeout_s"] == 0.05
assert payload["is_write"] is False
assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout"
# #endregion test_read_tool_timeout
# #region test_write_tool_timeout [C:2] [TYPE Function]
# @BRIEF GIVEN a write tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised with is_write=True logged.
@pytest.mark.asyncio
async def test_write_tool_timeout():
"""Write tool that sleeps longer than timeout_s must raise TimeoutError."""
from src.agent.tools import _execute_with_timeout
async def slow_write():
await asyncio.sleep(0.3)
return "never_reached"
with patch("src.agent.tools.logger.explore") as mock_explore:
with pytest.raises(TimeoutError):
await _execute_with_timeout("slow_write", slow_write, is_write=True, timeout_s=0.05)
mock_explore.assert_called_once()
call_args = mock_explore.call_args
assert call_args[0][0] == "Tool timeout"
payload = call_args[1]["payload"]
assert payload["tool"] == "slow_write"
assert payload["timeout_s"] == 0.05
assert payload["is_write"] is True
assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout"
# #endregion test_write_tool_timeout
# #endregion Test.AgentChat.ToolTimeout