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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user