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:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user