feat(agent+ui): fullstack agent module refactoring + UI/UX improvements

## Backend: agent module GRACE-Poly compliance
- Split app.py (749→~280 lines) into _tool_resolver, _confirmation, _persistence
- All 18 naked functions wrapped in #region/#endregion contracts
- Fixed @DEFGROUP→@defgroup typos; added @DATA_CONTRACT, @SIDE_EFFECT, CoT logs
- Conversation list API: added last_role, has_tool_calls, has_error, risk_level fields
- Message state detection: Russian/English error patterns (недоступен, unavailable)
- State field preserved in save_conversation messages
- HITL titles: descriptive tool names instead of generic "HITL resume"

## Backend: conversation title generation (two-layer)
- Layer 1: clean_title() — rule-based, strips file markers, pre-fetch blocks, JSON/CSV,
  URLs, code; truncates at 80 chars word boundary (25 unit tests, all edge cases)
- Layer 2: generate_llm_title() — async best-effort LLM titling via /v1/chat/completions
  with per-conversation lock, graceful degradation on failure

## Frontend: conversation list indicators (orthogonal system)
- Status dot (green/yellow/red/blue) per conversation state
- Icon column: tool activity, errors, waiting, completed
- Risk stripe (left border accent) + message count badge + relative time
- Fixed group labels: "Сегодня"/"Вчера" instead of "3 ч"/"5 ч"
- Hide "Окружение: —" when env is empty

## Frontend: guardrails card verification + fixes
- Confirmed all interaction modes: Enter/click confirm, Escape/click deny
- Auto-populate envId from environmentContextStore in DashboardDetailModel
- Better error message: missing_context_hint with recovery guidance

## Design system: semantic tokens
- Added category-* gradient tokens to tailwind.config.js
- Sidebar + Breadcrumbs use semantic tokens (10 categories)
- Raw Tailwind reduced from ~50 to 6 occurrences
- Added skip-to-content link in root layout (+layout.svelte)
- Added aria-label on DashboardDataGrid row checkboxes

