== 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
19 KiB
19 KiB
#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:
// ── 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<string, unknown>;
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:
export interface ToolCall {
id: string;
name: string;
status: "executing" | "retrying" | "completed" | "failed" | "timeout" | "cancelled";
input?: Record<string, unknown>;
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):
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):
import type { UIContext, ToolAffinityEntry, ConfirmMetadataV2 } from "./AgentChatTypes.js";
2b. ADD atoms (after existing envId atom, ~line 113):
// ── 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):
// ── 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<string, string> = {
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):
// ── 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:
// В методе, который сбрасывает состояние перед новым стримом:
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):
// После существующих 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:
// В обработчике 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<string, unknown> | undefined,
}];
host.pendingToolName = null;
host.pendingToolArgs = {};
}
File 4: frontend/src/routes/agent/+page.svelte
4a. ADD onMount context init:
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
// ... existing imports ...
onMount(() => {
// Read context from URL params
const params = new URLSearchParams($page.url.search);
model.setUIContextFromParams(params);
model.checkLlmStatus();
});
</script>
4b. ADD EnvSelector integration:
<!-- Если EnvSelector присутствует на странице агента: -->
<EnvSelector
selectedEnvId={model._activeEnvId}
onEnvChange={(envId) => model.updateActiveEnv(envId)}
/>
File 5: frontend/src/lib/components/agent/AgentChat.svelte
5a. ADD prod background binding (корневой div):
<div
class="h-full flex flex-col min-h-0 {model.showProdWarning ? 'bg-warning-light' : 'bg-surface-page'}"
>
5b. ADD prod banner (перед header):
{#if model.showProdWarning}
<div class="bg-destructive text-white text-xs font-bold text-center py-0.5 shrink-0" role="status" aria-live="polite">
<Icon name="warning" size={12} class="inline mr-1" />
PRODUCTION
</div>
{/if}
5c. UPDATE process steps rendering — первый шаг использует contextPillLabel:
<!-- Вместо хардкода "Контекст: ss-dev" использовать model.contextPillLabel -->
{#each model.processSteps as step}
<span class="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium {processStepClasses(step.state)}">
{step.label}
</span>
{/each}
File 6: frontend/src/lib/components/assistant/ConfirmationCard.svelte
6a. ADD env badge to header:
{#if meta.env_context}
<span class="rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold
{meta.env_context === 'prod' ? 'bg-destructive-light text-destructive' : ''}
{meta.env_context === 'staging' ? 'bg-warning-light text-warning' : ''}
{meta.env_context === 'dev' ? 'bg-info-light text-info' : ''}
">
{meta.env_context === 'prod' ? '👑 PRODUCTION' : meta.env_context}
</span>
{/if}
6b. ADD dangerous countdown logic:
<script lang="ts">
// ... existing ...
let dangerousTimer = $state(10);
let timerInterval: ReturnType<typeof setInterval> | null = null;
$effect(() => {
if (model.streamingState === "awaiting_confirmation" && model.pendingRiskLevel === "dangerous") {
dangerousTimer = 10;
timerInterval = setInterval(() => {
dangerousTimer--;
if (dangerousTimer <= 0) {
if (timerInterval) clearInterval(timerInterval);
timerInterval = null;
}
}, 1000);
}
return () => {
if (timerInterval) clearInterval(timerInterval);
};
});
</script>
<!-- Кнопка подтверждения для dangerous: -->
<Button
variant="destructive"
size="sm"
isLoading={loading === "confirming"}
disabled={loading !== "idle" || dangerousTimer > 0}
onclick={handleConfirm}
>
{dangerousTimer > 0
? `💀 Удалить (${dangerousTimer})`
: loading === "confirming"
? "Подтверждение…"
: "💀 Удалить"}
</Button>
6c. ADD permission_denied state (alternatives list):
{#if !meta.permission_granted}
<div class="mb-3 rounded-lg border border-border bg-surface-page px-3.5 py-2.5">
<p class="text-sm font-medium text-text mb-1">
🔒 Недостаточно прав
</p>
<p class="text-xs text-text-muted mb-2">
Требуется роль: <code class="rounded bg-surface-muted px-1 py-0.5 font-mono text-destructive">{meta.required_role}</code>
</p>
{#if meta.alternatives?.length}
<p class="text-xs font-medium text-text mb-1">💡 Альтернативы:</p>
<ul class="space-y-1">
{#each meta.alternatives as alt}
<li class="text-xs text-text-muted">• {alt.prompt}</li>
{/each}
</ul>
{/if}
</div>
<Button variant="ghost" size="sm" onclick={handleClosePermissionDenied}>Закрыть</Button>
{/if}
File 7: frontend/src/lib/components/assistant/ToolCallCard.svelte
7a. ADD new states:
<script lang="ts">
export let onRetry: (() => void) | undefined = undefined;
export let onCancelTimeout: (() => void) | undefined = undefined;
</script>
{#if tool.status === "retrying"}
<div class="flex items-center gap-2">
<span class="animate-spin">⟳</span>
<span class="text-xs text-text-muted">
Повторная попытка... ({tool.retryCount}/{tool.maxRetries})
</span>
</div>
{:else if tool.status === "timeout"}
<div class="flex items-center gap-2">
<span>⏱️</span>
<span class="text-xs text-warning">Превышено время ожидания ({tool.timeoutSeconds}с)</span>
</div>
{#if onRetry}
<Button variant="secondary" size="xs" onclick={onRetry} class="mt-1">
🔄 Повторить
</Button>
{/if}
{:else if tool.status === "cancelled"}
<div class="flex items-center gap-2">
<span>🚫</span>
<span class="text-xs text-text-muted">Отменено пользователем</span>
</div>
{/if}
File 8: frontend/src/lib/stores/assistantChat.svelte.ts
8a. ADD initialContext field:
// Добавить в существующий объект 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:
# В сигнатуре 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:
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)
# 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