Files
ss-tools/specs/035-agent-chat-context/contracts/modules.md
busya ff60865183 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
2026-07-04 22:47:17 +03:00

21 KiB
Raw Blame History

#region ModulesContract [C:4] [TYPE ADR] [SEMANTICS contracts,modules,agent-chat] @defgroup Contracts Module and function contracts for 035-agent-chat-context implementation. @RATIONALE Content model updated for Path B (/agent dedicated page), Gradio payload safety, two-layer RBAC enforcement, and permission_denied flow separation. @REJECTED Path A (drawer panel on any page) — rejected in favor of dedicated /agent route for simpler state management.

#region AgentChat.LangGraph.Handler [C:4] [TYPE Function] [SEMANTICS agent-chat,handler,context]

@ingroup AgentChat

@BRIEF Extended agent_handler — accepts uicontext JSON appended at end of positional args, injects into runtime context, filters tools by objectType.

@PRE JWT valid, user authenticated. uicontext_str is valid JSON or None.

@POST UIContext injected into system prompt. Tools filtered by objectType. Stream unchanged for uicontext=None (backward-compat).

@SIDE_EFFECT Calls LLM, invokes tools, writes checkpoints. UIContext validated and logged via CoT logger.

@RELATION DEPENDS_ON -> [AgentChat.ToolFilter]

@RELATION DEPENDS_ON -> [AgentChat.Confirmation]

@DATA_CONTRACT Input: (message, history, convId, userId, userJwt, envId, uicontext_str?) -> Output: AsyncGenerator[str]

@TEST_EDGE: null_uicontext -> All 22 tools passed to LLM (backward-compat).

@TEST_EDGE: dashboard_context -> Only 9 dashboard tools passed.

@TEST_EDGE: invalid_json_uicontext -> Gracefully ignored with audit log, all tools passed.

@TEST_EDGE: malformed_objectType -> Rejected with 422, audit log written.

@RATIONALE uicontext_str appended at END of positional args (position 6) — inserting in the middle would break existing Gradio clients that pass 6 args. Null default ensures zero breakage.

@REJECTED Middle-position insertion — rejected: breaking change for existing Gradio clients.

#endregion AgentChat.LangGraph.Handler

#region AgentChat.ToolFilter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,filter,context]

@defgroup AgentChat Context-aware tool filtering by objectType.

@LAYER Service

@RELATION DEPENDS_ON -> [AgentChat.Tools]

#region AgentChat.ToolFilter.Pipeline [C:4] [TYPE Function] [SEMANTICS agent-chat,tools,filter,pipeline]

@ingroup AgentChat

@BRIEF Canonical tool selection pipeline — applies in order: RBAC filter → context affinity filter.

@POST Returns list[BaseTool] after applying: (1) role-based exclusion, (2) context-type hard filter. show_capabilities always included.

@SIDE_EFFECT CoT logs reason for each excluded tool.

@RELATION DEPENDS_ON -> [AgentChat.Tools]

@DATA_CONTRACT Input: (all_tools, user_role, object_type) -> Output: filtered_tools

@RATIONALE Ordered pipeline ensures RBAC is enforced FIRST (security), then context filtering (UX). Two-layer: prompt filtering + invocation enforcement (see AGTL-FR-005).

@REJECTED Embedding-based filtering — rejected for initial release; postponed to post-MVP (AGTL-FR-006).

@TEST_EDGE: null_objectType -> Returns all RBAC-allowed tools.

@TEST_EDGE: dashboard+analyst -> Returns 9 dashboard tools minus any requiring admin.

@TEST_EDGE: unknown_objectType -> Returns all RBAC-allowed tools (graceful fallback).

_CONTEXT_TOOL_AFFINITY: dict[str, set[str]] = { "dashboard": {"search_dashboards", "get_health_summary", "deploy_dashboard", "run_llm_validation", "run_llm_documentation", "execute_migration", "create_branch", "commit_changes"}, "dataset": {"superset_explore_database", "superset_format_sql", "superset_audit_permissions", "superset_execute_sql", "superset_create_dataset", "search_dashboards", "get_task_status", "list_environments"}, "migration": {"execute_migration", "search_dashboards", "get_health_summary", "deploy_dashboard", "list_environments"}, }

_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"], }

