#region ModelChanges [C:3] [TYPE ADR] [SEMANTICS ux,model-changes,agent-chat] @defgroup Ux Precise edit instructions for existing model/component files. Implementation target for /speckit.implement. ## File 1: `frontend/src/lib/models/AgentChatTypes.ts` **ADD** after existing types: ```typescript // ── UI Context (035-agent-chat-context) ───────────────────────── /** Structured context about the user's current page — passed from frontend to agent on each message. */ export interface UIContext { objectType: "dashboard" | "dataset" | "migration" | null; objectId: string | null; objectName: string | null; envId: string | null; route: string; contextVersion: number; } /** Maps objectType to tool operation names — server-side, shown in debug panel. */ export interface ToolAffinityEntry { operation: string; active: boolean; // true = tool available for this context reason?: string; // why excluded (e.g. "dataset tool") } /** Extended confirmation metadata from backend SSE. */ export interface ConfirmMetadataV2 { type: "confirm_required"; thread_id: string; tool_name: string; tool_args: Record; risk: "read" | "write"; risk_level: "safe" | "guarded" | "dangerous"; dangerous: boolean; env_context: "prod" | "staging" | "dev" | null; permission_granted: boolean; required_role: string | null; alternatives: Array<{ action: string; prompt: string }> | null; prompt?: string; } ``` **EXTEND** `ToolCall` interface — add `"retrying" | "timeout" | "cancelled"` to `status` union: ```typescript export interface ToolCall { id: string; name: string; status: "executing" | "retrying" | "completed" | "failed" | "timeout" | "cancelled"; input?: Record; output?: { result: string }; error?: string; retryCount?: number; maxRetries?: number; timeoutSeconds?: number; } ``` **EXTEND** `StreamingState` union — add `"disconnected_permanent"` (already exists in model, ensure type includes it): ```typescript export type StreamingState = "idle" | "streaming" | "awaiting_confirmation" | "error" | "disconnected" | "disconnected_permanent"; ``` --- ## File 2: `frontend/src/lib/models/AgentChatModel.svelte.ts` ### 2a. ADD import (top of file): ```typescript import type { UIContext, ToolAffinityEntry, ConfirmMetadataV2 } from "./AgentChatTypes.js"; ``` ### 2b. ADD atoms (after existing `envId` atom, ~line 113): ```typescript // ── UI Context (035-agent-chat-context) ──────────────────────── uiContext: UIContext | null = $state(null); toolAffinity: ToolAffinityEntry[] = $state([]); dangerousCountdown: number = $state(0); dangerousCountdownActive: boolean = $state(false); /** Tracks env from EnvSelector (may differ from uiContext.envId during dialog). */ _activeEnvId: string | null = $state(null); /** Alternatives suggested by backend when permission denied. */ permissionAlternatives: Array<{ action: string; prompt: string }> | null = $state(null); ``` ### 2c. ADD derived (after existing derived block, ~line 317): ```typescript // ── Prod context detection ───────────────────────────────────── /** True when either uiContext or active env selector is prod. */ isProdContext = $derived( (this.uiContext?.envId === "prod") || this._activeEnvId === "prod" ); showProdWarning = $derived(this.isProdContext); // ── Context pill label (extended, replaces simple envId label) ─ contextPillLabel = $derived.by((): string => { const env = this._activeEnvId || this.uiContext?.envId || ""; if (!this.uiContext || !this.uiContext.objectType) { return env ? `🌐 ${env}` : "⚪ Контекст не выбран"; } const typeIcons: Record = { dashboard: "📋", dataset: "📊", migration: "🔄", }; const icon = typeIcons[this.uiContext.objectType] || "📄"; const idLabel = this.uiContext.objectId ? ` #${this.uiContext.objectId}` : ""; return `${icon} ${this.uiContext.objectType}${idLabel} · ${env}`; }); // ── Process steps (extended — first step uses contextPillLabel) ── processSteps = $derived.by((): AgentProcessStep[] => { const hasTools = this.activeToolCalls.length > 0 || this.messages.some((msg) => (msg.toolCalls || []).length > 0); const hasAssistantResult = this.messages.some((msg) => msg.role === "assistant" && msg.text.trim().length > 0); const failed = this.streamingState === "error"; return [ { id: "context", label: this.contextPillLabel, state: (this.uiContext || this._activeEnvId) ? (failed ? "blocked" : "done") : (failed ? "blocked" : "idle"), }, // ... rest of existing steps unchanged ... ]; }); // ── Confirmation risk (extended — use backend metadata) ──────── // НЕ менять существующий $derived — только убедиться что он читает // pendingConfirmationRisk, pendingRiskLevel из StreamProcessor ``` ### 2d. ADD actions (before `sendMessage`, ~line 410): ```typescript // ── Context actions (035-agent-chat-context) ──────────────────── /** Set UI context from URL params on page mount. */ setUIContextFromParams(params: URLSearchParams): void { const objectType = params.get("objectType") as UIContext["objectType"] || null; const objectId = params.get("objectId") || null; const objectName = params.get("objectName") || null; const envId = params.get("envId") || null; const route = params.get("route") || "/agent"; this.uiContext = { objectType, objectId, objectName, envId, route, contextVersion: 1, }; this._activeEnvId = envId; log("AgentChat.Model", "REASON", "UI context set from params", { objectType, objectId, envId, }); } /** Update environment from EnvSelector during dialog. */ updateActiveEnv(envId: string | null): void { this._activeEnvId = envId; // Note: envId for tool calls is still passed separately — this tracks for UI only } /** Start dangerous operation countdown. Called by StreamProcessor. */ startDangerousCountdown(): void { this.dangerousCountdown = 10; this.dangerousCountdownActive = true; const interval = setInterval(() => { this.dangerousCountdown--; if (this.dangerousCountdown <= 0) { this.dangerousCountdownActive = false; clearInterval(interval); } }, 1000); } ``` ### 2e. UPDATE `resetStreamingState` (internal helper) — add context reset: ```typescript // В методе, который сбрасывает состояние перед новым стримом: private _resetForStream(): void { // ... existing resets ... this.dangerousCountdown = 0; this.dangerousCountdownActive = false; this.permissionAlternatives = null; } ``` --- ## File 3: `frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts` ### 3a. ADD new metadata handlers (in `_handleMetadata` switch): ```typescript // После существующих case "tool_error": case "tool_retry": { const toolCall = host.activeToolCalls.find(t => t.name === meta.tool); if (toolCall) { toolCall.status = "retrying"; toolCall.retryCount = meta.attempt; toolCall.maxRetries = meta.max_attempts; } break; } case "tool_timeout": { const toolCall = host.activeToolCalls.find(t => t.name === meta.tool); if (toolCall) { toolCall.status = "timeout"; toolCall.timeoutSeconds = meta.timeout_seconds; } break; } // Расширить существующий case "confirm_required": case "confirm_required": { host.pendingToolName = meta.tool_name; host.pendingToolArgs = meta.tool_args || {}; host.pendingConfirmationRisk = meta.risk === "read" ? "read" : "write"; host.pendingRiskLevel = meta.risk_level; host.confirmationMessage = meta.prompt || null; host.streamingState = "awaiting_confirmation"; // NEW fields: if (meta.dangerous) { host.startDangerousCountdown?.(); } if (!meta.permission_granted) { host.permissionAlternatives = meta.alternatives; host.pendingRiskLevel = "permission_denied"; } break; } ``` ### 3b. EXTEND `handleResumeResponse` — cancelled tool card: ```typescript // В обработчике deny/confirm_resolved с result "denied": // Добавить ToolCall в activeToolCalls со статусом "cancelled" // чтобы карточка «Отменено» появилась в чате if (meta.type === "confirm_resolved" && meta.result === "denied") { const toolName = host.pendingToolName || "unknown_action"; host.activeToolCalls = [...host.activeToolCalls, { id: `cancelled-${Date.now()}`, name: toolName, status: "cancelled", input: host.pendingToolArgs as Record | undefined, }]; host.pendingToolName = null; host.pendingToolArgs = {}; } ``` --- ## File 4: `frontend/src/routes/agent/+page.svelte` ### 4a. ADD onMount context init: ```svelte ``` ### 4b. ADD EnvSelector integration: ```svelte model.updateActiveEnv(envId)} /> ``` --- ## File 5: `frontend/src/lib/components/agent/AgentChat.svelte` ### 5a. ADD prod background binding (корневой div): ```svelte
``` ### 5b. ADD prod banner (перед header): ```svelte {#if model.showProdWarning}
PRODUCTION
{/if} ``` ### 5c. UPDATE process steps rendering — первый шаг использует contextPillLabel: ```svelte {#each model.processSteps as step} {step.label} {/each} ``` --- ## File 6: `frontend/src/lib/components/assistant/ConfirmationCard.svelte` ### 6a. ADD env badge to header: ```svelte {#if meta.env_context} {meta.env_context === 'prod' ? '👑 PRODUCTION' : meta.env_context} {/if} ``` ### 6b. ADD dangerous countdown logic: ```svelte ``` ### 6c. ADD permission_denied state (alternatives list): ```svelte {#if !meta.permission_granted}

🔒 Недостаточно прав

Требуется роль: {meta.required_role}

{#if meta.alternatives?.length}

💡 Альтернативы:

    {#each meta.alternatives as alt}
  • • {alt.prompt}
  • {/each}
{/if}
{/if} ``` --- ## File 7: `frontend/src/lib/components/assistant/ToolCallCard.svelte` ### 7a. ADD new states: ```svelte {#if tool.status === "retrying"}
Повторная попытка... ({tool.retryCount}/{tool.maxRetries})
{:else if tool.status === "timeout"}
⏱️ Превышено время ожидания ({tool.timeoutSeconds}с)
{#if onRetry} {/if} {:else if tool.status === "cancelled"}
🚫 Отменено пользователем
{/if} ``` --- ## File 8: `frontend/src/lib/stores/assistantChat.svelte.ts` ### 8a. ADD initialContext field: ```typescript // Добавить в существующий объект assistantChatStore: initialContext: UIContext | null = $state(null); // Добавить action: setInitialContext(ctx: UIContext | null) { this.initialContext = ctx; } ``` --- ## File 9: `backend/src/agent/app.py` ### 9a. ADD uicontext parameter to agent_handler: ```python # В сигнатуре agent_handler: async def agent_handler( message: dict, history: list, conversation_id: str | None = None, uicontext_str: str | None = None, # NEW — JSON string from frontend user_id: str | None = None, user_jwt: str | None = None, env_id: str | None = None, ): # Parse uicontext: uicontext = None if uicontext_str: try: uicontext = json.loads(uicontext_str) except json.JSONDecodeError: pass # Inject into runtime context: if uicontext: runtime_parts = _build_agent_context(env_id) # existing runtime_parts = _inject_uicontext(runtime_parts, uicontext) # NEW # Filter tools by objectType: tools = get_all_tools() if uicontext and uicontext.get("objectType"): tools = _filter_tools_by_context(tools, uicontext["objectType"]) ``` ### 9b. NEW helper function: ```python def _inject_uicontext(runtime_context: str, uicontext: dict) -> str: """Add user-facing context to the runtime context block.""" lines = [runtime_context] lines.append("\n[USER CONTEXT]") lines.append(f"User is currently on page: {uicontext.get('route', 'unknown')}") if uicontext.get("objectType") and uicontext.get("objectId"): 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"Current environment: {uicontext['envId']}") lines.append("[/USER CONTEXT]") return "\n".join(lines) ``` --- ## File 10: `backend/src/agent/_tool_filter.py` (NEW) ```python # 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. from typing import Any # Mapping: objectType → set of relevant tool operations _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", "show_capabilities", "get_task_status", "list_environments", }, "dataset": { "superset_explore_database", "superset_format_sql", "superset_audit_permissions", "superset_execute_sql", "superset_create_dataset", "search_dashboards", "show_capabilities", "get_task_status", "list_environments", }, "migration": { "execute_migration", "search_dashboards", "get_health_summary", "deploy_dashboard", "show_capabilities", "list_environments", }, } def filter_tools_by_context(tools: list[Any], object_type: str | None) -> list[Any]: """Filter tool list to context-relevant tools only.""" if not object_type or object_type not in _CONTEXT_TOOL_AFFINITY: return tools # no filter — return all tools allowed = _CONTEXT_TOOL_AFFINITY[object_type] # Always include show_capabilities allowed.add("show_capabilities") return [t for t in tools if t.name in allowed] def get_tool_affinity(object_type: str | None) -> list[dict[str, Any]]: """Return affinity list for debug panel (all tools, marked active/inactive).""" # Used by debug endpoint ... # #endregion AgentChat.ToolFilter ``` --- ## Итого по изменениям | Файл | Тип изменения | Строк (±) | |------|:------------:|:---------:| | `AgentChatTypes.ts` | Расширение типов | +35 | | `AgentChatModel.svelte.ts` | Добавление атомов/derived/actions | +60 | | `AgentChat.StreamProcessor.svelte.ts` | Новые handler'ы | +35 | | `AgentChat.svelte` | Prod bg + banner + pill | +15 | | `ConfirmationCard.svelte` | Новые состояния + countdown | +50 | | `ToolCallCard.svelte` | Новые состояния + retry button | +25 | | `assistantChat.svelte.ts` | Поле initialContext | +5 | | `agent/+page.svelte` | onMount context init | +10 | | `backend: app.py` | uicontext parsing | +25 | | `backend: _tool_filter.py` | Новый файл | +40 | | **Всего** | | **~300 строк** | #endregion ModelChanges