fix(agent): auto-start scenario chat, robust HITL resume, strict service auth
- frontend: fix auto-start on dashboards->/agent navigation (undefined params ReferenceError), route initial connect through ConnectionManager with auto-retry, reset runModel on objectId change and failed recovery - agent: fix closure-over-loop-variable bug in _inject_env_id_into_tools (env now resolved from request-local ContextVar; idempotent wrapping), make execute_dashboard_result.result_key optional, resilient checkpoint resume with ToolMessage repair + direct-tool fallback, remove dead fast-path, consolidate tool_call parsing in _tool_resolver, context-safe ContextVar resets - backend: llm-config gated by strict service-only auth (no user-JWT fallback), tighten idempotent run reuse (dashboard/env/intent match + 6h staleness), terminal event transitions run.status to COMPLETED/FAILED/CANCELLED, null-safe metric parsing in dashboard query model - run.sh/docker-compose: require SERVICE_JWT (random per-run secret) instead of public default
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -116,3 +116,4 @@ backend/relative
|
||||
|
||||
# GitService runtime repos (test artifacts, lock files)
|
||||
backend/git_repos/
|
||||
.playwright-mcp
|
||||
@@ -12,6 +12,7 @@ from collections.abc import AsyncGenerator
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from ss_tools.agent._llm_params import chat_openai_kwargs
|
||||
@@ -19,7 +20,7 @@ from ss_tools.shared._llm_http import get_shared_http_client
|
||||
from ss_tools.agent._tool_resolver import (
|
||||
extract_tool_call_from_state,
|
||||
find_tool,
|
||||
normalize_tool_args,
|
||||
pending_tool_calls_from_state,
|
||||
)
|
||||
from ss_tools.agent.langgraph_setup import create_agent
|
||||
from ss_tools.agent.tools import get_all_tools
|
||||
@@ -27,6 +28,21 @@ from ss_tools.agent.tools import get_all_tools
|
||||
_pending_confirmations: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
# #region AgentChat.Confirmation.PendingToolCalls [C:2] [TYPE Function] [SEMANTICS agent-chat,hitl,resume,repair]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Extract tool calls from a graph state that are missing a ToolMessage.
|
||||
# @POST Returns list of (tool_name, tool_args, tool_call_id) — empty when the
|
||||
# checkpoint history is consistent.
|
||||
# @RATIONALE Single source of truth lives in AgentChat.ToolResolver
|
||||
# (pending_tool_calls_from_state); this alias keeps callers/tests stable and
|
||||
# avoids a second divergent parser. Checkpoints taken with interrupt_before=
|
||||
# ["tools"] (or left behind by a crashed tool run) contain AI messages whose
|
||||
# tool calls have no ToolMessage — the resume fallback executes these directly
|
||||
# so the user still receives a response instead of a dead stream.
|
||||
_pending_tool_calls = pending_tool_calls_from_state
|
||||
# #endregion AgentChat.Confirmation.PendingToolCalls
|
||||
|
||||
|
||||
# #region AgentChat.Confirmation.Contract [C:2] [TYPE Function] [SEMANTICS agent-chat,hitl,contract]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Build confirmation contract dict — risk level, prompt, operation metadata.
|
||||
@@ -244,6 +260,11 @@ def confirmation_metadata(
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Serialise confirmation into a JSON payload string for the Gradio event stream.
|
||||
# @POST Returns JSON string with content + metadata.
|
||||
# @SIDE_EFFECT Stores a title/args marker in _pending_confirmations so app.py can
|
||||
# build a descriptive conversation title on resume ("✅ inspect_dashboard_query_model"
|
||||
# instead of "HITL: confirm"). The marker is marked _fast_path=False so handle_resume
|
||||
# still continues the LangGraph checkpoint (multi-step scenario continuation) rather
|
||||
# than executing a single tool and stopping.
|
||||
def confirmation_payload(
|
||||
conv_id: str,
|
||||
state,
|
||||
@@ -251,9 +272,17 @@ def confirmation_payload(
|
||||
user_role: str | None = None,
|
||||
target_env: str | None = None,
|
||||
) -> str:
|
||||
metadata = confirmation_metadata(conv_id, state, user_text, user_role, target_env)
|
||||
tool_name = metadata.get("tool_name")
|
||||
if tool_name and conv_id:
|
||||
_pending_confirmations[conv_id] = {
|
||||
"tool_name": tool_name,
|
||||
"tool_args": metadata.get("tool_args", {}) or {},
|
||||
"_fast_path": False,
|
||||
}
|
||||
return json.dumps({
|
||||
"content": "⏸️ Требуется подтверждение",
|
||||
"metadata": confirmation_metadata(conv_id, state, user_text, user_role, target_env),
|
||||
"metadata": metadata,
|
||||
})
|
||||
# #endregion AgentChat.Confirmation.Payload
|
||||
|
||||
@@ -336,130 +365,162 @@ async def _format_tool_output_via_llm(
|
||||
|
||||
# #region AgentChat.Confirmation.HandleResume [C:4] [TYPE Function] [SEMANTICS agent-chat,hitl,resume,streaming]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Resume from HITL checkpoint — execute confirmed tool or abort on deny.
|
||||
# @BRIEF Resume from HITL checkpoint — continue the LangGraph run or abort on deny.
|
||||
# @PRE conversation_id is valid. action is "confirm" or "deny".
|
||||
# @POST Streams confirm_resolved, tool_start, tool_end/tool_error events via yield.
|
||||
# @SIDE_EFFECT Invokes LangChain tools; modifies _pending_confirmations dict.
|
||||
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
|
||||
# @DATA_CONTRACT Input: (conv_id, action, user_jwt, env_id) -> Output: AsyncGenerator[str]
|
||||
# @RATIONALE Fast-path resume (direct tool execution via _pending_confirmations dict)
|
||||
# chosen because the HITL confirmation payload already contains serialised tool
|
||||
# name + args — re-entering LangGraph to invoke the same tool is redundant.
|
||||
# Bypasses ~1-3s of LangGraph overhead (agent init, state reconstruction, tool
|
||||
# re-selection) per resume. Falls back to full LangGraph checkpoint resume when
|
||||
# _pending_confirmations is empty (e.g. after container restart).
|
||||
# @REJECTED ALWAYS checkpoint resume via create_agent(interrupt_before=[]) was
|
||||
# rejected — adds 1-3s latency to every resume for no reliability gain when
|
||||
# _pending_confirmations is populated. The full checkpoint path is preserved as
|
||||
# the fallback, providing defense-in-depth for container restart scenarios.
|
||||
# @RATIONALE Resume ALWAYS continues the LangGraph checkpoint (create_agent +
|
||||
# astream_events(None)) so multi-step scenario runs keep building after a
|
||||
# confirmation. When the checkpoint resume itself fails (e.g. INVALID_CHAT_HISTORY
|
||||
# from a stale run with unanswered tool calls), the still-pending tools are
|
||||
# executed directly as a fallback and the checkpoint is repaired with synthetic
|
||||
# ToolMessages so the thread stays usable. _pending_confirmations carries only a
|
||||
# title/args marker for descriptive conversation titles.
|
||||
# @REJECTED Fast-path single-tool resume (direct tool execution + format, no graph
|
||||
# continuation) — rejected because it stalls multi-step scenario flows after the
|
||||
# first confirmation.
|
||||
# @REJECTED Pure streaming without checkpoint — would lose unconfirmed operations
|
||||
# on crash with no rollback capability.
|
||||
async def handle_resume( # noqa: C901
|
||||
conversation_id: str, action: str,
|
||||
user_jwt: str = "", env_id: str | None = None,
|
||||
) -> AsyncGenerator[str]:
|
||||
from ss_tools.agent.context import reset_user_jwt, set_user_jwt
|
||||
from ss_tools.agent.context import reset_env_id, reset_user_jwt, set_env_id, set_user_jwt
|
||||
from ss_tools.shared.logger import logger
|
||||
|
||||
user_jwt_token = set_user_jwt(user_jwt)
|
||||
env_token = set_env_id(env_id or "")
|
||||
try:
|
||||
pending = _pending_confirmations.pop(conversation_id, None)
|
||||
if pending is not None:
|
||||
if action == "deny":
|
||||
yield json.dumps({
|
||||
"content": "⏹️ Операция отменена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "denied"},
|
||||
})
|
||||
return
|
||||
if action == "confirm":
|
||||
logger.reason(
|
||||
"Fast-path confirmation resume",
|
||||
payload={"tool": pending.get("tool_name"), "conv_id": conversation_id},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
tool_name = str(pending.get("tool_name") or "unknown_action")
|
||||
tool_args = normalize_tool_args(pending.get("tool_args"))
|
||||
yield json.dumps({
|
||||
"content": "▶️ Операция подтверждена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
|
||||
})
|
||||
yield json.dumps({
|
||||
"content": f"🛠️ {tool_name}",
|
||||
"metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
|
||||
})
|
||||
tool_obj = find_tool(tool_name)
|
||||
if tool_obj is None:
|
||||
error = f"Unknown tool: {tool_name}"
|
||||
logger.explore(
|
||||
"Unknown tool in resume",
|
||||
payload={"tool": tool_name}, error=error,
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {error}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": error},
|
||||
})
|
||||
return
|
||||
try:
|
||||
output = await tool_obj.ainvoke(tool_args)
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"Tool invocation failed in resume",
|
||||
payload={"tool": tool_name}, error=str(exc),
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {exc}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(exc)},
|
||||
})
|
||||
return
|
||||
yield json.dumps({
|
||||
"content": f"✅ {tool_name}",
|
||||
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
|
||||
})
|
||||
# Format tool output via LLM for a human-readable response
|
||||
async for chunk in _format_tool_output_via_llm(tool_name, str(output)):
|
||||
yield chunk
|
||||
logger.reflect(
|
||||
"Fast-path confirmation completed",
|
||||
payload={"tool": tool_name},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
return
|
||||
# Consume the title/args marker stored by confirmation_payload (used by
|
||||
# app.py to build a descriptive conversation title). Resume ALWAYS continues
|
||||
# the LangGraph checkpoint so multi-step scenario runs keep building after a
|
||||
# confirmation; executing a single tool and stopping (the former fast-path)
|
||||
# would stall those flows, so that path has been removed.
|
||||
_pending_confirmations.pop(conversation_id, None)
|
||||
|
||||
logger.reason(
|
||||
"LangGraph checkpoint resume",
|
||||
payload={"conv_id": conversation_id, "action": action},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
|
||||
# Resume with the SAME env-injection parity as the original run so tool
|
||||
# calls stored in the checkpoint (raw LLM args, without env_id) still get
|
||||
# environment_id filled in by the runtime wrapper.
|
||||
try:
|
||||
from ss_tools.agent.app import _inject_env_id_into_tools
|
||||
resume_tools = _inject_env_id_into_tools(get_all_tools(), env_id)
|
||||
except Exception:
|
||||
resume_tools = get_all_tools()
|
||||
agent = await create_agent(resume_tools, env_id, interrupt_before=[])
|
||||
if action == "confirm":
|
||||
config = {"configurable": {"thread_id": conversation_id}}
|
||||
yield json.dumps({
|
||||
"content": "▶️ Операция подтверждена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
|
||||
})
|
||||
async for event in agent.astream_events(None, config=config, version="v2"):
|
||||
kind = event.get("event")
|
||||
if kind == "on_chat_model_stream":
|
||||
chunk = event["data"]["chunk"]
|
||||
if hasattr(chunk, "content") and chunk.content:
|
||||
try:
|
||||
async for event in agent.astream_events(None, config=config, version="v2"):
|
||||
kind = event.get("event")
|
||||
if kind == "on_chat_model_stream":
|
||||
chunk = event["data"]["chunk"]
|
||||
if hasattr(chunk, "content") and chunk.content:
|
||||
yield json.dumps({
|
||||
"content": chunk.content,
|
||||
"metadata": {"type": "stream_token", "token": chunk.content},
|
||||
})
|
||||
elif kind == "on_tool_start":
|
||||
tool_name = event["name"]
|
||||
yield json.dumps({
|
||||
"content": chunk.content,
|
||||
"metadata": {"type": "stream_token", "token": chunk.content},
|
||||
"content": f"🛠️ {tool_name}",
|
||||
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
|
||||
})
|
||||
elif kind == "on_tool_start":
|
||||
tool_name = event["name"]
|
||||
elif kind == "on_tool_end":
|
||||
tool_name = event["name"]
|
||||
output = event["data"].get("output", "")
|
||||
yield json.dumps({
|
||||
"content": f"✅ {tool_name}",
|
||||
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
|
||||
})
|
||||
except Exception as exc:
|
||||
# Checkpoint may contain an AI message whose tool call has no
|
||||
# ToolMessage (interrupt_before=["tools"] or a crashed prior run).
|
||||
# LangGraph then raises INVALID_CHAT_HISTORY in the model node and
|
||||
# the whole resume would die. Fall back to executing the still-
|
||||
# pending tool calls directly so the user still gets a response.
|
||||
logger.explore(
|
||||
"Checkpoint resume failed — falling back to direct tool execution",
|
||||
payload={"conv_id": conversation_id},
|
||||
error=str(exc),
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
state = await agent.aget_state(config)
|
||||
pending = _pending_tool_calls(state)
|
||||
if pending:
|
||||
state_messages = list(state.values.get("messages", []))
|
||||
repaired_msgs: list[ToolMessage] = []
|
||||
for tool_name, tool_args, tcid in pending:
|
||||
yield json.dumps({
|
||||
"content": f"🛠️ {tool_name}",
|
||||
"metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
|
||||
})
|
||||
tool_obj = find_tool(tool_name)
|
||||
if tool_obj is None:
|
||||
err = f"Unknown tool: {tool_name}"
|
||||
logger.explore("Unknown tool in resume fallback",
|
||||
payload={"tool": tool_name}, error=err,
|
||||
extra={"src": "AgentChat.Confirmation"})
|
||||
repaired_msgs.append(ToolMessage(
|
||||
content=f"Error: {err}", tool_call_id=tcid, name=tool_name))
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {err}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": err},
|
||||
})
|
||||
continue
|
||||
try:
|
||||
output = await tool_obj.ainvoke(tool_args)
|
||||
except Exception as tool_exc:
|
||||
logger.explore("Tool invocation failed in resume fallback",
|
||||
payload={"tool": tool_name}, error=str(tool_exc),
|
||||
extra={"src": "AgentChat.Confirmation"})
|
||||
repaired_msgs.append(ToolMessage(
|
||||
content=f"Error: {tool_exc}", tool_call_id=tcid, name=tool_name))
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {tool_exc}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(tool_exc)},
|
||||
})
|
||||
continue
|
||||
repaired_msgs.append(ToolMessage(
|
||||
content=str(output), tool_call_id=tcid, name=tool_name))
|
||||
yield json.dumps({
|
||||
"content": f"✅ {tool_name}",
|
||||
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
|
||||
})
|
||||
async for chunk in _format_tool_output_via_llm(tool_name, str(output)):
|
||||
yield chunk
|
||||
# Repair the checkpoint: answer the pending tool calls with
|
||||
# ToolMessages so the thread is consistent and the NEXT user
|
||||
# message does not fail LangGraph INVALID_CHAT_HISTORY, and a
|
||||
# repeated confirm cannot re-execute the same tool calls.
|
||||
if repaired_msgs:
|
||||
try:
|
||||
agent.update_state(config, {"messages": [*state_messages, *repaired_msgs]})
|
||||
logger.reason(
|
||||
"Checkpoint repaired after resume fallback",
|
||||
payload={"count": len(repaired_msgs), "conv_id": conversation_id},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
except Exception as repair_exc:
|
||||
logger.explore(
|
||||
"Checkpoint repair failed after resume fallback",
|
||||
payload={"conv_id": conversation_id}, error=str(repair_exc),
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
else:
|
||||
yield json.dumps({
|
||||
"content": f"🛠️ {tool_name}",
|
||||
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
|
||||
})
|
||||
elif kind == "on_tool_end":
|
||||
tool_name = event["name"]
|
||||
output = event["data"].get("output", "")
|
||||
yield json.dumps({
|
||||
"content": f"✅ {tool_name}",
|
||||
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
|
||||
"content": f"❌ Ошибка возобновления: {exc}",
|
||||
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},
|
||||
})
|
||||
elif action == "deny":
|
||||
logger.reflect(
|
||||
@@ -473,5 +534,6 @@ async def handle_resume( # noqa: C901
|
||||
})
|
||||
finally:
|
||||
reset_user_jwt(user_jwt_token)
|
||||
reset_env_id(env_token)
|
||||
# #endregion AgentChat.Confirmation.HandleResume
|
||||
# #endregion AgentChat.Confirmation
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
_GRAPH_NODE_NAMES = {"agent", "tools", "__start__", "__end__"}
|
||||
|
||||
|
||||
@@ -56,10 +58,47 @@ def coerce_tool_call(tool_call: Any) -> tuple[str | None, dict[str, Any]]:
|
||||
# #endregion AgentChat.ToolResolver.CoerceCall
|
||||
|
||||
|
||||
# #region AgentChat.ToolResolver.PendingCalls [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,pending,state]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Extract ALL tool calls from a LangGraph state that are missing a ToolMessage.
|
||||
# @POST Returns list of (tool_name, tool_args, tool_call_id) — empty when the
|
||||
# checkpoint history is consistent.
|
||||
# @RATIONALE Single source of truth for "which tool calls are still pending" across
|
||||
# the confirmation metadata (the gated tool is the first pending call) and the
|
||||
# resume fallback (all pending calls are executed directly). Divergent parsers
|
||||
# would let the confirmation title name a different tool than the fallback runs.
|
||||
def pending_tool_calls_from_state(state: Any) -> list[tuple[str, dict[str, Any], str]]:
|
||||
messages = list(state.values.get("messages", [])) if hasattr(state, "values") else []
|
||||
executed_ids = {
|
||||
getattr(m, "tool_call_id", None)
|
||||
for m in messages
|
||||
if isinstance(m, ToolMessage)
|
||||
}
|
||||
pending: list[tuple[str, dict[str, Any], str]] = []
|
||||
for m in messages:
|
||||
if not isinstance(m, AIMessage):
|
||||
continue
|
||||
for tc in getattr(m, "tool_calls", None) or []:
|
||||
tcid = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
|
||||
tname = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
|
||||
targs = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", None)
|
||||
if tcid and tname and tcid not in executed_ids:
|
||||
pending.append((str(tname), normalize_tool_args(targs), str(tcid)))
|
||||
return pending
|
||||
# #endregion AgentChat.ToolResolver.PendingCalls
|
||||
|
||||
|
||||
# #region AgentChat.ToolResolver.ExtractCall [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,extract,state]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Extract pending tool call from LangGraph state messages.
|
||||
# @BRIEF Extract the pending tool call from LangGraph state messages.
|
||||
def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None, dict[str, Any]]:
|
||||
# Prefer the first unanswered tool call (the one the HITL gate is blocking).
|
||||
# This keeps the confirmation marker/title in sync with what the resume will
|
||||
# actually execute, unlike a raw "last AI message, first call" scan which can
|
||||
# name an already-answered call or skip an older pending one.
|
||||
pending = pending_tool_calls_from_state(state)
|
||||
if pending:
|
||||
return pending[0][0], pending[0][1]
|
||||
known_tools = known_agent_tool_names()
|
||||
try:
|
||||
messages = (state.values.get("messages") if hasattr(state, "values") else []) or []
|
||||
|
||||
@@ -59,7 +59,14 @@ from ss_tools.agent._persistence import (
|
||||
prefetch_databases,
|
||||
save_conversation,
|
||||
)
|
||||
from ss_tools.agent.context import reset_user_jwt, reset_user_role, set_user_jwt, set_user_role
|
||||
from ss_tools.agent.context import (
|
||||
reset_env_id,
|
||||
reset_user_jwt,
|
||||
reset_user_role,
|
||||
set_env_id,
|
||||
set_user_jwt,
|
||||
set_user_role,
|
||||
)
|
||||
from ss_tools.agent.document_parser import parse_upload
|
||||
from ss_tools.agent.langgraph_setup import create_agent, llm_diagnostics
|
||||
from ss_tools.agent.middleware import (
|
||||
@@ -293,18 +300,33 @@ def _inject_uicontext(runtime_context: str, uicontext: dict) -> str:
|
||||
# @BRIEF Wrap tools to auto-inject environment_id from runtime context when LLM omits it.
|
||||
# @POST Tool functions injected with env_id; args_schema input intercepts before validation.
|
||||
# @RATIONALE LLM often omits environment_id even when system prompt instructs it. Auto-injection
|
||||
# makes tools resilient — missing env_id gets filled from the runtime context.
|
||||
# @SIDE_EFFECT Mutates tool objects (_parse_input, coroutine).
|
||||
# makes tools resilient — missing env_id gets filled from the request-local context.
|
||||
# @SIDE_EFFECT Mutates tool objects (_parse_input, coroutine) ONCE per tool.
|
||||
# @INVARIANT Idempotent: each tool is wrapped at most once; the wrappers resolve the
|
||||
# environment from the request-local ContextVar (set_env_id/get_env_id) at call time,
|
||||
# so repeated _inject_env_id_into_tools calls do not stack wrappers and concurrent
|
||||
# conversations with different environments do not bleed env into each other.
|
||||
def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list:
|
||||
"""Wrap tools so environment_id is auto-injected from runtime context when LLM omits it.
|
||||
|
||||
Works by intercepting _parse_input (before args_schema validation) and injecting
|
||||
env_id into the tool input dict. Also wraps coroutine as a safety net.
|
||||
env_id into the tool input dict. Also wraps coroutine as a safety net. The env is
|
||||
read from the request-local ContextVar (get_env_id) at invocation time, and tools
|
||||
are wrapped at most once (marked with _env_aware_injected), so repeated calls on
|
||||
the shared tool singletons (send path + handle_resume) do not accumulate wrapper
|
||||
layers or leak one conversation's environment into another.
|
||||
"""
|
||||
from ss_tools.agent.context import get_env_id
|
||||
|
||||
if not env_id:
|
||||
return tools
|
||||
|
||||
for tool in tools:
|
||||
# Idempotent: skip tools already wrapped by a previous call (the wrapper
|
||||
# resolves the env from request-local context, so re-wrapping is unnecessary
|
||||
# and would only stack closures on the shared singletons).
|
||||
if getattr(tool, "_env_aware_injected", False):
|
||||
continue
|
||||
# Only wrap tools that accept 'environment_id' parameter
|
||||
sig = inspect.signature(tool.coroutine if tool.coroutine else (tool.func if tool.func else tool._run))
|
||||
if "environment_id" not in sig.parameters:
|
||||
@@ -314,32 +336,42 @@ def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list:
|
||||
# Use UNBOUND class method to avoid double-binding issues
|
||||
orig_unbound = type(tool)._parse_input
|
||||
|
||||
def _make_parse_wrapper(orig_fn, eid: str):
|
||||
"""Create a wrapper that injects env_id into tool_input dict."""
|
||||
def _make_parse_wrapper(orig_fn):
|
||||
"""Create a wrapper that injects env_id from request-local context."""
|
||||
|
||||
@functools.wraps(orig_fn)
|
||||
def wrapped(self, tool_input, tool_call_id=None):
|
||||
if isinstance(tool_input, dict):
|
||||
if tool_input.get("environment_id") is None:
|
||||
if isinstance(tool_input, dict) and tool_input.get("environment_id") is None:
|
||||
eid = get_env_id()
|
||||
if eid:
|
||||
tool_input = {**tool_input, "environment_id": eid}
|
||||
return orig_fn(self, tool_input, tool_call_id)
|
||||
|
||||
return wrapped
|
||||
|
||||
tool._parse_input = _make_parse_wrapper(orig_unbound, env_id).__get__(tool, type(tool))
|
||||
tool._parse_input = _make_parse_wrapper(orig_unbound).__get__(tool, type(tool))
|
||||
|
||||
# Wrap coroutine as safety net
|
||||
original_coro = tool.coroutine
|
||||
if original_coro:
|
||||
|
||||
@functools.wraps(original_coro)
|
||||
async def env_aware_coro(*args, **kwargs):
|
||||
if kwargs.get("environment_id") is None and env_id:
|
||||
kwargs["environment_id"] = env_id
|
||||
return await original_coro(*args, **kwargs)
|
||||
async def env_aware_coro(*args, _orig=original_coro, **kwargs):
|
||||
# _orig binds original_coro AT DEFINITION TIME — the loop variable
|
||||
# is rebound each iteration, and a closure over `original_coro`
|
||||
# would otherwise make every wrapper call the LAST tool's coroutine
|
||||
# (all env-injected tools silently became create_verification_run_tool,
|
||||
# crashing tool execution with unexpected/missing keyword errors).
|
||||
if kwargs.get("environment_id") is None:
|
||||
eid = get_env_id()
|
||||
if eid:
|
||||
kwargs["environment_id"] = eid
|
||||
return await _orig(*args, **kwargs)
|
||||
|
||||
tool.coroutine = env_aware_coro
|
||||
|
||||
tool._env_aware_injected = True
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
@@ -372,6 +404,14 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
"""
|
||||
# ── Auth: user JWT passed from frontend via additional_input —─
|
||||
user_jwt_str = user_jwt_str_param or ""
|
||||
if not user_jwt_str:
|
||||
# Gradio may omit the hidden additional input on reconnects. Preserve
|
||||
# the browser bearer token for tool calls and lifecycle audit writes,
|
||||
# without logging or placing it in the event payload.
|
||||
request_headers = getattr(request, "headers", {}) or {}
|
||||
authorization = request_headers.get("authorization") or request_headers.get("Authorization")
|
||||
if isinstance(authorization, str) and authorization.lower().startswith("bearer "):
|
||||
user_jwt_str = authorization[7:].strip()
|
||||
token_payload: dict[str, Any] = {}
|
||||
if user_jwt_str:
|
||||
try:
|
||||
@@ -382,12 +422,16 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
user_jwt_token = set_user_jwt(user_jwt_str)
|
||||
user_role = token_payload.get("role") or token_payload.get("user_role") or "viewer"
|
||||
user_role_token = set_user_role(user_role)
|
||||
# Request-local env for tool auto-injection (read by the env-aware wrappers at
|
||||
# call time, so concurrent conversations with different envs stay isolated).
|
||||
env_token = set_env_id(env_id or "")
|
||||
|
||||
# ── Per-user lock ──
|
||||
user_id = user_id_str or (extract_user_id(user_jwt_str) if user_jwt_str else "admin")
|
||||
if _user_locks.get(user_id, False):
|
||||
reset_user_jwt(user_jwt_token)
|
||||
reset_user_role(user_role_token)
|
||||
reset_env_id(env_token)
|
||||
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND", "detail": "Другой запрос уже обрабатывается. Дождитесь завершения перед отправкой нового."}})
|
||||
return
|
||||
_user_locks[user_id] = True
|
||||
@@ -936,6 +980,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
del _conv_locks[conv_id]
|
||||
reset_user_jwt(user_jwt_token)
|
||||
reset_user_role(user_role_token)
|
||||
reset_env_id(env_token)
|
||||
|
||||
|
||||
# #endregion AgentChat.GradioApp.Handler
|
||||
|
||||
@@ -13,6 +13,7 @@ from contextvars import ContextVar, Token
|
||||
_user_jwt: ContextVar[str] = ContextVar("agent_user_jwt", default="")
|
||||
_service_jwt: ContextVar[str] = ContextVar("agent_service_jwt", default="")
|
||||
_user_role: ContextVar[str] = ContextVar("agent_user_role", default="viewer")
|
||||
_env_id: ContextVar[str] = ContextVar("agent_env_id", default="")
|
||||
|
||||
|
||||
# #region AgentChat.Context.SetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,set]
|
||||
@@ -30,6 +31,21 @@ def get_user_jwt() -> str:
|
||||
# #endregion AgentChat.Context.GetUserJwt
|
||||
|
||||
|
||||
# #region AgentChat.Context.SetEnvId [C:1] [TYPE Function] [SEMANTICS agent-chat,context,env,set]
|
||||
# @BRIEF Store request-local environment ID in a ContextVar for tool env auto-injection.
|
||||
# @POST Returns a reset token for restoring the previous request context.
|
||||
def set_env_id(env_id: str) -> Token[str]:
|
||||
return _env_id.set(env_id or "")
|
||||
# #endregion AgentChat.Context.SetEnvId
|
||||
|
||||
|
||||
# #region AgentChat.Context.GetEnvId [C:1] [TYPE Function] [SEMANTICS agent-chat,context,env,get]
|
||||
# @BRIEF Retrieve request-local environment ID for tool env auto-injection.
|
||||
def get_env_id() -> str:
|
||||
return _env_id.get()
|
||||
# #endregion AgentChat.Context.GetEnvId
|
||||
|
||||
|
||||
# #region AgentChat.Context.SetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,set]
|
||||
# @BRIEF Store request-local user role for RBAC enforcement in tool pipeline.
|
||||
# @POST Returns a reset token for restoring the previous request context.
|
||||
@@ -64,15 +80,37 @@ def get_service_jwt() -> str:
|
||||
# @BRIEF Restore request-local JWT and role values after a request completes.
|
||||
# @PRE Tokens were returned by the corresponding set_* functions in the same context.
|
||||
# @POST Previous ContextVar values are restored; concurrent request contexts remain isolated.
|
||||
# @RATIONALE ContextVar.reset(token) raises ValueError when the async generator is
|
||||
# closed (GeneratorExit) from a different asyncio context than the one where the
|
||||
# token was created — e.g. gradio closing a SSE stream mid-yield. The reset is
|
||||
# best-effort: the offending context is being torn down, so there is nothing to
|
||||
# restore, and swallowing the error prevents the cleanup crash from masking the
|
||||
# actual stream result.
|
||||
def reset_user_jwt(token: Token[str]) -> None:
|
||||
_user_jwt.reset(token)
|
||||
try:
|
||||
_user_jwt.reset(token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def reset_user_role(token: Token[str]) -> None:
|
||||
_user_role.reset(token)
|
||||
try:
|
||||
_user_role.reset(token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def reset_service_jwt(token: Token[str]) -> None:
|
||||
_service_jwt.reset(token)
|
||||
try:
|
||||
_service_jwt.reset(token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def reset_env_id(token: Token[str]) -> None:
|
||||
try:
|
||||
_env_id.reset(token)
|
||||
except ValueError:
|
||||
pass
|
||||
# #endregion AgentChat.Context.Reset
|
||||
# #endregion AgentChat.Context
|
||||
|
||||
@@ -21,7 +21,7 @@ from psycopg.rows import dict_row
|
||||
import pydantic as _pydantic
|
||||
import pydantic_core as _pydantic_core
|
||||
|
||||
from ss_tools.agent._config import AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE, FASTAPI_URL
|
||||
from ss_tools.agent._config import AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE, FASTAPI_URL, SERVICE_JWT
|
||||
from ss_tools.agent._llm_params import chat_openai_kwargs
|
||||
from ss_tools.shared._llm_http import get_shared_http_client
|
||||
from ss_tools.shared.logger import logger
|
||||
@@ -189,8 +189,12 @@ async def _fetch_llm_config() -> dict | None:
|
||||
)
|
||||
try:
|
||||
fastapi_url = FASTAPI_URL
|
||||
headers = {}
|
||||
service_token = (SERVICE_JWT or "").strip()
|
||||
if service_token:
|
||||
headers["Authorization"] = f"Bearer {service_token}"
|
||||
client = get_shared_http_client(timeout=10)
|
||||
resp = await client.get(f"{fastapi_url}/api/agent/llm-config")
|
||||
resp = await client.get(f"{fastapi_url}/api/agent/llm-config", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
config = resp.json()
|
||||
if config.get("configured"):
|
||||
|
||||
@@ -1299,7 +1299,7 @@ class ExecuteDashboardResultInput(BaseModel):
|
||||
dashboard_id: int = Field(..., description="Superset dashboard ID")
|
||||
chart_id: int | None = Field(None, description="Chart ID to execute (required for chart queries)")
|
||||
dataset_id: int | None = Field(None, description="Dataset ID (alternative to chart_id)")
|
||||
result_key: str = Field(..., description="Metric key to extract from result")
|
||||
result_key: str | None = Field(None, description="Metric key to extract from result (optional — pass the metric name from the dashboard query model; omit if unknown)")
|
||||
normalized_filters_json: str = Field(default="", description="JSON string of normalized filter context (from normalize_filters)")
|
||||
# #endregion AgentChat.Tools.ExecuteDashboardResultInput
|
||||
|
||||
@@ -1309,11 +1309,15 @@ class ExecuteDashboardResultInput(BaseModel):
|
||||
# @BRIEF Execute a Superset-native chart/dataset query and return normalized result.
|
||||
# @PRE User authenticated, dashboard/chart accessible.
|
||||
# @POST Returns normalized metric value — no SQL injection possible.
|
||||
# @RATIONALE result_key is optional because the LLM cannot always know the metric
|
||||
# keys ahead of time; a missing key falls back to the raw record instead of
|
||||
# crashing the tool (previously a hard TypeError/ValidationError aborted the
|
||||
# whole HITL resume stream for dashboard-testing scenarios).
|
||||
@tool(args_schema=ExecuteDashboardResultInput)
|
||||
async def execute_dashboard_result(
|
||||
environment_id: str,
|
||||
dashboard_id: int,
|
||||
result_key: str,
|
||||
result_key: str | None = None,
|
||||
chart_id: int | None = None,
|
||||
dataset_id: int | None = None,
|
||||
normalized_filters_json: str = "",
|
||||
@@ -1321,7 +1325,7 @@ async def execute_dashboard_result(
|
||||
"""Execute a dashboard chart query through Superset-native APIs — no SQL."""
|
||||
logger.reason("Execute dashboard result",
|
||||
payload={"environment_id": environment_id, "dashboard_id": dashboard_id,
|
||||
"chart_id": chart_id, "result_key": result_key},
|
||||
"chart_id": chart_id, "result_key": result_key or None},
|
||||
extra={"src": "AgentChat.Tools.ExecuteDashboardResult"})
|
||||
|
||||
# Parse normalized filters if provided
|
||||
@@ -1336,7 +1340,7 @@ async def execute_dashboard_result(
|
||||
body: dict[str, Any] = {
|
||||
"environment_id": environment_id,
|
||||
"dashboard_id": dashboard_id,
|
||||
"result_key": result_key,
|
||||
"result_key": result_key or "",
|
||||
"normalized_filters": normalized_filters or {"schema_version": 1, "filters": [], "filters_hash": "sha256:empty"},
|
||||
}
|
||||
if chart_id:
|
||||
|
||||
@@ -239,80 +239,58 @@ class TestFormatToolOutput:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region Test.AgentChat.TestHandleResumeIntegration [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for handle_resume — fast-path confirm/deny, error paths,
|
||||
# LLM formatting integration, and title race-condition coverage.
|
||||
# @BRIEF Integration tests for handle_resume — checkpoint resume, error paths,
|
||||
# fallback direct tool execution, and title race-condition coverage.
|
||||
class TestHandleResumeIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_deny_yields_cancelled(self):
|
||||
"""Deny always goes through the LangGraph checkpoint path and yields the
|
||||
cancel event; the pending title marker is consumed."""
|
||||
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
|
||||
_pending_confirmations["conv-deny"] = {
|
||||
"tool_name": "list_environments",
|
||||
"tool_args": {},
|
||||
"_fast_path": False,
|
||||
}
|
||||
chunks = await _collect(handle_resume("conv-deny", "deny"))
|
||||
|
||||
mock_agent = MagicMock()
|
||||
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
|
||||
chunks = await _collect(handle_resume("conv-deny", "deny"))
|
||||
data = _json_chunks(chunks)
|
||||
assert len(data) == 1
|
||||
assert data[0]["metadata"]["type"] == "confirm_resolved"
|
||||
assert data[0]["metadata"]["result"] == "denied"
|
||||
assert "отменена" in data[0]["content"].lower()
|
||||
# Pending is popped
|
||||
# Pending marker is popped
|
||||
assert "conv-deny" not in _pending_confirmations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_executes_tool_and_formats_via_llm(self):
|
||||
async def test_fallback_unknown_tool_yields_error_and_repairs_checkpoint(self):
|
||||
"""When the checkpoint resume fails and the pending tool is unknown, the
|
||||
fallback yields tool_error and still appends a ToolMessage so the thread
|
||||
stays consistent."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
|
||||
|
||||
tool = MagicMock()
|
||||
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev","name":"Dev"}]')
|
||||
_pending_confirmations["conv-confirm"] = {
|
||||
"tool_name": "list_environments",
|
||||
"tool_args": {},
|
||||
}
|
||||
|
||||
async def collect():
|
||||
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool), \
|
||||
patch("ss_tools.agent._confirmation._format_tool_output_via_llm") as mock_fmt:
|
||||
async def _fake_fmt(tool_name, output):
|
||||
yield json.dumps({
|
||||
"content": f"SUMMARY: {tool_name} -> {output[:20]}",
|
||||
"metadata": {"type": "stream_token", "token": "X"},
|
||||
})
|
||||
mock_fmt.side_effect = _fake_fmt
|
||||
chunks = [c async for c in handle_resume("conv-confirm", "confirm")]
|
||||
return chunks
|
||||
|
||||
chunks = await collect()
|
||||
data = _json_chunks(chunks)
|
||||
|
||||
types = [d["metadata"]["type"] for d in data]
|
||||
assert "confirm_resolved" in types
|
||||
assert "tool_start" in types
|
||||
assert "tool_end" in types
|
||||
assert "stream_token" in types
|
||||
|
||||
# Check tool_end has the output summary
|
||||
tool_end = [d for d in data if d["metadata"]["type"] == "tool_end"]
|
||||
assert len(tool_end) == 1
|
||||
assert tool_end[0]["metadata"]["output"]["result"].startswith('[{"id":"ss-dev"')
|
||||
|
||||
# Check LLM formatting chunk
|
||||
stream_tokens = [d for d in data if d["metadata"]["type"] == "stream_token"]
|
||||
assert len(stream_tokens) >= 1
|
||||
assert "SUMMARY" in stream_tokens[0]["content"]
|
||||
|
||||
# Pending is popped
|
||||
assert "conv-confirm" not in _pending_confirmations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_unknown_tool_yields_error(self):
|
||||
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
|
||||
pending_state = MagicMock()
|
||||
pending_state.values.get.return_value = [
|
||||
HumanMessage(content="start"),
|
||||
AIMessage(content="", tool_calls=[{
|
||||
"name": "nonexistent_tool_xyz", "args": {}, "id": "call_xyz", "type": "tool_call",
|
||||
}]),
|
||||
]
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.astream_events = MagicMock(side_effect=ValueError("INVALID_CHAT_HISTORY"))
|
||||
mock_agent.aget_state = AsyncMock(return_value=pending_state)
|
||||
mock_agent.update_state = MagicMock(return_value=None)
|
||||
|
||||
_pending_confirmations["conv-unknown"] = {
|
||||
"tool_name": "nonexistent_tool_xyz",
|
||||
"tool_args": {},
|
||||
"tool_name": "nonexistent_tool_xyz", "tool_args": {}, "_fast_path": False,
|
||||
}
|
||||
|
||||
with patch("ss_tools.agent._confirmation.find_tool", return_value=None):
|
||||
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]), \
|
||||
patch("ss_tools.agent._confirmation.find_tool", return_value=None):
|
||||
chunks = await _collect(handle_resume("conv-unknown", "confirm"))
|
||||
data = _json_chunks(chunks)
|
||||
|
||||
@@ -321,23 +299,39 @@ class TestHandleResumeIntegration:
|
||||
assert "tool_start" in types
|
||||
assert "tool_error" in types
|
||||
assert "stream_token" not in types, "No LLM output on tool error"
|
||||
|
||||
error_chunk = [d for d in data if d["metadata"]["type"] == "tool_error"]
|
||||
assert len(error_chunk) == 1
|
||||
assert "Unknown tool" in error_chunk[0]["metadata"]["error"]
|
||||
# Checkpoint repair ran with a ToolMessage for the pending call.
|
||||
assert mock_agent.update_state.called
|
||||
repaired = mock_agent.update_state.call_args.args[1]["messages"]
|
||||
assert repaired[-1].tool_call_id == "call_xyz"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_tool_invocation_failure_yields_error(self):
|
||||
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
|
||||
async def test_fallback_tool_invocation_failure_yields_error_and_repairs(self):
|
||||
"""When the fallback tool invocation raises, tool_error is yielded and a
|
||||
ToolMessage with the error content repairs the checkpoint."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from ss_tools.agent._confirmation import handle_resume
|
||||
|
||||
pending_state = MagicMock()
|
||||
pending_state.values.get.return_value = [
|
||||
HumanMessage(content="start"),
|
||||
AIMessage(content="", tool_calls=[{
|
||||
"name": "list_environments", "args": {}, "id": "call_env", "type": "tool_call",
|
||||
}]),
|
||||
]
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.astream_events = MagicMock(side_effect=ValueError("INVALID_CHAT_HISTORY"))
|
||||
mock_agent.aget_state = AsyncMock(return_value=pending_state)
|
||||
mock_agent.update_state = MagicMock(return_value=None)
|
||||
|
||||
tool = MagicMock()
|
||||
tool.ainvoke = AsyncMock(side_effect=RuntimeError("API timeout"))
|
||||
_pending_confirmations["conv-fail"] = {
|
||||
"tool_name": "list_environments",
|
||||
"tool_args": {},
|
||||
}
|
||||
|
||||
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool):
|
||||
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]), \
|
||||
patch("ss_tools.agent._confirmation.find_tool", return_value=tool):
|
||||
chunks = await _collect(handle_resume("conv-fail", "confirm"))
|
||||
data = _json_chunks(chunks)
|
||||
|
||||
@@ -345,10 +339,14 @@ class TestHandleResumeIntegration:
|
||||
assert "tool_error" in types
|
||||
assert "tool_end" not in types, "No tool_end on failure"
|
||||
assert "stream_token" not in types, "No LLM output on failure"
|
||||
|
||||
error_chunk = [d for d in data if d["metadata"]["type"] == "tool_error"]
|
||||
assert len(error_chunk) == 1
|
||||
assert "API timeout" in error_chunk[0]["metadata"]["error"]
|
||||
# Checkpoint repaired with the error ToolMessage.
|
||||
assert mock_agent.update_state.called
|
||||
repaired = mock_agent.update_state.call_args.args[1]["messages"]
|
||||
assert repaired[-1].tool_call_id == "call_env"
|
||||
assert "Error: API timeout" in repaired[-1].content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_with_no_pending_falls_to_langgraph(self):
|
||||
@@ -380,6 +378,153 @@ class TestHandleResumeIntegration:
|
||||
assert data[0]["metadata"]["type"] == "confirm_resolved"
|
||||
assert data[0]["metadata"]["result"] == "denied"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint_resume_failure_falls_back_to_direct_tool_execution(self):
|
||||
"""When the LangGraph checkpoint resume raises (e.g. INVALID_CHAT_HISTORY
|
||||
from a checkpoint whose tool call has no ToolMessage), handle_resume falls
|
||||
back to executing the still-pending tool directly and streaming its result."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from ss_tools.agent._confirmation import handle_resume
|
||||
|
||||
pending_state = MagicMock()
|
||||
pending_state.values.get.return_value = [
|
||||
HumanMessage(content="start"),
|
||||
AIMessage(content="", tool_calls=[{
|
||||
"name": "list_environments",
|
||||
"args": {},
|
||||
"id": "call_env",
|
||||
"type": "tool_call",
|
||||
}]),
|
||||
]
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.astream_events = MagicMock(side_effect=ValueError(
|
||||
"Found AIMessages with tool_calls that do not have a corresponding ToolMessage"
|
||||
))
|
||||
mock_agent.aget_state = AsyncMock(return_value=pending_state)
|
||||
|
||||
tool = MagicMock()
|
||||
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev"}]')
|
||||
|
||||
async def _fake_fmt(tool_name, output):
|
||||
yield json.dumps({
|
||||
"content": f"SUMMARY: {tool_name}",
|
||||
"metadata": {"type": "stream_token", "token": "S"},
|
||||
})
|
||||
|
||||
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]), \
|
||||
patch("ss_tools.agent._confirmation.find_tool", return_value=tool), \
|
||||
patch("ss_tools.agent._confirmation._format_tool_output_via_llm") as mock_fmt:
|
||||
mock_fmt.side_effect = _fake_fmt
|
||||
chunks = await _collect(handle_resume("conv-fallback", "confirm"))
|
||||
|
||||
data = _json_chunks(chunks)
|
||||
types = [d["metadata"]["type"] for d in data]
|
||||
assert "confirm_resolved" in types
|
||||
assert "tool_start" in types
|
||||
assert "tool_end" in types
|
||||
assert "stream_token" in types
|
||||
tool_end = [d for d in data if d["metadata"]["type"] == "tool_end"]
|
||||
assert tool_end[0]["metadata"]["output"]["result"].startswith('[{"id":"ss-dev"')
|
||||
# No error event — the fallback produced a usable response
|
||||
assert "error" not in types
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint_resume_failure_with_no_pending_yields_error(self):
|
||||
"""When the checkpoint resume fails and there are no pending tool calls,
|
||||
a clear error event is yielded instead of a silent dead stream."""
|
||||
from ss_tools.agent._confirmation import handle_resume
|
||||
|
||||
pending_state = MagicMock()
|
||||
pending_state.values.get.return_value = [] # no messages
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.astream_events = MagicMock(side_effect=ValueError("boom"))
|
||||
mock_agent.aget_state = AsyncMock(return_value=pending_state)
|
||||
|
||||
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
|
||||
chunks = await _collect(handle_resume("conv-no-pending2", "confirm"))
|
||||
data = _json_chunks(chunks)
|
||||
types = [d["metadata"]["type"] for d in data]
|
||||
assert "error" in types
|
||||
assert data[-1]["metadata"]["code"] == "PROCESSING_ERROR"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_tool_calls_skips_answered_calls(self):
|
||||
"""_pending_tool_calls only returns tool calls that lack a ToolMessage."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from ss_tools.agent._confirmation import _pending_tool_calls
|
||||
|
||||
state = MagicMock()
|
||||
state.values.get.return_value = [
|
||||
HumanMessage(content="start"),
|
||||
AIMessage(content="", tool_calls=[{
|
||||
"name": "list_environments", "args": {}, "id": "call_env", "type": "tool_call",
|
||||
}]),
|
||||
ToolMessage(content="[ok]", tool_call_id="call_env", name="list_environments"),
|
||||
AIMessage(content="", tool_calls=[{
|
||||
"name": "get_health_summary", "args": {"x": 1}, "id": "call_health", "type": "tool_call",
|
||||
}]),
|
||||
]
|
||||
pending = _pending_tool_calls(state)
|
||||
assert len(pending) == 1
|
||||
assert pending[0][0] == "get_health_summary"
|
||||
assert pending[0][1] == {"x": 1}
|
||||
assert pending[0][2] == "call_health"
|
||||
|
||||
def test_confirmation_payload_stores_graph_resume_marker(self):
|
||||
"""confirmation_payload stores a title marker with _fast_path=False so
|
||||
the multi-step scenario continues via the LangGraph checkpoint, while
|
||||
app.py can still build a descriptive conversation title."""
|
||||
from langchain_core.messages import AIMessage
|
||||
from ss_tools.agent._confirmation import confirmation_payload, _pending_confirmations
|
||||
|
||||
state = MagicMock()
|
||||
state.values.get.return_value = [
|
||||
AIMessage(content="", tool_calls=[{
|
||||
"name": "inspect_dashboard_query_model",
|
||||
"args": {"dashboard_id": 3},
|
||||
"id": "call_inspect", "type": "tool_call",
|
||||
}]),
|
||||
]
|
||||
out = confirmation_payload("conv-marker", state, "start")
|
||||
data = json.loads(out)
|
||||
assert data["metadata"]["type"] == "confirm_required"
|
||||
marker = _pending_confirmations.pop("conv-marker", None)
|
||||
assert marker is not None
|
||||
assert marker["tool_name"] == "inspect_dashboard_query_model"
|
||||
assert marker["tool_args"] == {"dashboard_id": 3}
|
||||
assert marker["_fast_path"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_always_uses_langgraph_checkpoint(self):
|
||||
"""Resume always continues the LangGraph checkpoint (multi-step scenario
|
||||
continuation); the pending marker is consumed for the title only."""
|
||||
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
|
||||
|
||||
_pending_confirmations["conv-graph"] = {
|
||||
"tool_name": "list_environments",
|
||||
"tool_args": {},
|
||||
"_fast_path": False,
|
||||
}
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.astream_events = MagicMock(return_value=_make_async_iter([]))
|
||||
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
|
||||
chunks = await _collect(handle_resume("conv-graph", "confirm"))
|
||||
data = _json_chunks(chunks)
|
||||
types = [d["metadata"]["type"] for d in data]
|
||||
# Confirm resolved then the graph resume path runs (create_agent called).
|
||||
assert "confirm_resolved" in types
|
||||
# The marker was consumed.
|
||||
assert "conv-graph" not in _pending_confirmations
|
||||
assert "tool_start" not in types, "Fast-path must be bypassed for graph markers"
|
||||
assert "tool_error" not in types
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_streams_langgraph_events_when_no_pending(self):
|
||||
"""When no pending and LangGraph agent streams events, they are forwarded."""
|
||||
|
||||
@@ -182,6 +182,8 @@ class TestConfirmationMetadata:
|
||||
assert meta["risk_level"] == "safe"
|
||||
|
||||
def test_fast_resume_deny_closes_without_langgraph(self):
|
||||
"""Deny closes via the LangGraph checkpoint path and yields the cancel
|
||||
event; the pending title marker is consumed."""
|
||||
from ss_tools.agent._confirmation import _pending_confirmations, handle_resume
|
||||
|
||||
_pending_confirmations["conv-fast-deny"] = {
|
||||
@@ -190,35 +192,45 @@ class TestConfirmationMetadata:
|
||||
}
|
||||
|
||||
async def collect():
|
||||
return [chunk async for chunk in handle_resume("conv-fast-deny", "deny")]
|
||||
with patch("ss_tools.agent._confirmation.create_agent", return_value=MagicMock()), \
|
||||
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
|
||||
return [chunk async for chunk in handle_resume("conv-fast-deny", "deny")]
|
||||
|
||||
chunks = asyncio.run(collect())
|
||||
data = json.loads(chunks[0])
|
||||
assert data["metadata"] == {"type": "confirm_resolved", "result": "denied"}
|
||||
assert "conv-fast-deny" not in _pending_confirmations
|
||||
|
||||
def test_fast_resume_confirm_executes_tool_directly(self):
|
||||
def test_resume_confirm_continues_langgraph_checkpoint(self):
|
||||
"""Confirm always continues the LangGraph checkpoint (multi-step scenario
|
||||
continuation) and yields the confirm_resolved event."""
|
||||
from ss_tools.agent._confirmation import _pending_confirmations, handle_resume
|
||||
|
||||
tool = MagicMock()
|
||||
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev"}]')
|
||||
mock_chunk = MagicMock()
|
||||
mock_chunk.content = "resumed output"
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.astream_events = MagicMock(return_value=_make_async_iter([
|
||||
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
|
||||
{"event": "on_tool_start", "name": "list_environments", "data": {"input": {}}},
|
||||
{"event": "on_tool_end", "name": "list_environments", "data": {"output": "ok"}},
|
||||
]))
|
||||
_pending_confirmations["conv-fast-confirm"] = {
|
||||
"tool_name": "list_environments",
|
||||
"tool_args": {},
|
||||
}
|
||||
|
||||
async def collect():
|
||||
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool), patch("ss_tools.agent._confirmation._format_tool_output_via_llm") as mock_format:
|
||||
# Make the mock format helper yield nothing — don't need LLM in unit test
|
||||
async def _empty_format(*_args, **_kwargs):
|
||||
return
|
||||
yield # pragma: no cover — async generator requires at least one yield
|
||||
|
||||
mock_format.side_effect = _empty_format
|
||||
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
|
||||
return [chunk async for chunk in handle_resume("conv-fast-confirm", "confirm")]
|
||||
|
||||
chunks = asyncio.run(collect())
|
||||
metadata_types = [json.loads(chunk)["metadata"]["type"] for chunk in chunks]
|
||||
assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"]
|
||||
assert metadata_types[0] == "confirm_resolved"
|
||||
assert "stream_token" in metadata_types
|
||||
assert "tool_start" in metadata_types
|
||||
assert "tool_end" in metadata_types
|
||||
assert "conv-fast-confirm" not in _pending_confirmations
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.TestConfirmationMetadata
|
||||
|
||||
@@ -14,7 +14,7 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.routes.agent_lifecycle import router
|
||||
from src.dependencies import get_current_user
|
||||
from src.dependencies import get_agent_service_user, get_current_user
|
||||
from src.models.auth import User, Role
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ def admin_client():
|
||||
tc, session_factory = _build_app_and_db()
|
||||
mock_user = _mock_user("admin-1", is_admin=True)
|
||||
tc.app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
tc.app.dependency_overrides[get_agent_service_user] = lambda: mock_user
|
||||
return tc, session_factory
|
||||
|
||||
|
||||
@@ -89,6 +90,7 @@ def user_client():
|
||||
tc, _ = _build_app_and_db()
|
||||
mock_user = _mock_user("regular-user-1", is_admin=False)
|
||||
tc.app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
tc.app.dependency_overrides[get_agent_service_user] = lambda: mock_user
|
||||
return tc
|
||||
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ async def check_active_session():
|
||||
# from FastAPI REST instead of requiring duplicate env vars.
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.database import get_db
|
||||
from ...dependencies import get_config_manager, get_agent_service_user
|
||||
from ...dependencies import get_config_manager, get_agent_service_user_strict
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
|
||||
|
||||
@@ -340,12 +340,14 @@ from ...services.llm_provider import LLMProviderService
|
||||
async def get_agent_llm_config(
|
||||
db: Session = Depends(get_db),
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_service_user=Depends(get_agent_service_user_strict),
|
||||
):
|
||||
"""Return active LLM provider config with decrypted API key.
|
||||
|
||||
Internal endpoint — no user auth required. Gradio agent calls this at startup
|
||||
within the Docker network. Returns the provider configured in
|
||||
'assistant_planner_provider' setting, or first active provider as fallback.
|
||||
Internal endpoint — gated by service JWT ONLY (no user-JWT fallback: this
|
||||
returns a decrypted credential). The Gradio agent sends its SERVICE_JWT
|
||||
bearer token when fetching this at startup. Returns the provider configured
|
||||
in 'assistant_planner_provider' setting, or first active provider as fallback.
|
||||
"""
|
||||
service = LLMProviderService(db)
|
||||
providers = service.get_all_providers()
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status as http_sta
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...dependencies import get_current_user
|
||||
from ...dependencies import get_agent_service_user, get_current_user
|
||||
from ...models.auth import User
|
||||
from ...schemas.agent_lifecycle import (
|
||||
EventListResponse,
|
||||
@@ -33,7 +33,7 @@ router = APIRouter(prefix="/api/agent/events", tags=["Agent-Lifecycle"])
|
||||
@router.post("", response_model=EventWriteResponse, status_code=http_status.HTTP_201_CREATED)
|
||||
async def create_event(
|
||||
body: EventWriteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_agent_service_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Write a lifecycle event. The payload is automatically reduced to safe keys.
|
||||
|
||||
@@ -435,9 +435,12 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
async def network_error_handler(request: Request, exc: NetworkError):
|
||||
with belief_scope("network_error_handler"):
|
||||
logger.explore("Network error", error=str(exc))
|
||||
return HTTPException(
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
detail="Environment unavailable. Please check if the Superset instance is running.",
|
||||
content={
|
||||
"detail": "Environment unavailable. Please check if the Superset instance is running.",
|
||||
"path": request.url.path,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -720,6 +720,36 @@ def get_agent_service_user(
|
||||
# #endregion Dependencies.AppDependencies.GetAgentServiceUser
|
||||
|
||||
|
||||
# #region Dependencies.AppDependencies.GetAgentServiceUserStrict [C:4] [TYPE Function]
|
||||
# @ingroup Dependencies
|
||||
# @BRIEF Service-token-ONLY dependency for secret-bearing agent endpoints (e.g.
|
||||
# /api/agent/llm-config which returns the decrypted LLM API key). Unlike
|
||||
# get_agent_service_user it does NOT fall back to get_current_user, so an
|
||||
# ordinary authenticated user cannot read provider secrets.
|
||||
# @PRE SERVICE_JWT env var must be configured on the backend.
|
||||
# @POST Returns _ServiceUser when the bearer token equals SERVICE_JWT; 401 otherwise.
|
||||
# @RATIONALE llm-config returns a decrypted credential. Allowing any logged-in user
|
||||
# through the get_current_user fallback would expose the API key to every
|
||||
# account. Secret endpoints must be reachable only by the service identity.
|
||||
# @SIDE_EFFECT Reads os.environ["SERVICE_JWT"]; raises HTTPException(401).
|
||||
def get_agent_service_user_strict(
|
||||
token: str | None = Depends(oauth2_scheme_optional),
|
||||
x_user_jwt: str | None = Header(None, alias="X-User-JWT"),
|
||||
):
|
||||
service_jwt = os.environ.get("SERVICE_JWT", "")
|
||||
effective_token = (x_user_jwt or token or "")
|
||||
|
||||
if service_jwt and effective_token == service_jwt:
|
||||
return _ServiceUser()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Service authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
# #endregion Dependencies.AppDependencies.GetAgentServiceUserStrict
|
||||
|
||||
|
||||
# #region Dependencies.AppDependencies.TrackSessionActivity [C:3] [TYPE Function]
|
||||
# @ingroup Dependencies
|
||||
# @BRIEF Update or create SessionActivity row for the current JWT.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# @BRIEF Repository for agent-run CRUD — ownership check, sequence uniqueness, terminal immutability.
|
||||
# @LAYER Service
|
||||
# @RELATION DEPENDS_ON -> [Models.AgentRun]
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -32,6 +32,41 @@ class AgentRunRepository:
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_active_by_conversation(
|
||||
self,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
dashboard_id: str | None = None,
|
||||
environment_id: str | None = None,
|
||||
intent: str | None = None,
|
||||
max_age_seconds: int = 6 * 3600,
|
||||
) -> AgentRun | None:
|
||||
"""Return the most recent non-terminal run for a conversation (idempotency
|
||||
for auto-start).
|
||||
|
||||
Reuse is additionally gated on the requested run context (dashboard_id /
|
||||
environment_id / intent) so a genuinely new scenario request in the same
|
||||
conversation is never served a run that belongs to a different dashboard,
|
||||
and on a staleness bound (created within ``max_age_seconds``) so a run left
|
||||
in CREATED/RUNNING by a crashed agent cannot wedge the conversation forever.
|
||||
"""
|
||||
query = (
|
||||
self.db.query(AgentRun)
|
||||
.filter(
|
||||
AgentRun.conversation_id == conversation_id,
|
||||
AgentRun.user_id == user_id,
|
||||
AgentRun.status.notin_(("COMPLETED", "FAILED", "CANCELLED")),
|
||||
)
|
||||
)
|
||||
if dashboard_id is not None:
|
||||
query = query.filter(AgentRun.dashboard_id == dashboard_id)
|
||||
if environment_id is not None:
|
||||
query = query.filter(AgentRun.environment_id == environment_id)
|
||||
if intent is not None:
|
||||
query = query.filter(AgentRun.intent == intent)
|
||||
query = query.filter(AgentRun.created_at >= _now() - timedelta(seconds=max_age_seconds))
|
||||
return query.order_by(AgentRun.created_at.desc()).first()
|
||||
|
||||
def is_terminal(self, run: AgentRun) -> bool:
|
||||
return run.status in ("COMPLETED", "FAILED", "CANCELLED")
|
||||
|
||||
|
||||
@@ -43,16 +43,38 @@ def create_agent_run(
|
||||
user_id: str,
|
||||
conversation_id: str | None = None,
|
||||
) -> AgentRunSnapshot:
|
||||
"""Create a durable agent run for valid UIContext v2."""
|
||||
"""Create a durable agent run for valid UIContext v2.
|
||||
|
||||
Idempotent per conversation: when a conversation_id is supplied and an
|
||||
active (non-terminal) run already exists for it, the existing snapshot is
|
||||
returned instead of creating a duplicate. This makes the frontend auto-start
|
||||
safe across reconnects and repeated navigation.
|
||||
"""
|
||||
repo = AgentRunRepository(db)
|
||||
|
||||
ctx = req.context
|
||||
if ctx.intent != "build_dashboard_test_scenario":
|
||||
raise ValueError("intent must be build_dashboard_test_scenario")
|
||||
|
||||
conversation_id = conversation_id or req.conversation_id
|
||||
if conversation_id:
|
||||
# Reuse only when the existing run targets the SAME dashboard/environment
|
||||
# and is not stale — otherwise a new scenario request would be served a
|
||||
# run bound to a different dashboard, or a crashed run could wedge the
|
||||
# conversation forever.
|
||||
existing = repo.get_active_by_conversation(
|
||||
conversation_id,
|
||||
user_id,
|
||||
dashboard_id=str(ctx.objectId),
|
||||
environment_id=ctx.envId,
|
||||
intent=ctx.intent or "dashboard_scenario_build",
|
||||
)
|
||||
if existing is not None:
|
||||
return _snapshot_from_run(repo, existing)
|
||||
|
||||
run = AgentRun(
|
||||
id=None, # auto-generated
|
||||
conversation_id=conversation_id or req.conversation_id,
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
intent=ctx.intent or "dashboard_scenario_build",
|
||||
trigger="manual",
|
||||
@@ -210,8 +232,16 @@ def append_event(
|
||||
run.last_sequence = sequence
|
||||
if stage:
|
||||
run.current_stage = stage
|
||||
if status == "completed" and stage == "save":
|
||||
run.status = "COMPLETED"
|
||||
# A terminal event (agent sends event_type="terminal" with no stage) must
|
||||
# transition the run's own status, otherwise completed runs stay RUNNING
|
||||
# forever and recovery snapshots show a live run for a finished scenario.
|
||||
if event_type == "terminal" or (status == "completed" and stage == "save"):
|
||||
if status == "completed":
|
||||
run.status = "COMPLETED"
|
||||
elif status == "failed":
|
||||
run.status = "FAILED"
|
||||
elif status == "skipped":
|
||||
run.status = "CANCELLED"
|
||||
run.finished_at = _now()
|
||||
|
||||
db.flush()
|
||||
|
||||
@@ -106,6 +106,10 @@ def _parse_native_filters(raw_filters: list) -> list[NativeFilterModel]:
|
||||
# #region BaselineEngine.QueryModel.Inspect.ParseMetrics [C:2] [TYPE Function] [SEMANTICS parsing,metrics]
|
||||
# @ingroup BaselineEngine
|
||||
# @BRIEF Parse metrics from raw metric list (strings or dicts with expression type).
|
||||
# @RATIONALE Null-safe: Superset metrics may carry label=None or metric_name=None
|
||||
# (e.g. dashboard #3) — None is coerced to a non-empty fallback instead of
|
||||
# failing MetricDescriptor validation (previously 500 "Input should be a valid
|
||||
# string" which aborted the whole query-model inspection).
|
||||
def _parse_metrics(raw_metrics: list) -> list[MetricDescriptor]:
|
||||
"""Parse metrics from raw metric list."""
|
||||
result: list[MetricDescriptor] = []
|
||||
@@ -114,15 +118,29 @@ def _parse_metrics(raw_metrics: list) -> list[MetricDescriptor]:
|
||||
result.append(MetricDescriptor(
|
||||
metric_name=rm, label=rm, expression_type="SIMPLE"))
|
||||
elif isinstance(rm, dict):
|
||||
metric_name = rm.get("metric_name") or rm.get("label") or ""
|
||||
label = rm.get("label") or metric_name or ""
|
||||
expr = rm.get("expressionType")
|
||||
expression_type = (
|
||||
expr if expr in ("SIMPLE", "SQL_EXPRESSION", "SAVED_METRIC") else "SIMPLE"
|
||||
)
|
||||
column_ref = None
|
||||
raw_column = rm.get("column")
|
||||
if isinstance(raw_column, dict):
|
||||
column_ref = ColumnRef(
|
||||
column_name=raw_column.get("column_name") or "",
|
||||
type=raw_column.get("type"),
|
||||
)
|
||||
aggregate = rm.get("aggregate")
|
||||
sql_expression = rm.get("sqlExpression")
|
||||
result.append(MetricDescriptor(
|
||||
metric_name=rm.get("metric_name", rm.get("label", "")),
|
||||
label=rm.get("label", rm.get("metric_name", "")),
|
||||
expression_type=rm.get("expressionType", "SIMPLE"),
|
||||
column=ColumnRef(column_name=rm.get("column", {}).get("column_name", ""),
|
||||
type=rm.get("column", {}).get("type"))
|
||||
if rm.get("column") else None,
|
||||
aggregate=rm.get("aggregate"),
|
||||
sql_expression=rm.get("sqlExpression")))
|
||||
metric_name=metric_name,
|
||||
label=label,
|
||||
expression_type=expression_type,
|
||||
column=column_ref,
|
||||
aggregate=aggregate if isinstance(aggregate, str) else None,
|
||||
sql_expression=sql_expression if isinstance(sql_expression, str) else None,
|
||||
))
|
||||
return result
|
||||
# #endregion BaselineEngine.QueryModel.Inspect.ParseMetrics
|
||||
|
||||
@@ -218,10 +236,10 @@ def _process_datasets_data(datasets_data: list) -> list[DatasetQueryModel]:
|
||||
]
|
||||
ds_metrics = [
|
||||
MetricDescriptor(
|
||||
metric_name=m.get("metric_name", ""),
|
||||
label=m.get("verbose_name", m.get("metric_name", "")),
|
||||
metric_name=m.get("metric_name") or "",
|
||||
label=(m.get("verbose_name") or m.get("metric_name") or ""),
|
||||
expression_type="SIMPLE",
|
||||
column=ColumnRef(column_name=m.get("column", {}).get("column_name", "")))
|
||||
column=ColumnRef(column_name=(m.get("column") or {}).get("column_name") or ""))
|
||||
for m in ds.get("metrics", [])
|
||||
]
|
||||
result.append(DatasetQueryModel(
|
||||
|
||||
@@ -21,6 +21,24 @@ _src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
_SERVICE_JWT = "test-service-secret"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_service_jwt_env():
|
||||
"""Secret-bearing endpoints (llm-config) require SERVICE_JWT to be configured."""
|
||||
old = os.environ.get("SERVICE_JWT")
|
||||
os.environ["SERVICE_JWT"] = _SERVICE_JWT
|
||||
yield
|
||||
if old is None:
|
||||
os.environ.pop("SERVICE_JWT", None)
|
||||
else:
|
||||
os.environ["SERVICE_JWT"] = old
|
||||
|
||||
|
||||
def _service_auth() -> dict:
|
||||
return {"Authorization": f"Bearer {_SERVICE_JWT}"}
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.agent_conversations import router, agent_router
|
||||
@@ -283,7 +301,7 @@ class TestGetAgentLlmConfig:
|
||||
get_db: lambda: mock_db,
|
||||
get_config_manager: lambda: mock_config_mgr,
|
||||
})
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
resp = client.get("/api/agent/llm-config", headers=_service_auth())
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["configured"] is True
|
||||
@@ -314,7 +332,7 @@ class TestGetAgentLlmConfig:
|
||||
get_db: lambda: mock_db,
|
||||
get_config_manager: lambda: mock_config_mgr,
|
||||
})
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
resp = client.get("/api/agent/llm-config", headers=_service_auth())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["configured"] is True
|
||||
|
||||
@@ -335,7 +353,7 @@ class TestGetAgentLlmConfig:
|
||||
get_db: lambda: mock_db,
|
||||
get_config_manager: lambda: mock_config_mgr,
|
||||
})
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
resp = client.get("/api/agent/llm-config", headers=_service_auth())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["configured"] is False
|
||||
assert resp.json()["reason"] == "no_active_provider"
|
||||
@@ -361,8 +379,46 @@ class TestGetAgentLlmConfig:
|
||||
get_db: lambda: mock_db,
|
||||
get_config_manager: lambda: mock_config_mgr,
|
||||
})
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
resp = client.get("/api/agent/llm-config", headers=_service_auth())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["configured"] is False
|
||||
assert resp.json()["reason"] == "invalid_api_key"
|
||||
|
||||
def test_requires_service_auth(self):
|
||||
"""The decrypted API key must not be exposed without service/user auth."""
|
||||
from src.api.routes.agent_conversations import agent_router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(agent_router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
|
||||
# No get_agent_service_user_strict override → unauthenticated.
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_user_jwt_does_not_grant_llm_config(self):
|
||||
"""A valid ordinary user JWT must NOT read the decrypted API key."""
|
||||
from src.api.routes.agent_conversations import agent_router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
from datetime import datetime
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(agent_router)
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
|
||||
# Simulate an authenticated human user (not the service identity).
|
||||
app.dependency_overrides[get_current_user] = lambda: User(
|
||||
id="user-1", username="testuser", email="t@x.com", auth_source="LOCAL",
|
||||
created_at=datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="User", description="", permissions=[])],
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/api/agent/llm-config",
|
||||
headers={"Authorization": "Bearer some-user-jwt"})
|
||||
assert resp.status_code == 401
|
||||
# #endregion Test.Api.AgentConversations
|
||||
|
||||
@@ -47,7 +47,7 @@ def _make_client(user_mock=None, db_mock=None, overrides=None) -> TestClient:
|
||||
"""Build a TestClient with the agent lifecycle router."""
|
||||
from src.api.routes.agent_lifecycle import router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_current_user
|
||||
from src.dependencies import get_agent_service_user, get_current_user
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
@@ -58,6 +58,10 @@ def _make_client(user_mock=None, db_mock=None, overrides=None) -> TestClient:
|
||||
db_mock = MagicMock()
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: user_mock
|
||||
# POST /api/agent/events authenticates via get_agent_service_user (accepts
|
||||
# either a real end-user JWT or the SERVICE_JWT shared secret for the agent
|
||||
# process). Tests bypass auth identically for both dependencies.
|
||||
app.dependency_overrides[get_agent_service_user] = lambda: user_mock
|
||||
app.dependency_overrides[get_db] = lambda: db_mock
|
||||
if overrides:
|
||||
for dep, fn in overrides.items():
|
||||
|
||||
@@ -96,6 +96,31 @@ class TestAppendEvent:
|
||||
append_event(db_session, run_id, "user-1",
|
||||
event_type="progress", stage="validate", status="completed", sequence=3)
|
||||
|
||||
def test_terminal_event_transitions_run_status(self, db_session, run_id):
|
||||
"""The agent's emit_terminal sends event_type="terminal" WITHOUT a stage;
|
||||
it must still transition the run status (previously runs stayed RUNNING)."""
|
||||
# Before terminal: run is RUNNING
|
||||
snap0 = get_agent_run_snapshot(db_session, run_id, "user-1")
|
||||
assert snap0.status == "RUNNING"
|
||||
|
||||
append_event(db_session, run_id, "user-1",
|
||||
event_type="terminal", stage=None, status="completed", sequence=2,
|
||||
payload={"error_code": None, "error_detail": None})
|
||||
db_session.commit()
|
||||
|
||||
snap = get_agent_run_snapshot(db_session, run_id, "user-1")
|
||||
assert snap.status == "COMPLETED"
|
||||
assert snap.finished_at is not None
|
||||
|
||||
def test_terminal_event_failed_transitions_status(self, db_session, run_id):
|
||||
append_event(db_session, run_id, "user-1",
|
||||
event_type="terminal", stage=None, status="failed", sequence=2,
|
||||
payload={"error_code": "PROCESSING_ERROR"})
|
||||
db_session.commit()
|
||||
|
||||
snap = get_agent_run_snapshot(db_session, run_id, "user-1")
|
||||
assert snap.status == "FAILED"
|
||||
|
||||
def test_foreign_owner_cannot_append(self, db_session, run_id):
|
||||
with pytest.raises(ValueError, match="run not found"):
|
||||
append_event(db_session, run_id, "user-2",
|
||||
|
||||
@@ -66,6 +66,65 @@ class TestRunCRUD:
|
||||
run.status = "COMPLETED"
|
||||
assert repo.is_terminal(run)
|
||||
|
||||
def test_get_active_by_conversation_finds_active_run(self, repo):
|
||||
run = _make_run()
|
||||
run.conversation_id = "conv-1"
|
||||
run.status = "RUNNING"
|
||||
repo.create(run)
|
||||
|
||||
found = repo.get_active_by_conversation("conv-1", "user-1")
|
||||
assert found is not None
|
||||
assert found.id == run.id
|
||||
|
||||
def test_get_active_by_conversation_ignores_terminal(self, repo):
|
||||
run = _make_run()
|
||||
run.conversation_id = "conv-1"
|
||||
run.status = "COMPLETED"
|
||||
repo.create(run)
|
||||
|
||||
assert repo.get_active_by_conversation("conv-1", "user-1") is None
|
||||
|
||||
def test_get_active_by_conversation_scoped_to_owner(self, repo):
|
||||
run = _make_run(user_id="user-1")
|
||||
run.conversation_id = "conv-1"
|
||||
run.status = "RUNNING"
|
||||
repo.create(run)
|
||||
|
||||
assert repo.get_active_by_conversation("conv-1", "user-2") is None
|
||||
|
||||
def test_get_active_by_conversation_context_filter(self, repo):
|
||||
"""Reuse must only match runs targeting the SAME dashboard/environment."""
|
||||
from datetime import timedelta
|
||||
|
||||
run = _make_run(dashboard_id="42")
|
||||
run.environment_id = "dev"
|
||||
run.conversation_id = "conv-1"
|
||||
run.status = "RUNNING"
|
||||
repo.create(run)
|
||||
|
||||
# Different dashboard → not reusable
|
||||
assert repo.get_active_by_conversation(
|
||||
"conv-1", "user-1", dashboard_id="43", environment_id="dev"
|
||||
) is None
|
||||
# Matching dashboard/env → reusable
|
||||
found = repo.get_active_by_conversation(
|
||||
"conv-1", "user-1", dashboard_id="42", environment_id="dev"
|
||||
)
|
||||
assert found is not None and found.id == run.id
|
||||
|
||||
def test_get_active_by_conversation_staleness(self, repo):
|
||||
"""A stale non-terminal run (older than the age bound) must not wedge a
|
||||
conversation — a new run can then be created."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
run = _make_run()
|
||||
run.conversation_id = "conv-1"
|
||||
run.status = "RUNNING"
|
||||
run.created_at = datetime.now(UTC) - timedelta(hours=12)
|
||||
repo.create(run)
|
||||
|
||||
assert repo.get_active_by_conversation("conv-1", "user-1") is None
|
||||
|
||||
|
||||
class TestEventSequence:
|
||||
def test_append_event(self, repo):
|
||||
|
||||
@@ -176,4 +176,48 @@ async def test_missing_metadata_not_invented():
|
||||
assert result.title == "Empty Dashboard"
|
||||
# #endregion Test.DashboardTesting.QueryModel.MissingMetadataNotInvented
|
||||
|
||||
# #region Test.DashboardTesting.QueryModel.NullMetricLabel [C:3] [TYPE Function] [SEMANTICS testing,baseline,edge-case,null-safety]
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_metric_label_does_not_crash_inspection():
|
||||
"""Dashboard #3 regression: Superset metrics may carry label=None (or
|
||||
metric_name=None). Inspection must coerce them instead of failing with
|
||||
MetricDescriptor validation error (previously a 500)."""
|
||||
client = _make_mock_client(
|
||||
dashboard_result={
|
||||
"id": 3, "dashboard_title": "Misc Charts", "slug": "misc-charts",
|
||||
"json_metadata": json.dumps({}),
|
||||
"position_json": json.dumps({
|
||||
"CHART-1": {"id": "CHART-1", "meta": {"chartId": 100}},
|
||||
}),
|
||||
},
|
||||
charts_result=[
|
||||
{
|
||||
"id": 100, "uuid": "u-100", "slice_name": "Chart 100",
|
||||
"form_data": json.dumps({"viz_type": "table"}),
|
||||
"params": json.dumps({
|
||||
"metrics": [
|
||||
{"metric_name": "sum__sales", "label": None,
|
||||
"expressionType": "SIMPLE", "aggregate": "SUM"},
|
||||
{"label": None, "metric_name": None,
|
||||
"expressionType": "ADHOC", "column": None},
|
||||
],
|
||||
"groupby": [],
|
||||
}),
|
||||
"datasource_id": 5, "datasource_name_text": "ds",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = await inspect_dashboard_query_model(client, "ss-prod", 3)
|
||||
assert len(result.charts) == 1
|
||||
metrics = result.charts[0].metrics
|
||||
assert len(metrics) == 2
|
||||
assert metrics[0].metric_name == "sum__sales"
|
||||
assert metrics[0].label == "sum__sales" # None coerced to metric_name
|
||||
assert metrics[1].metric_name == "" # both None -> empty fallback
|
||||
assert metrics[1].label == ""
|
||||
assert metrics[1].expression_type == "SIMPLE" # invalid expr coerced
|
||||
assert metrics[1].column is None
|
||||
# #endregion Test.DashboardTesting.QueryModel.NullMetricLabel
|
||||
|
||||
# #endregion Test.DashboardTesting.QueryModel
|
||||
|
||||
@@ -63,9 +63,12 @@ class TestExceptionHandlers:
|
||||
from src.core.utils.network import NetworkError
|
||||
request = _make_mock_request()
|
||||
response = await network_error_handler(request, NetworkError("down"))
|
||||
assert isinstance(response, HTTPException)
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 503
|
||||
assert "Environment unavailable" in response.detail
|
||||
body = json.loads(response.body)
|
||||
assert "Environment unavailable" in body["detail"]
|
||||
# #endregion Test.AppModule.TestNetworkErrorHandler
|
||||
|
||||
# #region Test.AppModule.TestGlobalHandlerWithQueryParams [C:2] [TYPE Function]
|
||||
|
||||
@@ -129,7 +129,7 @@ services:
|
||||
FASTAPI_URL: http://backend:8000
|
||||
CERTS_PATH: /opt/certs
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env.enterprise-clean}
|
||||
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
|
||||
SERVICE_JWT: ${SERVICE_JWT:?Set SERVICE_JWT in .env — do not use a public default for the service secret}
|
||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools}
|
||||
GRADIO_SERVER_PORT: 7860
|
||||
GRADIO_ROOT_PATH: /api/agent/gradio
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
||||
FEATURES__DATASET_REVIEW: ${FEATURES__DATASET_REVIEW:-true}
|
||||
FEATURES__HEALTH_MONITOR: ${FEATURES__HEALTH_MONITOR:-true}
|
||||
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
|
||||
SERVICE_JWT: ${SERVICE_JWT:?Set SERVICE_JWT in .env — do not use a public default for the service secret}
|
||||
LLM_CA_CERT_URLS: ${LLM_CA_CERT_URLS:-}
|
||||
ports:
|
||||
- "${BACKEND_HOST_PORT:-8001}:8000"
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o}
|
||||
FASTAPI_URL: http://backend:8000
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env}
|
||||
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
|
||||
SERVICE_JWT: ${SERVICE_JWT:?Set SERVICE_JWT in .env — do not use a public default for the service secret}
|
||||
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
|
||||
GRADIO_SERVER_PORT: 7860
|
||||
GRADIO_ROOT_PATH: /api/agent/gradio
|
||||
|
||||
@@ -54,6 +54,23 @@ export class ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial connection attempt. On failure, drops into the auto-retry reconnect
|
||||
* loop so the agent chat recovers without manual intervention. On success the
|
||||
* shared onConnected path (recovery + scenario auto-start) runs.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
this._reconnectAttempts = 0;
|
||||
try {
|
||||
const client = await Client.connect(getGradioBaseUrl());
|
||||
this.onReconnect(client);
|
||||
} catch (e: unknown) {
|
||||
log("AgentChat.ConnectionManager", "EXPLORE", "Initial connect failed, starting reconnect loop", {}, e instanceof Error ? e.message : "Client.connect threw");
|
||||
this.cb.onDisconnected();
|
||||
this._startReconnectLoop();
|
||||
}
|
||||
}
|
||||
|
||||
/** Call when the Gradio client emits a disconnect event. */
|
||||
onDisconnect(): void {
|
||||
this.cb.onDisconnected();
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
// @REJECTED WebSocket-based model — rejected with custom WebSocket protocol.
|
||||
// @REJECTED Active/follower multi-tab — rejected in favor of client-side gate.
|
||||
import { type Client as GradioClient } from "@gradio/client";
|
||||
import { browser } from "$app/environment";
|
||||
import { setAssistantConversationId } from "$lib/stores/assistantChat.svelte.js";
|
||||
import {
|
||||
getAssistantConversations,
|
||||
@@ -138,6 +139,10 @@ export class AgentChatModel {
|
||||
|
||||
// ── Private fields ─────────────────────────────────────────────
|
||||
_client: GradioClient | null = null; // non-private for legacy Object.assign usage
|
||||
/** 039: objectId for which an auto-started scenario run was dispatched (prevents double-start). */
|
||||
_autoStartedScenarioFor: string | null = null;
|
||||
/** 036/039: run_id from the URL — used to recover an existing durable run on (re)connect. */
|
||||
recoveryRunId: string | null = $state(null);
|
||||
private _submission: ReturnType<GradioClient["submit"]> | null = null;
|
||||
private _conversationsPage: number = 1;
|
||||
private _conversationsHasNext: boolean = $state(false);
|
||||
@@ -400,7 +405,14 @@ export class AgentChatModel {
|
||||
if (options?.userJwt) this.userJwt = options.userJwt;
|
||||
if (options?.envId) this.envId = options.envId;
|
||||
const connectionCbs: ConnectionManagerCallbacks = {
|
||||
onConnected: (client) => { this._client = client; this.connectionState = "connected"; },
|
||||
onConnected: (client) => {
|
||||
this._client = client;
|
||||
this.connectionState = "connected";
|
||||
this.loadConversations(true);
|
||||
// 036/039: recover an existing run or auto-start the scenario run.
|
||||
// Centralized here so initial connect AND reconnect share one path.
|
||||
void this._runScenarioConnectedFlow();
|
||||
},
|
||||
onDisconnected: () => {
|
||||
this.connectionState = "disconnected";
|
||||
if (this.streamingState === "streaming") {
|
||||
@@ -493,6 +505,96 @@ export class AgentChatModel {
|
||||
await this.sendMessage(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 039: Auto-start a dashboard test scenario run when navigating to /agent with
|
||||
* scenario intent. Builds a scenario-start prompt from the UI context and sends
|
||||
* it through the normal stream path; the agent creates the durable run and
|
||||
* emits scenario_progress metadata that AgentRunModel consumes.
|
||||
* @POST Returns true when a message was dispatched, false when blocked (not
|
||||
* connected, input locked, already started for this objectId).
|
||||
*/
|
||||
startScenarioRun(): boolean {
|
||||
if (!this.scenarioMode) return false;
|
||||
if (!this.uiContext?.objectId) return false;
|
||||
if (this._autoStartedScenarioFor === this.uiContext.objectId) return false;
|
||||
if (this.connectionState !== "connected") return false;
|
||||
if (!this._client) return false;
|
||||
if (this.isInputLocked || this.streamingState !== "idle") return false;
|
||||
if (this.runModel && this.runModel.state !== "absent") return false;
|
||||
|
||||
const objectId = this.uiContext.objectId;
|
||||
const objectName = this.uiContext.objectName || `#${objectId}`;
|
||||
const envId = this.uiContext.envId || this.envId || this._activeEnvId || "";
|
||||
const envSuffix = envId ? ` в окружении ${envId}` : "";
|
||||
const prompt = `Подготовь тестовый сценарий для дашборда «${objectName}» (id ${objectId})${envSuffix}. Начни с анализа дашборда и собери структуру сценария.`;
|
||||
|
||||
this._autoStartedScenarioFor = objectId;
|
||||
log("AgentChat.Model", "REASON", "Auto-starting scenario run", { objectId, envId });
|
||||
// fire-and-forget through the stream path; failures surface in the chat
|
||||
void this.sendMessage(prompt);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reset the auto-start marker (e.g. when the route context changes). */
|
||||
resetAutoStartedScenario(objectId: string | null = null): void {
|
||||
this._autoStartedScenarioFor = objectId;
|
||||
}
|
||||
|
||||
/** Set the run_id recovered from the URL (used on (re)connect to resume a run). */
|
||||
setRecoveryRunId(runId: string | null): void {
|
||||
this.recoveryRunId = runId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 036/039: On (re)connection, recover an existing durable run when run_id or a
|
||||
* stored session marker is present; otherwise auto-start the scenario run when
|
||||
* the scenario intent is active. Centralized so initial connect and reconnect
|
||||
* share one path — fixes auto-start not firing on natural navigation from the
|
||||
* dashboards page (the previous page-level connect handler referenced an
|
||||
* undefined `params` and never reached startScenarioRun on connect success).
|
||||
*/
|
||||
private async _runScenarioConnectedFlow(): Promise<void> {
|
||||
const runId = this.recoveryRunId;
|
||||
const urlObjectId = this.uiContext?.objectId ?? null;
|
||||
if (runId && this.runModel) {
|
||||
const ok = await this.runModel.recover(runId);
|
||||
if (ok && browser) {
|
||||
sessionStorage.setItem("agent_scenario_run", JSON.stringify({ runId, objectId: urlObjectId }));
|
||||
} else if (this.scenarioMode) {
|
||||
// Recovery failed → recover() leaves runModel.state="disconnected", which
|
||||
// would permanently block startScenarioRun. Reset so auto-start can fire.
|
||||
this.runModel.reset();
|
||||
this.recoveryRunId = null;
|
||||
this.startScenarioRun();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (browser) {
|
||||
const storedRaw = sessionStorage.getItem("agent_scenario_run");
|
||||
let recovered = false;
|
||||
if (storedRaw) {
|
||||
try {
|
||||
const stored = JSON.parse(storedRaw);
|
||||
if (stored?.runId && (!urlObjectId || stored.objectId === urlObjectId)) {
|
||||
recovered = (await this.runModel?.recover(stored.runId)) ?? false;
|
||||
}
|
||||
} catch {
|
||||
// corrupt marker — fall through to auto-start
|
||||
}
|
||||
}
|
||||
if (!recovered && this.scenarioMode) {
|
||||
this.runModel?.reset();
|
||||
this.recoveryRunId = null;
|
||||
this.startScenarioRun();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.scenarioMode) {
|
||||
this.runModel?.reset();
|
||||
this.startScenarioRun();
|
||||
}
|
||||
}
|
||||
|
||||
setUIContextFromParams(params: URLSearchParams): void {
|
||||
const rawType = params.get("objectType");
|
||||
const validTypes = ["dashboard", "dataset", "migration"];
|
||||
|
||||
@@ -73,6 +73,25 @@ export class DashboardScenarioWorkspaceModel {
|
||||
log("DashboardTesting.WorkspaceModel", "REASON", "Scenario inspection started");
|
||||
}
|
||||
|
||||
/** Leave scenario mode (bare /agent, context params removed). */
|
||||
reset(): void {
|
||||
this.mode = "ordinary";
|
||||
this.phase = "idle";
|
||||
this.scenario = null;
|
||||
this.parameterDrafts = {};
|
||||
this.parameterErrors = {};
|
||||
this.baselineSummary = null;
|
||||
this.draftStatus = "draft";
|
||||
this.blockers = [];
|
||||
this.vlmFindings = [];
|
||||
this.dispositions = {};
|
||||
this.evidence = [];
|
||||
this.domainError = "";
|
||||
this.recoveryHint = "";
|
||||
this.pendingGateIntent = null;
|
||||
log("DashboardTesting.WorkspaceModel", "REASON", "Scenario workspace reset");
|
||||
}
|
||||
|
||||
/** Enter scenario mode with a compiled scenario (AGUI-FR-002). */
|
||||
enterScenario(scenario: DashboardTestScenario): void {
|
||||
this.mode = "scenario";
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// frontend/src/lib/models/__tests__/AgentChatScenarioAutostart.test.ts
|
||||
// #region Test.AgentChat.ScenarioAutostart [C:3] [TYPE Module] [SEMANTICS test,agent,scenario,autostart]
|
||||
// @BRIEF 039: startScenarioRun auto-dispatches the scenario prompt only when scenario
|
||||
// mode is active, the client is connected, and no run is already active.
|
||||
// @RELATION BINDS_TO -> [AgentChat.Model]
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AgentChatModel } from "../AgentChatModel.svelte.ts";
|
||||
|
||||
function scenarioModel(
|
||||
overrides: Partial<{
|
||||
connectionState: string;
|
||||
streamingState: string;
|
||||
runState: string;
|
||||
objectName: string;
|
||||
}> = {},
|
||||
): AgentChatModel {
|
||||
const m = new AgentChatModel();
|
||||
m.setUIContextFromParams(
|
||||
new URLSearchParams({
|
||||
objectType: "dashboard",
|
||||
objectId: "42",
|
||||
objectName: overrides.objectName ?? "Deck.gl Demo",
|
||||
envId: "ss-prod",
|
||||
contextVersion: "2",
|
||||
intent: "build_dashboard_test_scenario",
|
||||
}),
|
||||
);
|
||||
m.connectionState = (overrides.connectionState ?? "connected") as AgentChatModel["connectionState"];
|
||||
m.streamingState = (overrides.streamingState ?? "idle") as AgentChatModel["streamingState"];
|
||||
if (overrides.connectionState !== "disconnected" && overrides.connectionState !== "disconnected_permanent") {
|
||||
// Simulate an established Gradio client so the dispatch path is reachable.
|
||||
(m as { _client: unknown })._client = { submit: vi.fn() };
|
||||
}
|
||||
if (overrides.runState && m.runModel) {
|
||||
m.runModel.state = overrides.runState as typeof m.runModel.state;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
describe("AgentChatModel.startScenarioRun (039 auto-start)", () => {
|
||||
it("dispatches a scenario prompt built from the UI context", () => {
|
||||
const m = scenarioModel();
|
||||
const spy = vi.spyOn(m, "sendMessage").mockResolvedValue(undefined);
|
||||
const ok = m.startScenarioRun();
|
||||
expect(ok).toBe(true);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const prompt = spy.mock.calls[0][0] as string;
|
||||
expect(prompt).toContain("42");
|
||||
expect(prompt).toContain("Deck.gl Demo");
|
||||
expect(prompt).toContain("ss-prod");
|
||||
});
|
||||
|
||||
it("is idempotent per objectId (no double-start)", () => {
|
||||
const m = scenarioModel();
|
||||
const spy = vi.spyOn(m, "sendMessage").mockResolvedValue(undefined);
|
||||
expect(m.startScenarioRun()).toBe(true);
|
||||
expect(m.startScenarioRun()).toBe(false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns false when disconnected", () => {
|
||||
const m = scenarioModel({ connectionState: "disconnected" });
|
||||
const spy = vi.spyOn(m, "sendMessage").mockResolvedValue(undefined);
|
||||
expect(m.startScenarioRun()).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when scenario mode is inactive", () => {
|
||||
const m = new AgentChatModel();
|
||||
m.setUIContextFromParams(new URLSearchParams({ objectType: "dashboard", objectId: "7" }));
|
||||
m.connectionState = "connected";
|
||||
expect(m.startScenarioRun()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when an active run exists (recovery path)", () => {
|
||||
const m = scenarioModel({ runState: "running" });
|
||||
const spy = vi.spyOn(m, "sendMessage").mockResolvedValue(undefined);
|
||||
expect(m.startScenarioRun()).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false while input is locked", () => {
|
||||
const m = scenarioModel({ streamingState: "streaming" });
|
||||
const spy = vi.spyOn(m, "sendMessage").mockResolvedValue(undefined);
|
||||
expect(m.startScenarioRun()).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-starts the scenario run on (re)connection when scenario mode is active", async () => {
|
||||
const m = scenarioModel({ connectionState: "disconnected" });
|
||||
const spy = vi.spyOn(m, "startScenarioRun").mockReturnValue(false);
|
||||
// Simulate the connection being established (mirrors ConnectionManager.onConnected).
|
||||
(m as { _client: unknown })._client = { submit: vi.fn() };
|
||||
m.connectionState = "connected";
|
||||
await (m as unknown as { _runScenarioConnectedFlow: () => Promise<void> })._runScenarioConnectedFlow();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not auto-start on (re)connection when scenario mode is inactive", async () => {
|
||||
const m = new AgentChatModel();
|
||||
m.setUIContextFromParams(new URLSearchParams({ objectType: "dashboard", objectId: "7" }));
|
||||
m.connectionState = "connected";
|
||||
(m as { _client: unknown })._client = { submit: vi.fn() };
|
||||
const spy = vi.spyOn(m, "startScenarioRun").mockReturnValue(false);
|
||||
await (m as unknown as { _runScenarioConnectedFlow: () => Promise<void> })._runScenarioConnectedFlow();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
// #endregion Test.AgentChat.ScenarioAutostart
|
||||
@@ -11,7 +11,6 @@
|
||||
import { page } from "$app/stores";
|
||||
import { browser } from "$app/environment";
|
||||
import { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
|
||||
import { Client } from "@gradio/client";
|
||||
import AgentChat from "$lib/components/agent/AgentChat.svelte";
|
||||
import ConversationList from "$lib/components/assistant/ConversationList.svelte";
|
||||
import ScenarioWorkspace from "$lib/components/agent/dashboard-testing/ScenarioWorkspace.svelte";
|
||||
@@ -30,11 +29,58 @@
|
||||
let currentEnvironmentId = $derived(
|
||||
$environmentContextStore?.selectedEnvId || (browser ? localStorage.getItem("selected_env_id") || "" : ""),
|
||||
);
|
||||
let currentPageSearchString = $derived($page.url.search);
|
||||
|
||||
// Guard against double initialization in dev HMR
|
||||
let _initialized = false;
|
||||
|
||||
/** 039: objectId for which scenario mode was last activated (prevents reset/restart on unrelated reactivity). */
|
||||
let _lastContextObjectId: string | null = null;
|
||||
|
||||
/** 039: last applied search string — the effect only re-applies context when the URL actually changed. */
|
||||
let _lastAppliedSearch: string | null = null;
|
||||
|
||||
/**
|
||||
* 039: Apply UI context from the current URL search. Activates the scenario
|
||||
* workspace and auto-starts the agent run when the dashboard scenario intent is
|
||||
* present and the objectId changed. Resets the workspace on a bare /agent URL.
|
||||
* Re-runs on query-only navigation (SvelteKit reuses the page component, so
|
||||
* onMount alone would ignore new scenario params).
|
||||
*/
|
||||
function applyContextFromSearch(m: AgentChatModel, w: DashboardScenarioWorkspaceModel, search: string): void {
|
||||
const params = new URLSearchParams(search);
|
||||
m.setUIContextFromParams(params);
|
||||
if (!m.uiContext || !m.scenarioMode) {
|
||||
if (w.mode === "scenario") w.reset();
|
||||
m.resetAutoStartedScenario(null);
|
||||
if (browser) sessionStorage.removeItem("agent_scenario_run");
|
||||
_lastContextObjectId = null;
|
||||
return;
|
||||
}
|
||||
const objectId = m.uiContext.objectId || null;
|
||||
if (objectId !== _lastContextObjectId) {
|
||||
_lastContextObjectId = objectId;
|
||||
w.beginScenario();
|
||||
m.resetAutoStartedScenario(null);
|
||||
// A different dashboard was targeted — the previous run (if any) belongs to
|
||||
// another object; reset the run model so a NEW scenario can auto-start
|
||||
// (startScenarioRun is gated on runModel.state === "absent").
|
||||
m.runModel?.reset();
|
||||
// A different dashboard was targeted — any stored run belongs to another object.
|
||||
if (browser) {
|
||||
const storedRaw = sessionStorage.getItem("agent_scenario_run");
|
||||
if (storedRaw) {
|
||||
try {
|
||||
const stored = JSON.parse(storedRaw);
|
||||
if (!stored?.objectId || stored.objectId !== objectId) sessionStorage.removeItem("agent_scenario_run");
|
||||
} catch {
|
||||
sessionStorage.removeItem("agent_scenario_run");
|
||||
}
|
||||
}
|
||||
}
|
||||
m.startScenarioRun();
|
||||
}
|
||||
}
|
||||
|
||||
// Sync model with reactive values before each send
|
||||
function syncModel(m: AgentChatModel, envId: string = currentEnvironmentId): void {
|
||||
m.userId = $auth.user?.id ?? "";
|
||||
@@ -48,6 +94,19 @@
|
||||
if (model) syncModel(model, envId);
|
||||
});
|
||||
|
||||
// 039: re-apply UI context on query-only navigation (route component reuse).
|
||||
// Guarded by _lastAppliedSearch so a change to model/connection state does not
|
||||
// re-run setUIContextFromParams (which allocates a fresh uiContext object and
|
||||
// would otherwise drive an infinite effect loop).
|
||||
$effect(() => {
|
||||
const m = model;
|
||||
const search = $page.url.search;
|
||||
if (!m) return;
|
||||
if (search === _lastAppliedSearch) return;
|
||||
_lastAppliedSearch = search;
|
||||
applyContextFromSearch(m, workspace, search);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
@@ -55,10 +114,8 @@
|
||||
const m = new AgentChatModel();
|
||||
m.onBeforeSend = () => syncModel(m);
|
||||
syncModel(m);
|
||||
// Read UIContext from URL params (035-agent-chat-context)
|
||||
const params = new URLSearchParams(currentPageSearchString);
|
||||
m.setUIContextFromParams(params);
|
||||
if (m.scenarioMode) workspace.beginScenario();
|
||||
m.connectionState = "disconnected";
|
||||
// UI context is applied by the search $effect once `model` is assigned below.
|
||||
const unsubscribeEnvironment = environmentContextStore.subscribe((state) => {
|
||||
syncModel(m, state?.selectedEnvId || (browser ? localStorage.getItem("selected_env_id") || "" : ""));
|
||||
});
|
||||
@@ -70,39 +127,21 @@
|
||||
const handleViewportChange = (event: MediaQueryListEvent) => syncSidebarForViewport(event.matches);
|
||||
desktopQuery.addEventListener("change", handleViewportChange);
|
||||
model = m;
|
||||
m.connectionState = "disconnected";
|
||||
|
||||
const gradioBaseUrl = `${window.location.origin}/api/agent/gradio`;
|
||||
|
||||
// Delegate connection to ConnectionManager for unified lifecycle
|
||||
// Delegate connection to ConnectionManager for unified lifecycle: initial
|
||||
// connect with auto-retry, and the scenario recovery / auto-start flow on
|
||||
// (re)connect. Fixes auto-start not firing on natural navigation from the
|
||||
// dashboards page (the previous page-level connect handler referenced an
|
||||
// undefined `params` and never reached startScenarioRun on connect success,
|
||||
// and a failed initial connect had no retry path).
|
||||
m.setRecoveryRunId($page.url.searchParams.get("run_id"));
|
||||
initializeEnvironmentContext().then(() => syncModel(m));
|
||||
|
||||
Client.connect(gradioBaseUrl).then((client) => {
|
||||
Object.assign(m, { _client: client });
|
||||
m.connectionState = "connected";
|
||||
m.loadConversations(true);
|
||||
// ── 036: Recover scenario run if run_id present in URL ──
|
||||
const runId = params.get("run_id");
|
||||
if (runId && m.runModel) {
|
||||
m.runModel.recover(runId).then((ok) => {
|
||||
if (ok && browser) {
|
||||
// Store in sessionStorage so reload without URL param still recovers
|
||||
sessionStorage.setItem("agent_scenario_run_id", runId);
|
||||
}
|
||||
});
|
||||
} else if (browser) {
|
||||
const stored = sessionStorage.getItem("agent_scenario_run_id");
|
||||
if (stored && m.runModel) {
|
||||
m.runModel.recover(stored);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
m.connectionState = "disconnected";
|
||||
});
|
||||
void m.connection.connect();
|
||||
|
||||
return () => {
|
||||
unsubscribeEnvironment();
|
||||
desktopQuery.removeEventListener("change", handleViewportChange);
|
||||
m.connection.stopReconnect();
|
||||
_initialized = false;
|
||||
};
|
||||
});
|
||||
|
||||
12
run.sh
12
run.sh
@@ -15,6 +15,18 @@ FRONTEND_PORT=${FRONTEND_PORT:-5173}
|
||||
AGENT_PORT=${AGENT_PORT:-7860}
|
||||
SKIP_INSTALL=false
|
||||
|
||||
# Service-to-service shared secret between backend and the Gradio agent.
|
||||
# Used for agent lifecycle persistence and the internal /api/agent/llm-config
|
||||
# endpoint (which returns the decrypted LLM API key). When unset, a random
|
||||
# per-run secret is generated so every service started by this run.sh shares it —
|
||||
# the old hardcoded default ('agent-service-secret') was public in the repo and
|
||||
# would let anyone forge service auth for the secrets-returning endpoint.
|
||||
if [ -z "${SERVICE_JWT:-}" ]; then
|
||||
export SERVICE_JWT="$(openssl rand -hex 24 2>/dev/null || echo "svc-$(date +%s%N)-$$")"
|
||||
echo -e "\033[0;33m[SERVICE_JWT]\033[0m Not set — generated a random service secret for this run (all services share it)."
|
||||
echo -e "\033[0;33m[SERVICE_JWT]\033[0m Set SERVICE_JWT explicitly when running services in separate terminals."
|
||||
fi
|
||||
|
||||
# Help message
|
||||
show_help() {
|
||||
echo "Usage: ./run.sh [options]"
|
||||
|
||||
@@ -51,8 +51,12 @@ async def _check_llm_provider_health() -> str:
|
||||
# Fetch LLM config from backend's own API (same as agent container does)
|
||||
try:
|
||||
fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000")
|
||||
headers = {}
|
||||
service_token = os.getenv("SERVICE_JWT", "").strip()
|
||||
if service_token:
|
||||
headers["Authorization"] = f"Bearer {service_token}"
|
||||
client = get_shared_http_client(timeout=10)
|
||||
resp = await client.get(f"{fastapi_url}/api/agent/llm-config")
|
||||
resp = await client.get(f"{fastapi_url}/api/agent/llm-config", headers=headers)
|
||||
if resp.status_code != 200:
|
||||
_llm_status["status"] = "unavailable"
|
||||
_llm_status["last_error"] = f"LLM config endpoint returned {resp.status_code}"
|
||||
|
||||
Reference in New Issue
Block a user