def build_tool_pipeline(tools, user_role, object_type): """Apply RBAC + context filtering in canonical order.""" pass

#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 — validates tool execution permission regardless of prompt filtering.

@PRE tool_name extracted from tool invocation request. User role resolved from JWT.

@POST If role insufficient: yield permission_denied SSE event (NOT confirm_required). If role sufficient: proceed.

@SIDE_EFFECT CoT logger.explore on denied. Audit log written.

@DATA_CONTRACT Input: (tool_name, user_role) -> Output: None (allowed) or yield permission_denied SSE

@RATIONALE Two-layer enforcement (AGTL-FR-005): prompt filtering prevents LLM from proposing disallowed tools, but invocation guard catches hallucinated/replayed/direct calls. Security-critical — prompt filtering alone is insufficient.

@TEST_EDGE: analyst_calls_deploy -> permission_denied SSE, tool not executed.

@TEST_EDGE: admin_calls_deploy -> allowed, no event.

@TEST_EDGE: unknown_tool -> rejected with error, not permission_denied.

def enforce_tool_permission(tool_name, user_role): """Invocation-time RBAC enforcement — yield permission_denied or proceed.""" pass

#endregion AgentChat.ToolFilter.InvocationGuard

#endregion AgentChat.ToolFilter

#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 classified as read-only (risk_level="safe"). Error is 5xx or connection error.

@POST Retries once with fixed 1s delay. On success: returns response. On exhaust: raises with retry_exhausted=True.

@SIDE_EFFECT HTTP call retried; CoT loggers REASON/EXPLORE/REFLECT on each attempt.

@RELATION DEPENDS_ON -> [EXT:httpx:AsyncClient]

@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 (read-only only), raises immediately.

@RATIONALE 1 retry with 1s fixed delay catches ~80% of transient failures. Read-only only — write idempotency not guaranteed.

@REJECTED Exponential backoff — rejected: single retry cannot be exponential; 2+ retries with backoff adds 3s+ latency.

@REJECTED Retry all tools — rejected: write operations may not be idempotent.

async def _retry_read_tool(func, *args, **kwargs): """Auto-retry read-only tool on transient HTTP errors. Fixed 1s delay.""" pass

#endregion AgentChat.Tools.Retry

#region AgentChat.Tools.Summarise [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,summarise,response]

@ingroup AgentChat

@BRIEF Structured response summarisation — top-N items + total count for large tool responses.

@POST When response >4000 chars and JSON array: "Found {total} items:\n - {item1}\n ...\n + {total-5} more."

When >4000 chars and JSON object: "Result keys: {keys}. Values: {sample}..."

When >4000 chars and not JSON: truncate at sentence boundary near 4000 chars + "..."

When ≤4000 chars: return original.

@RATIONALE Structured truncation preserves semantic value for LLM context better than hard cut.

@REJECTED LLM-based summarisation — extra token cost and latency.

@REJECTED Pagination in every tool — 22 tools to modify. Terminology: "structured truncation" not "semantic summarisation."

def _summarise_response(text: str, limit: int = 4000) -> str: """Structured truncation — top-N + count for large responses.""" pass

#endregion AgentChat.Tools.Summarise

#region AgentChat.Tools.Timeout [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,timeout]

@ingroup AgentChat

@BRIEF Configurable timeout wrapper for tool execution (default 30s).

@PRE asyncio event loop running.

@POST Returns tool result within timeout. On timeout: yields tool_timeout SSE. For write tools: message says "operation status unknown — check status" (NOT "retry").

@SIDE_EFFECT asyncio.wait_for with timeout; CoT logger.explore on timeout.

@RELATION DEPENDS_ON -> [EXT:asyncio]

@TEST_EDGE: tool_completes_under_timeout -> Returns result normally.

@TEST_EDGE: read_tool_exceeds_30s -> Yields tool_timeout with retryable=true.

@TEST_EDGE: write_tool_exceeds_30s -> Yields tool_timeout with retryable=false, status_unknown=true.

@RATIONALE 30s default balances responsiveness with Superset API variability. Write-tool timeout is NOT safe to retry — the mutation may have succeeded server-side.

@REJECTED Auto-retry on write timeout — rejected: unsafe, may duplicate mutations.

