first commit

This commit is contained in:
2026-06-13 08:23:21 -03:00
commit 89c23fb0ed
439 changed files with 32801 additions and 0 deletions

15
app/agents/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Agentes do Template Backend Enterprise
Os arquivos desta pasta preservam a estrutura real esperada pelo workflow, mas
não executam lógica de negócio pronta.
Cada agente mostra:
- como emitir IC;
- como emitir NOC;
- como emitir GRL;
- como coletar MCP via `_collect_tool_context()`;
- como recuperar RAG via `_retrieve_rag_context()`;
- onde chamar LLM/cache.
A implementação original do exemplo está comentada no fim de cada arquivo.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,421 @@
from __future__ import annotations
from typing import Any
from app.agents.prompting import apply_agent_profile_prompt
from app.identity_extraction import extract_identity_from_text
from app.agents.runtime import AgentRuntimeMixin
class BackofficeAgent(AgentRuntimeMixin):
"""Agente Backoffice/ANATEL usando somente contratos nativos do framework.
Este agente não contém grafo próprio, node legado ou orquestração paralela ao
framework. A execução segue o padrão uniforme:
1. LangGraph do framework faz guardrails de entrada, roteamento e checkpoint.
2. EnterpriseRouter injeta intent, route e mcp_tools a partir de YAML.
3. AgentRuntimeMixin executa MCP Tool Router, RAG, cache, IC/NOC/GRL.
4. LLM recebe contexto já normalizado e gera a resposta.
5. Workflow do framework aplica OutputSupervisor, output guardrails, judges e persistência.
Regras específicas do domínio ficam em prompt_policy/routing/tools/mcp mapping,
não em um fluxo particular dentro do agente.
"""
name = "backoffice_agent"
_domain_graph_cache: dict[str, Any] = {}
DEFAULT_TOOLS_BY_INTENT: dict[str, list[str]] = {
"backoffice_anatel_triage": [
"consultar_reclamacao",
"consultar_cliente_backoffice",
"consultar_siebel_caso",
"consultar_imdb_cliente",
"consultar_speech_analytics",
"consultar_tais_kb",
"consultar_abrt",
"consultar_portabilidade",
],
"backoffice_customer_lookup": [
"consultar_cliente_backoffice",
"consultar_imdb_cliente",
"consultar_portabilidade",
],
"backoffice_action_register": [
"consultar_reclamacao",
"consultar_siebel_caso",
"registrar_acao_backoffice",
"registrar_acao_siebel",
],
"backoffice_response_emulator": [
"consultar_reclamacao",
"buscar_templates_emulador",
"gerar_rascunho_emulador",
],
}
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state: dict[str, Any]) -> dict[str, Any]:
await self._emit_ic(
"AGA.001",
state,
{
"status": "Entrada recebida pelo agente nativo do framework",
"framework_native": True,
"business_component": "backoffice_anatel",
},
component="agent.backoffice.native.start",
)
normalized_state = self._normalize_state_tools(state)
await self._emit_ic(
"AGA.018",
normalized_state,
{
"status": "Contexto canônico validado pelo framework",
"missing_fields": self._missing_required_context(normalized_state),
"framework_native": True,
},
component="agent.backoffice.native.context",
)
if self._has_original_ticket_context(normalized_state):
await self._emit_ic(
"BACKOFFICE_TICKET_CONTEXT_DETECTED",
normalized_state,
{
"status": "Contexto de ticket detectado; execução checklist deve ocorrer pelo BackofficeNativeRuntime nas rotas /agent/*",
"framework_native": True,
"reason": "o agente conversacional não compila nem executa grafos de domínio",
},
component="agent.backoffice.native.ticket_context",
)
rag_context, rag_metadata = await self._retrieve_rag_context(normalized_state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"AGA.012",
normalized_state,
{
"status": "RAG/TAIS KB consultado pelo serviço nativo do framework",
"document_count": rag_metadata.get("document_count", 0),
"top_document_ids": rag_metadata.get("top_document_ids", []),
},
component="agent.backoffice.native.rag",
)
mcp_results = await self._execute_tools_by_intent(normalized_state)
await self._emit_ic(
"AGA.014",
normalized_state,
{
"status": "Ferramentas MCP selecionadas pelo roteador do framework",
"intent": normalized_state.get("intent"),
"tool_count": len(mcp_results),
"tools": [r.get("tool_name") or r.get("tool") for r in mcp_results],
},
component="agent.backoffice.native.tools",
)
answer = await self._generate_answer(normalized_state, rag_context, rag_metadata, mcp_results)
await self._emit_ic(
"AGA.043",
normalized_state,
{
"status": "Resposta produzida pelo agente nativo e entregue ao workflow do framework",
"answer_chars": len(answer or ""),
"framework_native": True,
},
component="agent.backoffice.native.completed",
)
await self._emit_noc(
"001",
normalized_state,
{"message": "Backoffice native agent completed", "type": "INFO"},
component="agent.backoffice.native.completed",
)
return {
"answer": answer,
"next_state": self._next_state(normalized_state, mcp_results),
"mcp_results": mcp_results,
"rag_metadata": rag_metadata,
"framework_native": {
"agent": self.name,
"intent": normalized_state.get("intent"),
"route": normalized_state.get("route"),
"tools": normalized_state.get("mcp_tools") or [],
},
}
def _normalize_state_tools(self, state: dict[str, Any]) -> dict[str, Any]:
intent = state.get("intent") or "backoffice_anatel_triage"
configured_tools = list(state.get("mcp_tools") or [])
default_tools = self.DEFAULT_TOOLS_BY_INTENT.get(intent, self.DEFAULT_TOOLS_BY_INTENT["backoffice_anatel_triage"])
tools = configured_tools or default_tools
# Garante ordem estável e remove duplicidade sem perder a configuração do roteador.
seen: set[str] = set()
deduped = []
for tool in tools:
if tool and tool not in seen:
seen.add(tool)
deduped.append(tool)
return {
**state,
"route": state.get("route") or self.name,
"active_agent": self.name,
"intent": intent,
"mcp_tools": deduped,
}
def _missing_required_context(self, state: dict[str, Any]) -> list[str]:
ctx = state.get("context") or {}
bc = ctx.get("business_context") or state.get("business_context") or {}
interaction = (bc.get("interaction_key") if isinstance(bc, dict) else None) or ctx.get("interaction_key")
if not interaction:
interaction = (
ctx.get("protocol_id")
or ctx.get("protocolo")
or ctx.get("complaint_id")
or ctx.get("complaintProtocol")
or ctx.get("transactionId")
or ctx.get("transaction_id")
or ctx.get("ticket_id")
)
return [] if interaction else ["interaction_key"]
async def _execute_tools_by_intent(self, state: dict[str, Any]) -> list[dict[str, Any]]:
intent = state.get("intent") or "backoffice_anatel_triage"
tools = list(state.get("mcp_tools") or [])
results: list[dict[str, Any]] = []
for tool in tools:
arguments = self._tool_arguments(tool, intent, state)
if tool.startswith("registrar_") and not arguments.get("action_text"):
# O framework não deve registrar ação operacional sem texto explícito.
results.append({
"ok": False,
"tool_name": tool,
"skipped": True,
"reason": "action_text ausente; registro não executado por segurança operacional",
})
await self._emit_ic(
"AGA.008",
state,
{"status": "Registro operacional não executado sem action_text", "tool_name": tool},
component="agent.backoffice.native.tool.skip",
)
continue
result = await self._call_mcp_tool(tool, arguments, state)
results.append(result)
if tool in {"consultar_speech_analytics"}:
await self._emit_ic("AGA.010", state, {"status": "Speech Analytics consultado", "tool_ok": result.get("ok")}, component="agent.backoffice.native.speech")
elif tool in {"consultar_imdb_cliente", "consultar_cliente_backoffice"}:
await self._emit_ic("AGA.011", state, {"status": "Cliente/IMDB consultado", "tool_ok": result.get("ok")}, component="agent.backoffice.native.customer")
elif tool in {"consultar_tais_kb", "buscar_templates_emulador"}:
await self._emit_ic("AGA.020", state, {"status": "Templates/TAIS consultados", "tool_ok": result.get("ok")}, component="agent.backoffice.native.templates")
elif tool in {"registrar_acao_backoffice", "registrar_acao_siebel"}:
await self._emit_ic("AGA.006", state, {"status": "Ação operacional solicitada", "tool_ok": result.get("ok")}, component="agent.backoffice.native.action")
return results
def _tool_arguments(self, tool: str, intent: str, state: dict[str, Any]) -> dict[str, Any]:
ctx = state.get("context") or {}
# Para query/prompt usamos o texto sanitizado; para extração de CPF/CNPJ
# precisamos do texto original, pois o guardrail MSK mascara o documento.
text = state.get("sanitized_input") or state.get("user_text") or ""
original_text = (
ctx.get("message")
or ctx.get("text")
or ctx.get("query")
or state.get("user_text")
or text
or ""
)
bc = ctx.get("business_context") or state.get("business_context") or {}
explicit = ctx.get("tool_arguments") or state.get("tool_arguments") or {}
def pick(*names: str) -> Any:
for name in names:
cur: Any = None
if name in explicit:
cur = explicit.get(name)
elif isinstance(bc, dict) and name in bc:
cur = bc.get(name)
elif name in ctx:
cur = ctx.get(name)
elif name in state:
cur = state.get(name)
if cur not in (None, "", {}, []):
return cur
return None
protocol_id = pick(
"protocol_id",
"protocolo",
"interaction_key",
"complaint_id",
"complaintProtocol",
"transactionId",
"transaction_id",
"ticket_id",
)
extracted_identity = extract_identity_from_text(str(original_text))
# Identificador explicitamente digitado na mensagem atual prevalece sobre
# defaults da sessão/front (ex.: user_id/msisdn 11999999999).
customer_key = extracted_identity.get("customer_key") or pick("customer_key", "cpf", "cnpj", "document", "msisdn", "customer_id", "cpf_hash", "document_hash", "user_id")
if not protocol_id:
protocol_id = extracted_identity.get("protocol_id") or extracted_identity.get("interaction_key")
contract_key = pick("contract_key", "contract_id", "account_id", "asset_id")
session_key = pick("session_key", "conversation_key", "session_id")
# Extra args são passados ao MCPToolRouter. O mapper do framework ainda
# aplica mcp_parameter_mapping.yaml, mas estes aliases tornam a chamada
# resiliente para tools que exigem protocol_id diretamente.
common = {
"query": text,
"operator_instructions": text,
"selected_actions": explicit.get("selected_actions") or [],
}
if protocol_id:
common["protocol_id"] = str(protocol_id)
common["interaction_key"] = str(protocol_id)
if customer_key:
common["customer_key"] = str(customer_key)
# Mantém aliases documentais para tools TIM que aceitam CPF/CNPJ/document.
for identity_key in ("cpf", "cnpj", "document", "document_type"):
if extracted_identity.get(identity_key):
common[identity_key] = str(extracted_identity[identity_key])
if contract_key:
common["contract_key"] = str(contract_key)
if session_key:
common["session_key"] = str(session_key)
common["operator_session"] = str(session_key)
if tool.startswith("registrar_"):
action_text = explicit.get("action_text")
if not action_text and intent == "backoffice_action_register":
action_text = text
common["action_text"] = action_text
return common
async def _generate_answer(
self,
state: dict[str, Any],
rag_context: str,
rag_metadata: dict[str, Any],
mcp_results: list[dict[str, Any]],
) -> str:
missing = self._missing_required_context(state)
if missing:
return (
"[BackofficeAgent] Para seguir no padrão do framework, preciso do identificador canônico "
f"{', '.join(missing)}. Informe o protocolo/reclamação/chamado ou configure o mapping em identity.yaml."
)
system_prompt = apply_agent_profile_prompt(state, self._system_prompt())
messages = [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": (
"Mensagem do usuário:\n"
f"{state.get('sanitized_input') or state.get('user_text') or ''}\n\n"
"Intent/rota escolhidos pelo framework:\n"
f"intent={state.get('intent')} route={state.get('route')}\n\n"
"Resultados MCP normalizados pelo framework:\n"
f"{mcp_results}\n\n"
"Contexto RAG nativo do framework:\n"
f"{rag_context or '[sem contexto RAG]'}\n\n"
"Metadados RAG:\n"
f"{rag_metadata}"
),
},
]
try:
generated = await self._invoke_llm_cached(state, "BackofficeNativeAgent", messages)
return f"[BackofficeAgent] {generated}"
except Exception as exc:
await self._emit_noc(
"005",
state,
{"message": f"LLM failed in native backoffice agent: {type(exc).__name__}: {exc}", "type": "FAILURE"},
component="agent.backoffice.native.llm",
)
return self._fallback_answer(state, mcp_results)
def _has_original_ticket_context(self, state: dict[str, Any]) -> bool:
ctx = state.get("context") or {}
rc = ctx.get("request_context") or ctx
return bool(
rc.get("transactionId")
and isinstance(rc.get("complaint") or {}, dict)
and isinstance(rc.get("customer") or {}, dict)
)
async def _run_original_checklist_workflow(self, state: dict[str, Any]) -> dict[str, Any]:
"""Deprecated compatibility stub.
A migração framework-native não permite que o agente conversacional
compile/execute ``src.agent.graphs`` diretamente. Os fluxos checklist e
response emulator são executados pelo BackofficeNativeRuntime chamado
pelas rotas/adapters do backend.
"""
return {
"executed": False,
"error": {
"type": "DeprecatedDirectGraphExecution",
"message": "Use BackofficeNativeRuntime.execute_workflow('backoffice_checklist')",
},
}
def _system_prompt(self) -> str:
return """
Você é o agente Backoffice/ANATEL executando exclusivamente pelo framework corporativo.
Use apenas estes insumos:
- BusinessContext canônico do framework.
- Ferramentas MCP selecionadas pelo EnterpriseRouter.
- Contexto RAG retornado pelo RagService do framework.
- Memória/checkpoint já carregados pelo workflow.
Regras obrigatórias:
1. Não invente protocolo, cliente, contrato, status, SLA, parecer ou ação Siebel.
2. Se uma ferramenta MCP retornar erro ou ausência de dados, diga exatamente que a evidência não foi encontrada.
3. Não confirme registro de ação sem retorno ok/registered da tool correspondente.
4. Se faltar protocolo/chamado/reclamação, peça somente esse identificador.
5. Responda de forma operacional, curta e rastreável.
6. A resposta será validada por OutputSupervisor, guardrails de saída e judges do framework.
""".strip()
def _fallback_answer(self, state: dict[str, Any], mcp_results: list[dict[str, Any]]) -> str:
ok_tools = [r.get("tool_name") or r.get("tool") for r in mcp_results if r.get("ok")]
failed_tools = [r.get("tool_name") or r.get("tool") for r in mcp_results if not r.get("ok")]
return (
"[BackofficeAgent] Fluxo nativo executado pelo framework. "
f"Intent: {state.get('intent')}. "
f"Tools com sucesso: {ok_tools or 'nenhuma'}. "
f"Tools pendentes/erro: {failed_tools or 'nenhuma'}. "
"A resposta final não foi enriquecida pelo LLM porque houve falha controlada nessa etapa."
)
def _next_state(self, state: dict[str, Any], mcp_results: list[dict[str, Any]]) -> str:
if self._missing_required_context(state):
return "WAITING_BACKOFFICE_IDENTIFIER"
if any(r.get("skipped") for r in mcp_results):
return "BACKOFFICE_WAITING_ACTION_TEXT"
return "BACKOFFICE_ACTIVE"