## Protocol: INV_7 pragmatic exception
- Modules may exceed 400 lines when contract-dense (every function has #region)
- Recorded in semantics-core SKILL.md with rationale

Total: 5841+ contracts, 2993+ edges, backend 41/41, frontend 2501/2501
This commit is contained in:
2026-06-30 15:21:05 +03:00
parent e8d6d7d0db
commit 3b4ac807a5
33 changed files with 2076 additions and 374 deletions

View File

@@ -0,0 +1,190 @@
# backend/src/agent/_tool_resolver.py
# #region AgentChat.ToolResolver [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,classification,resolution]
# @defgroup AgentChat Tool classification constants and resolution helpers for the LangGraph agent.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE Centralised tool resolution prevents duplication of tool-name matching logic across
# the handler and confirmation subsystems. Single source of truth for tool risk classification.
from typing import Any
from src.agent.tools import get_all_tools
from src.core.cot_logger import log
# #region AgentChat.ToolResolver.Sets [C:1] [TYPE Constants] [SEMANTICS agent-chat,tools,sets]
# @ingroup AgentChat
# @BRIEF Tool classification sets — safe (read-only), guarded (write), dangerous (deploy).
_SAFE_AGENT_TOOLS = {
"show_capabilities",
"search_dashboards",
"get_health_summary",
"list_environments",
"get_task_status",
"list_llm_providers",
"get_llm_status",
"list_maintenance_events",
}
_GUARDED_AGENT_TOOLS = {
"create_branch",
"commit_changes",
"execute_migration",
"run_backup",
"run_llm_validation",
"run_llm_documentation",
"start_maintenance",
"end_maintenance",
}
_DANGEROUS_AGENT_TOOLS = {
"deploy_dashboard",
}
_GRAPH_NODE_NAMES = {"agent", "tools", "__start__", "__end__"}
_FAST_CONFIRM_TOOLS = {
"show_capabilities",
"list_environments",
"list_llm_providers",
"get_llm_status",
"list_maintenance_events",
}
# #endregion AgentChat.ToolResolver.Sets
# #region AgentChat.ToolResolver.KnownNames [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,catalog]
# @ingroup AgentChat
# @BRIEF Return registered LangChain tool names without letting helper failures break HITL UX.
# @POST Returns set of tool name strings; falls back to static union on failure.
def known_agent_tool_names() -> set[str]:
try:
return {str(tool_obj.name) for tool_obj in get_all_tools() if getattr(tool_obj, "name", None)}
except Exception as exc:
log("AgentChat.ToolResolver", "EXPLORE", "tool catalog lookup failed", {"error": str(exc)})
return _SAFE_AGENT_TOOLS | _GUARDED_AGENT_TOOLS | _DANGEROUS_AGENT_TOOLS
# #endregion AgentChat.ToolResolver.KnownNames
# #region AgentChat.ToolResolver.NormalizeArgs [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,args]
# @ingroup AgentChat
# @BRIEF Normalize raw tool arguments into a plain dict regardless of input format.
# @POST Returns dict (empty dict for None/unparseable input).
def normalize_tool_args(raw_args: Any) -> dict[str, Any]:
if raw_args is None:
return {}
if isinstance(raw_args, dict):
return raw_args
if hasattr(raw_args, "model_dump"):
dumped = raw_args.model_dump()
return dumped if isinstance(dumped, dict) else {}
if hasattr(raw_args, "dict"):
dumped = raw_args.dict()
return dumped if isinstance(dumped, dict) else {}
return {}
# #endregion AgentChat.ToolResolver.NormalizeArgs
# #region AgentChat.ToolResolver.CoerceToolCall [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,coerce]
# @ingroup AgentChat
# @BRIEF Extract (tool_name, tool_args) tuple from a dict or object tool call.
# @POST Returns (name, args) tuple; name may be None if unparseable.
def coerce_tool_call(tool_call: Any) -> tuple[str | None, dict[str, Any]]:
if isinstance(tool_call, dict):
return (
tool_call.get("name") or tool_call.get("tool") or tool_call.get("id"),
normalize_tool_args(tool_call.get("args") or tool_call.get("input")),
)
return (
getattr(tool_call, "name", None),
normalize_tool_args(getattr(tool_call, "args", None)),
)
# #endregion AgentChat.ToolResolver.CoerceToolCall
# #region AgentChat.ToolResolver.InferFromText [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,inference]
# @ingroup AgentChat
# @BRIEF Infer which tool the user likely wants based on keywords in the message text.
# @POST Returns tool name string, or None if no match.
# @RATIONALE Some LLMs fail to emit tool calls even when instructed. This fallback
# uses keyword matching to guess the user's intent and auto-trigger HITL.
def infer_tool_from_text(text: str) -> str | None:
lowered = (text or "").lower()
if any(word in lowered for word in ["окруж", "environment", "env"]):
return "list_environments"
if any(word in lowered for word in ["maintenance", "обслуж", "баннер"]):
if any(word in lowered for word in ["start", "созда", "запусти", "начни"]):
return "start_maintenance"
if any(word in lowered for word in ["end", "закрой", "заверши", "останов"]):
return "end_maintenance"
return "list_maintenance_events"
if any(word in lowered for word in ["дашборд", "dashboard", "dashboards", "дашборды"]):
return "search_dashboards"
if any(word in lowered for word in ["здоров", "health", "статус системы", "system status"]):
return "get_health_summary"
if any(word in lowered for word in ["задач", "task", "таск"]):
return "get_task_status"
if any(word in lowered for word in ["llm", "provider", "провайдер", "модель"]):
return "list_llm_providers"
if any(word in lowered for word in ["branch", "ветк"]):
return "create_branch"
if any(word in lowered for word in ["commit", "коммит"]):
return "commit_changes"
if any(word in lowered for word in ["deploy", "депло", "разверн"]):
return "deploy_dashboard"
if any(word in lowered for word in ["миграц", "migration", "migrate"]):
return "execute_migration"
if any(word in lowered for word in ["backup", "бэкап", "резерв"]):
return "run_backup"
if any(word in lowered for word in ["валидац", "validation", "validate"]):
return "run_llm_validation"
if any(word in lowered for word in ["документ", "documentation", "docs"]):
return "run_llm_documentation"
if any(word in lowered for word in ["инструмент", "tool", "capabilit", "умеешь", "можешь"]):
return "show_capabilities"
return None
# #endregion AgentChat.ToolResolver.InferFromText
# #region AgentChat.ToolResolver.ExtractFromState [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,checkpoint]
# @ingroup AgentChat
# @BRIEF Extract pending tool name and args from the LangGraph checkpoint; infer only as last resort.
# @POST Returns (tool_name, args) tuple; (None, {}) if nothing found.
def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None, dict[str, Any]]:
known_tools = known_agent_tool_names()
try:
messages = (state.values.get("messages") if hasattr(state, "values") else []) or []
for msg in reversed(messages[-5:]):
if hasattr(msg, "tool_calls") and msg.tool_calls:
tool_name, tool_args = coerce_tool_call(msg.tool_calls[0])
if tool_name:
return (str(tool_name), tool_args)
except Exception as exc:
log("AgentChat.ToolResolver", "EXPLORE", "tool_call extraction failed", {"error": str(exc)})
if getattr(state, "next", None):
node_or_tool = str(state.next[0])
if node_or_tool in known_tools and node_or_tool not in _GRAPH_NODE_NAMES:
return (node_or_tool, {})
inferred_tool = infer_tool_from_text(user_text)
if inferred_tool:
log("AgentChat.ToolResolver", "EXPLORE", "tool_call inferred from user text", {"tool": inferred_tool})
return (inferred_tool, {})
return (None, {})
# #endregion AgentChat.ToolResolver.ExtractFromState
# #region AgentChat.ToolResolver.FindTool [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,lookup]
# @ingroup AgentChat
# @BRIEF Find a registered LangChain tool by name.
# @POST Returns tool object or None if not found.
def find_tool(tool_name: str):
return next((tool_obj for tool_obj in get_all_tools() if getattr(tool_obj, "name", None) == tool_name), None)
# #endregion AgentChat.ToolResolver.FindTool
# #region AgentChat.ToolResolver.FastConfirm [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,fast-path]
# @ingroup AgentChat
# @BRIEF Check if user text maps to a tool eligible for fast-track HITL confirmation.
# @POST Returns tool name if match, None otherwise.
def fast_confirmation_tool(text: str) -> str | None:
tool_name = infer_tool_from_text(text)
return tool_name if tool_name in _FAST_CONFIRM_TOOLS else None
# #endregion AgentChat.ToolResolver.FastConfirm
# #endregion AgentChat.ToolResolver