async def _execute_with_timeout(tool_fn, is_write: bool = False, timeout_s: int = 30): """Execute tool with configurable timeout. Write tools: no auto-retry on timeout.""" pass

#endregion AgentChat.Tools.Timeout

#region AgentChat.Confirmation.GuardV2 [C:4] [TYPE Function] [SEMANTICS agent-chat,hitl,confirmation,guardrails]

@ingroup AgentChat

@BRIEF Build extended confirmation contract — three-axis risk classification with env context.

@POST Returns dict with risk, risk_level, dangerous, env_context, permission_granted, required_role, alternatives.

@RELATION DEPENDS_ON -> [AgentChat.ToolResolver]

@RELATION DEPENDS_ON -> [EXT:FastAPI:RBAC]

@DATA_CONTRACT Input: (tool_name, tool_args, user_role, target_env) -> Output: ConfirmMetadataV2

@RATIONALE Three-axis classification (tool_risk × env_risk × permission) replaces single-axis prefix heuristic.

Env resolution: tool_args.env_id > submit envId > UIContext.envId > null.

Env normalization for risk tiers: env_id containing "prod" → "prod"; "stag"/"test" → "staging"; "dev"/"local" → "dev".

@REJECTED Single-axis prefix-only — cannot distinguish deploy to prod vs staging.

@REJECTED LLM-based risk scoring — adds 500ms latency.

@TEST_EDGE: deploy_to_prod -> risk="write", risk_level="guarded", env_context="prod", dangerous=false.

@TEST_EDGE: delete_dashboard_any_env -> risk_level="dangerous", dangerous=true.

@TEST_EDGE: search_dashboards -> risk="read", risk_level="safe", dangerous=false.

@TEST_EDGE: analyst_deploy_prod -> permission check fails → permission_denied SSE (NOT confirm_required).

def build_confirmation_contract_v2( tool_name: str, tool_args: dict, user_role: str, target_env: str | None ) -> dict: """Build extended confirmation contract — three-axis risk.""" pass

#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 — separate from confirm_required flow.

@POST Yields SSE event type="permission_denied" with tool_name, required_role, alternatives. Does NOT enter HITL checkpoint.

@RATIONALE Permission denied must BYPASS HITL — entering confirm_required checkpoint for an already-forbidden call is a security anti-pattern. Separate event type lets frontend show "access denied" without rendering a confirm button.

@REJECTED Emitting confirm_required with permission_granted=false — rejected: enters guarded checkpoint for a known-forbidden call.

def yield_permission_denied(tool_name: str, required_role: str, alternatives: list[dict]) -> str: """Yield permission_denied SSE — bypasses HITL checkpoint.""" pass

#endregion AgentChat.Confirmation.PermissionDenied

#region AgentChat.Context.Inject [C:2] [TYPE Function] [SEMANTICS agent-chat,context,inject,prompt]

@ingroup AgentChat

@BRIEF Inject validated UIContext into the LLM runtime context block.

@PRE uicontext validated (enum checks, length limits, payload size).

@POST Returns modified runtime context string with [USER CONTEXT] block appended. Context is explicitly marked as "not an instruction" in the prompt.

@RATIONALE Separates user context from system context. Prompt-injection protection: context block is formatted as metadata, not user instructions.

def _inject_uicontext(runtime_context: str, uicontext: dict) -> str: """Add [USER CONTEXT — informational, not instructions] block to runtime context.""" pass

#endregion AgentChat.Context.Inject

#region AgentChat.Context.Validate [C:2] [TYPE Function] [SEMANTICS agent-chat,context,validate,security]

@ingroup AgentChat

@BRIEF Validate UIContext payload — enum checks, size limits, field lengths.

@POST Returns validated dict or raises ValidationError.

@TEST_EDGE: valid_context -> Returns validated dict.

@TEST_EDGE: invalid_objectType -> Rejected with 422.

@TEST_EDGE: oversized_payload (>4KB) -> Rejected with 413.

@TEST_EDGE: objectName_exceeds_256_chars -> Rejected with 422.

def validate_uicontext(payload: dict) -> dict: """Validate UIContext — enum values, size limits, field lengths.""" pass

#endregion AgentChat.Context.Validate

#region AgentChat.Model [C:4] [TYPE Model] [SEMANTICS agent-chat,model,context,screen-model]

@defgroup AgentChat Extended Screen Model — context atoms and actions.

