diff --git a/backend/src/agent/app.py b/backend/src/agent/app.py
index 669fb1ab..15a48f81 100644
--- a/backend/src/agent/app.py
+++ b/backend/src/agent/app.py
@@ -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
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 ───────────────────────────────────────────────
# In-memory per-user lock (keyed by user_id)
_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]"
else:
text = truncated + "\n[...truncated]"
+ visible_user_text = text
# ── File upload ──
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)
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 ──
if file_storage_path and file_original_name:
yield json.dumps({
@@ -338,7 +371,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
try:
emitted_any = False
async for event in agent.astream_events(
- {"messages": [HumanMessage(content=text)]},
+ {"messages": [HumanMessage(content=agent_text)]},
config=config,
version="v2",
):
@@ -382,7 +415,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
state = await agent.aget_state(config)
if getattr(state, "next", None):
emitted_any = True
- yield confirmation_payload(conv_id, state, text)
+ yield confirmation_payload(conv_id, state, visible_user_text)
return
elif not emitted_any:
yield json.dumps({
@@ -408,7 +441,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"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
except (APITimeoutError, httpx.ReadTimeout) as exc:
@@ -426,7 +459,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"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
except AuthenticationError as exc:
@@ -444,7 +477,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"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
except OutputParserException as e:
@@ -472,7 +505,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
try:
state = await agent.aget_state(config)
if getattr(state, "next", None):
- yield confirmation_payload(conv_id, state, text)
+ yield confirmation_payload(conv_id, state, visible_user_text)
return
except Exception:
pass
@@ -480,12 +513,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": f"❌ Ошибка: {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
- 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)
- asyncio.create_task(generate_llm_title(conv_id, text))
+ asyncio.create_task(generate_llm_title(conv_id, visible_user_text))
logger.reflect(
"Agent handler completed",
payload={"conv_id": conv_id, "assistant_len": len("".join(assistant_parts))},
diff --git a/backend/src/agent/langgraph_setup.py b/backend/src/agent/langgraph_setup.py
index 1936c91d..f14a7bc4 100644
--- a/backend/src/agent/langgraph_setup.py
+++ b/backend/src/agent/langgraph_setup.py
@@ -192,7 +192,12 @@ async def create_agent(
"You handle all intent detection — multi-intent queries, negations (\"don't run\"), "
"synonyms (\"панели\" = \"дашборды\"), and typos are your responsibility. "
"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:
prompt += f"\n\nCurrent environment: '{env_id}'. When calling tools that accept env_id, use this value."
diff --git a/backend/tests/test_agent/test_app.py b/backend/tests/test_agent/test_app.py
index 09480e3d..689a23c1 100644
--- a/backend/tests/test_agent/test_app.py
+++ b/backend/tests/test_agent/test_app.py
@@ -265,6 +265,41 @@ class TestAgentHandler:
token_data = json.loads(results[0])
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
async def test_tool_events_yielded(self, mock_request):
from src.agent.app import agent_handler
diff --git a/frontend/src/lib/components/agent/AgentChat.svelte b/frontend/src/lib/components/agent/AgentChat.svelte
index 3180d9ba..a61f9931 100644
--- a/frontend/src/lib/components/agent/AgentChat.svelte
+++ b/frontend/src/lib/components/agent/AgentChat.svelte
@@ -36,6 +36,7 @@
let messagesContainer: HTMLDivElement | undefined = $state();
let pendingFile: File | null = $state(null);
let isDragOver = $state(false);
+ let isDiagnosticsMenuOpen = $state(false);
let dragCounter = 0;
const ALLOWED_FILE_TYPES = [".pdf", ".xlsx", ".json", ".csv", ".txt", ".png", ".jpeg", ".jpg"];
@@ -268,6 +269,17 @@
pendingFile = file;
}
+ function closeDiagnosticsMenu() {
+ isDiagnosticsMenuOpen = false;
+ }
+
+ function handleDiagnosticsKeydown(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ closeDiagnosticsMenu();
+ }
+ }
+
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
@@ -286,6 +298,13 @@
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() {
const lastMsg = model.messages.at(-1);
const payload = {
@@ -380,29 +399,57 @@
-
-
+
+
+ {#if isDiagnosticsMenuOpen}
+
+
+
+
+
+
+ {/if}
+
{#if model.streamingState === "streaming"}