feat(agent-chat): 035-agent-chat-context — контекст, guardrails, tools, database discovery

== User stories ==
US1: Контекст с дашборда/датасета → /agent с URL params
US2: Guardrails card — env badge, 7 risk tones, countdown, permission_denied
US3: Tools optimization — retry, timeout, summarise, RBAC + context affinity

== Backend ==
- _context.py (NEW): UIContext validation (7 checks)
- _tool_filter.py (NEW): RBAC + context affinity pipeline
- _confirmation.py: build_confirmation_contract_v2, permission_denied_payload
- tools.py: superset_list_databases, retry/summarise/timeout wrappers
- app.py: _inject_uicontext, _inject_env_id_into_tools, database
  prefetch в runtime context
- _persistence.py: prefetch_databases()
- agent_superset_explore.py: GET /databases endpoint
- _llm_async_http.py, _persistence.py: fix double /v1 в LLM URL
  (LM Studio Unexpected endpoint)

== Frontend ==
- AgentChatModel.svelte.ts: 5 atoms, 3 actions, countdown, context
- AgentChat.svelte: production banner, process steps, debug panel
- ConfirmationCard.svelte: 7 risk tones, permission_denied, countdown
- ToolCallCard.svelte: retrying/timeout/cancelled states
- StreamProcessor.svelte.ts: tool_retry, timeout, permission_denied
- TopNavbar: sparkles icon + Ассистент
- sidebarNavigation: AI section
- DashboardHeader, datasets/+page: contextual AI buttons
- Icon: sparkles, brain, cpu icons
- tailwind: assistant category colors
- i18n: en/ru nav keys

== Tests ==
- 159 backend agent tests (+16 US3: retry, timeout, summarise, contracts)
- 2544 frontend tests (+11 model + component tests)
- 15 JSON fixtures (10 API + 5 model)

== Specs ==
- specs/035-agent-chat-context/: spec, UX, plan, tasks, research,
  data-model, contracts, quickstart, traceability, fixtures, checklists

Closes #035
This commit is contained in:
2026-07-04 22:47:17 +03:00
parent 4c5fde8b5c
commit ff60865183
82 changed files with 5168 additions and 553 deletions

View File