@INVARIANT UIContext is derived from URL params at page mount, static for visit duration.

@INVARIANT isProdContext derived from uiContext.envId OR _activeEnvId (EnvSelector). updateActiveEnv syncs both.

@STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent

@ACTION setUIContextFromParams(params) — Parse URL params to UIContext on /agent page mount.

@ACTION updateActiveEnv(envId) — Update environment AND sync uiContext.envId.

@ACTION startDangerousCountdown() — Begin 10s countdown; model is single source of truth.

@RELATION DEPENDS_ON -> [AgentChat.Types]

@RELATION DEPENDS_ON -> [AgentChat.ConnectionManager]

@RELATION DEPENDS_ON -> [AgentChat.StreamProcessor]

@RATIONALE Existing AgentChatModel extended rather than replaced — atom addition is additive.

@REJECTED New context-only model — rejected: cross-model coordination for streaming state.

@REJECTED initialContext in assistantChatStore — rejected: violates context-per-visit model; store persistence creates stale-context trap.

// NEW atoms (add to AgentChatModel class): // uiContext: UIContext | null = $state(null); // dangerousCountdown: number = $state(0); // dangerousCountdownActive: boolean = $state(false); // _activeEnvId: string | null = $state(null); // permissionAlternatives: Array<{action: string; prompt: string}> | null = $state(null);

// NEW actions: // setUIContextFromParams(params: URLSearchParams): void { ... } // updateActiveEnv(envId: string | null): void { this._activeEnvId = envId; if (this.uiContext) this.uiContext.envId = envId; } // startDangerousCountdown(): void { ... } // #endregion AgentChat.Model

#region AgentChat.Model.SetContext [C:3] [TYPE Function] [SEMANTICS agent-chat,model,context,action]

@ingroup AgentChat

@BRIEF Parse URL search params into UIContext atom on /agent page mount.

@ACTION Public action — called from +page.svelte onMount.

@POST this.uiContext populated. this._activeEnvId set. CoT log emitted.

@TEST_EDGE: full_params -> uiContext populated with all fields, contextVersion=1.

@TEST_EDGE: no_params -> uiContext is null, pill shows "Контекст не выбран".

@TEST_EDGE: malformed_objectType -> treated as null (frontend validation).

// setUIContextFromParams(params: URLSearchParams): void { // const objectType = (["dashboard","dataset","migration"].includes(params.get("objectType")||"") // ? params.get("objectType") : null) as UIContext["objectType"]; // ... // }

#endregion AgentChat.Model.SetContext

#region AgentChat.Component [C:4] [TYPE Component] [SEMANTICS agent,chat,ui,svelte]

@ingroup AgentChat

@BRIEF Agent chat panel — extended with prod warning background+banner and context-aware process steps.

@RELATION BINDS_TO -> [AgentChat.Model]

@RELATION DEPENDS_ON -> [$lib/ui/Icon]

@UX_STATE prod_env -> bg-warning-light background + red PRODUCTION banner atop chat panel.

@UX_STATE non_prod_env -> Standard bg-surface-page background, no banner.

@UX_FEEDBACK Prod banner appears/disappears reactively on env change.

@UX_RECOVERY EnvSelector → select non-prod → background restores.

@RATIONALE Reactive background change gives immediate, non-intrusive prod warning.

@REJECTED Modal warning on every prod message — too intrusive.

// CHANGES to AgentChat.svelte: // 1. Root div: class binding for prod background // 2. Prod banner using $lib/ui/Icon (existing) // 3. Process steps: first step uses model.contextPillLabel #endregion AgentChat.Component

#region AgentChat.ConfirmationCard [C:4] [TYPE Component] [SEMANTICS agent-chat,confirmation,hitl,guardrails,svelte]

@ingroup AgentChat

@BRIEF HITL guardrails card — env badge, risk toning, dangerous countdown, permission-denied state.

@RELATION BINDS_TO -> [AgentChat.Model]

@RELATION DEPENDS_ON -> [$lib/ui/Button]

@RELATION DEPENDS_ON -> [$lib/ui/Icon]

@UX_STATE read/write_staging/write_prod/write_dev/dangerous/loading_confirm/loading_deny — 7 states.

@UX_STATE permission_denied — Separate rendering path; received as SSE event type="permission_denied" (NOT confirm_required).

