chore: commit remaining workspace updates

Agent:
- lifecycle: run tracking, middleware hardening, langgraph setup
- tests: agent lifecycle + langgraph setup coverage

Backend:
- async_job_runner: resilience hardening, tests
- agent_conversations: run lifecycle integration
- translate: scheduler + orchestrator SQL adjustments
- schemas/services: agent_lifecycle model extensions

Frontend:
- TaskDrawer: UX improvements
- TaskLogPanel/Viewer: safety hardening, i18n (en/ru)
- FilterBar: report filters contract + tests
- Reports page: layout adjustments

Specs:
- 036-agent-test-stabilization: runs contract, modules, events
- 037-superset-baseline-engine: catalog schema, testing API, modules
- 038-dashboard-scenario-model: scenario schema, capture profile, modules
- 039-dashboard-scenario-ui: screen models, release verification UX, modules
- dashboard-verification-usecases: new cross-cutting spec
This commit is contained in:
2026-07-17 19:11:09 +03:00
parent fdb6541372
commit 31b9a19a0c
65 changed files with 2621 additions and 194 deletions

View File

@@ -61,7 +61,7 @@ from ss_tools.agent._persistence import (
)
from ss_tools.agent.context import reset_user_jwt, reset_user_role, set_user_jwt, set_user_role
from ss_tools.agent.document_parser import parse_upload
from ss_tools.agent.langgraph_setup import create_agent
from ss_tools.agent.langgraph_setup import create_agent, llm_diagnostics
from ss_tools.agent.middleware import (
close_lifecycle_resources,
emit_lifecycle_event,
@@ -89,6 +89,34 @@ def _now_iso() -> str:
# #endregion AgentChat.GradioApp.NowIso
# #region AgentChat.GradioApp.LlmFailureDiagnostics [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,error,observability]
# @ingroup AgentChat
# @BRIEF Classify an LLM exception without logging credentials, user input, or provider response bodies.
# @POST Returns stable aggregate labels suitable for lifecycle events and production diagnosis.
def _llm_failure_diagnostics(exc: BaseException) -> dict[str, str | bool]:
"""Return a safe error fingerprint for a failed upstream LLM call."""
cause = exc.__cause__ or exc.__context__
diagnostics = {
**llm_diagnostics(),
"exception_type": type(exc).__name__,
"cause_type": type(cause).__name__ if cause else "",
"retryable": True,
}
cause_name = diagnostics["cause_type"]
if cause_name in {"gaierror", "NameResolutionError"}:
diagnostics["failure_class"] = "dns"
elif cause_name in {"ConnectionRefusedError"}:
diagnostics["failure_class"] = "connection_refused"
elif cause_name in {"SSLError", "ConnectError"}:
diagnostics["failure_class"] = "tls_or_connect"
elif isinstance(exc, (APITimeoutError, httpx.ReadTimeout)):
diagnostics["failure_class"] = "timeout"
else:
diagnostics["failure_class"] = "connection"
return diagnostics
# #endregion AgentChat.GradioApp.LlmFailureDiagnostics
# #region AgentChat.GradioApp.BuildAgentContext [C:3] [TYPE Function] [SEMANTICS agent-chat,context,runtime,build]
# @ingroup AgentChat
# @BRIEF Build hidden RUNTIME CONTEXT block with datetime, prefetched dashboards and databases.
@@ -670,12 +698,19 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["status"] = "unavailable"
_llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider connection failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
diagnostics = _llm_failure_diagnostics(exc)
logger.explore(
"LLM provider connection failed",
payload={"conv_id": conv_id, "attempt": _attempts_used, **diagnostics},
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"},
)
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id=conv_id,
error_code="LLM_PROVIDER_UNAVAILABLE",
attempt=_attempts_used,
**diagnostics,
)
yield json.dumps(
{
@@ -697,12 +732,19 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["status"] = "timeout"
_llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider timed out", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
diagnostics = _llm_failure_diagnostics(exc)
logger.explore(
"LLM provider timed out",
payload={"conv_id": conv_id, "attempt": _attempts_used, **diagnostics},
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"},
)
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id=conv_id,
error_code="LLM_TIMEOUT",
attempt=_attempts_used,
**diagnostics,
)
yield json.dumps(
{

View File

@@ -9,6 +9,7 @@
import inspect as _inspect
import os
from urllib.parse import urlsplit
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
@@ -74,6 +75,28 @@ async def init_checkpointer() -> None:
_llm_config: dict | None = None
# #region AgentChat.LangGraph.Setup.LlmDiagnostics [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,observability,redaction]
# @BRIEF Return diagnostic provider metadata while excluding URL paths, credentials and API keys.
# @INVARIANT Never returns api_key, full base_url, prompt or provider response content.
def llm_diagnostics(config: dict | None = None) -> dict[str, str | bool]:
"""Return the safe LLM identity needed to correlate agent failures."""
candidate = config if config is not None else _llm_config
if not candidate:
return {"configured": False}
parsed = urlsplit(str(candidate.get("base_url") or ""))
return {
"configured": bool(candidate.get("configured")),
"provider_id": str(candidate.get("provider_id") or ""),
"provider_name": str(candidate.get("provider_name") or ""),
"provider_type": str(candidate.get("provider_type") or ""),
"provider_host": parsed.hostname or "",
"provider_scheme": parsed.scheme or "",
"model": str(candidate.get("default_model") or ""),
"selection_source": str(candidate.get("selection_source") or ""),
}
# #endregion AgentChat.LangGraph.Setup.LlmDiagnostics
# #region AgentChat.LangGraph.Setup.ConfigureFromApi [C:1] [TYPE Function] [SEMANTICS agent-chat,langgraph,config,api]
# @ingroup AgentChat
# @BRIEF Store LLM config dict fetched from FastAPI for later use by create_agent.
@@ -88,6 +111,11 @@ def configure_from_api(llm_config: dict) -> None:
# @BRIEF Fetch LLM provider config from FastAPI /api/agent/llm-config.
async def _fetch_llm_config() -> dict | None:
global _llm_config
logger.reason(
"Fetching agent LLM configuration",
payload={"fastapi_host": urlsplit(FASTAPI_URL).hostname or ""},
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
try:
fastapi_url = FASTAPI_URL
client = get_shared_http_client(timeout=10)
@@ -96,9 +124,32 @@ async def _fetch_llm_config() -> dict | None:
config = resp.json()
if config.get("configured"):
_llm_config = config
logger.reflect(
"Agent LLM configuration loaded",
payload=llm_diagnostics(config),
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
return config
logger.explore(
"Agent LLM configuration is unavailable",
payload={"http_status": resp.status_code, **llm_diagnostics(config)},
error=str(config.get("reason") or "configured=false"),
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
else:
logger.explore(
"Agent LLM configuration request failed",
payload={"http_status": resp.status_code, "fastapi_host": urlsplit(fastapi_url).hostname or ""},
error=f"HTTP {resp.status_code}",
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
except Exception as e:
logger.explore("Failed to fetch LLM config from FastAPI", error=str(e), extra={"src": "AgentChat.LangGraph.Setup"})
logger.explore(
"Failed to fetch LLM config from FastAPI",
payload={"fastapi_host": urlsplit(FASTAPI_URL).hostname or "", "exception_type": type(e).__name__},
error=str(e),
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
return _llm_config
# #endregion AgentChat.LangGraph.Setup.FetchLlmConfig
@@ -132,7 +183,11 @@ async def create_agent(tools: list, env_id: str | None = None, interrupt_before:
model = config.get("default_model")
else:
raise RuntimeError("No LLM provider configured in backend. Configure one via Settings → AI Providers in the web UI.")
logger.reason("Creating LangGraph agent", payload={"model": model, "tools_count": len(tools), "env_id": env_id}, extra={"src": "AgentChat.LangGraph.Setup"})
logger.reason(
"Creating LangGraph agent",
payload={**llm_diagnostics(config), "tools_count": len(tools), "env_id": env_id},
extra={"src": "AgentChat.LangGraph.Setup.CreateAgent"},
)
llm = ChatOpenAI(
http_async_client=get_shared_http_client(),
**chat_openai_kwargs(model=model, base_url=base_url, api_key=api_key, max_tokens=2048),
@@ -169,7 +224,11 @@ async def create_agent(tools: list, env_id: str | None = None, interrupt_before:
checkpointer=checkpointer,
interrupt_before=_interrupt_before_from_env() if interrupt_before is None else interrupt_before,
)
logger.reflect("LangGraph agent created", payload={"model": model, "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)}, extra={"src": "AgentChat.LangGraph.Setup"})
logger.reflect(
"LangGraph agent created",
payload={**llm_diagnostics(config), "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)},
extra={"src": "AgentChat.LangGraph.Setup.CreateAgent"},
)
return graph
# #endregion AgentChat.LangGraph.Setup.CreateAgent
# #endregion AgentChat.LangGraph.Setup

View File

@@ -132,11 +132,19 @@ def emit_lifecycle_event(event_type: str, **payload) -> None:
and key.lower() not in _FORBIDDEN_LIFECYCLE_FIELDS
and not any(fragment in key.lower() for fragment in _FORBIDDEN_LIFECYCLE_FRAGMENTS)
}
logger.reason(
event_type,
payload=safe,
extra={"src": "AgentChat.Lifecycle"},
)
if event_type.endswith("_FAILED"):
logger.explore(
event_type,
payload=safe,
error=str(safe.get("error_code") or "agent lifecycle failure"),
extra={"src": "AgentChat.Lifecycle"},
)
else:
logger.reason(
event_type,
payload=safe,
extra={"src": "AgentChat.Lifecycle"},
)
# ── Best-effort async HTTP POST to backend ──
try:

View File

@@ -150,6 +150,28 @@ def test_lifecycle_strips_none_values(mock_logger):
# #endregion test_lifecycle_strips_none_values
# #region test_lifecycle_failure_uses_explore [C:2] [TYPE Function] [SEMANTICS test,lifecycle,log,failure]
# @BRIEF Failed lifecycle events carry an EXPLORE bond and retain only safe provider diagnostics.
def test_lifecycle_failure_uses_explore(mock_logger):
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id="conv-1",
error_code="LLM_PROVIDER_UNAVAILABLE",
provider_host="lite.ai.rusal.com",
provider_id="provider-1",
api_key="must-not-log",
)
mock_logger.explore.assert_called_once()
kwargs = mock_logger.explore.call_args.kwargs
assert kwargs["payload"]["provider_host"] == "lite.ai.rusal.com"
assert "api_key" not in kwargs["payload"]
assert kwargs["error"] == "LLM_PROVIDER_UNAVAILABLE"
# #endregion test_lifecycle_failure_uses_explore
# ═══════════════════════════════════════════════════════════════════
# emit_lifecycle_event — async HTTP persistence
# ═══════════════════════════════════════════════════════════════════

View File

@@ -39,6 +39,31 @@ class TestConfigureFromApi:
# #endregion test_configure_from_api
# #region test_llm_diagnostics [C:2] [TYPE Function] [SEMANTICS test,agent,llm,observability]
# @BRIEF Diagnostics identify the configured provider but never expose API credentials or full URL paths.
def test_llm_diagnostics_redacts_api_key_and_path():
import ss_tools.agent.langgraph_setup as ls
diagnostics = ls.llm_diagnostics({
"configured": True,
"provider_id": "provider-1",
"provider_name": "litellm",
"provider_type": "litellm",
"base_url": "https://key:secret@lite.ai.rusal.com/v1/private",
"api_key": "must-not-appear",
"default_model": "qwen-flash",
"selection_source": "assistant_planner_provider",
})
assert diagnostics["provider_host"] == "lite.ai.rusal.com"
assert diagnostics["provider_scheme"] == "https"
assert diagnostics["model"] == "qwen-flash"
assert "api_key" not in diagnostics
assert "base_url" not in diagnostics
assert all("secret" not in str(value) for value in diagnostics.values())
# #endregion test_llm_diagnostics
# #region test_create_agent [C:2] [TYPE Function]
# @BRIEF Test create_agent with various LLM config states.
class TestCreateAgent: