Improve agent UX and spec sync

This commit is contained in:
2026-07-03 16:47:10 +03:00
parent 3fae6add87
commit b1efc38306
22 changed files with 615 additions and 94 deletions

View File

@@ -55,6 +55,33 @@ from src.core.logger import logger
JWT_SECRET = os.environ["JWT_SECRET"] # @INVARIANT JWT_SECRET must be set in environment — crash-early, no default fallback JWT_SECRET = os.environ["JWT_SECRET"] # @INVARIANT JWT_SECRET must be set in environment — crash-early, no default fallback
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
def _now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
async def _build_agent_context(env_id: str | None) -> str:
"""Build hidden runtime context for the LLM without storing it as user text."""
parts = [
"[RUNTIME CONTEXT]",
f"Current datetime: {_now_iso()}",
"If the user asks to start/run something without an explicit start time, use Current datetime as start_time.",
"If the user gives a duration such as '15 minutes' or '2 hours', compute end_time from Current datetime.",
]
if env_id:
parts.append(f"Current environment_id: {env_id}")
dashboards = await prefetch_dashboards(env_id)
if dashboards:
parts.extend([
"",
"[PRE-FETCHED DASHBOARDS]",
dashboards,
"[/PRE-FETCHED DASHBOARDS]",
"Use the pre-fetched dashboards directly for dashboard name/id resolution.",
])
parts.append("[/RUNTIME CONTEXT]")
return "\n".join(parts)
# ── Session state ─────────────────────────────────────────────── # ── Session state ───────────────────────────────────────────────
# In-memory per-user lock (keyed by user_id) # In-memory per-user lock (keyed by user_id)
_user_locks: dict[str, bool] = {} _user_locks: dict[str, bool] = {}
@@ -262,6 +289,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
text = text[:last_sentence_end + 1] + "\n[...truncated]" text = text[:last_sentence_end + 1] + "\n[...truncated]"
else: else:
text = truncated + "\n[...truncated]" text = truncated + "\n[...truncated]"
visible_user_text = text
# ── File upload ── # ── File upload ──
file_storage_path: str | None = None file_storage_path: str | None = None
@@ -285,6 +313,11 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
parsed = parse_upload(file_obj) parsed = parse_upload(file_obj)
text = f"{text}\n\n--- Uploaded file content ---\n{parsed}" text = f"{text}\n\n--- Uploaded file content ---\n{parsed}"
runtime_context = await _build_agent_context(env_id)
agent_text = f"{visible_user_text}\n\n{runtime_context}"
if text != visible_user_text:
agent_text = f"{text}\n\n{runtime_context}"
# ── Yield file metadata for frontend download link ── # ── Yield file metadata for frontend download link ──
if file_storage_path and file_original_name: if file_storage_path and file_original_name:
yield json.dumps({ yield json.dumps({
@@ -338,7 +371,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
try: try:
emitted_any = False emitted_any = False
async for event in agent.astream_events( async for event in agent.astream_events(
{"messages": [HumanMessage(content=text)]}, {"messages": [HumanMessage(content=agent_text)]},
config=config, config=config,
version="v2", version="v2",
): ):
@@ -382,7 +415,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
state = await agent.aget_state(config) state = await agent.aget_state(config)
if getattr(state, "next", None): if getattr(state, "next", None):
emitted_any = True emitted_any = True
yield confirmation_payload(conv_id, state, text) yield confirmation_payload(conv_id, state, visible_user_text)
return return
elif not emitted_any: elif not emitted_any:
yield json.dumps({ yield json.dumps({
@@ -408,7 +441,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"retryable": True, "retryable": True,
}, },
}) })
await save_conversation(conv_id, text, user_id, assistant_text="") await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return return
except (APITimeoutError, httpx.ReadTimeout) as exc: except (APITimeoutError, httpx.ReadTimeout) as exc:
@@ -426,7 +459,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"retryable": True, "retryable": True,
}, },
}) })
await save_conversation(conv_id, text, user_id, assistant_text="") await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return return
except AuthenticationError as exc: except AuthenticationError as exc:
@@ -444,7 +477,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"retryable": False, "retryable": False,
}, },
}) })
await save_conversation(conv_id, text, user_id, assistant_text="") await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return return
except OutputParserException as e: except OutputParserException as e:
@@ -472,7 +505,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
try: try:
state = await agent.aget_state(config) state = await agent.aget_state(config)
if getattr(state, "next", None): if getattr(state, "next", None):
yield confirmation_payload(conv_id, state, text) yield confirmation_payload(conv_id, state, visible_user_text)
return return
except Exception: except Exception:
pass pass
@@ -480,12 +513,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": f"❌ Ошибка: {exc}", "content": f"❌ Ошибка: {exc}",
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)}, "metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},
}) })
await save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts)) await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(assistant_parts))
return return
await save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts)) await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(assistant_parts))
# Fire-and-forget: generate LLM title in background (best-effort) # Fire-and-forget: generate LLM title in background (best-effort)
asyncio.create_task(generate_llm_title(conv_id, text)) asyncio.create_task(generate_llm_title(conv_id, visible_user_text))
logger.reflect( logger.reflect(
"Agent handler completed", "Agent handler completed",
payload={"conv_id": conv_id, "assistant_len": len("".join(assistant_parts))}, payload={"conv_id": conv_id, "assistant_len": len("".join(assistant_parts))},

View File

@@ -192,7 +192,12 @@ async def create_agent(
"You handle all intent detection — multi-intent queries, negations (\"don't run\"), " "You handle all intent detection — multi-intent queries, negations (\"don't run\"), "
"synonyms (\"панели\" = \"дашборды\"), and typos are your responsibility. " "synonyms (\"панели\" = \"дашборды\"), and typos are your responsibility. "
"Call the right tool(s) for the job. If data is already provided in context, " "Call the right tool(s) for the job. If data is already provided in context, "
"use it directly rather than calling redundant tools." "use it directly rather than calling redundant tools. "
"For maintenance requests, use the RUNTIME CONTEXT current datetime when the user says "
"\"start\", \"run\", \"now\", \"запусти\", or \"сейчас\" without an explicit start time. "
"Convert user durations into end_time. Do not ask for ISO datetime in that case. "
"If a user asks for dashboard maintenance, resolve the dashboard from provided context or tools, "
"then infer affected tables when possible; ask for table names only after resolution fails."
) )
if env_id: if env_id:
prompt += f"\n\nCurrent environment: '{env_id}'. When calling tools that accept env_id, use this value." prompt += f"\n\nCurrent environment: '{env_id}'. When calling tools that accept env_id, use this value."

View File

@@ -265,6 +265,41 @@ class TestAgentHandler:
token_data = json.loads(results[0]) token_data = json.loads(results[0])
assert token_data["metadata"]["type"] == "stream_token" assert token_data["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_normal_send_hides_runtime_context_from_saved_history(self, mock_request):
from src.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "ok"
agent = _make_agent_mock([
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
])
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")), \
patch("src.agent.app.generate_llm_title", AsyncMock()), \
patch("src.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler(
"Запусти обслуживание дашборда USA на 15 минут",
[],
mock_request,
"conv-runtime",
None,
None,
None,
"ss-dev",
)]
assert len(results) == 1
messages_arg = agent.astream_events.call_args.args[0]["messages"]
llm_text = messages_arg[0].content
assert "[RUNTIME CONTEXT]" in llm_text
assert "Current datetime:" in llm_text
assert "Available dashboards" in llm_text
save_mock.assert_called_once()
assert save_mock.call_args.args[1] == "Запусти обслуживание дашборда USA на 15 минут"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_events_yielded(self, mock_request): async def test_tool_events_yielded(self, mock_request):
from src.agent.app import agent_handler from src.agent.app import agent_handler

View File

@@ -36,6 +36,7 @@
let messagesContainer: HTMLDivElement | undefined = $state(); let messagesContainer: HTMLDivElement | undefined = $state();
let pendingFile: File | null = $state(null); let pendingFile: File | null = $state(null);
let isDragOver = $state(false); let isDragOver = $state(false);
let isDiagnosticsMenuOpen = $state(false);
let dragCounter = 0; let dragCounter = 0;
const ALLOWED_FILE_TYPES = [".pdf", ".xlsx", ".json", ".csv", ".txt", ".png", ".jpeg", ".jpg"]; const ALLOWED_FILE_TYPES = [".pdf", ".xlsx", ".json", ".csv", ".txt", ".png", ".jpeg", ".jpg"];
@@ -268,6 +269,17 @@
pendingFile = file; pendingFile = file;
} }
function closeDiagnosticsMenu() {
isDiagnosticsMenuOpen = false;
}
function handleDiagnosticsKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
closeDiagnosticsMenu();
}
}
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + " B"; if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
@@ -286,6 +298,13 @@
return "border-border bg-surface-muted text-text-muted"; return "border-border bg-surface-muted text-text-muted";
} }
function processStepClasses(state: string): string {
if (state === "done") return "border-success bg-success-light text-success";
if (state === "active") return "border-warning bg-warning-light text-warning";
if (state === "blocked") return "border-destructive-ring bg-destructive-light text-destructive";
return "border-border bg-surface-page text-text-subtle";
}
async function copyDebugInfo() { async function copyDebugInfo() {
const lastMsg = model.messages.at(-1); const lastMsg = model.messages.at(-1);
const payload = { const payload = {
@@ -380,29 +399,57 @@
</div> </div>
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
<button <div class="relative" onkeydown={handleDiagnosticsKeydown}>
class={`rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${model.isDebugPanelOpen <button
? "border-primary-ring bg-primary-light text-primary" class={`rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${model.isDebugPanelOpen || isDiagnosticsMenuOpen
: "border-border-strong bg-surface-card text-text hover:bg-surface-muted"}`} ? "border-primary-ring bg-primary-light text-primary"
onclick={() => model.toggleDebugPanel()} : "border-border-strong bg-surface-card text-text hover:bg-surface-muted"}`}
title="Agent debug information" onclick={() => isDiagnosticsMenuOpen = !isDiagnosticsMenuOpen}
> title="Диагностика агента"
<div class="flex items-center gap-1.5"> aria-label="Диагностика агента"
<Icon name="info" size={14} /> aria-expanded={isDiagnosticsMenuOpen}
<span class="hidden sm:inline">Info</span> >
</div> <div class="flex items-center gap-1.5">
</button> <Icon name="info" size={14} />
<button <span class="hidden sm:inline">Диагностика</span>
class="hidden rounded-lg border border-border-strong bg-surface-card px-2.5 py-1.5 text-xs font-medium text-text transition hover:bg-surface-muted sm:inline-flex" </div>
onclick={copyDebugInfo} </button>
title="Copy agent debug information" {#if isDiagnosticsMenuOpen}
aria-label="Copy agent debug information" <button
> type="button"
<div class="flex items-center gap-1.5"> class="fixed inset-0 z-10 cursor-default"
<Icon name="copy" size={14} /> aria-label="Закрыть меню диагностики"
<span class="hidden sm:inline">Copy</span> onclick={closeDiagnosticsMenu}
</div> tabindex="-1"
</button> ></button>
<div class="absolute right-0 top-9 z-20 w-56 rounded-lg border border-border bg-surface-card p-1.5 text-xs shadow-lg" role="menu" aria-label="Диагностика агента">
<button
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-text hover:bg-surface-muted"
onclick={() => { model.toggleDebugPanel(); isDiagnosticsMenuOpen = false; }}
role="menuitem"
>
<Icon name="info" size={14} />
{model.isDebugPanelOpen ? "Скрыть debug-панель" : "Показать debug-панель"}
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-text hover:bg-surface-muted"
onclick={() => { copyDebugInfo(); isDiagnosticsMenuOpen = false; }}
role="menuitem"
>
<Icon name="copy" size={14} />
Скопировать debug info
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-text hover:bg-surface-muted"
onclick={() => { model.checkLlmStatus(); isDiagnosticsMenuOpen = false; }}
role="menuitem"
>
<Icon name="refresh" size={14} />
Проверить LLM
</button>
</div>
{/if}
</div>
{#if model.streamingState === "streaming"} {#if model.streamingState === "streaming"}
<button <button
class="rounded-lg border border-destructive-ring bg-destructive-light px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive hover:text-white" class="rounded-lg border border-destructive-ring bg-destructive-light px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive hover:text-white"
@@ -445,6 +492,32 @@
{/if} {/if}
</div> </div>
<div class="mt-3 rounded-lg border border-border bg-surface-page px-3 py-2">
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class={`h-2 w-2 shrink-0 rounded-full ${model.agentPhaseTone === "destructive" ? "bg-destructive" : model.agentPhaseTone === "warning" ? "bg-warning" : model.agentPhaseTone === "success" ? "bg-success" : "bg-border-strong"}`}></span>
<span class="truncate text-xs font-semibold text-text">{model.userFacingStatusTitle}</span>
</div>
<p class="mt-0.5 truncate text-xs text-text-muted">{model.userFacingStatusDetail}</p>
</div>
<div class="flex shrink-0 flex-wrap gap-1.5">
{#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)}`}>
{#if step.state === "done"}
<Icon name="check" size={12} />
{:else if step.state === "active"}
<Icon name="activity" size={12} />
{:else if step.state === "blocked"}
<Icon name="warning" size={12} />
{/if}
{step.label}
</span>
{/each}
</div>
</div>
</div>
{#if model.isDebugPanelOpen} {#if model.isDebugPanelOpen}
<div class="mt-3 grid gap-2 rounded-lg border border-border bg-surface-page px-3 py-2 text-[11px] text-text-muted sm:grid-cols-2 lg:grid-cols-5"> <div class="mt-3 grid gap-2 rounded-lg border border-border bg-surface-page px-3 py-2 text-[11px] text-text-muted sm:grid-cols-2 lg:grid-cols-5">
<!-- ── Row 1: IDs ── --> <!-- ── Row 1: IDs ── -->
@@ -595,7 +668,7 @@
{#if msg.role === "user"} {#if msg.role === "user"}
<!-- User bubble --> <!-- User bubble -->
<div class="max-w-[75%] rounded-2xl bg-primary px-4 py-3 text-white shadow-sm"> <div class="max-w-[75%] rounded-2xl bg-primary px-4 py-3 text-white shadow-sm">
<p class="text-sm whitespace-pre-wrap leading-relaxed">{msg.text}</p> <p class="text-sm whitespace-pre-wrap leading-relaxed">{model.cleanVisibleMessageText(msg.text)}</p>
{#if msg.attachment} {#if msg.attachment}
<a <a
href={`/api/storage/file?path=${encodeURIComponent(msg.attachment.file_path)}`} href={`/api/storage/file?path=${encodeURIComponent(msg.attachment.file_path)}`}
@@ -747,20 +820,46 @@
<!-- Error --> <!-- Error -->
{#if model.streamingState === "error" && model.error} {#if model.streamingState === "error" && model.error}
<div class="flex justify-start"> <div class="flex justify-start">
<div class="max-w-[80%] rounded-2xl border border-destructive-ring bg-destructive-light px-4 py-3" role="alert"> <div class="w-full max-w-[86%] rounded-2xl border border-destructive-ring bg-destructive-light px-4 py-3" role="alert">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-start gap-3">
<svg class="w-5 h-5 text-destructive shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-destructive">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/> <Icon name="warning" size={17} />
</svg> </div>
<span class="text-sm font-semibold text-destructive">Ошибка</span> <div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-destructive">Агент не завершил ответ</div>
<p class="mt-1 text-sm text-destructive">{model.error}</p>
<div class="mt-3 grid gap-2 sm:flex sm:flex-wrap">
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-destructive-ring bg-white px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive-light"
onclick={() => model.retryLastUserMessage()}
>
<Icon name="refresh" size={13} />
Повторить последний запрос
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-white px-3 py-1.5 text-xs font-semibold text-text transition hover:bg-surface-muted"
onclick={() => model.checkLlmStatus()}
>
<Icon name="activity" size={13} />
Проверить LLM
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-white px-3 py-1.5 text-xs font-semibold text-text transition hover:bg-surface-muted"
onclick={copyDebugInfo}
>
<Icon name="copy" size={13} />
Скопировать debug
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-white px-3 py-1.5 text-xs font-semibold text-text transition hover:bg-surface-muted"
onclick={() => model.createConversation()}
>
<Icon name="plus" size={13} />
Новый диалог
</button>
</div>
</div>
</div> </div>
<p class="text-sm text-destructive">{model.error}</p>
<button
class="mt-3 rounded-lg border border-destructive-ring bg-white px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive-light"
onclick={() => model.sendMessage(inputText)}
>
{$t.assistant?.retry || "Повторить"}
</button>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -57,6 +57,7 @@ export interface AgentChatModelOptions {
export type AgentPhase = "ready" | "thinking" | "tooling" | "confirming" | "failed" | "offline"; export type AgentPhase = "ready" | "thinking" | "tooling" | "confirming" | "failed" | "offline";
export type AgentPhaseTone = "success" | "warning" | "destructive" | "muted"; export type AgentPhaseTone = "success" | "warning" | "destructive" | "muted";
export type ConfirmationRisk = "read" | "write" | "unknown"; export type ConfirmationRisk = "read" | "write" | "unknown";
export type AgentProcessStepState = "done" | "active" | "blocked" | "idle";
export interface AgentQuickAction { export interface AgentQuickAction {
id: "dashboards" | "migration" | "tasks" | "health"; id: "dashboards" | "migration" | "tasks" | "health";
@@ -66,6 +67,12 @@ export interface AgentQuickAction {
prompt: string; prompt: string;
} }
export interface AgentProcessStep {
id: "context" | "reasoning" | "tools" | "confirmation" | "result";
label: string;
state: AgentProcessStepState;
}
export class AgentChatModel { export class AgentChatModel {
// ── Atoms ────────────────────────────────────────────────────── // ── Atoms ──────────────────────────────────────────────────────
messages: AgentMessage[] = $state([]); messages: AgentMessage[] = $state([]);
@@ -185,6 +192,63 @@ export class AgentChatModel {
? (this.conversationListItems.find((c) => c.id === this.currentConversationId)?.title || "Агент-чат") ? (this.conversationListItems.find((c) => c.id === this.currentConversationId)?.title || "Агент-чат")
: "Агент-чат", : "Агент-чат",
); );
hasSilentStream = $derived(
this.streamingState === "streaming" &&
this.partialText.length === 0 &&
this.partialTokens.length === 0 &&
this.activeToolCalls.length === 0,
);
userFacingStatusTitle = $derived.by(() => {
if (this.connectionState !== "connected") return "Нет соединения с агентом";
if (this.streamingState === "awaiting_confirmation") return "Требуется подтверждение";
if (this.streamingState === "error") return "Агент остановился";
if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "Выполняется инструмент";
if (this.hasSilentStream) return "Ожидаю первый ответ";
if (this.streamingState === "streaming") return "Агент отвечает";
return "Готов к задаче";
});
userFacingStatusDetail = $derived.by(() => {
if (this.connectionState !== "connected") return "Переподключитесь перед отправкой новой команды.";
if (this.streamingState === "awaiting_confirmation") return "Проверьте действие и параметры перед продолжением.";
if (this.streamingState === "error") return this.error || "Можно повторить последний запрос или начать новый диалог.";
if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "Действие уже передано инструменту, дождитесь результата.";
if (this.hasSilentStream) return "Если upstream LLM не начнет отвечать, чат покажет восстановление вместо бесконечного ожидания.";
if (this.streamingState === "streaming") return "Ответ поступает по стриму.";
if (this.envId) return `Контекст готов: окружение ${this.envId}.`;
return "Выберите окружение перед задачами, которые работают с Superset.";
});
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.envId ? `Контекст: ${this.envId}` : "Контекст",
state: this.envId ? "done" : failed ? "blocked" : "idle",
},
{
id: "reasoning",
label: this.hasSilentStream ? "Ожидание LLM" : "Понимание задачи",
state: failed ? "blocked" : this.streamingState === "streaming" && !hasTools ? "active" : hasAssistantResult || hasTools ? "done" : "idle",
},
{
id: "tools",
label: "Инструменты",
state: this.activeToolCalls.some((tool) => tool.status === "executing") ? "active" : hasTools ? "done" : "idle",
},
{
id: "confirmation",
label: "Подтверждение",
state: this.streamingState === "awaiting_confirmation" ? "active" : this.pendingThreadId ? "done" : "idle",
},
{
id: "result",
label: "Результат",
state: failed ? "blocked" : this.streamingState === "idle" && hasAssistantResult ? "done" : "idle",
},
];
});
agentPhase = $derived.by((): AgentPhase => { agentPhase = $derived.by((): AgentPhase => {
if (this.connectionState !== "connected") return "offline"; if (this.connectionState !== "connected") return "offline";
if (this.streamingState === "awaiting_confirmation") return "confirming"; if (this.streamingState === "awaiting_confirmation") return "confirming";
@@ -312,9 +376,11 @@ export class AgentChatModel {
normalizeConversationTitle(rawTitle: unknown): string { normalizeConversationTitle(rawTitle: unknown): string {
const title = String(rawTitle ?? "") const title = String(rawTitle ?? "")
// Strip file upload markers (fallback for old conversations) // Strip file upload markers (fallback for old conversations)
.replace(/\n--- Uploaded file content ---[\s\S]*$/i, "") .replace(/(?:^|\n)--- Uploaded file content ---[\s\S]*$/i, "")
// Strip pre-fetched data blocks // Strip pre-fetched data blocks
.replace(/\s*\[PRE-FETCHED DATA[\s\S]*$/i, "") .replace(/\s*\[PRE-FETCHED DATA[\s\S]*$/i, "")
.replace(/^✅\s*/, "")
.replace(/^(list|get|show|check|search)_\w+$/i, "Системное действие")
// Strip trailing "Available dashboards..." suffix // Strip trailing "Available dashboards..." suffix
.replace(/\s+Available(?:\s+\S+)*$/i, "") .replace(/\s+Available(?:\s+\S+)*$/i, "")
// Collapse whitespace // Collapse whitespace
@@ -323,6 +389,25 @@ export class AgentChatModel {
return title || "Новый диалог"; return title || "Новый диалог";
} }
cleanVisibleMessageText(rawText: unknown): string {
return String(rawText ?? "")
.replace(/(?:^|\n)--- Uploaded file content ---[\s\S]*$/i, "")
.replace(/\n?\[PRE-FETCHED DATA[\s\S]*?```/i, "")
.replace(/\n?\[PRE-FETCHED DATA[\s\S]*?<\/pre>/i, "")
.replace(/\n?\[PRE-FETCHED DATA[\s\S]*$/i, "")
.replace(/\n?\[\/PRE-FETCHED DATA\][\s\S]*$/i, "")
.trim();
}
async retryLastUserMessage(): Promise<void> {
const lastUserMessage = [...this.messages].reverse().find((message) => message.role === "user");
const text = this.cleanVisibleMessageText(lastUserMessage?.text || "");
if (!text) return;
this.streamingState = "idle";
this.error = null;
await this.sendMessage(text);
}
async sendMessage(text: string, files?: File[]): Promise<void> { async sendMessage(text: string, files?: File[]): Promise<void> {
// Sync reactive values from the page component // Sync reactive values from the page component
this.onBeforeSend?.(); this.onBeforeSend?.();
@@ -374,6 +459,7 @@ export class AgentChatModel {
// processStream iterates all data events from the Gradio SSE stream. // processStream iterates all data events from the Gradio SSE stream.
const streamDone = await Promise.race([ const streamDone = await Promise.race([
this.streamProcessor.processStream(this._submission, convId), this.streamProcessor.processStream(this._submission, convId),
this._firstActivityTimeout(60_000),
this.streamProcessor.streamCloseWatcher(this._client!, 180_000), this.streamProcessor.streamCloseWatcher(this._client!, 180_000),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 180_000)), new Promise<false>((resolve) => setTimeout(() => resolve(false), 180_000)),
]); ]);
@@ -444,6 +530,21 @@ export class AgentChatModel {
this.pendingRiskLevel = null; this.pendingRiskLevel = null;
} }
private async _firstActivityTimeout(ms: number): Promise<false> {
await new Promise((resolve) => setTimeout(resolve, ms));
const hasActivity =
this.partialText.length > 0 ||
this.partialTokens.length > 0 ||
this.activeToolCalls.length > 0 ||
this.streamingState === "awaiting_confirmation" ||
Boolean(this.pendingThreadId);
if (!hasActivity && this.streamingState === "streaming" && !this._userCancelled) {
this.error = "Агент не начал отвечать за 60 секунд. Проверьте LLM/Gradio и попробуйте ещё раз.";
return false;
}
return new Promise<false>(() => {});
}
/** Commit in-progress streaming text + tool calls as an assistant message. /** Commit in-progress streaming text + tool calls as an assistant message.
* Called before switching conversations or creating a new one while streaming. */ * Called before switching conversations or creating a new one while streaming. */
private _commitStreamingPartial(): void { private _commitStreamingPartial(): void {
@@ -528,6 +629,7 @@ export class AgentChatModel {
// if Gradio stream closes without a clean completion event. // if Gradio stream closes without a clean completion event.
const streamDone = await Promise.race([ const streamDone = await Promise.race([
this.streamProcessor.processStream(this._submission, this.currentConversationId), this.streamProcessor.processStream(this._submission, this.currentConversationId),
this._firstActivityTimeout(60_000),
this.streamProcessor.streamCloseWatcher(this._client!, 180_000), this.streamProcessor.streamCloseWatcher(this._client!, 180_000),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 180_000)), new Promise<false>((resolve) => setTimeout(() => resolve(false), 180_000)),
]); ]);
@@ -660,7 +762,7 @@ export class AgentChatModel {
id: (msg.message_id as string) ?? (msg.id as string) ?? "", id: (msg.message_id as string) ?? (msg.id as string) ?? "",
conversation_id: msg.conversation_id as string ?? "", conversation_id: msg.conversation_id as string ?? "",
role: msg.role as string ?? "assistant", role: msg.role as string ?? "assistant",
text: msg.text as string ?? "", text: this.cleanVisibleMessageText(msg.text),
metadata: msg.metadata as StreamMetadata, metadata: msg.metadata as StreamMetadata,
toolCalls: (msg.tool_calls as ToolCall[]) ?? [], toolCalls: (msg.tool_calls as ToolCall[]) ?? [],
created_at: msg.created_at as string ?? "", created_at: msg.created_at as string ?? "",
@@ -800,7 +902,10 @@ export class AgentChatModel {
private _loadFromStorage(conversationId: string): boolean { private _loadFromStorage(conversationId: string): boolean {
const data = this.storage.load(conversationId); const data = this.storage.load(conversationId);
if (data) { if (data) {
this.messages = data.messages; this.messages = data.messages.map((message) => ({
...message,
text: this.cleanVisibleMessageText(message.text),
}));
this.currentConversationId = data.conversationId; this.currentConversationId = data.conversationId;
return true; return true;
} }

View File

@@ -258,6 +258,45 @@ describe("AgentChatModel — Derived State", () => {
expect(model.isConversationSidebarOpen).toBe(false); expect(model.isConversationSidebarOpen).toBe(false);
}); });
it("user-facing status distinguishes silent stream from normal ready state", () => {
model.envId = "ss-dev";
expect(model.userFacingStatusTitle).toBe("Готов к задаче");
expect(model.userFacingStatusDetail).toContain("ss-dev");
model.streamingState = "streaming";
expect(model.hasSilentStream).toBe(true);
expect(model.userFacingStatusTitle).toBe("Ожидаю первый ответ");
expect(model.processSteps.find((step) => step.id === "reasoning")?.state).toBe("active");
model.error = "timeout";
model.streamingState = "error";
expect(model.userFacingStatusTitle).toBe("Агент остановился");
expect(model.processSteps.find((step) => step.id === "result")?.state).toBe("blocked");
});
it("normalizeConversationTitle removes service-only history noise", () => {
expect(model.normalizeConversationTitle("--- Uploaded file content ---\nid,name")).toBe("Новый диалог");
expect(model.normalizeConversationTitle("✅ list_environments")).toBe("Системное действие");
expect(model.normalizeConversationTitle("Покажи доступные дашборды\n[PRE-FETCHED DATA]\nsecret")).toBe("Покажи доступные дашборды");
});
it("retryLastUserMessage resends the last clean user request", async () => {
const sendSpy = vi.spyOn(model, "sendMessage").mockResolvedValue(undefined);
model.streamingState = "error";
model.error = "timeout";
model.messages = [
{ id: "1", conversation_id: "c1", role: "user", text: "first", toolCalls: [], created_at: "" },
{ id: "2", conversation_id: "c1", role: "assistant", text: "failed", toolCalls: [], created_at: "" },
{ id: "3", conversation_id: "c1", role: "user", text: "run\n[PRE-FETCHED DATA]\nsecret", toolCalls: [], created_at: "" },
];
await model.retryLastUserMessage();
expect(model.streamingState).toBe("idle");
expect(model.error).toBeNull();
expect(sendSpy).toHaveBeenCalledWith("run");
});
it("conversationListItems sanitizes pre-fetched prompt noise for views", () => { it("conversationListItems sanitizes pre-fetched prompt noise for views", () => {
model.conversations = [{ model.conversations = [{
id: "c1", id: "c1",
@@ -269,6 +308,20 @@ describe("AgentChatModel — Derived State", () => {
expect(model.conversationListItems[0].title).toBe("Покажи доступные дашборды"); expect(model.conversationListItems[0].title).toBe("Покажи доступные дашборды");
}); });
it("cleanVisibleMessageText strips hidden context from displayed messages", () => {
const raw = [
"Запусти обслуживание дашборда USA на 15 минут",
"",
"[PRE-FETCHED DATA — use this directly, do NOT call tools]",
"Available dashboards in environment 'ss-dev'",
"- USA Births Names (id: 15)",
"[/PRE-FETCHED DATA]",
].join("\n");
expect(model.cleanVisibleMessageText(raw)).toBe("Запусти обслуживание дашборда USA на 15 минут");
expect(model.cleanVisibleMessageText("Проанализируй\n\n--- Uploaded file content ---\na,b")).toBe("Проанализируй");
});
it("currentConversationTitle uses sanitized conversation title", () => { it("currentConversationTitle uses sanitized conversation title", () => {
model.currentConversationId = "c1"; model.currentConversationId = "c1";
model.conversations = [{ model.conversations = [{

View File

@@ -8,13 +8,14 @@
<!-- @REJECTED SvelteKit `load()` function rejected — Gradio client connection is browser-only (Client.connect) and cannot run during SSR. Inline sidebar toggle state in page (not delegated to model) rejected — ConversationList open/close is a cross-widget concern tied to the agent chat layout, not page-level UI. <Button> component from $lib/ui rejected for the sidebar toggle chrome control (line 102) — the w-6 h-12 shape + border-radius combination is too custom for Button's variant system; raw <button> is justified by @RATIONALE inline. --> <!-- @REJECTED SvelteKit `load()` function rejected — Gradio client connection is browser-only (Client.connect) and cannot run during SSR. Inline sidebar toggle state in page (not delegated to model) rejected — ConversationList open/close is a cross-widget concern tied to the agent chat layout, not page-level UI. <Button> component from $lib/ui rejected for the sidebar toggle chrome control (line 102) — the w-6 h-12 shape + border-radius combination is too custom for Button's variant system; raw <button> is justified by @RATIONALE inline. -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { browser } from "$app/environment";
import { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts"; import { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
import { Client } from "@gradio/client"; import { Client } from "@gradio/client";
import AgentChat from "$lib/components/agent/AgentChat.svelte"; import AgentChat from "$lib/components/agent/AgentChat.svelte";
import ConversationList from "$lib/components/assistant/ConversationList.svelte"; import ConversationList from "$lib/components/assistant/ConversationList.svelte";
import { sidebarStore } from "$lib/stores/sidebar.svelte.js"; import { sidebarStore } from "$lib/stores/sidebar.svelte.js";
import { auth } from "$lib/auth/store.svelte.js"; import { auth } from "$lib/auth/store.svelte.js";
import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js"; import { environmentContextStore, initializeEnvironmentContext } from "$lib/stores/environmentContext.svelte.js";
import { Icon, Button } from "$lib/ui"; import { Icon, Button } from "$lib/ui";
let model = $state<AgentChatModel | null>(null); let model = $state<AgentChatModel | null>(null);
@@ -25,9 +26,6 @@
// $auth is Svelte auto-subscription syntax for stores with .subscribe() // $auth is Svelte auto-subscription syntax for stores with .subscribe()
let currentUserId = $derived($auth.user?.id ?? ""); let currentUserId = $derived($auth.user?.id ?? "");
let currentUserJwt = $derived($auth.token ?? ""); let currentUserJwt = $derived($auth.token ?? "");
// Get selected environment from the top-bar selector
let currentEnvId = $derived($environmentContextStore.selectedEnvId ?? "");
// Guard against double initialization in dev HMR // Guard against double initialization in dev HMR
let _initialized = false; let _initialized = false;
@@ -35,7 +33,7 @@
function syncModel(m: AgentChatModel): void { function syncModel(m: AgentChatModel): void {
m.userId = $auth.user?.id ?? ""; m.userId = $auth.user?.id ?? "";
m.userJwt = $auth.token ?? ""; m.userJwt = $auth.token ?? "";
m.envId = $environmentContextStore.selectedEnvId ?? ""; m.envId = environmentContextStore.value?.selectedEnvId || (browser ? localStorage.getItem("selected_env_id") || "" : "");
} }
$effect(() => { $effect(() => {
@@ -62,6 +60,8 @@
const gradioBaseUrl = `${window.location.origin}/api/agent/gradio`; const gradioBaseUrl = `${window.location.origin}/api/agent/gradio`;
// Delegate connection to ConnectionManager for unified lifecycle // Delegate connection to ConnectionManager for unified lifecycle
initializeEnvironmentContext().then(() => syncModel(m));
Client.connect(gradioBaseUrl).then((client) => { Client.connect(gradioBaseUrl).then((client) => {
Object.assign(m, { _client: client }); Object.assign(m, { _client: client });
m.connectionState = "connected"; m.connectionState = "connected";

View File

@@ -30,6 +30,9 @@
- [x] CHK016 SC-006: Speed improvement ≥30% vs manual UI — measurable via usability benchmark. - [x] CHK016 SC-006: Speed improvement ≥30% vs manual UI — measurable via usability benchmark.
- [x] CHK017 SC-007: File upload ≤10MB without responsiveness degradation — measurable via load test. - [x] CHK017 SC-007: File upload ≤10MB without responsiveness degradation — measurable via load test.
- [x] CHK018 SC-008: Hybrid router ensures 50-75% token reduction; embedding model cold-start ~2s, subsequent calls 5-20ms. - [x] CHK018 SC-008: Hybrid router ensures 50-75% token reduction; embedding model cold-start ~2s, subsequent calls 5-20ms.
- [x] CHK018a SC-009: Hidden runtime/prefetch/upload context is not visible in chat bubbles or conversation list titles.
- [x] CHK018b SC-010: No-first-event stream surfaces recovery controls within 60-65 seconds.
- [x] CHK018c SC-011: Diagnostics are available without exposing debug fields as primary chat actions.
## 4. Edge Cases & Recovery ## 4. Edge Cases & Recovery
@@ -42,6 +45,9 @@
- [x] CHK025 Confirmation token expiry → inform user, offer re-issue (Edge Cases). - [x] CHK025 Confirmation token expiry → inform user, offer re-issue (Edge Cases).
- [x] CHK026 Multiple browser tabs with same conversation → reject concurrent writes (Edge Cases). - [x] CHK026 Multiple browser tabs with same conversation → reject concurrent writes (Edge Cases).
- [x] CHK027 Network loss during streaming → detect disconnection, offer reconnect (Edge Cases). - [x] CHK027 Network loss during streaming → detect disconnection, offer reconnect (Edge Cases).
- [x] CHK027a Silent Gradio/LLM stream → first-activity timeout and recovery card.
- [x] CHK027b User accidentally copies debug JSON → diagnostics are grouped in menu and raw JSON is not rendered into chat by default.
- [x] CHK027c Hidden runtime context in prompt/history → frontend strips service blocks and backend persists clean visible text.
## 5. Decision-Memory Readiness ## 5. Decision-Memory Readiness
@@ -59,7 +65,7 @@
- [x] CHK036 P2 stories cover UX quality: tool visibility + conversation context. - [x] CHK036 P2 stories cover UX quality: tool visibility + conversation context.
- [x] CHK037 P3 stories cover polish: persistence + file upload. - [x] CHK037 P3 stories cover polish: persistence + file upload.
- [x] CHK038 Key entities defined: AgentConversation, AgentMessage, AgentToolCall, AgentStreamingSession, AgentMemory. - [x] CHK038 Key entities defined: AgentConversation, AgentMessage, AgentToolCall, AgentStreamingSession, AgentMemory.
- [x] CHK039 23 functional requirements cover: streaming, tool use, hybrid routing, negation guard, embedding infrastructure, RBAC, persistence, UX preservation, backward compatibility. - [x] CHK039 Functional requirements cover: streaming, tool use, hybrid routing, negation guard, embedding infrastructure, RBAC, persistence, hidden runtime context, first-activity recovery, diagnostics, UX preservation, backward compatibility.
- [x] CHK040 Dependencies section names specific existing artifacts to integrate with. - [x] CHK040 Dependencies section names specific existing artifacts to integrate with.
## 7. Constitution Alignment ## 7. Constitution Alignment

View File

@@ -6,8 +6,8 @@
| Contract | C | File | Notes | | Contract | C | File | Notes |
|----------|---|------|-------| |----------|---|------|-------|
| AgentChat.GradioApp | C4 | `backend/src/agent/app.py` | `gr.ChatInterface(type="messages", multimodal=True, additional_inputs=[gr.Textbox("conversation_id"), gr.Textbox("action")], examples=[["Покажи дашборды",null,null],["Статус системы",null,null]])`. Handler: `def handler(message, history, request: gr.Request, conversation_id=None, action=None)`. Per-user lock via in-memory dict (no global concurrency_limit — Gradio handles concurrent users by default). | | AgentChat.GradioApp | C4 | `backend/src/agent/app.py` | `gr.ChatInterface(type="messages", multimodal=True, additional_inputs=[gr.Textbox("conversation_id"), gr.Textbox("action")], examples=[["Покажи дашборды",null,null],["Статус системы",null,null]])`. Handler: `def handler(message, history, request: gr.Request, conversation_id=None, action=None)`. Per-user lock via in-memory dict (no global concurrency_limit — Gradio handles concurrent users by default). |
| AgentChat.GradioApp.Handler | C4 | ↑ | Creates `create_react_agent()` with PostgresSaver and a compact tool subset. `astream_events(config={"configurable":{"thread_id":conversation_id}})`. Resume: detects `action="confirm"/"deny"` in additional_inputs, loads checkpoint, recreates graph with `interrupt_before=[]` to avoid repeated pause, and continues the checkpoint stream. | | AgentChat.GradioApp.Handler | C4 | ↑ | Creates `create_react_agent()` with PostgresSaver and a compact tool subset. `astream_events(config={"configurable":{"thread_id":conversation_id}})`. Resume: detects `action="confirm"/"deny"` in additional_inputs, loads checkpoint, recreates graph with `interrupt_before=[]` to avoid repeated pause, and continues the checkpoint stream. Injects hidden runtime context into LLM-facing message while saving clean visible user text. |
| AgentChat.LangGraph.Setup | C4 | `backend/src/agent/langgraph_setup.py` | `create_react_agent(model, tools=<subset>, checkpointer=PostgresSaver(conn_string), interrupt_before=<dangerous tools or []>)`. No `RunnableWithMessageHistory`; history/checkpoint continuity is keyed by `thread_id=conversation_id`. | | AgentChat.LangGraph.Setup | C4 | `backend/src/agent/langgraph_setup.py` | `create_react_agent(model, tools=<subset>, checkpointer=PostgresSaver(conn_string), interrupt_before=<dangerous tools or []>)`. No `RunnableWithMessageHistory`; history/checkpoint continuity is keyed by `thread_id=conversation_id`. System prompt includes maintenance relative-time rules using runtime context. |
| AgentChat.Tools | C4 | `backend/src/agent/tools.py` | 24 native `@tool` functions. Each: Pydantic `BaseModel` args_schema, calls FastAPI REST with dual-identity headers. Includes hybrid `get_tools_for_query()` — keyword primary + embedding fallback for context-budgeted subset selection. | | AgentChat.Tools | C4 | `backend/src/agent/tools.py` | 24 native `@tool` functions. Each: Pydantic `BaseModel` args_schema, calls FastAPI REST with dual-identity headers. Includes hybrid `get_tools_for_query()` — keyword primary + embedding fallback for context-budgeted subset selection. |
| AgentChat.EmbeddingRouter | C3 | `backend/src/agent/_embedding_router.py` | Lazy-loaded `paraphrase-multilingual-MiniLM-L12-v2`. Tool description corpus (RU+EN). `embedding_top_k(query)` → top-K tool names above cosine threshold. Graceful degradation to empty list if model unavailable. | | AgentChat.EmbeddingRouter | C3 | `backend/src/agent/_embedding_router.py` | Lazy-loaded `paraphrase-multilingual-MiniLM-L12-v2`. Tool description corpus (RU+EN). `embedding_top_k(query)` → top-K tool names above cosine threshold. Graceful degradation to empty list if model unavailable. |
| AgentChat.Middleware | C3 | `backend/src/agent/middleware.py` | `LoggingMiddleware` only (audit trail). | | AgentChat.Middleware | C3 | `backend/src/agent/middleware.py` | `LoggingMiddleware` only (audit trail). |
@@ -21,9 +21,9 @@
| Contract | C | File | Notes | | Contract | C | File | Notes |
|----------|---|------|-------| |----------|---|------|-------|
| AgentChat.Model | C4 | `frontend/src/lib/models/AgentChatModel.svelte.ts` | `submit("/chat", {text,files}, [conversationId, action])` lifecycle. Structured `ChatMessage.metadata` parsing. HITL resume via second submit with `additional_inputs[1]="confirm"/"deny"` and immediate streaming state. No tabRole, no follower. | | AgentChat.Model | C4 | `frontend/src/lib/models/AgentChatModel.svelte.ts` | `submit("/chat", {text,files}, [conversationId, action])` lifecycle. Structured `ChatMessage.metadata` parsing. HITL resume via second submit with `additional_inputs[1]="confirm"/"deny"` and immediate streaming state. Owns first-activity timeout, recovery actions, visible-text cleaning, process-step derivation. No tabRole, no follower. |
| AgentChat.Panel | C4 | `frontend/src/lib/components/agent/AgentChat.svelte` | `Client.connect("${window.location.origin}/api/agent/gradio")`, `submit()`, metadata-based rendering of tool cards + confirmation cards, debug/reference block. | | AgentChat.Panel | C4 | `frontend/src/lib/components/agent/AgentChat.svelte` | `Client.connect("${window.location.origin}/api/agent/gradio")`, `submit()`, metadata-based rendering of tool cards + confirmation cards, operator process strip, diagnostics menu, recovery card. |
| AgentChat.Page | C3 | `frontend/src/routes/agent/+page.svelte` | Full-page route hosting current `AgentChat.svelte`. | | AgentChat.Page | C3 | `frontend/src/routes/agent/+page.svelte` | Full-page route hosting current `AgentChat.svelte`; syncs auth and selected environment before sends. |
| AgentChat.ConversationList | C3 | `frontend/src/lib/components/assistant/ConversationList.svelte` | Search, infinite scroll, date grouping, archive action. | | AgentChat.ConversationList | C3 | `frontend/src/lib/components/assistant/ConversationList.svelte` | Search, infinite scroll, date grouping, archive action. |
| AgentChat.ToolCallCard | C2 | `frontend/src/lib/components/assistant/ToolCallCard.svelte` | Props from `metadata.type="tool_start"/"tool_end"/"tool_error"`. | | AgentChat.ToolCallCard | C2 | `frontend/src/lib/components/assistant/ToolCallCard.svelte` | Props from `metadata.type="tool_start"/"tool_end"/"tool_error"`. |
| AgentChat.ConfirmationCard | C3 | `frontend/src/lib/components/assistant/ConfirmationCard.svelte` | Renders on `metadata.type="confirm_required"`. Calls second `submit()` with `additional_inputs=[conversation_id, "confirm"/"deny"]`. | | AgentChat.ConfirmationCard | C3 | `frontend/src/lib/components/assistant/ConfirmationCard.svelte` | Renders on `metadata.type="confirm_required"`. Calls second `submit()` with `additional_inputs=[conversation_id, "confirm"/"deny"]`. |

View File

@@ -14,6 +14,7 @@ idle ──[submit("/chat")]──→ streaming ──[metadata.type: stream_tok
awaiting_confirmation ──[timeout 5min]──→ idle (expired) awaiting_confirmation ──[timeout 5min]──→ idle (expired)
streaming ──[final chunk]──→ idle streaming ──[final chunk]──→ idle
streaming ──[submission.cancel()]──→ idle (partial) streaming ──[submission.cancel()]──→ idle (partial)
streaming ──[no first event 60s]──→ error (recovery)
streaming ──[metadata.type: error]──→ error streaming ──[metadata.type: error]──→ error
idle ──[load history]──→ loading_history ──[success]──→ idle idle ──[load history]──→ loading_history ──[success]──→ idle
@@ -29,13 +30,16 @@ idle ──[connect fail]──→ disconnected ──[retry 5×5s]──→ idl
|-----------|--------|------|----------| |-----------|--------|------|----------|
| idle (with msgs) | Input enabled, Send active, Stop hidden. Connection dot green. | — | Send, switch/delete convos, expand to /agent | | idle (with msgs) | Input enabled, Send active, Stop hidden. Connection dot green. | — | Send, switch/delete convos, expand to /agent |
| idle (new convo) | Welcome card + examples chips (Gradio `examples`). | `aria-label="Новый диалог"` | Click chip, type | | idle (new convo) | Welcome card + examples chips (Gradio `examples`). | `aria-label="Новый диалог"` | Click chip, type |
| streaming | Tokens appear in real time. Tool cards inline. Send→Stop (destructive). Input locked. | `aria-live="polite" aria-busy="true"` | Stop, expand tool cards | | streaming | Tokens appear in real time. Tool cards inline. Send→Stop (destructive). Input locked. Operator process strip marks understanding/tool step active. | `aria-live="polite" aria-busy="true"` | Stop, expand tool cards |
| streaming_silent | No token/tool/confirmation yet. Process strip shows "Ожидаю первый ответ". First-activity watchdog active. | `aria-live="polite"` | Stop |
| awaiting_confirmation | Streaming pauses. Warning card: `bg-warning-light border-warning-DEFAULT`. Prompt text + Confirm/Deny buttons. Timer 5:00→0:00. | `role="alertdialog"` | Confirm, Deny | | awaiting_confirmation | Streaming pauses. Warning card: `bg-warning-light border-warning-DEFAULT`. Prompt text + Confirm/Deny buttons. Timer 5:00→0:00. | `role="alertdialog"` | Confirm, Deny |
| awaiting_confirmation (expired) | Card stays. Buttons disabled. Timer=0. "Повторить запрос?" | — | Retry | | awaiting_confirmation (expired) | Card stays. Buttons disabled. Timer=0. "Повторить запрос?" | — | Retry |
| error | Card: `bg-destructive-light border-destructive-ring`. Error + Retry. Partial text preserved. | `role="alert"` | Retry, new message | | error | Recovery card: `bg-destructive-light border-destructive-ring`. Error + actions: retry last request, check LLM, copy diagnostics, new dialog. Partial text preserved. | `role="alert"` | Retry, check LLM, copy debug, new dialog |
| disconnected | Dot red. Banner: "Агент недоступен. Переподключение через Xс..." | `role="alert"` | Read history, manual reconnect | | disconnected | Dot red. Banner: "Агент недоступен. Переподключение через Xс..." | `role="alert"` | Read history, manual reconnect |
| disconnected_permanent | Dot red. Manual reconnect button only. | `role="alert"` | Manual reconnect | | disconnected_permanent | Dot red. Manual reconnect button only. | `role="alert"` | Manual reconnect |
| loading_history | Skeleton cards (3-5, `animate-pulse bg-surface-muted`). Input locked. | `aria-busy="true"` | Wait | | loading_history | Skeleton cards (3-5, `animate-pulse bg-surface-muted`). Input locked. | `aria-busy="true"` | Wait |
| operator_status | Header strip shows user-facing state and process steps: context, understanding/LLM wait, tools, confirmation, result. | `aria-live="polite"` | Read state |
| diagnostics_menu | Header menu contains debug-panel toggle, copy JSON, LLM check. It closes on Escape and outside click. | `role="menu"` | Toggle debug, copy JSON, check LLM |
| debug_visible | Header reference block shows `conv_id`, pending `thread_id`, connection/streaming state, env/user, message count, last message id, active tool-call count, error. | — | Copy JSON, hide block | | debug_visible | Header reference block shows `conv_id`, pending `thread_id`, connection/streaming state, env/user, message count, last message id, active tool-call count, error. | — | Copy JSON, hide block |
## Feedback ## Feedback
@@ -50,6 +54,7 @@ idle ──[connect fail]──→ disconnected ──[retry 5×5s]──→ idl
| `metadata.type: confirm_resolved(result=confirmed)` | Card collapses, streaming resumes | | `metadata.type: confirm_resolved(result=confirmed)` | Card collapses, streaming resumes |
| `metadata.type: confirm_resolved(result=denied)` | Card collapses, agent responds | | `metadata.type: confirm_resolved(result=denied)` | Card collapses, agent responds |
| Stream complete | Stop→Send, input enabled | | Stream complete | Stop→Send, input enabled |
| No first activity for 60s | Recovery card with retry/check LLM/copy debug/new dialog |
| Connection lost | Dot red. Banner. Retry countdown | | Connection lost | Dot red. Banner. Retry countdown |
| Connection restored | Dot green. Banner dismissed | | Connection restored | Dot green. Banner dismissed |
@@ -58,6 +63,8 @@ idle ──[connect fail]──→ disconnected ──[retry 5×5s]──→ idl
| From | Action | To | | From | Action | To |
|------|--------|-----| |------|--------|-----|
| error | Retry (re-sends last msg) | streaming | | error | Retry (re-sends last msg) | streaming |
| error | Check LLM | error/idle with updated banner |
| error | New dialog | idle |
| disconnected | Auto-retry 5×5s or manual | idle | | disconnected | Auto-retry 5×5s or manual | idle |
| awaiting_confirmation (expired) | "Повторить запрос" | streaming | | awaiting_confirmation (expired) | "Повторить запрос" | streaming |
| streaming (cancelled) | Type new msg | idle→streaming | | streaming (cancelled) | Type new msg | idle→streaming |
@@ -81,9 +88,12 @@ idle ──[connect fail]──→ disconnected ──[retry 5×5s]──→ idl
| Confirm denied | awaiting_confirmation | 2nd submit with `additional_inputs[1]="deny"` | Card resolves, idle | | Confirm denied | awaiting_confirmation | 2nd submit with `additional_inputs[1]="deny"` | Card resolves, idle |
| Stream cancel | streaming | submission.cancel() | idle, partial text | | Stream cancel | streaming | submission.cancel() | idle, partial text |
| LLM error | streaming | LLM fails | "Ошибка: ..." + retry | | LLM error | streaming | LLM fails | "Ошибка: ..." + retry |
| Silent LLM hang | streaming_silent | 60s without token/tool/confirmation | Recovery card, no endless "Думаю" |
| Gradio disconnect | idle | Gradio dies | Dot red, 5×5s retry | | Gradio disconnect | idle | Gradio dies | Dot red, 5×5s retry |
| Multi-tab gate | idle | Tab 2 opens same convo | Tab 2: send rejected | | Multi-tab gate | idle | Tab 2 opens same convo | Tab 2: send rejected |
| Empty convo | idle (new) | Load | Examples chips visible | | Empty convo | idle (new) | Load | Examples chips visible |
| History load error | idle | API fails | Error + retry | | History load error | idle | API fails | Error + retry |
| Hidden context display | history/current bubble | Message contains `[PRE-FETCHED DATA]` or uploaded content block | Visible text excludes service block |
| Diagnostics menu | idle | Open menu, press Escape | Menu closes; debug panel state unchanged |
#endregion AgentChat.Ux #endregion AgentChat.Ux

View File

@@ -5,10 +5,12 @@
**Connection**: `Client.connect("${window.location.origin}/api/agent/gradio")` → Vite/nginx same-origin proxy → Gradio. JWT forwarded. **Connection**: `Client.connect("${window.location.origin}/api/agent/gradio")` → Vite/nginx same-origin proxy → Gradio. JWT forwarded.
**Primary submit**: `client.submit("/chat", {text, files}, [conversation_id, null])` — three-arg API. `additional_inputs=[conversation_id, action]` maps to Gradio textboxes. Handler: `handler(message, history, request, conversation_id, action)`. **Primary submit**: `client.submit("/chat", {text, files}, [conversation_id, null, user_id, user_jwt, env_id])`. `additional_inputs=[conversation_id, action, user_id, user_jwt, env_id]` maps to Gradio inputs. Handler uses persisted history/checkpoints and ignores Gradio-provided history.
**Cancel**: `submission.cancel()`. **Cancel**: `submission.cancel()`.
**First-activity watchdog**: frontend races stream processing with a 60s no-first-event timeout. Activity means any token, tool call, confirmation/checkpoint, or pending thread id.
### Streaming Events ### Streaming Events
`for await (event of submission)``event.data` is a `ChatMessage` with structured `metadata`: `for await (event of submission)``event.data` is a `ChatMessage` with structured `metadata`:
@@ -21,9 +23,20 @@
| `tool_error` | `{tool: "search_dashboards", error: "..."}` | Spinner→cross + detail | | `tool_error` | `{tool: "search_dashboards", error: "..."}` | Spinner→cross + detail |
| `confirm_required` | `{prompt: "Подтвердить деплой?", thread_id: "..."}` | Confirmation card + timer | | `confirm_required` | `{prompt: "Подтвердить деплой?", thread_id: "..."}` | Confirmation card + timer |
| `confirm_resolved` | `{result: "confirmed"/"denied"}` | Card collapses | | `confirm_resolved` | `{result: "confirmed"/"denied"}` | Card collapses |
| `error` | `{code: "LLM_UNAVAILABLE", detail: "..."}` | Error card + retry | | `error` | `{code: "LLM_UNAVAILABLE", detail: "..."}` | Recovery card |
| (none) | plain `{content: "..."}` | Normal text | | (none) | plain `{content: "..."}` | Normal text |
### Hidden Runtime Context
The handler MAY enrich the LLM-facing message with runtime context:
- current ISO datetime;
- selected `env_id`;
- relative-time rules for maintenance requests;
- compact dashboard prefetch.
This context is not part of the persisted user message and MUST NOT appear in `/api/assistant/history` or conversation list titles.
### HITL Resume Protocol ### HITL Resume Protocol
``` ```
@@ -99,6 +112,7 @@ Embedding fallback triggered when keyword matching yields <3 tools.
| `UNAUTHORIZED` | JWT | Redirect /login | | `UNAUTHORIZED` | JWT | Redirect /login |
| `FORBIDDEN` | RBAC | metadata.type="error" in stream | | `FORBIDDEN` | RBAC | metadata.type="error" in stream |
| `LLM_UNAVAILABLE` | LangChain | metadata.type="error" card + retry | | `LLM_UNAVAILABLE` | LangChain | metadata.type="error" card + retry |
| `NO_FIRST_ACTIVITY_TIMEOUT` | Frontend watchdog | Recovery card + debug copy/check LLM |
| `GRADIO_UNREACHABLE` | connect | Dot red, retry 5×5s | | `GRADIO_UNREACHABLE` | connect | Dot red, retry 5×5s |
| `TOOL_FAILURE` | FastAPI | metadata.type="tool_error" | | `TOOL_FAILURE` | FastAPI | metadata.type="tool_error" |

View File

@@ -19,8 +19,10 @@
| **Cancel** | A — Единая кнопка Stop с момента отправки. `submission.cancel()` | Нативный Gradio cancel | | **Cancel** | A — Единая кнопка Stop с момента отправки. `submission.cancel()` | Нативный Gradio cancel |
| **Confirmation timeout** | A — Карточка остаётся, кнопки заблокированы, «Повторить» | Явный контроль | | **Confirmation timeout** | A — Карточка остаётся, кнопки заблокированы, «Повторить» | Явный контроль |
| **Reconnect** | A — 5×5s, затем disconnected_permanent + ручная кнопка | Предсказуемо | | **Reconnect** | A — 5×5s, затем disconnected_permanent + ручная кнопка | Предсказуемо |
| **Silent stream** | A — 60s first-activity timeout → recovery card | Gradio/LLM can hang before first token without a connection error; endless "Думаю" is misleading |
| **Ошибка истории** | A — Сразу ошибка, без кэша | Консистентность | | **Ошибка истории** | A — Сразу ошибка, без кэша | Консистентность |
| **Multi-tab** | A — Client-side gate: `GET /api/agent/conversations/{id}/active` перед отправкой. Отклоняет вторую вкладку | Просто, без server-push. Per-user mutex в handler-е + REST gate; градио обрабатывает множественных пользователей нативно | | **Multi-tab** | A — Client-side gate: `GET /api/agent/conversations/{id}/active` перед отправкой. Отклоняет вторую вкладку | Просто, без server-push. Per-user mutex в handler-е + REST gate; градио обрабатывает множественных пользователей нативно |
| **Debug actions** | B — Diagnostics menu instead of primary header buttons | Keeps operator UI focused while preserving copyable state for debugging |
### Phase 3 — Interaction Design ### Phase 3 — Interaction Design
@@ -29,6 +31,8 @@
| **Отправка** | Gradio `submit()` с `additional_inputs=[conversation_id]` | Нативный механизм. История и checkpoint continuity keyed by `conversation_id` / `thread_id` | | **Отправка** | Gradio `submit()` с `additional_inputs=[conversation_id]` | Нативный механизм. История и checkpoint continuity keyed by `conversation_id` / `thread_id` |
| **Stop** | `submission.cancel()` — optimistic UI | Нативный Gradio cancel. Частичный текст сохранён | | **Stop** | `submission.cancel()` — optimistic UI | Нативный Gradio cancel. Частичный текст сохранён |
| **Подтверждение** | Second `submit()` с `additional_inputs[1]="confirm"/"deny"` | Два submit() к одному стриму. LangGraph `interrupt_before` + PostgresSaver checkpoint; resume uses `interrupt_before=[]` | | **Подтверждение** | Second `submit()` с `additional_inputs[1]="confirm"/"deny"` | Два submit() к одному стриму. LangGraph `interrupt_before` + PostgresSaver checkpoint; resume uses `interrupt_before=[]` |
| **Операционный статус** | Header process strip from model-derived steps | Gives non-technical operators a stable mental model before backend exposes explicit progress events |
| **Recovery** | Inline recovery card with retry/check LLM/copy debug/new dialog | Keeps failure handling in the same task context; avoids requiring users to paste debug JSON into chat |
### Phase 4 — API Design ### Phase 4 — API Design
@@ -44,5 +48,7 @@
- **Ручная передача Gradio history** — rejected: handler ignores Gradio `history`; persisted messages/checkpoints are keyed by `conversation_id` - **Ручная передача Gradio history** — rejected: handler ignores Gradio `history`; persisted messages/checkpoints are keyed by `conversation_id`
- **WebSocket-based active/follower multi-tab pattern** — rejected: сложный server-push, требует pub/sub на Gradio. Client-side gate + per-user mutex — accepted. - **WebSocket-based active/follower multi-tab pattern** — rejected: сложный server-push, требует pub/sub на Gradio. Client-side gate + per-user mutex — accepted.
- **@assistant_tool registry для новых тулов** — rejected: нативные @tool функции LangChain - **@assistant_tool registry для новых тулов** — rejected: нативные @tool функции LangChain
- **Always-visible debug buttons** — rejected: they looked like primary chat actions and encouraged copying service JSON into normal messages
- **No timeout before first token** — rejected: upstream hangs make the UI appear active while no work is visible
#endregion AgentChat.UxDecisions #endregion AgentChat.UxDecisions

View File

@@ -15,6 +15,9 @@
| Confirmation (convo delete) | `confirm()` (native browser) | Pattern | | Confirmation (convo delete) | `confirm()` (native browser) | Pattern |
| Custom confirmation (in-chat) | New `ConfirmationCard` inline within message | New component | | Custom confirmation (in-chat) | New `ConfirmationCard` inline within message | New component |
| Tool-call card | New `ToolCallCard` inline within message | New component | | Tool-call card | New `ToolCallCard` inline within message | New component |
| Operator status strip | Inline header band bound to `AgentChat.Model.processSteps` | Pattern |
| Diagnostics menu | Header menu using native buttons + `$lib/ui/Icon` | Pattern |
| Recovery card | Inline alert panel in message area | Pattern |
--- ---
@@ -98,6 +101,31 @@
| Messages area | `flex-1 overflow-y-auto bg-surface-page` | | Messages area | `flex-1 overflow-y-auto bg-surface-page` |
| Input area | `border-t border-border bg-surface-card p-4` | | Input area | `border-t border-border bg-surface-card p-4` |
### Operator Status Strip
| Element | Token |
|---------|-------|
| Strip shell | `rounded-lg border border-border bg-surface-page px-3 py-2` |
| Success step | `border-success bg-success-light text-success` |
| Active step | `border-warning bg-warning-light text-warning` |
| Blocked step | `border-destructive-ring bg-destructive-light text-destructive` |
| Idle step | `border-border bg-surface-page text-text-subtle` |
### Diagnostics Menu
| Element | Token |
|---------|-------|
| Trigger | `border-border-strong bg-surface-card text-text hover:bg-surface-muted` |
| Active trigger | `border-primary-ring bg-primary-light text-primary` |
| Menu | `rounded-lg border border-border bg-surface-card shadow-lg` |
| Menu item | `text-text hover:bg-surface-muted` |
### Recovery Card
| Element | Token |
|---------|-------|
| Card | `border-destructive-ring bg-destructive-light rounded-2xl` |
| Icon well | `bg-white text-destructive rounded-full` |
| Primary recovery action | `border-destructive-ring bg-white text-destructive hover:bg-destructive-light` |
| Secondary recovery action | `border-border-strong bg-white text-text hover:bg-surface-muted` |
### Welcome / Empty ### Welcome / Empty
| Element | Token | | Element | Token |
|---------|-------| |---------|-------|

View File

@@ -1,13 +1,13 @@
#region AgentChat.ScreenModels [C:3] [TYPE ADR] [SEMANTICS ux,screen-models,agent-chat] #region AgentChat.ScreenModels [C:3] [TYPE ADR] [SEMANTICS ux,screen-models,agent-chat]
@BRIEF Screen model inventory — revised for LangChain v1 native stack. No active/follower. No WebSocket. @BRIEF Screen model inventory — revised for LangChain v1 native stack. No active/follower. No WebSocket.
@RATIONALE Model-first per ADR-0010. AgentChatModel handles Gradio submit() lifecycle, streaming state, tool-call tracking, multi-tab gate. @RATIONALE Model-first per ADR-0010. AgentChatModel handles Gradio submit() lifecycle, streaming state, tool-call tracking, recovery, diagnostics, and operator process visibility.
## Screens Requiring Models ## Screens Requiring Models
| Screen | Route | Model ID | Complexity | Rationale | | Screen | Route | Model ID | Complexity | Rationale |
|--------|-------|----------|------------|-----------| |--------|-------|----------|------------|-----------|
| AssistantChatPanel (drawer + embedded) | overlay | `AgentChat.Model` | C4 | Gradio `submit()` lifecycle, streaming, tool-call parsing, confirmation card state, connection health | | AssistantChatPanel (drawer + embedded) | overlay | `AgentChat.Model` | C4 | Gradio `submit()` lifecycle, streaming, tool-call parsing, confirmation card state, connection health |
| Agent Chat Page (full) | `/agent` | `AgentChat.Model` (shared) | C4 | Same model, two-column layout | | Agent Chat Page (full) | `/agent` | `AgentChat.Model` (shared) | C4 | Same model, two-column layout, environment sync, operator process strip, diagnostics menu |
## Screens Using Inline State ## Screens Using Inline State
@@ -16,6 +16,9 @@
| ConversationList | `conversations[]` из модели. Поиск — локальный. Группировка — `$derived` | | ConversationList | `conversations[]` из модели. Поиск — локальный. Группировка — `$derived` |
| ToolCallCard | Пропсы от родителя | | ToolCallCard | Пропсы от родителя |
| ConfirmationCard | Пропсы от модели (`confirm_id`, `prompt`) | | ConfirmationCard | Пропсы от модели (`confirm_id`, `prompt`) |
| DiagnosticsMenu | Локальное раскрытие в `AgentChat.svelte`; команды делегируются модели |
| ProcessStrip | `$derived` из `AgentChat.Model.processSteps`, `userFacingStatusTitle`, `userFacingStatusDetail` |
| RecoveryCard | Рендерится из `model.streamingState === "error"` и вызывает `retryLastUserMessage()`, `checkLlmStatus()`, `createConversation()` |
| MessageBubble | Рендерит одно сообщение. Стриминг — через `$derived` | | MessageBubble | Рендерит одно сообщение. Стриминг — через `$derived` |
| ConnectionIndicator | `model.connectionState` → dot color | | ConnectionIndicator | `model.connectionState` → dot color |
@@ -23,7 +26,17 @@
| Threshold | Est. | Action | | Threshold | Est. | Action |
|-----------|:----:|--------| |-----------|:----:|--------|
| Lines > 400 | ~220 | Within limit | | Lines > 400 | ~900 | Exceeded; decomposition exists via StreamProcessor, ConnectionManager, LocalStorage. Further split should target UI subcomponents, not state ownership. |
| Methods > 40 | ~12 | Within limit | | Methods > 40 | ~25 | Within limit |
## Derived Operator State
| Field | Source | Purpose |
|-------|--------|---------|
| `hasSilentStream` | `streamingState`, `partialText`, `partialTokens`, `activeToolCalls`, `pendingThreadId` | Detect first-event hang before endless "thinking" |
| `userFacingStatusTitle` | connection/streaming/error/tool state | Short operator state label |
| `userFacingStatusDetail` | env/error/streaming state | Recovery/context hint |
| `processSteps` | env/tool/confirmation/error/message state | Header process strip |
| `cleanVisibleMessageText()` | raw message text | Strip hidden runtime/prefetch/upload blocks before rendering/retry |
#endregion AgentChat.ScreenModels #endregion AgentChat.ScreenModels

View File

@@ -36,6 +36,27 @@ Tables: `agent_conversations`, `agent_messages`. Legacy `assistant_messages` pre
`frontend/src/types/agent.ts` — matches Pydantic schemas. Includes `StreamMetadata`. `frontend/src/types/agent.ts` — matches Pydantic schemas. Includes `StreamMetadata`.
## 4a. Frontend Screen-State Derivations
`AgentChatModel` owns operator-only derived state. These fields are not persisted and do not cross the API boundary:
```typescript
type AgentProcessStepState = "done" | "active" | "blocked" | "idle";
interface AgentProcessStep {
id: "context" | "reasoning" | "tools" | "confirmation" | "result";
label: string;
state: AgentProcessStepState;
}
```
Derived state:
- `hasSilentStream`: `streaming` with no token, no tool call, no confirmation/checkpoint.
- `userFacingStatusTitle` / `userFacingStatusDetail`: operator-readable status copy.
- `processSteps`: UI process strip; currently inferred from stream/tool/confirmation/error state.
- `cleanVisibleMessageText(rawText)`: strips hidden runtime/prefetch/upload context before rendering or retrying.
## 5. PostgreSQL Checkpointer ## 5. PostgreSQL Checkpointer
LangGraph checkpointer: `langgraph-checkpoint-postgres`. Thread ID = conversation_id. Survives container restarts. Same PostgreSQL instance as FastAPI. LangGraph checkpointer: `langgraph-checkpoint-postgres`. Thread ID = conversation_id. Survives container restarts. Same PostgreSQL instance as FastAPI.
@@ -49,4 +70,21 @@ X-User-JWT: {user_jwt}
Audit: `{service_actor: "agent", user_actor: user_id}`. Audit: `{service_actor: "agent", user_actor: user_id}`.
## 7. Hidden Runtime Context
The backend may prepend hidden runtime context to the LLM-facing message. This is not a persisted message field.
```text
[RUNTIME CONTEXT]
Current datetime: <ISO>
Environment: <env_id>
Rules: interpret now/start/duration from current datetime...
Dashboards: compact bounded prefetch...
[/RUNTIME CONTEXT]
<visible user text>
```
Persistence invariant: `agent_messages.text`, title generation input, and frontend-rendered user bubbles use the visible user text only.
#endregion AgentChat.DataModel #endregion AgentChat.DataModel

View File

@@ -1,12 +1,12 @@
# Implementation Plan: Gradio Agent Chat (LangGraph) # Implementation Plan: Gradio Agent Chat (LangGraph)
**Branch**: `033-gradio-agent-chat` | **Date**: 2026-06-08 | **Updated**: 2026-06-30 | **Spec**: `spec.md` **Branch**: `033-gradio-agent-chat` | **Date**: 2026-06-08 | **Updated**: 2026-07-03 | **Spec**: `spec.md`
## Summary ## Summary
Gradio agent backend + Svelte frontend via `@gradio/client submit()`. LangGraph `create_react_agent()` + static `interrupt_before` + `PostgresSaver`. Hybrid keyword+embedding tool router (keyword primary <1ms, embedding fallback 5-20ms). Negation guard for fast-track HITL. Smart dashboard prefetch trigger. Structured JSON metadata streaming. No REST confirmations, no custom WebSocket, no deprecated assistant tool registry for agent runtime. Gradio agent backend + Svelte frontend via `@gradio/client submit()`. LangGraph `create_react_agent()` + static `interrupt_before` + `PostgresSaver`. Hybrid keyword+embedding tool router (keyword primary <1ms, embedding fallback 5-20ms). Negation guard for fast-track HITL. Smart dashboard prefetch trigger. Hidden runtime context for operational prompts. Structured JSON metadata streaming. Operator recovery for silent streams. No REST confirmations, no custom WebSocket, no deprecated assistant tool registry for agent runtime.
## Current Implementation Snapshot (2026-06-30) ## Current Implementation Snapshot (2026-07-03)
- `/agent` works through SvelteKit `AgentChat.svelte` and `AgentChatModel.svelte.ts`, connecting with `@gradio/client` to `${window.location.origin}/api/agent/gradio`. - `/agent` works through SvelteKit `AgentChat.svelte` and `AgentChatModel.svelte.ts`, connecting with `@gradio/client` to `${window.location.origin}/api/agent/gradio`.
- `DEV_MODE=true ./run.sh` wires `BACKEND_URL`, `GRADIO_URL`, and `GRADIO_SERVER_PORT`; `backend/src/agent/run.py` allows port fallback only with `GRADIO_ALLOW_PORT_FALLBACK=true`. - `DEV_MODE=true ./run.sh` wires `BACKEND_URL`, `GRADIO_URL`, and `GRADIO_SERVER_PORT`; `backend/src/agent/run.py` allows port fallback only with `GRADIO_ALLOW_PORT_FALLBACK=true`.
@@ -14,10 +14,11 @@ Gradio agent backend + Svelte frontend via `@gradio/client submit()`. LangGraph
- `backend/src/agent/_embedding_router.py` provides embedding-based tool similarity fallback (cosine similarity via `paraphrase-multilingual-MiniLM-L12-v2`). - `backend/src/agent/_embedding_router.py` provides embedding-based tool similarity fallback (cosine similarity via `paraphrase-multilingual-MiniLM-L12-v2`).
- `fast_confirmation_tool()` includes negation guard (`\bне\b`, `\bno\b`) to bypass fast-track for negated requests. - `fast_confirmation_tool()` includes negation guard (`\bне\b`, `\bno\b`) to bypass fast-track for negated requests.
- Dashboard prefetch uses intent-aware trigger (excludes creation/diagnostic patterns). - Dashboard prefetch uses intent-aware trigger (excludes creation/diagnostic patterns).
- Agent handler injects hidden runtime context (current datetime, selected environment, relative-time rules, bounded dashboard prefetch) into the LLM-facing message while saving clean visible user text.
- `run_llm_validation` uses `/api/validation-tasks`; Git, maintenance, migration, backup, and documentation tools call their existing FastAPI REST endpoints with dual identity headers. - `run_llm_validation` uses `/api/validation-tasks`; Git, maintenance, migration, backup, and documentation tools call their existing FastAPI REST endpoints with dual identity headers.
- HITL resume path recreates the agent with `interrupt_before=[]`, preventing repeated guardrail cards after confirm. - HITL resume path recreates the agent with `interrupt_before=[]`, preventing repeated guardrail cards after confirm.
- `/agent` exposes debug info: `conv_id`, pending `thread_id`, connection/streaming state, env/user, message count, last message id, active tool calls, and error. - `/agent` synchronizes selected environment before each send and exposes operator process strip, diagnostics menu, debug JSON copy, first-activity timeout, visible-text cleaning, and recovery card.
- Verified: backend agent tests `375 passed`; frontend `AgentChatModel.test.ts` `73 passed`; frontend build passed. - Verified: backend hidden-context regression passed; frontend `AgentChatModel.test.ts` `88 passed`; frontend build passed with existing unrelated Svelte warnings.
## Technical Context ## Technical Context
@@ -57,4 +58,10 @@ The runtime MUST use the hybrid router: keyword primary + embedding fallback.
Tool subset is 5-10 tools (50-75% token reduction vs full 24-tool catalog). Tool subset is 5-10 tools (50-75% token reduction vs full 24-tool catalog).
Embedding model (`paraphrase-multilingual-MiniLM-L12-v2`) lazy-loaded on first fallback call; graceful degradation to keyword-only if `sentence-transformers` unavailable. Embedding model (`paraphrase-multilingual-MiniLM-L12-v2`) lazy-loaded on first fallback call; graceful degradation to keyword-only if `sentence-transformers` unavailable.
Dashboard-prefetched requests MUST avoid adding `search_dashboards` to the tool schema (prefetch data already in context). Dashboard-prefetched requests MUST avoid adding `search_dashboards` to the tool schema (prefetch data already in context).
## Operator UX Recovery Rule
The UI MUST not display endless "thinking" when the upstream stream has no first observable event.
`AgentChatModel` races stream processing with a 60s first-activity timeout. If no token, tool call, confirmation/checkpoint, or pending thread id arrives, it transitions to an actionable recovery state.
The recovery card provides retry of the last clean user request, LLM status check, debug JSON copy, and new-dialog escape.
#endregion (plan summary) #endregion (plan summary)

View File

@@ -46,7 +46,11 @@ AUTH_SECRET_KEY=test-secret-key-for-agent-run-tests backend/.venv/bin/python -m
backend/tests/test_agent/test_run.py backend/tests/test_agent/test_run.py
# Current frontend model slice # Current frontend model slice
cd frontend && npm run test -- src/lib/models/__tests__/AgentChatModel.test.ts --run cd frontend && npm run test -- AgentChatModel.test.ts
# Hidden runtime-context regression
AUTH_SECRET_KEY=test-secret JWT_SECRET=test-secret backend/.venv/bin/python -m pytest \
backend/tests/test_agent/test_app.py::TestAgentHandler::test_normal_send_hides_runtime_context_from_saved_history -q
# Frontend build # Frontend build
cd frontend && npm run build cd frontend && npm run build
@@ -71,7 +75,10 @@ GRADIO_ALLOW_PORT_FALLBACK=false
## Troubleshooting ## Troubleshooting
- If LM Studio or another local OpenAI-compatible runtime returns `request exceeds the available context size`, verify the request is using `get_tools_for_query()` and keep `AGENT_PREFETCH_DASHBOARD_LIMIT` at or below `25`. - If LM Studio or another local OpenAI-compatible runtime returns `request exceeds the available context size`, verify the request is using `get_tools_for_query()` and keep `AGENT_PREFETCH_DASHBOARD_LIMIT` at or below `25`.
- The `/agent` header has a toggleable debug block. Use it to inspect `conv_id`, pending `thread_id`, connection/streaming state, active tool calls, and current error. - The `/agent` header has a `Диагностика` menu. Use it to show the debug panel, copy JSON diagnostics, or force an LLM status check. The debug panel includes `conv_id`, pending `thread_id`, connection/streaming state, env/user, active tool calls, and current error.
- The `/agent` process strip is the operator-facing source of truth for current UI state. `Ожидаю первый ответ` means the request reached streaming state but no first token/tool/confirmation has arrived yet.
- If the recovery card appears after 60 seconds, use `Проверить LLM` first, then retry the last request. `Скопировать debug` is intended for bug reports and must not be pasted as a normal chat message.
- Repeated guardrail cards after pressing Confirm indicate a regression: resume must create the graph with `interrupt_before=[]`. - Repeated guardrail cards after pressing Confirm indicate a regression: resume must create the graph with `interrupt_before=[]`.
- If an agent asks for exact ISO datetime despite a relative maintenance request ("сейчас", "на 15 минут"), verify hidden runtime context is present in the LLM-facing message and absent from saved history.
#endregion AgentChat.Quickstart #endregion AgentChat.Quickstart

View File

@@ -12,7 +12,7 @@
**Decision**: `@tool`-decorated functions with Pydantic `args_schema`. Each tool calls FastAPI REST via HTTP with dual-identity headers (`Authorization: Bearer <service_jwt>` + `X-User-JWT: <user_jwt>`). **Decision**: `@tool`-decorated functions with Pydantic `args_schema`. Each tool calls FastAPI REST via HTTP with dual-identity headers (`Authorization: Bearer <service_jwt>` + `X-User-JWT: <user_jwt>`).
**Rationale**: Single source of metadata. Auto OpenAI function schema. Old `@assistant_tool``@DEPRECATED`. **Rationale**: Single source of metadata. Auto OpenAI function schema. Old `@assistant_tool``@DEPRECATED`.
**Rejected**: Dual registration (`@assistant_tool` + `StructuredTool`) — redundant. **Rejected**: Dual registration (`@assistant_tool` + `StructuredTool`) — redundant.
**Current fact (2026-06-30)**: `backend/src/agent/tools.py` contains 24 native LangChain tools plus `get_tools_for_query()` for hybrid intent-based subset selection. Agent runtime does not call the deprecated assistant registry. **Current fact (2026-07-03)**: `backend/src/agent/tools.py` contains 24 native LangChain tools plus `get_tools_for_query()` for hybrid intent-based subset selection. Agent runtime does not call the deprecated assistant registry.
## 3. Confirmation: `interrupt_before=DANGEROUS_TOOLS` ## 3. Confirmation: `interrupt_before=DANGEROUS_TOOLS`
@@ -59,7 +59,7 @@
**Rejected**: Full 24-tool catalog causes Tool Paralysis in Gemma-level models. **Rejected**: Full 24-tool catalog causes Tool Paralysis in Gemma-level models.
**Rejected**: Embedding-only adds 5-20ms to every request, blind to negations. **Rejected**: Embedding-only adds 5-20ms to every request, blind to negations.
**Rejected**: Pure substring with cosmetic fixes cannot handle "панели"≠"дашборды". **Rejected**: Pure substring with cosmetic fixes cannot handle "панели"≠"дашборды".
**Current fact (2026-06-30)**: `get_tools_for_query()` uses keyword primary with embedding fallback in `_embedding_router.py`. `fast_confirmation_tool()` uses negation guard. **Current fact (2026-07-03)**: `get_tools_for_query()` uses keyword primary with embedding fallback in `_embedding_router.py`. `fast_confirmation_tool()` uses negation guard.
## 10. Embedding Model Selection ## 10. Embedding Model Selection
@@ -69,6 +69,16 @@
- `bge-small-en-v1.5` (130MB) EN-only. Rejected. - `bge-small-en-v1.5` (130MB) EN-only. Rejected.
- `intfloat/multilingual-e5-small` (470MB) best quality but heavier. Kept as configurable alternative. - `intfloat/multilingual-e5-small` (470MB) best quality but heavier. Kept as configurable alternative.
- `rubert-tiny2` (110MB) RU-only, no EN. Rejected. - `rubert-tiny2` (110MB) RU-only, no EN. Rejected.
**Current fact (2026-06-30)**: Model not yet deployed; planned in Phase 1 implementation of hybrid router. **Current fact (2026-07-03)**: `_embedding_router.py` is implemented with lazy model loading and graceful fallback. Runtime can degrade to keyword-only if `sentence-transformers` or model weights are unavailable.
## 11. Hidden Runtime Context and Operator Recovery
**Decision**: Runtime data needed by the agent (current ISO datetime, selected environment, maintenance duration rules, bounded dashboard prefetch) is injected into the LLM-facing message and stripped from persisted/user-visible text.
**Rationale**: The agent needs operational context, but showing service blocks in the chat damages trust and makes users copy internal JSON back into the conversation.
**Rejected**: Rendering prefetch/runtime blocks in user bubbles confuses users and pollutes conversation titles.
**Decision**: `/agent` uses a first-activity watchdog and recovery card when Gradio/LLM produces no first event for 60 seconds.
**Rationale**: A stream can hang before any token/tool event while the connection remains technically open; the UI must distinguish "thinking" from "no observable progress".
**Rejected**: Infinite thinking indicator misleading and not recoverable.
#endregion AgentChat.Research #endregion AgentChat.Research

View File

@@ -8,11 +8,13 @@
@REJECTED @assistant_tool for new tools — native LangChain `@tool` is SSOT. Old registry → @DEPRECATED for FR-020. @REJECTED @assistant_tool for new tools — native LangChain `@tool` is SSOT. Old registry → @DEPRECATED for FR-020.
**Feature Branch**: `033-gradio-agent-chat` **Feature Branch**: `033-gradio-agent-chat`
**Created**: 2026-06-08 | **Status**: Implemented fact snapshot | **Last revised**: 2026-06-30 (hybrid router, negation guard, embedding fallback) **Created**: 2026-06-08 | **Status**: Implemented fact snapshot | **Last revised**: 2026-07-03 (operator UX recovery, hidden runtime context, environment sync)
## Current Implementation Facts (2026-06-30) ## Current Implementation Facts (2026-07-03)
- `/agent` is served by SvelteKit `AgentChat.svelte` + `AgentChatModel.svelte.ts`; the browser connects through `@gradio/client` to absolute `${window.location.origin}/api/agent/gradio`. - `/agent` is served by SvelteKit `AgentChat.svelte` + `AgentChatModel.svelte.ts`; the browser connects through `@gradio/client` to absolute `${window.location.origin}/api/agent/gradio`.
- `/agent` synchronizes the selected Superset environment from `environmentContextStore` and falls back to `localStorage.selected_env_id` after `initializeEnvironmentContext()` so Gradio receives `env_id` before every send.
- The backend injects hidden `[RUNTIME CONTEXT]` into the LLM message: current ISO datetime, environment id, relative-time rules, and bounded dashboard prefetch. The persisted/user-visible message stays clean.
- `DEV_MODE=true ./run.sh` starts FastAPI, frontend, and Gradio with explicit `BACKEND_URL`, `GRADIO_URL`, `GRADIO_SERVER_PORT`; Gradio no longer silently falls back to a random port unless `GRADIO_ALLOW_PORT_FALLBACK=true`. - `DEV_MODE=true ./run.sh` starts FastAPI, frontend, and Gradio with explicit `BACKEND_URL`, `GRADIO_URL`, `GRADIO_SERVER_PORT`; Gradio no longer silently falls back to a random port unless `GRADIO_ALLOW_PORT_FALLBACK=true`.
- `backend/src/agent/tools.py` is the agent tool SSOT. It exposes 24 native LangChain `@tool` functions: `show_capabilities`, `search_dashboards`, `get_health_summary`, `list_environments`, `get_task_status`, `list_llm_providers`, `get_llm_status`, `create_branch`, `commit_changes`, `deploy_dashboard`, `execute_migration`, `run_backup`, `run_llm_validation`, `run_llm_documentation`, `list_maintenance_events`, `start_maintenance`, `end_maintenance`, `superset_execute_sql`, `superset_explore_database`, `superset_audit_permissions`, `superset_create_dashboard`, `superset_copy_dashboard`, `superset_create_dataset`, `superset_format_sql`. - `backend/src/agent/tools.py` is the agent tool SSOT. It exposes 24 native LangChain `@tool` functions: `show_capabilities`, `search_dashboards`, `get_health_summary`, `list_environments`, `get_task_status`, `list_llm_providers`, `get_llm_status`, `create_branch`, `commit_changes`, `deploy_dashboard`, `execute_migration`, `run_backup`, `run_llm_validation`, `run_llm_documentation`, `list_maintenance_events`, `start_maintenance`, `end_maintenance`, `superset_execute_sql`, `superset_explore_database`, `superset_audit_permissions`, `superset_create_dashboard`, `superset_copy_dashboard`, `superset_create_dataset`, `superset_format_sql`.
- Deprecated `backend/src/api/routes/assistant/_tool_registry.py` is legacy only and MUST NOT be used by the agent runtime. - Deprecated `backend/src/api/routes/assistant/_tool_registry.py` is legacy only and MUST NOT be used by the agent runtime.
@@ -20,8 +22,12 @@
- **Negation guard** in `fast_confirmation_tool()`: regex pre-check for negation patterns (`\bне\b`, `\bno\b`, `\bdon't\b`) bypasses fast-track and routes to LLM. Prevents false-positive confirmations for negated requests like "не показывай схему". - **Negation guard** in `fast_confirmation_tool()`: regex pre-check for negation patterns (`\bне\b`, `\bno\b`, `\bdon't\b`) bypasses fast-track and routes to LLM. Prevents false-positive confirmations for negated requests like "не показывай схему".
- **Smart prefetch trigger**: dashboard prefetch fires only for search/list intent, excludes creation/diagnostic patterns (`как создать`, `почему`, `speed`, `performance`). Capped by `AGENT_PREFETCH_DASHBOARD_LIMIT` (default `25`). - **Smart prefetch trigger**: dashboard prefetch fires only for search/list intent, excludes creation/diagnostic patterns (`как создать`, `почему`, `speed`, `performance`). Capped by `AGENT_PREFETCH_DASHBOARD_LIMIT` (default `25`).
- HITL guardrails use LangGraph `interrupt_before` for dangerous tools. Resume creates the agent with `interrupt_before=[]` so the confirmed checkpoint does not pause again before the same tool. - HITL guardrails use LangGraph `interrupt_before` for dangerous tools. Resume creates the agent with `interrupt_before=[]` so the confirmed checkpoint does not pause again before the same tool.
- The `/agent` header includes a collapsible debug/reference block with `conv_id`, pending `thread_id`, connection state, streaming state, env/user, message count, last message id, active tool-call count, and current error. - The `/agent` header includes an operator status strip: user-facing state, environment context, and process steps (`Контекст`, `Понимание задачи`, `Инструменты`, `Подтверждение`, `Результат`). These steps are currently derived from client state and tool metadata; explicit backend progress events remain a future hardening target.
- Current verification: backend agent tests `375 passed`; frontend `AgentChatModel.test.ts` `73 passed`; frontend build passed with existing unrelated Svelte warnings. - Debug/reference actions are behind a `Диагностика` menu (debug panel, copy JSON, check LLM) so service data does not compete with primary chat actions.
- User-visible message rendering and conversation titles strip hidden context (`[PRE-FETCHED DATA]`, uploaded-file content blocks). System-only history titles such as `✅ list_environments` normalize to `Системное действие`.
- First-activity timeout: if Gradio/LLM emits no token, tool call, confirmation, or thread id within 60s, the stream is converted into an actionable error/recovery state instead of endless "Думаю".
- Recovery card offers `Повторить последний запрос`, `Проверить LLM`, `Скопировать debug`, and `Новый диалог`.
- Current verification: targeted backend hidden-context test passed; frontend `AgentChatModel.test.ts` `88 passed`; frontend build passed with existing unrelated Svelte warnings.
## Architecture Overview ## Architecture Overview
@@ -111,6 +117,7 @@ Browser (SvelteKit) Docker
### Edge Cases ### Edge Cases
- Gradio unreachable reconnect 5×5s - Gradio unreachable reconnect 5×5s
- LLM malformed retry once, then error - LLM malformed retry once, then error
- LLM/Gradio accepts the request but emits no first event first-activity timeout after 60s and recovery card
- Message too long truncate + warn - Message too long truncate + warn
- Tool >30s → async, result when ready - Tool >30s → async, result when ready
- Multi-tab → REST gate: `GET /api/agent/conversations/{id}/active` rejects second tab - Multi-tab → REST gate: `GET /api/agent/conversations/{id}/active` rejects second tab
@@ -183,6 +190,18 @@ Browser (SvelteKit) Docker
- **FR-036 Embedding Router Infrastructure**: `backend/src/agent/_embedding_router.py` MUST provide: (a) tool description corpus (RU+EN, 1-3 sentences per tool) for embedding; (b) lazy-loaded `paraphrase-multilingual-MiniLM-L12-v2` model (configurable via `EMBEDDING_MODEL` env var); (c) pre-embedded tool descriptions at load time; (d) `embedding_top_k(query)` returning tool names above cosine threshold. Fails gracefully (returns `[]`) when `sentence-transformers` unavailable. - **FR-036 Embedding Router Infrastructure**: `backend/src/agent/_embedding_router.py` MUST provide: (a) tool description corpus (RU+EN, 1-3 sentences per tool) for embedding; (b) lazy-loaded `paraphrase-multilingual-MiniLM-L12-v2` model (configurable via `EMBEDDING_MODEL` env var); (c) pre-embedded tool descriptions at load time; (d) `embedding_top_k(query)` returning tool names above cosine threshold. Fails gracefully (returns `[]`) when `sentence-transformers` unavailable.
- **FR-037 Hidden Runtime Context**: The agent handler MUST pass runtime context to the LLM without persisting or rendering it as user text. Runtime context includes current ISO datetime, current environment, relative-time interpretation rules, and compact dashboard prefetch when available. Saved conversation history and generated titles MUST use the clean user message.
- **FR-038 Environment Sync**: `/agent` MUST sync `env_id` before each send from the selected environment store, with a localStorage fallback during initial route hydration. Debug info MUST show `env_id` so missing context is visible during diagnosis.
- **FR-039 Operator Process Strip**: `/agent` MUST show user-facing operational status and process steps for agent work. Minimum steps: context readiness, task understanding/LLM wait, tool execution, confirmation, result. If backend lacks explicit progress metadata, the UI MAY derive step state from streaming/tool/confirmation/error state, but this must be documented as heuristic.
- **FR-040 First-Activity Timeout Recovery**: If a stream enters `streaming` but no token, tool call, confirmation, or checkpoint/thread id arrives within 60 seconds, the frontend MUST cancel/return the submission where possible and show an actionable recovery panel.
- **FR-041 Clean Visible Message Text**: UI history, current user bubbles, and conversation titles MUST strip hidden prefetch/runtime/upload blocks. System-only titles SHOULD normalize to a human-readable system label.
- **FR-042 Diagnostics Menu**: Debug/reference actions MUST be grouped behind a diagnostics menu. The menu MUST expose debug-panel toggle, JSON copy, and LLM status check; it MUST be keyboard dismissible and not render raw JSON into chat.
## Success Criteria ## Success Criteria
- **SC-001**: Tool selection 92% accuracy (keyword 85% + embedding fallback covers synonym/typo remainder) - **SC-001**: Tool selection 92% accuracy (keyword 85% + embedding fallback covers synonym/typo remainder)
@@ -193,6 +212,9 @@ Browser (SvelteKit) Docker
- **SC-006**: Multi-step 30% faster than manual UI - **SC-006**: Multi-step 30% faster than manual UI
- **SC-007**: File upload 10MB, no degradation - **SC-007**: File upload 10MB, no degradation
- **SC-008**: Hybrid router ensures 50-75% token reduction (5-10 tools vs 24 full catalog). Embedding model lazy-loaded on first fallback call (cold-start ~2s), subsequent calls 5-20ms. - **SC-008**: Hybrid router ensures 50-75% token reduction (5-10 tools vs 24 full catalog). Embedding model lazy-loaded on first fallback call (cold-start ~2s), subsequent calls 5-20ms.
- **SC-009**: No hidden runtime/prefetch/upload context is visible in chat bubbles or conversation list titles.
- **SC-010**: A no-first-event stream surfaces recovery controls within 60-65 seconds.
- **SC-011**: Operator can copy diagnostics and verify LLM status from `/agent` without exposing debug fields as primary chat actions.
## Dependencies ## Dependencies

View File

@@ -1,7 +1,7 @@
#region AgentChat.Tasks [C:3] [TYPE ADR] [SEMANTICS tasks,implementation,agent-chat] #region AgentChat.Tasks [C:3] [TYPE ADR] [SEMANTICS tasks,implementation,agent-chat]
@BRIEF Implementation tasks for Gradio Agent Chat — phased by user story, dependency-ordered, with contract inlining for C3+ functions. @BRIEF Implementation tasks for Gradio Agent Chat — phased by user story, dependency-ordered, with contract inlining for C3+ functions.
## Current Fact Update — 2026-06-30 ## Current Fact Update — 2026-07-03
- [x] T097 [FACT] Replace deprecated assistant tool registry for agent runtime with 24 native LangChain `@tool` functions in `backend/src/agent/tools.py`. - [x] T097 [FACT] Replace deprecated assistant tool registry for agent runtime with 24 native LangChain `@tool` functions in `backend/src/agent/tools.py`.
@POST Agent tools are sourced from `backend/src/agent/tools.py`; `_tool_registry.py` remains legacy/deprecated only. @POST Agent tools are sourced from `backend/src/agent/tools.py`; `_tool_registry.py` remains legacy/deprecated only.
@@ -22,7 +22,7 @@
@POST `DEV_MODE=true ./run.sh` passes deterministic backend/Gradio URLs, frontend connects to same-origin `/api/agent/gradio`, and Gradio port fallback is opt-in via `GRADIO_ALLOW_PORT_FALLBACK=true`. @POST `DEV_MODE=true ./run.sh` passes deterministic backend/Gradio URLs, frontend connects to same-origin `/api/agent/gradio`, and Gradio port fallback is opt-in via `GRADIO_ALLOW_PORT_FALLBACK=true`.
- [x] T103 [FACT] Current verification snapshot. - [x] T103 [FACT] Current verification snapshot.
@POST Backend agent tests: `375 passed`. Frontend AgentChatModel tests: `73 passed`. Frontend build: passed with existing unrelated Svelte warnings. @POST Superseded by T111. Historical verification snapshot retained for chronology; current verification is T111.
- [x] T104 [FACT] Implement negation guard in `fast_confirmation_tool()`. - [x] T104 [FACT] Implement negation guard in `fast_confirmation_tool()`.
@POST Regex `\b(?:не|нет|no|don't|do not|stop|отмена|отмени)\b` pre-check bypasses fast-track → routes to LLM. Prevents false-positive confirmations for negated requests. @POST Regex `\b(?:не|нет|no|don't|do not|stop|отмена|отмени)\b` pre-check bypasses fast-track → routes to LLM. Prevents false-positive confirmations for negated requests.
@@ -30,6 +30,24 @@
- [x] T105 [FACT] Create `backend/src/agent/_embedding_router.py` — lazy-loaded embedding model, tool description corpus, `embedding_top_k()`. - [x] T105 [FACT] Create `backend/src/agent/_embedding_router.py` — lazy-loaded embedding model, tool description corpus, `embedding_top_k()`.
@POST Cosine similarity fallback when keyword matching <3 tools. Tool descriptions cover all 24 tools in RU+EN. Model: `paraphrase-multilingual-MiniLM-L12-v2` (configurable). Graceful degradation to `[]` if `sentence-transformers` unavailable. @POST Cosine similarity fallback when keyword matching <3 tools. Tool descriptions cover all 24 tools in RU+EN. Model: `paraphrase-multilingual-MiniLM-L12-v2` (configurable). Graceful degradation to `[]` if `sentence-transformers` unavailable.
- [x] T106 [FACT] Inject hidden runtime context for agent runs while persisting only the clean user message.
@POST Backend sends current datetime, env id, relative-time rules, and bounded dashboard prefetch to the LLM; saved history/title input excludes `[RUNTIME CONTEXT]`.
- [x] T107 [FACT] Sync `/agent` environment context before each Gradio submit.
@POST `initializeEnvironmentContext()` hydrates environment state; `syncModel()` uses selected environment with `localStorage.selected_env_id` fallback. Debug shows `env/user`.
- [x] T108 [FACT] Strip hidden context from visible chat/history UI.
@POST Current user bubbles, loaded history, localStorage history, and conversation titles strip `[PRE-FETCHED DATA]` and uploaded-file content blocks. System-only tool titles normalize to `Системное действие`.
- [x] T109 [FACT] Add first-activity timeout and recovery UX.
@POST If no token/tool/confirmation/checkpoint appears within 60 seconds, the frontend exits endless thinking and shows recovery actions: retry last request, check LLM, copy debug, new dialog.
- [x] T110 [FACT] Add operator process strip and diagnostics menu.
@POST `/agent` shows user-facing process steps and moves debug actions into a keyboard-dismissible diagnostics menu.
- [x] T111 [FACT] Current verification snapshot.
@POST Backend hidden-context regression: passed. Frontend AgentChatModel tests: `88 passed`. Frontend build: passed with existing unrelated Svelte warnings.
## Phase 1: Setup (shared infrastructure) ## Phase 1: Setup (shared infrastructure)
- [ ] T001 [P] Add `gradio>=5.0`, `langgraph>=0.2`, `langchain-core>=0.3`, `langchain-openai>=0.3`, `langgraph-checkpoint-postgres`, `pdfplumber` to `backend/requirements.txt` - [ ] T001 [P] Add `gradio>=5.0`, `langgraph>=0.2`, `langchain-core>=0.3`, `langchain-openai>=0.3`, `langgraph-checkpoint-postgres`, `pdfplumber` to `backend/requirements.txt`

View File

@@ -23,7 +23,11 @@
| **US6**: File Upload | AssistantChatPanel | AgentChat.Model | FX_AgentChat.Document.ParsePdf.Valid | `submit("/chat", {text, files})` | T065, T066 | T068, T069 | Test.AgentChat.Document | | **US6**: File Upload | AssistantChatPanel | AgentChat.Model | FX_AgentChat.Document.ParsePdf.Valid | `submit("/chat", {text, files})` | T065, T066 | T068, T069 | Test.AgentChat.Document |
| **US6**: PDF Encrypted | — | AgentChat.Document | FX_AgentChat.Document.ParsePdf.Encrypted | — | T065 | — | Test.AgentChat.Document | | **US6**: PDF Encrypted | — | AgentChat.Document | FX_AgentChat.Document.ParsePdf.Encrypted | — | T065 | — | Test.AgentChat.Document |
| **US6**: XLSX Password | — | AgentChat.Document | FX_AgentChat.Document.ParseXlsx.PasswordProtected | — | T065 | — | Test.AgentChat.Document | | **US6**: XLSX Password | — | AgentChat.Document | FX_AgentChat.Document.ParseXlsx.PasswordProtected | — | T065 | — | Test.AgentChat.Document |
| **—**: Debug Info | `/agent` | AgentChat.Model | — | UI-derived state | T101 | T073 | Browser snapshot | | **—**: Hidden Runtime Context | `/agent` | AgentChat.Handler | — | `submit("/chat")` LLM-facing message | T106 | T108 | Test.Agent.App.HiddenContext |
| **—**: Env Sync | `/agent` | AgentChat.Model | — | `submit("/chat", ..., env_id)` | T107 | T107 | Browser snapshot |
| **—**: First Activity Recovery | `/agent` | AgentChat.Model | — | Gradio stream watchdog | T109 | T109 | Test.AgentChat.Model |
| **—**: Operator Status Strip | `/agent` | AgentChat.Model | — | UI-derived state | T110 | T110 | Test.AgentChat.Model + Browser snapshot |
| **—**: Diagnostics Menu | `/agent` | AgentChat.Model | — | UI-derived state | T101 | T110 | Browser snapshot |
| **—**: DEV Mode Transport | `/agent` | AgentChat.GradioApp | — | `/api/agent/gradio` same-origin proxy | T102 | T073 | Browser snapshot + run tests | | **—**: DEV Mode Transport | `/agent` | AgentChat.GradioApp | — | `/api/agent/gradio` same-origin proxy | T102 | T073 | Browser snapshot + run tests |
| **—**: Service Auth | — | — | FX_AgentChat.ServiceToken.Valid | `POST /api/auth/service-token` | T080 | — | Test.AgentChat.Api | | **—**: Service Auth | — | — | FX_AgentChat.ServiceToken.Valid | `POST /api/auth/service-token` | T080 | — | Test.AgentChat.Api |
| **—**: Rejected Paths | — | AgentChat.Model | FX_AgentChat.Model.RejectedPath | — | — | — | Test.AgentChat.Model.RejectedPaths | | **—**: Rejected Paths | — | AgentChat.Model | FX_AgentChat.Model.RejectedPath | — | — | — | Test.AgentChat.Model.RejectedPaths |
@@ -42,6 +46,9 @@
| `fast_confirmation_tool()` negation guard | — | Test.IntentKeyword.Edges | Gradio agent handler | | `fast_confirmation_tool()` negation guard | — | Test.IntentKeyword.Edges | Gradio agent handler |
| Document parser | FX_AgentChat.Document.ParsePdf.Valid, ParseXlsx.Valid | Test.AgentChat.Document | Gradio agent handler | | Document parser | FX_AgentChat.Document.ParsePdf.Valid, ParseXlsx.Valid | Test.AgentChat.Document | Gradio agent handler |
| `@gradio/client` transport | FX_AgentChat.Model.RejectedPath (WebSocket guardrail) | Test.AgentChat.Model.RejectedPaths | AssistantChatPanel | | `@gradio/client` transport | FX_AgentChat.Model.RejectedPath (WebSocket guardrail) | Test.AgentChat.Model.RejectedPaths | AssistantChatPanel |
| Hidden runtime context / visible text cleaning | — | Test.Agent.App.HiddenContext, Test.AgentChat.Model | /agent, ConversationList |
| First-activity timeout and recovery card | — | Test.AgentChat.Model + browser snapshot | /agent |
| Diagnostics menu and process strip | — | Test.AgentChat.Model + browser snapshot | /agent |
| `POST /api/auth/service-token` | FX_AgentChat.ServiceToken.Valid, InvalidSecret | Test.AgentChat.Api | Gradio container startup | | `POST /api/auth/service-token` | FX_AgentChat.ServiceToken.Valid, InvalidSecret | Test.AgentChat.Api | Gradio container startup |
## Fixture Count ## Fixture Count
@@ -52,12 +59,13 @@
| Model fixtures | 5 | | Model fixtures | 5 |
| **Total** | **19** | | **Total** | **19** |
## Current Verification Snapshot (2026-06-30) ## Current Verification Snapshot (2026-07-03)
| Slice | Command | Result | | Slice | Command | Result |
|-------|---------|--------| |-------|---------|--------|
| Backend agent | `cd backend && source .venv/bin/activate && python -m pytest tests/test_agent/ -v` | 375 passed, 18 warnings | | Backend hidden context | `AUTH_SECRET_KEY=test-secret JWT_SECRET=test-secret backend/.venv/bin/python -m pytest backend/tests/test_agent/test_app.py::TestAgentHandler::test_normal_send_hides_runtime_context_from_saved_history -q` | 1 passed |
| Frontend model | `cd frontend && npm run test -- src/lib/models/__tests__/AgentChatModel.test.ts --run` | 73 passed | | Frontend model | `cd frontend && npm run test -- AgentChatModel.test.ts` | 88 passed |
| Frontend build | `cd frontend && npm run build` | Passed; existing unrelated Svelte warnings | | Frontend build | `cd frontend && npm run build` | Passed; existing unrelated Svelte warnings |
| Browser UX | Chrome DevTools snapshot/screenshot of `http://localhost:5173/agent` | Process strip visible; diagnostics menu opens; system titles normalized |
#endregion Traceability #endregion Traceability

View File

@@ -1,31 +1,35 @@
#region AgentChat.UxReference [C:3] [TYPE ADR] [SEMANTICS ux,reference,agent-chat,final] #region AgentChat.UxReference [C:3] [TYPE ADR] [SEMANTICS ux,reference,agent-chat,final]
@BRIEF Final UX reference — Gradio `submit()`, LangChain v1 HITL, structured metadata, Archive UX. @BRIEF Final UX reference — Gradio `submit()`, LangChain v1 HITL, structured metadata, Archive UX, operator recovery.
## 1. Persona ## 1. Persona
Platform operator in superset-tools Svelte interface. Opens chat (drawer or `/agent` page). Streams responses, confirms dangerous ops via inline card, uploads files for analysis. Platform operator in superset-tools Svelte interface. Opens chat (drawer or `/agent` page). Streams responses, confirms dangerous ops via inline card, uploads files for analysis, and diagnoses LLM/Gradio stalls without leaving the page.
## 2. Happy Path ## 2. Happy Path
`submit("/chat", {message}, conversation_id)`. Tokens stream in real time. Tool cards appear from structured metadata. Confirmation card for dangerous ops — Confirm via second `submit()`. Archive conversations via delete (soft-delete). `submit("/chat", {message}, conversation_id, user_id, user_jwt, env_id)`. The UI synchronizes selected environment before each send. Tokens stream in real time. Tool cards appear from structured metadata. Confirmation card for dangerous ops — Confirm via second `submit()`. Archive conversations via delete (soft-delete).
## 3. Key Elements ## 3. Key Elements
- **Input**: Textarea + paperclip (PDF/XLSX/JSON/CSV/PNG/JPEG) - **Input**: Textarea + paperclip (PDF/XLSX/JSON/CSV/PNG/JPEG)
- **Context indicator**: Header shows selected environment and process strip (`Контекст`, `Понимание задачи`, `Инструменты`, `Подтверждение`, `Результат`).
- **Streaming**: Text character-by-character. Tool cards from `metadata.type`. - **Streaming**: Text character-by-character. Tool cards from `metadata.type`.
- **Stop**: `submission.cancel()` — native. - **Stop**: `submission.cancel()` — native.
- **Confirmation**: Warning card from `metadata.type="confirm_required"`. Confirm → second `submit()`. - **Confirmation**: Warning card from `metadata.type="confirm_required"`. Confirm → second `submit()`.
- **Debug reference**: `/agent` header toggle shows `conv_id`, pending `thread_id`, connection/streaming state, env/user, message count, last message id, active tool calls, and current error. Copy exports JSON. - **Diagnostics**: `/agent` header menu exposes debug panel, copy JSON diagnostics, and LLM status check. Debug panel shows `conv_id`, pending `thread_id`, connection/streaming state, env/user, message count, last message id, active tool calls, and current error.
- **Visible text hygiene**: User bubbles and conversation titles do not show hidden runtime/prefetch/upload context. System-only tool titles normalize to `Системное действие`.
- **Conversation Switcher**: Search, date-grouped, infinite scroll. - **Conversation Switcher**: Search, date-grouped, infinite scroll.
- **Connection**: Green/red dot. - **Connection**: Green/red dot.
## 4. Error Scenarios ## 4. Error Scenarios
- **Gradio down**: Dot red, retry 5×5s. - **Gradio down**: Dot red, retry 5×5s.
- **LLM error**: Stream `metadata.type="error"`card + retry. - **LLM error**: Stream `metadata.type="error"`recovery card.
- **Silent LLM/Gradio hang**: No first token/tool/confirmation/checkpoint for 60s → recovery card with retry/check LLM/copy debug/new dialog.
- **Tool failure**: `metadata.type="tool_error"` → cross + detail. - **Tool failure**: `metadata.type="tool_error"` → cross + detail.
- **File parse**: Error chip: "Не удалось прочитать". - **File parse**: Error chip: "Не удалось прочитать".
- **Multi-tab**: REST gate rejects second tab. - **Multi-tab**: REST gate rejects second tab.
- **Relative maintenance request**: Hidden runtime context gives the LLM current ISO datetime and duration rules. The hidden block must not render in chat/history.
## 5. Gradual Transition ## 5. Gradual Transition