@UX_FEEDBACK Dangerous countdown aria-live="assertive" every 1s (aligned with 1s decrement).

@UX_RECOVERY Deny → "Операция отменена" agent message in chat.

@UX_RECOVERY Permission denied → close card → agent message with alternatives.

@INVARIANT Card renders from confirm_required for permitted operations. permission_denied events bypass HITL checkpoint.

@INVARIANT Countdown timer owned by model (dangerousCountdown atom), component reads model only.

@RATIONALE Model-owns-countdown eliminates race condition between model and component timers.

@REJECTED Component-owned countdown — rejected: violates model-first pattern, creates timer duplication.

// CHANGES to ConfirmationCard.svelte: // 1. 7 risk-based states (add write_dev) // 2. Countdown reads model.dangerousCountdown (no component timer) // 3. permission_denied: listens for SSE event type="permission_denied" // 4. Risk-based toneClasses extended with write_dev #endregion AgentChat.ConfirmationCard

#region AgentChat.ToolCallCard [C:3] [TYPE Component] [SEMANTICS agent-chat,tools,retry,timeout,svelte]

@ingroup AgentChat

@BRIEF Inline tool call display — retrying, timeout, cancelled states.

@RELATION DEPENDS_ON -> [$lib/ui/Button]

@UX_STATE retrying/timeout/cancelled

@UX_FEEDBACK Retry button triggers message resubmission for read-only tools. Write tool timeout: no retry button ("check operation status").

@UX_RECOVERY Read timeout → retry. Write timeout → check status.

@RATIONALE Write-tool retry is unsafe — timeout does not mean mutation didn't happen.

@REJECTED Retry for all timeouts — rejected: write retry may duplicate mutations.

// CHANGES to ToolCallCard.svelte: // 1. onRetry prop shown only for read tools / transient errors // 2. Write timeout: "⏱️ Operation status unknown — check Superset" (no retry button) #endregion AgentChat.ToolCallCard

#region AgentChat.Page [C:3] [TYPE Component] [SEMANTICS agent-chat,page,svelte]

@ingroup AgentChat

@BRIEF Agent page wrapper — reads UIContext from URL params on mount.

@RELATION BINDS_TO -> [AgentChat.Model]

@UX_STATE mount -> Reads URL params, calls model.setUIContextFromParams().

// CHANGES to +page.svelte: // import { page } from "$app/stores"; // onMount(() => { // const params = new URLSearchParams($page.url.search); // model.setUIContextFromParams(params); // }); #endregion AgentChat.Page

#region AgentChat.StreamProcessor [C:4] [TYPE Module] [SEMANTICS agent-chat,streaming,sse,svelte]

@ingroup AgentChat

@BRIEF SSE stream processor — handles tool_retry, tool_timeout, permission_denied (new), extended confirm_required.

@RELATION BINDS_TO -> [AgentChat.Model]

@RELATION DEPENDS_ON -> [AgentChat.Types]

// NEW event handler: // case "permission_denied": render permission-denied card (separate from confirm_required)

// EXTENDED event handler: // case "confirm_required": parse dangerous, env_context, permission_granted. If permission_granted=false → redirect to permission_denied handler.

// NEW handlers: // case "tool_retry": update ToolCall status=retrying // case "tool_timeout": update ToolCall status=timeout; if write tool → no retry button #endregion AgentChat.StreamProcessor

#region AgentChat.Types [C:2] [TYPE Module] [SEMANTICS agent-chat,types,svelte]

@ingroup AgentChat

@BRIEF Shared TypeScript types — UIContext, ToolAffinityEntry, ConfirmMetadataV2, PermissionDeniedMetadata.

// ADD interfaces (see data-model.md for full shapes): // export interface UIContext { objectType, objectId, objectName, envId, route, contextVersion } // export interface ConfirmMetadataV2 { ... with dangerous, env_context, permission_granted } // export interface PermissionDeniedMetadata { type:"permission_denied", tool_name, required_role, alternatives } // export type ToolCallStatus = "executing" | "retrying" | "completed" | "failed" | "timeout" | "cancelled"; // EXTEND ToolCall: +retryCount?, +maxRetries?, +timeoutSeconds?, +isWriteTool? #endregion AgentChat.Types

#endregion ModulesContract