diff --git a/backend/src/agent/_jwt_decoder.py b/backend/src/agent/_jwt_decoder.py new file mode 100644 index 00000000..7d3c44d9 --- /dev/null +++ b/backend/src/agent/_jwt_decoder.py @@ -0,0 +1,40 @@ +# backend/src/agent/_jwt_decoder.py +# #region AgentChat.JwtDecoder [C:1] [TYPE Module] [SEMANTICS agent-chat,jwt,decode] +# @BRIEF Lightweight JWT decode for agent — uses JWT_SECRET env var directly, avoids +# pulling src.core.auth.jwt which requires AUTH_DATABASE_URL and ORM deps. +# @RATIONALE The agent only needs stateless JWT validation (exp, sub, signature). +# Token blacklisting is only relevant for backend auth flow — the agent +# processes one request at a time and doesn't need DB-backed revocation. +# @REJECTED Importing src.core.auth.jwt.decode_token was rejected — it drags in +# AuthConfig → AUTH_DATABASE_URL/SECRET_KEY validators → SQLAlchemy models, +# bloating the agent image with backend infrastructure it doesn't need. +import os + +from jose import jwt + + +_DEFAULT_SECRET = os.getenv("JWT_SECRET", "") +_DEFAULT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") + + +def decode_token(token: str) -> dict: + """Decode and validate a JWT token using JWT_SECRET from environment. + + Performs stateless validation: signature, expiration, and required claims + (exp, sub). + Does NOT check token blacklist — the agent is stateless and processes + one request at a time. + """ + return jwt.decode( + token, + _DEFAULT_SECRET, + algorithms=[_DEFAULT_ALGORITHM], + options={ + "verify_signature": True, + "verify_exp": True, + "require": ["exp", "sub"], + }, + ) + + +# #endregion AgentChat.JwtDecoder diff --git a/backend/src/agent/_persistence.py b/backend/src/agent/_persistence.py index 9cb82f31..1abcddae 100644 --- a/backend/src/agent/_persistence.py +++ b/backend/src/agent/_persistence.py @@ -24,6 +24,7 @@ TITLE_MAX_LENGTH = 80 # ── Rule-based title cleaning ──────────────────────────────────── + # #region AgentChat.Persistence.CleanTitle [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,title] # @ingroup AgentChat # @BRIEF Clean raw user text into a readable conversation title — strip file markers, @@ -47,11 +48,11 @@ def clean_title(user_text: str) -> str: # noqa: C901 # ── Strip file upload markers (cut before first occurrence) ── file_markers = [ "\n--- Uploaded file content ---", - "--- Uploaded file content ---", # no leading newline + "--- Uploaded file content ---", # no leading newline "\n[PRE-FETCHED DATA", - "[PRE-FETCHED DATA", # no leading newline + "[PRE-FETCHED DATA", # no leading newline "\n[/PRE-FETCHED DATA]", - "[/PRE-FETCHED DATA]", # no leading newline + "[/PRE-FETCHED DATA]", # no leading newline ] cut_pos = len(text) for marker in file_markers: @@ -67,11 +68,11 @@ def clean_title(user_text: str) -> str: # noqa: C901 # ── Take first meaningful segment ── # Priority: first sentence ending with .!?, else first line, else full text sentence_end = -1 - for m in re.finditer(r'[.!?]\s', text): + for m in re.finditer(r"[.!?]\s", text): sentence_end = m.start() break if sentence_end > 3: # don't cut "Привет." into "Привет" - text = text[:sentence_end + 1].strip() + text = text[: sentence_end + 1].strip() elif "\n" in text: text = text.split("\n")[0].strip() @@ -86,6 +87,7 @@ def clean_title(user_text: str) -> str: # noqa: C901 if text.startswith("http://") or text.startswith("https://"): try: from urllib.parse import urlparse + domain = urlparse(text).netloc or "ссылка" except Exception: domain = "ссылка" @@ -108,6 +110,8 @@ def clean_title(user_text: str) -> str: # noqa: C901 return "Новый диалог" return text + + # #endregion AgentChat.Persistence.CleanTitle @@ -124,6 +128,8 @@ def detect_message_state(text: str) -> str | None: if any(m in t for m in error_markers): return "error" return None + + # #endregion AgentChat.Persistence.DetectState @@ -133,11 +139,14 @@ def detect_message_state(text: str) -> str | None: # @POST Returns user_id string or "unknown". def extract_user_id(jwt_str: str) -> str: try: - from src.core.auth.jwt import decode_token + from src.agent._jwt_decoder import decode_token + payload = decode_token(jwt_str) return payload.get("sub", payload.get("user_id", "unknown")) except Exception: return "unknown" + + # #endregion AgentChat.Persistence.ExtractUserId @@ -182,11 +191,7 @@ async def _call_llm_for_title(user_text: str) -> str | None: if not clean_text or clean_text in ("Новый диалог",): return None - prompt = ( - f"Сгенерируй заголовок из 3-5 слов на русском для диалога. " - f"Только заголовок, без кавычек и пояснений.\n\n" - f"Диалог: {clean_text}" - ) + prompt = f"Сгенерируй заголовок из 3-5 слов на русском для диалога. Только заголовок, без кавычек и пояснений.\n\nДиалог: {clean_text}" api_key = config.get("api_key", "") base_url = config.get("base_url", "") @@ -214,7 +219,8 @@ async def _call_llm_for_title(user_text: str) -> str | None: if resp.status_code != 200: _logger.explore( "LLM title: API error", - payload={"status": resp.status_code, "reason": resp.reason_phrase}, error=f"HTTP {resp.status_code}: {resp.text[:100]}", + payload={"status": resp.status_code, "reason": resp.reason_phrase}, + error=f"HTTP {resp.status_code}: {resp.text[:100]}", extra={"src": "AgentChat.Persistence"}, ) return None @@ -222,7 +228,7 @@ async def _call_llm_for_title(user_text: str) -> str | None: title = data.get("choices", [{}])[0].get("message", {}).get("content", "") if title: # Strip markdown/formatting - title = re.sub(r'[*_`#"\']', '', title).strip() + title = re.sub(r'[*_`#"\']', "", title).strip() title = title[:100] # safety cap if title: return title @@ -274,11 +280,14 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None: except Exception as e: logger.explore( "LLM title save failed", - payload={"conv_id": conv_id}, error=str(e), + payload={"conv_id": conv_id}, + error=str(e), extra={"src": "AgentChat.Persistence"}, ) finally: _title_locks.pop(conv_id, None) + + # #endregion AgentChat.Persistence.GenerateLlmTitle @@ -286,6 +295,7 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None: # Prefetch dashboards # ═══════════════════════════════════════════════════════════════════ + # #region AgentChat.Persistence.PrefetchDashboards [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,prefetch] # @ingroup AgentChat # @BRIEF Pre-fetch dashboard data so the LLM has it in context without needing to call a tool. @@ -296,6 +306,7 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None: async def prefetch_dashboards(env_id: str) -> str: try: from src.agent.tools import FASTAPI_URL, _dual_auth_headers + async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{FASTAPI_URL}/api/dashboards", @@ -326,10 +337,13 @@ async def prefetch_dashboards(env_id: str) -> str: except Exception as e: logger.explore( "Prefetch dashboards failed", - payload={"env_id": env_id}, error=str(e), + payload={"env_id": env_id}, + error=str(e), extra={"src": "AgentChat.Persistence.PrefetchDashboards"}, ) return "" + + # #endregion AgentChat.Persistence.PrefetchDashboards @@ -341,6 +355,7 @@ async def prefetch_dashboards(env_id: str) -> str: async def prefetch_databases(env_id: str) -> str: try: from src.agent.tools import FASTAPI_URL, _dual_auth_headers + async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{FASTAPI_URL}/api/agent/superset/databases", @@ -365,10 +380,13 @@ async def prefetch_databases(env_id: str) -> str: except Exception as e: logger.explore( "Prefetch databases failed", - payload={"env_id": env_id}, error=str(e), + payload={"env_id": env_id}, + error=str(e), extra={"src": "AgentChat.Persistence.PrefetchDatabases"}, ) return "" + + # #endregion AgentChat.Persistence.PrefetchDatabases @@ -404,14 +422,16 @@ async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin" ] if assistant_text.strip(): assistant_state = detect_message_state(assistant_text) - messages.append({ - "id": str(uuid.uuid4()), - "conversation_id": conv_id, - "role": "assistant", - "text": assistant_text.strip(), - "state": assistant_state, - "created_at": datetime.utcnow().isoformat(), - }) + messages.append( + { + "id": str(uuid.uuid4()), + "conversation_id": conv_id, + "role": "assistant", + "text": assistant_text.strip(), + "state": assistant_state, + "created_at": datetime.utcnow().isoformat(), + } + ) title = clean_title(user_text) payload = { @@ -431,8 +451,11 @@ async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin" except Exception as e: logger.explore( "Failed to save conversation", - payload={"conv_id": conv_id}, error=str(e), + payload={"conv_id": conv_id}, + error=str(e), extra={"src": "AgentChat.Persistence"}, ) + + # #endregion AgentChat.Persistence.SaveConversation # #endregion AgentChat.Persistence diff --git a/backend/src/agent/app.py b/backend/src/agent/app.py index a6915e80..05015b3e 100644 --- a/backend/src/agent/app.py +++ b/backend/src/agent/app.py @@ -58,7 +58,7 @@ from src.agent.tools import ( get_all_tools, start_tool_retry_event_buffer, ) -from src.core.auth.jwt import decode_token +from src.agent._jwt_decoder import decode_token from src.core.cot_logger import seed_trace_id from src.core.logger import logger @@ -83,22 +83,26 @@ async def _build_agent_context(env_id: str | None) -> str: parts.append(f"Current environment_id: {env_id}") dashboards = await prefetch_dashboards(env_id) if dashboards: - parts.extend([ - "", - "[PRE-FETCHED DASHBOARDS]", - dashboards, - "[/PRE-FETCHED DASHBOARDS]", - "Use the pre-fetched dashboards directly for dashboard name/id resolution.", - ]) + parts.extend( + [ + "", + "[PRE-FETCHED DASHBOARDS]", + dashboards, + "[/PRE-FETCHED DASHBOARDS]", + "Use the pre-fetched dashboards directly for dashboard name/id resolution.", + ] + ) databases = await prefetch_databases(env_id) if databases: - parts.extend([ - "", - "[PRE-FETCHED DATABASES]", - databases, - "[/PRE-FETCHED DATABASES]", - "Use the pre-fetched databases directly for database_id resolution. Always use database_id from this list.", - ]) + parts.extend( + [ + "", + "[PRE-FETCHED DATABASES]", + databases, + "[/PRE-FETCHED DATABASES]", + "Use the pre-fetched databases directly for database_id resolution. Always use database_id from this list.", + ] + ) parts.append("[/RUNTIME CONTEXT]") return "\n".join(parts) @@ -127,6 +131,8 @@ async def _generate_title_best_effort(conv_id: str, visible_user_text: str) -> N error=str(exc), extra={"src": "AgentChat.GradioApp.Title"}, ) + + # #endregion AgentChat.GradioApp.TitleBestEffort # ── Session state ─────────────────────────────────────────────── @@ -145,6 +151,7 @@ _LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts" # ── File persistence ──────────────────────────────────────────── + # #region AgentChat.GradioApp.PersistFile [C:3] [TYPE Function] [SEMANTICS agent-chat,storage,file] # @ingroup AgentChat # @BRIEF Copy uploaded chat file to storage under chat_uploads category. @@ -187,6 +194,8 @@ def _persist_chat_file(file_path: str, conv_id: str) -> str | None: extra={"src": "AgentChat.PersistFile"}, ) return rel_path + + # #endregion AgentChat.GradioApp.PersistFile # Per-conversation mutex for HITL resume (FR-026): keyed by conversation_id _conv_locks: dict[str, asyncio.Event] = {} @@ -204,6 +213,7 @@ _service_jwt_cache: dict[str, str] = {} # the generator and sends yielded JSON strings as event data to the frontend. # @REJECTED Returning a single response (non-streaming) was rejected — violates FR-003 (streaming mandate). + # #region AgentChat.GradioApp.LlmHealthCheck [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,health] # @ingroup AgentChat # @BRIEF Check LLM provider connectivity with in-memory cache (30s TTL). @@ -218,16 +228,19 @@ async def _check_llm_provider_health() -> str: try: from src.agent.langgraph_setup import _fetch_llm_config as _get_config + config = await _get_config() if not config or not config.get("configured"): return _llm_status["status"] - llm = ChatOpenAI(**chat_openai_kwargs( - model=config.get("default_model", "gpt-4o-mini"), - base_url=config.get("base_url", "https://api.openai.com/v1"), - api_key=config.get("api_key", ""), - max_tokens=1, - )) + llm = ChatOpenAI( + **chat_openai_kwargs( + model=config.get("default_model", "gpt-4o-mini"), + base_url=config.get("base_url", "https://api.openai.com/v1"), + api_key=config.get("api_key", ""), + max_tokens=1, + ) + ) await llm.ainvoke([HumanMessage(content="ping")]) _llm_status["status"] = "ok" _llm_status["last_error"] = "" @@ -254,10 +267,10 @@ async def _check_llm_provider_health() -> str: _llm_status["last_error"] = "" _llm_status["last_check_ts"] = time.time() return "ok" - logger.explore("LLM health check failed", - error=str(exc), - extra={"src": "AgentChat.GradioApp.LlmHealthCheck"}) + logger.explore("LLM health check failed", error=str(exc), extra={"src": "AgentChat.GradioApp.LlmHealthCheck"}) return _llm_status["status"] # return cached status on unexpected errors + + # #endregion AgentChat.GradioApp.LlmHealthCheck @@ -279,6 +292,8 @@ def _inject_uicontext(runtime_context: str, uicontext: dict) -> str: lines.append(f"Environment: {uicontext['envId']}") lines.append("[/USER CONTEXT]") return "\n".join(lines) + + # #endregion AgentChat.GradioApp.InjectUIContext @@ -298,14 +313,10 @@ def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list: if not env_id: return tools - for tool in tools: # 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: + sig = inspect.signature(tool.coroutine if tool.coroutine else (tool.func if tool.func else tool._run)) + if "environment_id" not in sig.parameters: continue # Intercept input before args_schema validation @@ -314,12 +325,14 @@ def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list: def _make_parse_wrapper(orig_fn, eid: str): """Create a wrapper that injects env_id into tool_input dict.""" + @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: - tool_input = {**tool_input, 'environment_id': eid} + if tool_input.get("environment_id") is None: + 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)) @@ -327,14 +340,18 @@ def _inject_env_id_into_tools(tools: list, env_id: str | None) -> list: # 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 + if kwargs.get("environment_id") is None and env_id: + kwargs["environment_id"] = env_id return await original_coro(*args, **kwargs) + tool.coroutine = env_aware_coro return tools + + # #endregion AgentChat.GradioApp.EnvInjection @@ -390,8 +407,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio is_resume = action in ("confirm", "deny") logger.reason( "Agent handler invoked", - payload={"user_id": user_id, "conv_id": conv_id, "action": action, "env_id": env_id, - "is_resume": is_resume, "msg_len": len(str(message))}, + payload={"user_id": user_id, "conv_id": conv_id, "action": action, "env_id": env_id, "is_resume": is_resume, "msg_len": len(str(message))}, extra={"src": "AgentChat.GradioApp.Handler"}, ) @@ -406,15 +422,17 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio if len(text) > max_msg_length: truncated = text[:max_msg_length] last_sentence_end = max( - truncated.rfind('. '), truncated.rfind('! '), truncated.rfind('? '), - truncated.rfind('.\n'), truncated.rfind('!\n'), truncated.rfind('?\n'), - truncated.rfind('.\r'), truncated.rfind('!\r'), truncated.rfind('?\r'), - ) - text = ( - text[:last_sentence_end + 1] + "\n[...truncated]" - if last_sentence_end > max_msg_length * 0.8 - else truncated + "\n[...truncated]" + truncated.rfind(". "), + truncated.rfind("! "), + truncated.rfind("? "), + truncated.rfind(".\n"), + truncated.rfind("!\n"), + truncated.rfind("?\n"), + truncated.rfind(".\r"), + truncated.rfind("!\r"), + truncated.rfind("?\r"), ) + text = text[: last_sentence_end + 1] + "\n[...truncated]" if last_sentence_end > max_msg_length * 0.8 else truncated + "\n[...truncated]" visible_user_text = text # ── File upload ── @@ -427,10 +445,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio if file_path and os.path.exists(file_path): file_size = os.path.getsize(file_path) if file_size > MAX_FILE_SIZE_BYTES: - yield json.dumps({ - "content": f"❌ File exceeds 10MB limit ({file_size / 1024 / 1024:.1f} MB)", - "metadata": {"type": "error", "code": "FILE_TOO_LARGE", "detail": "Max file size is 10 MB"}, - }) + yield json.dumps( + { + "content": f"❌ File exceeds 10MB limit ({file_size / 1024 / 1024:.1f} MB)", + "metadata": {"type": "error", "code": "FILE_TOO_LARGE", "detail": "Max file size is 10 MB"}, + } + ) return # Persist file to storage for download if conv_id: @@ -445,6 +465,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio try: uicontext = json.loads(uicontext_str) from src.agent._context import validate_uicontext + uicontext = validate_uicontext(uicontext) logger.reason("UIContext received", payload={"objectType": uicontext.get("objectType"), "objectId": uicontext.get("objectId")}, extra={"src": "AgentChat.Handler"}) except json.JSONDecodeError: @@ -465,14 +486,16 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio # ── Yield file metadata for frontend download link ── if file_storage_path and file_original_name: - yield json.dumps({ - "metadata": { - "type": "file_uploaded", - "file_name": file_original_name, - "file_path": file_storage_path, - "file_size": file_size, + yield json.dumps( + { + "metadata": { + "type": "file_uploaded", + "file_name": file_original_name, + "file_path": file_storage_path, + "file_size": file_size, + } } - }) + ) # ── HITL resume path ── if action in ("confirm", "deny"): @@ -483,10 +506,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio try: await asyncio.wait_for(lock.wait(), timeout=2.0) except TimeoutError: - yield json.dumps({ - "content": "❌ Ошибка: предыдущий стрим ещё не завершён", - "metadata": {"type": "error", "code": "STREAM_CLEANUP_TIMEOUT", "detail": "Предыдущий поток не завершился. Повторите запрос."}, - }) + yield json.dumps( + { + "content": "❌ Ошибка: предыдущий стрим ещё не завершён", + "metadata": {"type": "error", "code": "STREAM_CLEANUP_TIMEOUT", "detail": "Предыдущий поток не завершился. Повторите запрос."}, + } + ) return # Capture tool_name BEFORE handle_resume pops the pending confirmation pending_pre = _pending_confirmations.get(conv_id, {}) if conv_id else {} @@ -507,20 +532,23 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio agent_tools = get_all_tools() # Apply tool pipeline: RBAC → context affinity (035-agent-chat-context) from src.agent._tool_filter import build_tool_pipeline + agent_tools = build_tool_pipeline( agent_tools, user_role, uicontext.get("objectType") if uicontext else None, ) - yield json.dumps({ - "content": "", - "metadata": { - "type": "pipeline_result", - "tools": [tool.name for tool in agent_tools], - "object_type": uicontext.get("objectType") if uicontext else None, - "user_role": user_role, - }, - }) + yield json.dumps( + { + "content": "", + "metadata": { + "type": "pipeline_result", + "tools": [tool.name for tool in agent_tools], + "object_type": uicontext.get("objectType") if uicontext else None, + "user_role": user_role, + }, + } + ) # Auto-inject environment_id into tool calls when LLM omits it agent_tools = _inject_env_id_into_tools(agent_tools, env_id) agent = await create_agent(agent_tools, env_id) @@ -552,43 +580,45 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio if isinstance(content, str): token_text = content elif isinstance(content, list): - token_text = "".join( - str(item.get("text") or item.get("content") or "") - if isinstance(item, dict) else str(item) - for item in content - ) + token_text = "".join(str(item.get("text") or item.get("content") or "") if isinstance(item, dict) else str(item) for item in content) else: token_text = str(content) if not token_text: continue emitted_any = True assistant_parts.append(token_text) - yield json.dumps({ - "content": token_text, - "metadata": {"type": "stream_token", "token": token_text}, - }) + yield json.dumps( + { + "content": token_text, + "metadata": {"type": "stream_token", "token": token_text}, + } + ) elif kind == "on_tool_start": tool_name = event["name"] emitted_any = True redacted_input = _redact_sensitive_fields(event["data"].get("input", {})) - yield json.dumps({ - "content": f"🛠️ {tool_name}", - "metadata": {"type": "tool_start", "tool": tool_name, "input": redacted_input}, - }) + yield json.dumps( + { + "content": f"🛠️ {tool_name}", + "metadata": {"type": "tool_start", "tool": tool_name, "input": redacted_input}, + } + ) elif kind == "on_tool_end": tool_name = event["name"] output = event["data"].get("output", "") emitted_any = True - yield json.dumps({ - "content": f"✅ {tool_name}", - "metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}}, - }) + yield json.dumps( + { + "content": f"✅ {tool_name}", + "metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}}, + } + ) elif kind == "on_tool_error": tool_name = event["name"] err = str(event["data"].get("error", "Unknown")) emitted_any = True if "PERMISSION_DENIED:" in err: - marker = err[err.index("PERMISSION_DENIED:"):] + marker = err[err.index("PERMISSION_DENIED:") :] _, denied_tool, required_role, denied_role = marker.split(":", 3) denied_role = denied_role.split()[0].strip("'\"") yield permission_denied_payload( @@ -599,24 +629,33 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio continue if "timed out" in err.lower() or "timeout" in err.lower(): is_write_tool = "operation status unknown" in err.lower() or tool_name in { - "deploy_dashboard", "commit_changes", "create_branch", "run_backup", - "execute_migration", "start_maintenance", "end_maintenance", + "deploy_dashboard", + "commit_changes", + "create_branch", + "run_backup", + "execute_migration", + "start_maintenance", + "end_maintenance", } - yield json.dumps({ - "content": f"⏱️ {tool_name} — timeout", - "metadata": { - "type": "tool_timeout", - "tool": tool_name, - "timeout_seconds": 30, - "is_write_tool": is_write_tool, - "retryable": not is_write_tool, - }, - }) + yield json.dumps( + { + "content": f"⏱️ {tool_name} — timeout", + "metadata": { + "type": "tool_timeout", + "tool": tool_name, + "timeout_seconds": 30, + "is_write_tool": is_write_tool, + "retryable": not is_write_tool, + }, + } + ) continue - yield json.dumps({ - "content": f"❌ {tool_name} — {err}", - "metadata": {"type": "tool_error", "tool": tool_name, "error": err}, - }) + yield json.dumps( + { + "content": f"❌ {tool_name} — {err}", + "metadata": {"type": "tool_error", "tool": tool_name, "error": err}, + } + ) state = await agent.aget_state(config) for retry_event in drain_tool_retry_events(): @@ -627,30 +666,30 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id) return elif not emitted_any: - yield json.dumps({ - "content": "❌ Агент завершился без ответа.", - "metadata": {"type": "error", "code": "EMPTY_AGENT_RESPONSE", - "detail": "Агент завершил обработку без ответа. Попробуйте переформулировать запрос.", - "state_next": repr(getattr(state, "next", None)), - "state_tasks": repr(getattr(state, "tasks", None))[:500]}, - }) + yield json.dumps( + { + "content": "❌ Агент завершился без ответа.", + "metadata": {"type": "error", "code": "EMPTY_AGENT_RESPONSE", "detail": "Агент завершил обработку без ответа. Попробуйте переформулировать запрос.", "state_next": repr(getattr(state, "next", None)), "state_tasks": repr(getattr(state, "tasks", None))[:500]}, + } + ) break except (APIConnectionError, httpx.ConnectError) as exc: _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"}) - yield json.dumps({ - "content": "❌ LLM провайдер недоступен", - "metadata": { - "type": "error", "code": "LLM_PROVIDER_UNAVAILABLE", - "detail": "LLM провайдер недоступен. Проверьте подключение к upstream API.", - "retryable": True, - }, - }) + logger.explore("LLM provider connection failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"}) + yield json.dumps( + { + "content": "❌ LLM провайдер недоступен", + "metadata": { + "type": "error", + "code": "LLM_PROVIDER_UNAVAILABLE", + "detail": "LLM провайдер недоступен. Проверьте подключение к upstream API.", + "retryable": True, + }, + } + ) await save_conversation(conv_id, visible_user_text, user_id, assistant_text="") return @@ -658,17 +697,18 @@ 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"}) - yield json.dumps({ - "content": "❌ LLM провайдер не отвечает", - "metadata": { - "type": "error", "code": "LLM_TIMEOUT", - "detail": "LLM провайдер не отвечает. Таймаут соединения.", - "retryable": True, - }, - }) + logger.explore("LLM provider timed out", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"}) + yield json.dumps( + { + "content": "❌ LLM провайдер не отвечает", + "metadata": { + "type": "error", + "code": "LLM_TIMEOUT", + "detail": "LLM провайдер не отвечает. Таймаут соединения.", + "retryable": True, + }, + } + ) await save_conversation(conv_id, visible_user_text, user_id, assistant_text="") return @@ -676,17 +716,18 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio _llm_status["status"] = "auth_error" _llm_status["last_error"] = str(exc) _llm_status["last_check_ts"] = time.time() - logger.explore("LLM provider auth failed", - error=str(exc), - extra={"src": "AgentChat.GradioApp.Handler"}) - yield json.dumps({ - "content": "❌ API ключ LLM отклонён", - "metadata": { - "type": "error", "code": "LLM_AUTH_ERROR", - "detail": "API ключ LLM отклонён. Проверьте credentials.", - "retryable": False, - }, - }) + logger.explore("LLM provider auth failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"}) + yield json.dumps( + { + "content": "❌ API ключ LLM отклонён", + "metadata": { + "type": "error", + "code": "LLM_AUTH_ERROR", + "detail": "API ключ LLM отклонён. Проверьте credentials.", + "retryable": False, + }, + } + ) await save_conversation(conv_id, visible_user_text, user_id, assistant_text="") return @@ -694,17 +735,18 @@ 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 rate limited", - error=str(exc), - extra={"src": "AgentChat.GradioApp.Handler"}) - yield json.dumps({ - "content": "❌ Превышен лимит запросов к LLM. Попробуйте позже.", - "metadata": { - "type": "error", "code": "LLM_RATE_LIMITED", - "detail": "Превышена квота LLM провайдера. Повторите запрос через несколько минут.", - "retryable": True, - }, - }) + logger.explore("LLM provider rate limited", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"}) + yield json.dumps( + { + "content": "❌ Превышен лимит запросов к LLM. Попробуйте позже.", + "metadata": { + "type": "error", + "code": "LLM_RATE_LIMITED", + "detail": "Превышена квота LLM провайдера. Повторите запрос через несколько минут.", + "retryable": True, + }, + } + ) await save_conversation(conv_id, visible_user_text, user_id, assistant_text="") return @@ -718,10 +760,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio error=str(e), extra={"src": "AgentChat.GradioApp.Handler"}, ) - yield json.dumps({ - "content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.", - "metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)}, - }) + yield json.dumps( + { + "content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.", + "metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)}, + } + ) except Exception as exc: logger.explore( @@ -743,10 +787,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio return except Exception: pass - yield json.dumps({ - "content": f"❌ Ошибка: {exc}", - "metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)}, - }) + yield json.dumps( + { + "content": f"❌ Ошибка: {exc}", + "metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)}, + } + ) await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(str(part) for part in assistant_parts)) return @@ -764,11 +810,14 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio if conv_id and conv_id in _conv_locks: _conv_locks[conv_id].set() del _conv_locks[conv_id] + + # #endregion AgentChat.GradioApp.Handler # ── Gradio interface ── + # #region AgentChat.GradioApp.CreateInterface [C:2] [TYPE Function] [SEMANTICS agent-chat,gradio,interface] # @ingroup AgentChat # @BRIEF Create the Gradio ChatInterface with additional inputs for conv_id, action, user_id, jwt, env_id. @@ -792,6 +841,8 @@ def create_chat_interface(): ["Запусти миграцию", None, None], ], ) + + # #endregion AgentChat.GradioApp.CreateInterface @@ -800,6 +851,8 @@ def create_chat_interface(): # @BRIEF Healthcheck endpoint for Docker. async def health(): return {"status": "ok", "uptime": os.times().elapsed if hasattr(os.times(), "elapsed") else 0} + + # #endregion AgentChat.GradioApp.Health diff --git a/build.sh b/build.sh index 6f327d1f..73b8797d 100755 --- a/build.sh +++ b/build.sh @@ -307,8 +307,6 @@ services: LLM_MODEL: \${LLM_MODEL:-gpt-4o} FASTAPI_URL: http://backend:8000 JWT_SECRET: \${JWT_SECRET:-super-secret-key} - AUTH_SECRET_KEY: \${JWT_SECRET:-super-secret-key} - AUTH_DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools} SERVICE_JWT: \${SERVICE_JWT:-agent-service-secret} DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools} GRADIO_SERVER_PORT: 7860 @@ -613,8 +611,6 @@ services: LLM_MODEL: \${LLM_MODEL:-gpt-4o} FASTAPI_URL: http://backend:8000 JWT_SECRET: \${JWT_SECRET:-super-secret-key} - AUTH_SECRET_KEY: \${JWT_SECRET:-super-secret-key} - AUTH_DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools} SERVICE_JWT: \${SERVICE_JWT:-agent-service-secret} DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools} GRADIO_SERVER_PORT: 7860 diff --git a/docker-compose.enterprise-clean.yml b/docker-compose.enterprise-clean.yml index 6420f18c..51186960 100644 --- a/docker-compose.enterprise-clean.yml +++ b/docker-compose.enterprise-clean.yml @@ -104,8 +104,6 @@ services: LLM_MODEL: ${LLM_MODEL:-gpt-4o} FASTAPI_URL: http://backend:8000 JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set — crash-early, no default fallback} - AUTH_SECRET_KEY: ${JWT_SECRET:?JWT_SECRET must be set — crash-early, no default fallback} - AUTH_DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@${POSTGRES_HOST:?Set POSTGRES_HOST}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools} SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret} DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:?Set POSTGRES_HOST in .env.enterprise-clean}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools} GRADIO_SERVER_PORT: 7860 diff --git a/docker-compose.yml b/docker-compose.yml index 1d45b9da..71bcf6a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,8 +60,6 @@ services: LLM_MODEL: ${LLM_MODEL:-gpt-4o} FASTAPI_URL: http://backend:8000 JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set — crash-early, no default fallback} - AUTH_SECRET_KEY: ${JWT_SECRET:?JWT_SECRET must be set — crash-early, no default fallback} - AUTH_DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@${POSTGRES_HOST:?Set POSTGRES_HOST}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools} SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret} DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools GRADIO_SERVER_PORT: 7860 diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index c60ab63f..9137b1d1 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -45,9 +45,6 @@ COPY backend/src/core/__init__.py /app/backend/src/core/__init__.py COPY backend/src/core/cot_logger.py /app/backend/src/core/cot_logger.py COPY backend/src/core/logger.py /app/backend/src/core/logger.py COPY backend/src/core/ws_log_handler.py /app/backend/src/core/ws_log_handler.py -COPY backend/src/core/auth/__init__.py /app/backend/src/core/auth/__init__.py -COPY backend/src/core/auth/config.py /app/backend/src/core/auth/config.py -COPY backend/src/core/auth/jwt.py /app/backend/src/core/auth/jwt.py COPY backend/src/agent/ /app/backend/src/agent/ # Gradio server