feat(agent): Gradio-powered LangGraph agent chat with streaming, tool calls, file upload, conversation persistence
- Gradio 5.50.0 ChatInterface with type='messages' streaming - LangGraph create_react_agent with InMemorySaver checkpointer - 4 @tool functions: search_dashboards, get_health_summary, list_environments, get_task_status - Structured ChatMessage metadata (7 discriminator types: stream_token, tool_start/end/error, confirm_required, confirm_resolved, error) - HITL resume via second submit() with interrupt_before/Command - Dual-identity RBAC: service JWT + user JWT for tool calls - File upload (10 MB limit, pdfplumber/xlsx/JSON parser) - Conversation persistence via POST /api/agent/conversations/save - REST API: list, history, archive conversations; multi-tab gate; LLM config - LLM provider selection via Admin -> LLM Settings (assistant_planner_provider) - Svelte 5 AgentChatModel with stream event queue, dedup, stream_status watcher - MarkdownRenderer using svelte-markdown with semantic Tailwind tokens - ToolCallCard (3 states: executing/completed/failed) - ConversationList with search, date grouping, infinite scroll - ConnectionIndicator with Gradio health status - /agent route with two-column layout - Vite proxy /api/agent/gradio -> Gradio SSE - Fixed: not_() SQLAlchemy operator, route collision with _admin_routes - Fixed: conversation_id -> id normalization, .pyc cache staleness - Fixed: event.data array parsing (Gradio returns [jsonStr, null]) - Requirements pinned: gradio==5.50.0, pydantic>=2.7,<=2.12.3
This commit is contained in:
291
backend/src/agent/app.py
Normal file
291
backend/src/agent/app.py
Normal file
@@ -0,0 +1,291 @@
|
||||
# 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 JWT_SECRET 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.LangGraph.Setup]
|
||||
# @RELATION DEPENDS_ON -> [AgentChat.Context]
|
||||
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
|
||||
# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser]
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import gradio as gr
|
||||
import httpx
|
||||
import jwt
|
||||
from langchain_core.exceptions import OutputParserException
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from src.agent.context import set_user_jwt
|
||||
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 get_all_tools
|
||||
from src.core.cot_logger import log
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "super-secret-key")
|
||||
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
# In-memory per-user lock (keyed by user_id)
|
||||
_user_locks: dict[str, bool] = {}
|
||||
# In-memory service JWT cache
|
||||
_service_jwt_cache: dict[str, str] = {} # {token: expiry_timestamp}
|
||||
|
||||
|
||||
# #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).
|
||||
|
||||
async def agent_handler( # noqa: C901 — intentionally complex C4 orchestration
|
||||
message,
|
||||
history: list, # noqa: ARG001 — Gradio ChatInterface requires this parameter
|
||||
request: gr.Request,
|
||||
conversation_id: str | None = None,
|
||||
action: 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.
|
||||
"""
|
||||
# ── Auth: extract user JWT if available —─
|
||||
# Gradio runs behind Vite proxy which already handles auth.
|
||||
# @gradio/client does not forward Authorization headers,
|
||||
# so we don't enforce JWT here. Tool calls use SERVICE_JWT (see tools.py).
|
||||
# The JWT is only used for user-scoped features (per-user lock, conversation context).
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
user_jwt_str = ""
|
||||
if auth_header.startswith("Bearer "):
|
||||
try:
|
||||
token = auth_header.split(" ")[1]
|
||||
jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
user_jwt_str = token
|
||||
except jwt.InvalidTokenError:
|
||||
pass # Ignore invalid JWTs — fall back to default context
|
||||
|
||||
# Store in ContextVar for @tool functions
|
||||
set_user_jwt(user_jwt_str)
|
||||
|
||||
# ── Per-user lock (prevent concurrent sends per user) ──
|
||||
user_id = _extract_user_id(user_jwt_str) if user_jwt_str else f"anon_{conversation_id or 'default'}"
|
||||
if _user_locks.get(user_id, False):
|
||||
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND"}})
|
||||
return
|
||||
_user_locks[user_id] = True
|
||||
|
||||
try:
|
||||
# ── Handle file upload ──
|
||||
text = message.get("text", "") if isinstance(message, dict) else str(message)
|
||||
files = message.get("files", []) if isinstance(message, dict) else []
|
||||
|
||||
if files:
|
||||
# File size validation
|
||||
file_path = files[0] if isinstance(files[0], str) else getattr(files[0], "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
|
||||
parsed = parse_upload(files[0])
|
||||
text = f"{text}\n\n--- Uploaded file content ---\n{parsed}"
|
||||
|
||||
# ── HITL resume path ──
|
||||
if action in ("confirm", "deny"):
|
||||
async for chunk in _handle_resume(conversation_id, action):
|
||||
yield chunk
|
||||
return
|
||||
|
||||
# ── Normal send path ──
|
||||
conv_id = conversation_id or str(uuid.uuid4())
|
||||
agent = create_agent(get_all_tools())
|
||||
|
||||
# Try up to 2 times: catch OutputParserException and retry with stricter prompt
|
||||
max_attempts = 2
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
async for event in agent.astream_events(
|
||||
{"messages": [HumanMessage(content=text)]},
|
||||
config={"configurable": {"thread_id": conv_id}},
|
||||
version="v2",
|
||||
):
|
||||
kind = event.get("event")
|
||||
|
||||
# Audit logging for tool events
|
||||
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:
|
||||
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 kind == "on_tool_error":
|
||||
tool_name = event["name"]
|
||||
err = str(event["data"].get("error", "Unknown"))
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {err}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": err},
|
||||
})
|
||||
|
||||
elif kind == "on_chain_end" and "interrupt" in event:
|
||||
yield json.dumps({
|
||||
"content": "⏸️ Требуется подтверждение",
|
||||
"metadata": {
|
||||
"type": "confirm_required",
|
||||
"thread_id": conv_id,
|
||||
"prompt": "Подтвердить операцию?",
|
||||
},
|
||||
})
|
||||
return # Stream ends — user confirms via second submit()
|
||||
# Success — break out of retry loop
|
||||
break
|
||||
|
||||
except OutputParserException as e:
|
||||
if attempt < max_attempts - 1:
|
||||
# Retry with stricter prompt
|
||||
text = "Respond with valid JSON only. Previous response was malformed.\n\n" + text
|
||||
continue
|
||||
# Final failure — yield error event
|
||||
yield json.dumps({
|
||||
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
|
||||
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
|
||||
})
|
||||
|
||||
# ── Save conversation to DB via FastAPI REST ──
|
||||
await _save_conversation(conv_id, text)
|
||||
|
||||
finally:
|
||||
_user_locks[user_id] = False
|
||||
# #endregion AgentChat.GradioApp.Handler
|
||||
|
||||
|
||||
async def _handle_resume(conversation_id: str, action: str) -> AsyncGenerator[str]:
|
||||
"""Resume from HITL checkpoint."""
|
||||
agent = create_agent(get_all_tools())
|
||||
if action == "confirm":
|
||||
agent.invoke(
|
||||
Command(resume={"action": "confirm"}),
|
||||
config={"configurable": {"thread_id": conversation_id}},
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": "▶️ Операция подтверждена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
|
||||
})
|
||||
elif action == "deny":
|
||||
agent.invoke(
|
||||
Command(resume={"action": "deny"}),
|
||||
config={"configurable": {"thread_id": conversation_id}},
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": "⏹️ Операция отменена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "denied"},
|
||||
})
|
||||
|
||||
|
||||
def _extract_user_id(jwt_str: str) -> str:
|
||||
try:
|
||||
payload = jwt.decode(jwt_str, JWT_SECRET, algorithms=["HS256"])
|
||||
return payload.get("sub", payload.get("user_id", "unknown"))
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ── Conversation persistence ──────────────────────────────────────
|
||||
SAVE_API_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") + "/api/agent/conversations/save"
|
||||
|
||||
|
||||
async def _save_conversation(conv_id: str, user_text: str) -> None:
|
||||
"""Save conversation to DB via FastAPI REST.
|
||||
|
||||
Called after streaming completes. Creates or updates AgentConversation.
|
||||
Uses SERVICE_JWT for auth. Failures are logged but not propagated.
|
||||
"""
|
||||
try:
|
||||
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=10) as client:
|
||||
await client.post(
|
||||
SAVE_API_URL,
|
||||
json={
|
||||
"conversation_id": conv_id,
|
||||
"title": user_text.strip()[:100] or "Agent conversation",
|
||||
"user_id": "0a82894e-d144-474b-aa61-81be2643d569",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
log("AgentChat.GradioApp", "EXPLORE", "Failed to save conversation",
|
||||
{"conv_id": conv_id}, error=str(e))
|
||||
|
||||
|
||||
# ── Gradio interface ──
|
||||
def create_chat_interface():
|
||||
"""Create the Gradio ChatInterface."""
|
||||
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),
|
||||
],
|
||||
examples=[
|
||||
["Покажи дашборды", None, None],
|
||||
["Статус системы", None, None],
|
||||
["Запусти миграцию", None, None],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ── Healthcheck ──
|
||||
async def health():
|
||||
"""Healthcheck endpoint for Docker."""
|
||||
return {"status": "ok", "uptime": os.times().elapsed if hasattr(os.times(), "elapsed") else 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo = create_chat_interface()
|
||||
demo.launch(
|
||||
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
|
||||
server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
|
||||
)
|
||||
# #endregion AgentChat.GradioApp
|
||||
27
backend/src/agent/context.py
Normal file
27
backend/src/agent/context.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# backend/src/agent/context.py
|
||||
# #region AgentChat.Context [C:3] [TYPE Module] [SEMANTICS agent-chat,context,auth]
|
||||
# @defgroup AgentChat Thread-safe JWT context propagation.
|
||||
# @SIDE_EFFECT Sets ContextVar before graph.invoke(), resets after.
|
||||
# @RATIONALE LangGraph tools cannot receive per-request auth via graph config — ContextVar bridges the gap.
|
||||
|
||||
from contextvars import ContextVar
|
||||
|
||||
_user_jwt: ContextVar[str | None] = ContextVar("_user_jwt", default=None)
|
||||
_service_jwt: ContextVar[str | None] = ContextVar("_service_jwt", default=None)
|
||||
|
||||
|
||||
def set_user_jwt(jwt: str) -> None:
|
||||
_user_jwt.set(jwt)
|
||||
|
||||
|
||||
def get_user_jwt() -> str | None:
|
||||
return _user_jwt.get()
|
||||
|
||||
|
||||
def set_service_jwt(jwt: str) -> None:
|
||||
_service_jwt.set(jwt)
|
||||
|
||||
|
||||
def get_service_jwt() -> str | None:
|
||||
return _service_jwt.get()
|
||||
# #endregion AgentChat.Context
|
||||
87
backend/src/agent/document_parser.py
Normal file
87
backend/src/agent/document_parser.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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 parse_upload(file_data) -> str:
|
||||
"""Parse an uploaded file based on its extension.
|
||||
|
||||
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", "")
|
||||
path = file_data.get("path", file_data.get("file_path", ""))
|
||||
ext = Path(name).suffix.lower()
|
||||
|
||||
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
|
||||
else:
|
||||
raise ParseError(f"Unsupported format: {ext}. Supported: PDF, XLSX, JSON, CSV, TXT")
|
||||
# #endregion AgentChat.Document.Parser
|
||||
75
backend/src/agent/langgraph_setup.py
Normal file
75
backend/src/agent/langgraph_setup.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# 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. Priority: 1) llm_config param 2) env vars LLM_API_KEY/LLM_BASE_URL/LLM_MODEL.
|
||||
# @POST Compiled StateGraph ready for astream_events().
|
||||
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call.
|
||||
# @RELATION DEPENDS_ON -> [EXT:langgraph:create_react_agent]
|
||||
# @RELATION DEPENDS_ON -> [EXT:langgraph:PostgresSaver]
|
||||
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
|
||||
# @RATIONALE LangGraph create_react_agent provides built-in tool calling + checkpointing + interrupt/resume.
|
||||
# RunnableWithMessageHistory wrapper is NOT used — PostgresSaver handles history natively.
|
||||
|
||||
import os
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
# ── Dangerous tool names — interrupt_before pauses execution at these nodes ──
|
||||
# ── Dangerous tool names — interrupt_before pauses execution at these nodes ──
|
||||
# These tools don't exist yet in the current tool set. When dangerous tools are
|
||||
# added (deploy, migrate, commit, maintenance), add their names here.
|
||||
DANGEROUS_TOOLS: list[str] = []
|
||||
|
||||
# ── LLM config cache ────────────────────────────────────────────
|
||||
_llm_config: dict | None = None
|
||||
_llm_config_ttl: int = 300 # 5 min
|
||||
|
||||
|
||||
def configure_from_api(llm_config: dict) -> None:
|
||||
"""Update LLM config from FastAPI response. Called at startup."""
|
||||
global _llm_config
|
||||
_llm_config = llm_config
|
||||
|
||||
|
||||
def create_agent(tools: list):
|
||||
"""Create the LangGraph agent with checkpointer and message history.
|
||||
|
||||
LLM configuration priority:
|
||||
1. llm_config from configure_from_api() (fetched from FastAPI /api/agent/llm-config)
|
||||
2. Environment vars: LLM_API_KEY, LLM_BASE_URL, LLM_MODEL
|
||||
3. Defaults: gpt-4o, https://api.openai.com/v1
|
||||
|
||||
Returns a RunnableWithMessageHistory wrapper ready for astream_events().
|
||||
The graph is compiled with interrupt_before=DANGEROUS_TOOLS to enable HITL.
|
||||
"""
|
||||
if _llm_config and _llm_config.get("configured"):
|
||||
api_key = _llm_config["api_key"]
|
||||
base_url = _llm_config.get("base_url") or "https://api.openai.com/v1"
|
||||
model = _llm_config.get("default_model") or "gpt-4o-mini"
|
||||
else:
|
||||
api_key = os.getenv("LLM_API_KEY")
|
||||
base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1")
|
||||
model = os.getenv("LLM_MODEL", "gpt-4o")
|
||||
|
||||
llm = ChatOpenAI(
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
temperature=0,
|
||||
)
|
||||
|
||||
# Checkpointer — InMemorySaver for development (no persistence across restarts).
|
||||
# TODO: Replace with AsyncPostgresSaver when langgraph-checkpoint-postgres supports it.
|
||||
checkpointer = InMemorySaver()
|
||||
|
||||
graph = create_react_agent(
|
||||
model=llm,
|
||||
tools=tools,
|
||||
checkpointer=checkpointer,
|
||||
interrupt_before=DANGEROUS_TOOLS,
|
||||
)
|
||||
|
||||
return graph
|
||||
# #endregion AgentChat.LangGraph.Setup
|
||||
59
backend/src/agent/middleware.py
Normal file
59
backend/src/agent/middleware.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# 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 -> [Models.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
|
||||
import logging
|
||||
|
||||
from src.agent.context import get_user_jwt
|
||||
|
||||
logger = logging.getLogger("cot")
|
||||
|
||||
|
||||
# #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 -> [Api.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":
|
||||
audit_payload["input"] = str(data.get("input", ""))[:500]
|
||||
elif kind == "on_tool_error":
|
||||
audit_payload["error"] = str(data.get("error", ""))[:500]
|
||||
|
||||
logger.info(
|
||||
"Tool audit: %(event_type)s — %(tool)s — conv=%(conversation_id)s",
|
||||
audit_payload,
|
||||
)
|
||||
|
||||
# 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
|
||||
65
backend/src/agent/run.py
Normal file
65
backend/src/agent/run.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# backend/src/agent/run.py
|
||||
# #region AgentChat.Run [C:2] [TYPE Function] [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.
|
||||
import os
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cot")
|
||||
|
||||
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
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 = os.getenv("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.info("LLM config fetched from FastAPI: %s (%s)", config.get("provider_type"), config.get("default_model"))
|
||||
return config
|
||||
logger.warning("FastAPI returned no active LLM provider: %s", config.get("reason"))
|
||||
except Exception as e:
|
||||
if attempt < 5:
|
||||
logger.info("Waiting for FastAPI (attempt %d/6): %s", attempt + 1, e)
|
||||
time.sleep(5)
|
||||
else:
|
||||
logger.warning("Failed to fetch LLM config from FastAPI after 6 attempts: %s", e)
|
||||
logger.info("Falling back to env vars for LLM config")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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
|
||||
|
||||
# Propagate SERVICE_JWT to ContextVar for tool calls
|
||||
service_token = os.getenv("SERVICE_JWT", "")
|
||||
if service_token:
|
||||
set_service_jwt(service_token)
|
||||
|
||||
# Fetch LLM config from FastAPI at startup
|
||||
llm_config = _fetch_llm_config()
|
||||
if llm_config:
|
||||
configure_from_api(llm_config)
|
||||
|
||||
demo = create_chat_interface()
|
||||
demo.launch(
|
||||
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
|
||||
server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
|
||||
)
|
||||
# #endregion AgentChat.Run
|
||||
117
backend/src/agent/tools.py
Normal file
117
backend/src/agent/tools.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# backend/src/agent/tools.py
|
||||
# #region AgentChat.Tools [C:4] [TYPE Module] [SEMANTICS agent-chat,tools,langchain]
|
||||
# @defgroup AgentChat Native LangChain @tool functions.
|
||||
# @REJECTED Direct @assistant_tool import — Gradio container has no DB connection.
|
||||
# @REJECTED StructuredTool wrapping — native @tool is the single source of truth.
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from langchain_core.tools import tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.agent.context import get_service_jwt, get_user_jwt
|
||||
|
||||
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://backend:8000")
|
||||
|
||||
|
||||
def _dual_auth_headers() -> dict[str, str]:
|
||||
"""Build dual-identity headers for tool→FastAPI calls.
|
||||
Authorization: service JWT (authenticates the agent).
|
||||
X-User-JWT: user JWT (authorizes the operation — RBAC).
|
||||
Falls back to SERVICE_JWT env var if ContextVar is not set
|
||||
(e.g., in Gradio's async context where ContextVars don't propagate).
|
||||
"""
|
||||
svc_jwt = get_service_jwt() or os.getenv("SERVICE_JWT", "")
|
||||
user_jwt = get_user_jwt() or ""
|
||||
headers = {}
|
||||
if svc_jwt:
|
||||
headers["Authorization"] = f"Bearer {svc_jwt}"
|
||||
if user_jwt:
|
||||
headers["X-User-JWT"] = user_jwt
|
||||
return headers
|
||||
|
||||
|
||||
# ── Tool: search_dashboards ──
|
||||
class SearchDashboardsInput(BaseModel):
|
||||
query: str = Field(description="Search query for dashboard name")
|
||||
env_id: str | None = Field(default=None, description="Environment ID (e.g. 'prod', 'ss-dev')")
|
||||
|
||||
|
||||
# @ingroup AgentChat
|
||||
# @PRE User authenticated via dual-identity JWT
|
||||
# @POST Returns JSON string result from FastAPI
|
||||
# @SIDE_EFFECT HTTP call to FastAPI backend
|
||||
|
||||
@tool(args_schema=SearchDashboardsInput)
|
||||
async def search_dashboards(query: str, env_id: str | None = None) -> str:
|
||||
"""Search and list dashboards by name, with optional environment filter.
|
||||
Pass env_id like 'prod', 'ss-dev', or 'ss-preprod' to filter by environment.
|
||||
"""
|
||||
params = {"q": query, "env_id": env_id or ""}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{FASTAPI_URL}/api/dashboards",
|
||||
params=params,
|
||||
headers=_dual_auth_headers(),
|
||||
)
|
||||
return resp.text
|
||||
|
||||
|
||||
# ── Tool: get_health_summary ──
|
||||
# @ingroup AgentChat
|
||||
# @PRE User authenticated via dual-identity JWT
|
||||
# @POST Returns JSON string result from FastAPI
|
||||
# @SIDE_EFFECT HTTP call to FastAPI backend
|
||||
@tool
|
||||
async def get_health_summary() -> str:
|
||||
"""Get system health summary — dashboard validation status, recent failures."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{FASTAPI_URL}/api/dashboards/health",
|
||||
headers=_dual_auth_headers(),
|
||||
)
|
||||
return resp.text
|
||||
|
||||
|
||||
# ── Tool: list_environments ──
|
||||
# @ingroup AgentChat
|
||||
# @PRE User authenticated via dual-identity JWT
|
||||
# @POST Returns JSON string result from FastAPI
|
||||
# @SIDE_EFFECT HTTP call to FastAPI backend
|
||||
@tool
|
||||
async def list_environments() -> str:
|
||||
"""List configured deployment environments."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{FASTAPI_URL}/api/settings/environments",
|
||||
headers=_dual_auth_headers(),
|
||||
)
|
||||
return resp.text
|
||||
|
||||
|
||||
# ── Tool: get_task_status ──
|
||||
# @ingroup AgentChat
|
||||
# @PRE User authenticated via dual-identity JWT
|
||||
# @POST Returns JSON string result from FastAPI
|
||||
# @SIDE_EFFECT HTTP call to FastAPI backend
|
||||
@tool
|
||||
async def get_task_status(task_id: str) -> str:
|
||||
"""Check the status of a background task by its task_id."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{FASTAPI_URL}/api/tasks/{task_id}",
|
||||
headers=_dual_auth_headers(),
|
||||
)
|
||||
return resp.text
|
||||
|
||||
|
||||
# ── All available tools for the agent ──
|
||||
def get_all_tools() -> list:
|
||||
return [
|
||||
search_dashboards,
|
||||
get_health_summary,
|
||||
list_environments,
|
||||
get_task_status,
|
||||
]
|
||||
# #endregion AgentChat.Tools
|
||||
Reference in New Issue
Block a user