from __future__ import annotations import hashlib from typing import Any class AgentRuntimeMixin: """Mixin operacional para agentes. Integra RAG, cache, telemetria e chamadas MCP usando BusinessContext. Os agentes não precisam conhecer nomes reais de parâmetros do domínio (msisdn, invoice_id, order_id etc.); eles repassam as chaves canônicas e o MCPParameterMapper converte para cada tool configurada. """ async def _emit_ic(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None: """Emite Item de Controle (IC) sem impactar a execução do agente. Este helper é intencionalmente fail-open: erro de observabilidade não pode quebrar a jornada de negócio do agente. O desenvolvedor pode usar o mesmo padrão para ICs específicos da sua squad. """ observer = getattr(self, "observer", None) if not observer: return ctx = state.get("context") or {} base = { "session_id": state.get("conversation_key") or state.get("session_id"), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "message_id": ctx.get("message_id"), "channel_id": ctx.get("channel"), } base.update(payload or {}) try: await observer.emit_ic(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}") except Exception: return async def _emit_noc(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None: """Emite evento NOC sem acoplar a lógica de negócio à observabilidade.""" observer = getattr(self, "observer", None) if not observer: return ctx = state.get("context") or {} base = { "session_id": state.get("conversation_key") or state.get("session_id"), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "message_id": ctx.get("message_id"), "channel_id": ctx.get("channel"), } base.update(payload or {}) try: await observer.emit_noc(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}") except Exception: return async def _emit_grl(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None: """Emite evento GRL opcional para custom rails implementados no backend.""" observer = getattr(self, "observer", None) if not observer: return ctx = state.get("context") or {} base = { "session_id": state.get("conversation_key") or state.get("session_id"), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "message_id": ctx.get("message_id"), "channel_id": ctx.get("channel"), } base.update(payload or {}) try: await observer.emit_grl(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}") except Exception: return async def _retrieve_rag_context(self, state: dict[str, Any]) -> tuple[str, dict[str, Any]]: rag_service = getattr(self, "rag_service", None) if not rag_service: return "", {"enabled": False} text = state.get("sanitized_input") or state.get("user_text") or "" namespace = ( (state.get("agent_profile") or {}).get("rag_namespace") or state.get("agent_id") or state.get("route") or "default" ) ctx = state.get("context") or {} business_context = ctx.get("business_context") or {} graph_node = ( ctx.get("graph_node") or business_context.get("customer_key") or business_context.get("contract_key") or ctx.get("customer_id") ) result = await rag_service.retrieve(text, namespace=namespace, graph_node=graph_node) context = result.as_prompt_context() return context, { "enabled": True, "namespace": namespace, "latency_ms": result.latency_ms, "document_count": len(result.documents), "graph_neighbors": len(result.graph_neighbors), "top_document_ids": [d.id for d in result.documents[:5]], "top_scores": [d.score for d in result.documents[:5]], } async def _call_mcp_tool(self, tool: str, arguments: dict[str, Any] | None, state: dict[str, Any]) -> dict[str, Any]: """Chama uma ferramenta via MCPToolRouter usando o contrato canônico do framework. Use este helper quando o agente precisa passar argumentos específicos além do BusinessContext mapeado em mcp_parameter_mapping.yaml. Observabilidade IC.MCP_TOOL_CALLED/IC.TOOL_CALLED permanece uniforme. """ if not getattr(self, "tool_router", None): return {"ok": False, "tool_name": tool, "error": "tool_router não configurado"} ctx = state.get("context") or {} business_context = ctx.get("business_context") or state.get("business_context") or {} original_context = { **ctx, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "conversation_key": state.get("conversation_key") or state.get("session_id"), } observer = getattr(self, "observer", None) if observer: await observer.emit_ic( "IC.MCP_TOOL_CALLED", { "session_id": original_context.get("conversation_key") or original_context.get("session_id"), "tenant_id": original_context.get("tenant_id"), "agent_id": original_context.get("agent_id"), "tool_name": tool, "framework_native": True, }, component="agent_runtime.native_mcp", ) try: res = await self.tool_router.call( tool, arguments or {}, business_context=business_context, original_context=original_context, ) result_payload = res.model_dump(mode="json") if hasattr(res, "model_dump") else dict(res) except Exception as exc: result_payload = {"ok": False, "tool_name": tool, "error": str(exc), "error_type": type(exc).__name__} result_payload.setdefault("tool_name", tool) if observer: await observer.emit_ic( "IC.TOOL_CALLED", { "session_id": original_context.get("conversation_key") or original_context.get("session_id"), "tenant_id": original_context.get("tenant_id"), "agent_id": original_context.get("agent_id"), "tool_name": tool, "ok": result_payload.get("ok"), "server_name": result_payload.get("server_name"), "error": result_payload.get("error"), "framework_native": True, }, component="agent_runtime.native_mcp", ) return result_payload async def _collect_mcp_context(self, state: dict[str, Any]) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] if not getattr(self, "tool_router", None): return results tools = state.get("mcp_tools") or [] ctx = state.get("context") or {} business_context = ctx.get("business_context") or state.get("business_context") or {} original_context = { **ctx, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "conversation_key": state.get("conversation_key") or state.get("session_id"), } for tool in tools: observer = getattr(self, "observer", None) if observer: await observer.emit_ic( "IC.MCP_TOOL_CALLED", { "session_id": original_context.get("conversation_key") or original_context.get("session_id"), "tenant_id": original_context.get("tenant_id"), "agent_id": original_context.get("agent_id"), "tool_name": tool, }, component="agent_runtime", ) res = await self.tool_router.call( tool, {}, business_context=business_context, original_context=original_context, ) result_payload = res.model_dump(mode="json") if observer: await observer.emit_ic( "IC.TOOL_CALLED", { "session_id": original_context.get("conversation_key") or original_context.get("session_id"), "tenant_id": original_context.get("tenant_id"), "agent_id": original_context.get("agent_id"), "tool_name": tool, "ok": result_payload.get("ok"), "server_name": result_payload.get("server_name"), "error": result_payload.get("error"), }, component="agent_runtime", ) results.append(result_payload) return results async def _cache_get(self, key: str): cache = getattr(self, "cache", None) if not cache: return None return await cache.get(key) async def _cache_set(self, key: str, value: Any, ttl_seconds: int | None = None): cache = getattr(self, "cache", None) if not cache: return await cache.set(key, value, ttl_seconds) def _llm_cache_key(self, state: dict[str, Any], agent_name: str, prompt_parts: list[Any]) -> str: business_context = (state.get("context") or {}).get("business_context") or {} raw = "|".join([ agent_name, state.get("tenant_id") or "", state.get("agent_id") or "", state.get("intent") or "", business_context.get("customer_key") or "", business_context.get("contract_key") or "", business_context.get("interaction_key") or "", state.get("sanitized_input") or state.get("user_text") or "", repr(prompt_parts), ]) return "llm:" + hashlib.sha256(raw.encode("utf-8")).hexdigest() async def _invoke_llm_cached(self, state: dict[str, Any], agent_name: str, messages: list[dict[str, str]]): ttl = int(getattr(getattr(self, "settings", None), "CACHE_TTL_SECONDS", 300) or 300) key = self._llm_cache_key(state, agent_name, messages) cached = await self._cache_get(key) if cached is not None: telemetry = getattr(self, "telemetry", None) if telemetry: await telemetry.event("cache.llm.hit", {"agent": agent_name, "key": key}, kind="cache") return cached telemetry = getattr(self, "telemetry", None) if telemetry: await telemetry.event("cache.llm.miss", {"agent": agent_name, "key": key}, kind="cache") answer = await self.llm.ainvoke(messages) await self._cache_set(key, answer, ttl) return answer