View File

@@ -0,0 +1,70 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class BillingAgent(AgentRuntimeMixin):
name = "billingAgent"
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state):
await self._emit_ic(
"IC.BILLING_AGENT_STARTED",
state,
{"business_component": "faturas"},
component="agent.billing.start",
)
session = (state.get("context") or {}).get("session", {})
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.BILLING_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.billing.mcp",
)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.BILLING_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.billing.rag",
)
messages = [
{"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.")},
{"role": "user", "content": (
f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n"
f"Contexto de sessão: {session}\n"
f"Intent: {state.get('intent')}\n"
f"Dados MCP: {tool_context}\n"
f"Contexto RAG: {rag_context}"
)},
]
answer = await self._invoke_llm_cached(state, "BillingAgent", messages)
result = {"answer": f"[BillingAgent] {answer}", "next_state": "BILLING_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata}
await self._emit_ic(
"IC.BILLING_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
},
component="agent.billing.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,69 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class OrdersAgent(AgentRuntimeMixin):
name = "orders_agent"
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state):
await self._emit_ic(
"IC.ORDERS_AGENT_STARTED",
state,
{"business_component": "pedidos"},
component="agent.orders.start",
)
session = (state.get("context") or {}).get("session", {})
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.ORDERS_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.orders.mcp",
)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.ORDERS_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.orders.rag",
)
messages = [
{"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.")},
{"role": "user", "content": (
f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n"
f"Sessão: {session}\n"
f"Intent: {state.get('intent')}\n"
f"Dados MCP: {tool_context}\n"
f"Contexto RAG: {rag_context}"
)},
]
answer = await self._invoke_llm_cached(state, "OrdersAgent", messages)
result = {"answer": f"[OrdersAgent] {answer}", "next_state": "ORDER_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata}
await self._emit_ic(
"IC.ORDERS_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
},
component="agent.orders.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,70 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class ProductAgent(AgentRuntimeMixin):
name = "productAgent"
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state):
await self._emit_ic(
"IC.PRODUCT_AGENT_STARTED",
state,
{"business_component": "produtos"},
component="agent.product.start",
)
session = (state.get("context") or {}).get("session", {})
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.PRODUCT_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.product.mcp",
)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.PRODUCT_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.product.rag",
)
messages = [
{"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente especialista em produtos, planos e serviços. Explique sem fazer oferta proativa e sem executar ações sem confirmação. Use dados MCP quando disponíveis.")},
{"role": "user", "content": (
f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n"
f"Contexto de sessão: {session}\n"
f"Intent: {state.get('intent')}\n"
f"Dados MCP: {tool_context}\n"
f"Contexto RAG: {rag_context}"
)},
]
answer = await self._invoke_llm_cached(state, "ProductAgent", messages)
result = {"answer": f"[ProductAgent] {answer}", "next_state": "PRODUCT_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata}
await self._emit_ic(
"IC.PRODUCT_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
},
component="agent.product.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

15
app/agents/prompting.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str:
"""Adiciona o prefixo de prompt configurado para o agent_template selecionado.
Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso
mantém prompts isolados sem duplicar o código dos agentes especializados.
"""
profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {}
metadata = profile.get("metadata") or {}
prefix = (metadata.get("system_prefix") or "").strip()
if not prefix:
return default_prompt
return f"{prefix}\n\n{default_prompt}"

267
app/agents/runtime.py Normal file
View File

@@ -0,0 +1,267 @@
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

View File

@@ -0,0 +1,67 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class SupportAgent(AgentRuntimeMixin):
name = "support_agent"
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state):
await self._emit_ic(
"IC.SUPPORT_AGENT_STARTED",
state,
{"business_component": "suporte"},
component="agent.support.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.SUPPORT_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.support.mcp",
)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.SUPPORT_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.support.rag",
)
messages = [
{"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente de suporte de varejo para troca, devolução e garantia.")},
{"role": "user", "content": (
f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n"
f"Intent: {state.get('intent')}\n"
f"Dados MCP: {tool_context}\n"
f"Contexto RAG: {rag_context}"
)},
]
answer = await self._invoke_llm_cached(state, "SupportAgent", messages)
result = {"answer": f"[SupportAgent] {answer}", "next_state": "SUPPORT_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata}
await self._emit_ic(
"IC.SUPPORT_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
},
component="agent.support.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)