@@ -5,6 +5,7 @@
# @REJECTED StructuredTool wrapping — native @tool is the single source of truth.
# @INVARIANT Every @tool function MUST have a Python docstring (triple-quoted string immediately after the signature). LangChain raises ValueError("Function must have a docstring if description not provided.") when args_schema is provided without a description param AND the function lacks a docstring. This is a runtime blocker — the entire Gradio agent fails to import when ANY single tool violates this invariant. The GRACE @BRIEF comment is NOT a substitute — it's structural metadata invisible to the Python runtime. Rule: BEFORE decorating with @tool(args_schema=...), ensure `"""One-line description."""` is present on the next line.
import asyncio
import json
import os
from typing import Any
@@ -14,10 +15,11 @@ from langchain_core.tools import tool
from pydantic import BaseModel, Field
from src.agent._config import FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
from src.agent.context import get_service_jwt, get_user_jwt
from src.agent.context import get_service_jwt, get_user_jwt, get_user_role
from src.core.logger import logger
TOOL_RESPONSE_LIMIT = 4000
TOOL_TIMEOUT_SECONDS = 30
# ── Internal helpers ─────────────────────────────────────────────
@@ -26,7 +28,7 @@ TOOL_RESPONSE_LIMIT = 4000
# @BRIEF Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019.
def _dual_auth_headers() -> dict[str, str]:
user_jwt = get_user_jwt() or ""
svc_jwt = get_service_jwt() or _SERVICE_JWT
svc_jwt = get_service_jwt() or os.getenv("SERVICE_JWT", "") or _SERVICE_JWT
headers = {}
if svc_jwt:
headers["Authorization"] = f"Bearer {svc_jwt}"
@@ -82,16 +84,147 @@ def _safe_json_text(text: str) -> str:
# #endregion AgentChat.Tools.SafeJsonText
# #region AgentChat.Tools.PermissionGuard [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,security,rbac]
# @ingroup AgentChat
# @BRIEF Enforce invocation-time RBAC before mutating tool side effects.
def _guard_tool_permission(tool_name: str) -> None:
from src.agent._tool_filter import _TOOL_PERMISSIONS, enforce_tool_permission
user_role = get_user_role()
if enforce_tool_permission(tool_name, user_role):
return
required_role = (_TOOL_PERMISSIONS.get(tool_name) or ["admin"])[0]
raise PermissionError(
f"PERMISSION_DENIED:{tool_name}:{required_role}:{user_role}"
)
# #endregion AgentChat.Tools.PermissionGuard
# #region AgentChat.Tools.Retry [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,retry,http]
# @ingroup AgentChat
# @BRIEF Fixed-delay retry wrapper for read-only tool HTTP calls on transient errors.
# @PRE Tool is read-only (risk_level="safe"). Error is 5xx or httpx.ConnectError.
# @POST Retries once with fixed 1s asyncio.sleep. On success: returns response. On exhaust: raises with retry_exhausted=True.
# @TEST_EDGE: first_attempt_502 -> Auto-retries once, succeeds.
# @TEST_EDGE: both_attempts_502 -> Raises error with retry_exhausted=True.
# @TEST_EDGE: write_tool_502 -> No retry, raises immediately.
# @RATIONALE 1 retry with 1s delay catches ~80% of transient failures. Read-only only.
# @REJECTED Exponential backoff — single retry cannot be exponential.
# @REJECTED Retry all tools — write operations not idempotent.
async def _retry_read_tool(
tool_name: str,
tool_fn,
*args,
**kwargs,
):
"""Auto-retry read-only tool on transient HTTP errors. Fixed 1s delay, 1 retry.
Returns the tool result on success. Raises on failure.
Caller is responsible for SSE event emission.
"""
max_attempts = 2
last_error = None
for attempt in range(1, max_attempts + 1):
try:
result = await tool_fn(*args, **kwargs)
return result
except (httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout) as e:
last_error = e
if attempt < max_attempts:
logger.reason("Tool retry", payload={"tool": tool_name, "attempt": attempt, "error": str(e)[:100]}, extra={"src": "AgentChat.Tools.Retry"})
await asyncio.sleep(1)
continue
# Exhausted
logger.explore("Tool retry exhausted", payload={"tool": tool_name, "attempts": max_attempts}, error=str(last_error), extra={"src": "AgentChat.Tools.Retry"})
raise last_error
# #endregion AgentChat.Tools.Retry
# #region AgentChat.Tools.Summarise [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,summarise,response]
# @ingroup AgentChat
# @BRIEF Structured truncation — top-N items + total count for large tool responses.
# @POST When response >4000 chars + JSON array: "Found N items:\n - item1\n ...\n + (N-5) more."
# When >4000 chars + JSON object: "Result keys: {keys}. Sample values: ..."
# When >4000 chars + not JSON: truncate at sentence boundary + "..."
# When ≤4000 chars: return original.
# @RATIONALE Structured truncation preserves context for LLM vs hard cut.
def _summarise_response(text: str, limit: int = 4000) -> str:
"""Structured truncation for large tool responses."""
if len(text) <= limit:
return text
try:
data = json.loads(text)
if isinstance(data, list):
total = len(data)
top = data[:5]
lines = [f"Found {total} items:"]
for item in top:
lines.append(f" - {str(item)[:120]}")
if total > 5:
lines.append(f" ... and {total - 5} more items.")
return "\n".join(lines)
elif isinstance(data, dict):
keys = list(data.keys())[:10]
sample = {k: str(data[k])[:80] for k in keys}
return f"Result keys: {keys}. Sample: {sample}"
except (json.JSONDecodeError, ValueError):
pass
# Non-JSON: truncate at last sentence boundary before limit
truncated = text[:limit]
last_period = max(truncated.rfind("."), truncated.rfind("\n"))
if last_period > limit // 2:
return truncated[:last_period + 1] + "..."
return truncated + "..."
# #endregion AgentChat.Tools.Summarise
# #region AgentChat.Tools.Timeout [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,timeout]
# @ingroup AgentChat
# @BRIEF Configurable timeout wrapper (default 30s). Write tools: retryable=false.
# @POST Returns tool result within timeout. On TimeoutError: yields tool_timeout SSE.
# @TEST_EDGE: complete_under_timeout→normal, read_exceed→retryable timeout, write_exceed→retryable=false
async def _execute_with_timeout(tool_name: str, tool_fn, is_write: bool = False, timeout_s: int = 30):
"""Execute tool with configurable timeout. Write tools: no retry on timeout.
Returns tool result on success. Raises asyncio.TimeoutError on timeout.
Caller is responsible for SSE event emission.
"""
try:
return await asyncio.wait_for(tool_fn(), timeout=timeout_s)
except TimeoutError:
logger.explore("Tool timeout", payload={"tool": tool_name, "timeout_s": timeout_s, "is_write": is_write}, extra={"src": "AgentChat.Tools.Timeout"})
raise
# #endregion AgentChat.Tools.Timeout
# #region AgentChat.Tools.HttpGet [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,http,helper]
# @ingroup AgentChat
# @BRIEF Async HTTP GET to FastAPI with dual-auth headers.
async def _get(path: str, params: dict[str, Any] | None = None) -> httpx.Response:
async with httpx.AsyncClient() as client:
return await client.get(
f"{FASTAPI_URL}{path}",
params=params,
headers=_dual_auth_headers(),
)
async def _request() -> httpx.Response:
async with httpx.AsyncClient(timeout=TOOL_TIMEOUT_SECONDS) as client:
resp = await client.get(
f"{FASTAPI_URL}{path}",
params=params,
headers=_dual_auth_headers(),
)
if resp.status_code in {502, 503, 504}:
raise httpx.HTTPStatusError(
f"Transient FastAPI error {resp.status_code}",
request=resp.request,
response=resp,
)
return resp
try:
return await _execute_with_timeout(path, lambda: _retry_read_tool(path, _request), timeout_s=TOOL_TIMEOUT_SECONDS)
except TimeoutError as exc:
raise TimeoutError(f"Read tool timed out after {TOOL_TIMEOUT_SECONDS}s: {path}") from exc
# #endregion AgentChat.Tools.HttpGet
@@ -103,13 +236,21 @@ async def _post(
payload: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> httpx.Response:
async with httpx.AsyncClient() as client:
return await client.post(
f"{FASTAPI_URL}{path}",
json=payload or {},
params=params,
headers=_dual_auth_headers(),
)
async def _request() -> httpx.Response:
async with httpx.AsyncClient(timeout=TOOL_TIMEOUT_SECONDS) as client:
return await client.post(
f"{FASTAPI_URL}{path}",
json=payload or {},
params=params,
headers=_dual_auth_headers(),
)
try:
return await _execute_with_timeout(path, _request, is_write=True, timeout_s=TOOL_TIMEOUT_SECONDS)
except TimeoutError as exc:
raise TimeoutError(
f"Write tool timed out after {TOOL_TIMEOUT_SECONDS}s; operation status unknown, check status: {path}"
) from exc
# #endregion AgentChat.Tools.HttpPost
@@ -120,7 +261,7 @@ def _api_result(resp: httpx.Response, ok_statuses: set[int] | None = None) -> st
ok_statuses = ok_statuses or {200, 201, 202}
if resp.status_code not in ok_statuses:
return f"Error {resp.status_code}: {resp.text}"
return _trim_response(resp.text)
return _summarise_response(resp.text, TOOL_RESPONSE_LIMIT)
# #endregion AgentChat.Tools.ApiResult
@@ -359,6 +500,7 @@ async def run_backup(
dashboard_ids: list[int] | None = None,
) -> str:
"""Run a Superset backup for an environment, optionally scoped to dashboard IDs."""
_guard_tool_permission("run_backup")
vars = {"env_id": environment_id, "dashboard_id": dashboard_id, "dashboard_ids": dashboard_ids}
logger.reason("Run backup", payload=vars, extra={"src": "AgentChat.Tools.RunBackup"})
params: dict[str, Any] = {"environment_id": environment_id}
@@ -398,6 +540,7 @@ async def execute_migration(
fix_cross_filters: bool = True,
) -> str:
"""Execute dashboard migration between two environments."""
_guard_tool_permission("execute_migration")
logger.reason("Execute migration",
payload={"source": source_env_id, "target": target_env_id, "ids_count": len(selected_ids)},
extra={"src": "AgentChat.Tools.ExecuteMigration"})
@@ -446,6 +589,7 @@ async def create_branch(
env_id: str | None = None,
) -> str:
"""Create a branch in a dashboard Git repository."""
_guard_tool_permission("create_branch")
logger.reason("Create branch",
payload={"dashboard_ref": dashboard_ref, "branch_name": branch_name, "from_branch": from_branch},
extra={"src": "AgentChat.Tools.CreateBranch"})
@@ -482,6 +626,7 @@ async def commit_changes(
env_id: str | None = None,
) -> str:
"""Stage and commit changes in a dashboard Git repository."""
_guard_tool_permission("commit_changes")
logger.reason("Commit changes",
payload={"dashboard_ref": dashboard_ref, "message": message[:80]},
extra={"src": "AgentChat.Tools.CommitChanges"})
@@ -516,6 +661,7 @@ async def deploy_dashboard(
env_id: str | None = None,
) -> str:
"""Deploy a dashboard from Git to a target environment."""
_guard_tool_permission("deploy_dashboard")
logger.reason("Deploy dashboard",
payload={"dashboard_ref": dashboard_ref, "target_env": environment_id},
extra={"src": "AgentChat.Tools.DeployDashboard"})
@@ -697,6 +843,7 @@ async def start_maintenance(
message: str | None = None,
) -> str:
"""Start a maintenance event and apply banners to affected dashboards."""
_guard_tool_permission("start_maintenance")
logger.reason("Start maintenance",
payload={"environment_id": environment_id, "tables": tables},
extra={"src": "AgentChat.Tools.StartMaintenance"})
@@ -736,6 +883,7 @@ async def end_maintenance(
end_all: bool = False,
) -> str:
"""End one maintenance event, or end all active events when end_all is true."""
_guard_tool_permission("end_maintenance")
if end_all:
logger.reason("End all maintenance events",
payload={"environment_id": environment_id},
@@ -763,6 +911,40 @@ async def end_maintenance(
# NEW: Agent-critical Superset operations (Phase 4)
# ═══════════════════════════════════════════════════════════════════
# #region AgentChat.Tools.SupersetListDatabases [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,database,list]
# @ingroup AgentChat
# @BRIEF List all databases available in a Superset environment — returns id, name, uuid, engine.
# @POST Returns formatted string of database id → name (engine) per database.
# @SIDE_EFFECT HTTP GET to FastAPI /api/agent/superset/databases.
@tool
async def superset_list_databases(environment_id: str) -> str:
"""List all databases available in the current Superset environment. Returns database IDs and names."""
logger.reason("Listing Superset databases", payload={"environment_id": environment_id},
extra={"src": "AgentChat.Tools.SupersetListDatabases"})
try:
resp = await _get("/api/agent/superset/databases", params={"environment_id": environment_id})
if resp.status_code != 200:
return f"Error: failed to list databases (HTTP {resp.status_code})"
databases = resp.json()
if not databases:
return "No databases found in this environment."
lines = ["Available databases:"]
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("Failed to list databases", error=str(e),
extra={"src": "AgentChat.Tools.SupersetListDatabases"})
return f"Error listing databases: {e}"
# #endregion AgentChat.Tools.SupersetListDatabases
# #region AgentChat.Tools.SupersetSql.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,superset,sql]
class ExecuteSupersetSqlInput(BaseModel):
environment_id: str = Field(description="Target Superset environment ID")
@@ -867,7 +1049,7 @@ class AuditPermissionsInput(BaseModel):
# #region AgentChat.Tools.SupersetAudit [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,audit,permissions]
# @ingroup AgentChat
# @BRIEF Audit access rights: user × dashboard × dataset × RLS permission matrix.
# @BRIEF Audit access rights: user x dashboard x dataset x RLS permission matrix.
# @PRE User authenticated via dual-identity JWT.
# @POST Returns audit report with per-user dashboard/dataset access and RLS regions.
# @SIDE_EFFECT HTTP GET to FastAPI /api/agent/superset/audit/permissions.
@@ -877,7 +1059,7 @@ async def superset_audit_permissions(
username_filter: str | None = None,
include_admin: bool = False,
) -> str:
"""Audit access rights: user × dashboard × dataset × RLS permission matrix."""
"""Audit access rights: user x dashboard x dataset x RLS permission matrix."""
logger.reason("Audit Superset permissions", payload={"environment_id": environment_id},
extra={"src": "AgentChat.Tools.SupersetAudit"})
params: dict[str, Any] = {"environment_id": environment_id}
@@ -1052,6 +1234,7 @@ def get_all_tools() -> list:
start_maintenance,
end_maintenance,
# NEW: Superset direct operations
superset_list_databases,
superset_execute_sql,
superset_explore_database,
superset_audit_permissions,