Projeto do Agent Contas ORACLE
This commit is contained in:
963
app/workflows/agent_graph.py
Normal file
963
app/workflows/agent_graph.py
Normal file
@@ -0,0 +1,963 @@
|
||||
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
|
||||
from agent_framework.workflows import END, START, FrameworkStateGraph
|
||||
|
||||
from agent_framework.guardrails.pipeline import GuardrailPipeline
|
||||
from agent_framework.guardrails.output_supervisor import OutputSupervisor
|
||||
from agent_framework.guardrails.rail_action import RailAction
|
||||
from agent_framework.guardrails.rail_result import RailResult
|
||||
from agent_framework.judges.judge import JudgePipeline
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
from agent_framework.supervisor.supervisor import Supervisor
|
||||
from agent_framework.observability.workflow_events import WorkflowTelemetry
|
||||
from agent_framework.observability.guardrail_events import GuardrailTelemetry
|
||||
from agent_framework.observability.judge_events import JudgeTelemetry
|
||||
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
|
||||
from agent_framework.observability.observer import AgentObserver
|
||||
from app.agents.faturas_agent import FaturasAgent
|
||||
from app.agents.vas_agent import VasAgent
|
||||
from app.agents.contestacao_agent import ContestacaoAgent
|
||||
from app.agents.suporte_contas_agent import SuporteContasAgent
|
||||
from app.state import AgentState
|
||||
from agent_framework.rag.rag_service import RagService
|
||||
from agent_framework.rag.embedding_provider import create_embedding_provider
|
||||
from agent_framework.cache.cache import create_cache
|
||||
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
|
||||
|
||||
|
||||
class FrameworkOutputGuardrailRail:
|
||||
"""Adapter: reutiliza GuardrailPipeline.run_output dentro do OutputSupervisor novo.
|
||||
|
||||
O framework antigo retornava decisões allowed=True/False. O OutputSupervisor
|
||||
corporativo trabalha com RailAction (allow/sanitize/retry/block/handover).
|
||||
Este adapter evita reescrever todos os rails agora e mantém compatibilidade.
|
||||
"""
|
||||
|
||||
code = "LEGACY_OUTPUT_GUARDRAILS"
|
||||
|
||||
def __init__(self, pipeline: GuardrailPipeline):
|
||||
self.pipeline = pipeline
|
||||
|
||||
async def evaluate(self, candidate: str, context: dict):
|
||||
final, decisions = await self.pipeline.run_output(candidate, context)
|
||||
serialized = [d.model_dump() for d in decisions]
|
||||
|
||||
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
|
||||
if blocked:
|
||||
first = blocked[0]
|
||||
code = (getattr(first, "code", "") or "").upper()
|
||||
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
|
||||
return RailResult(
|
||||
code=code or self.code,
|
||||
action=action,
|
||||
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
|
||||
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
|
||||
sanitized_text=final,
|
||||
metadata={"framework_decisions": serialized},
|
||||
)
|
||||
|
||||
if final != candidate:
|
||||
return RailResult(
|
||||
code=self.code,
|
||||
action=RailAction.SANITIZE,
|
||||
reason="Resposta sanitizada por guardrail de saída do framework.",
|
||||
sanitized_text=final,
|
||||
metadata={"framework_decisions": serialized},
|
||||
)
|
||||
|
||||
return RailResult(
|
||||
code=self.code,
|
||||
action=RailAction.ALLOW,
|
||||
reason="Resposta aprovada pelos guardrails de saída do framework.",
|
||||
sanitized_text=final,
|
||||
metadata={"framework_decisions": serialized},
|
||||
)
|
||||
|
||||
|
||||
class AgentWorkflow:
|
||||
"""Workflow principal com dois modos de roteamento.
|
||||
|
||||
Modos suportados por configuração:
|
||||
ROUTING_MODE=router
|
||||
input_guardrails -> routing_decision/EnterpriseRouter -> 1 agente -> output_guardrails
|
||||
|
||||
ROUTING_MODE=supervisor
|
||||
input_guardrails -> routing_decision/Supervisor -> supervisor_agent -> N agentes -> consolidação
|
||||
|
||||
Em ambos os modos, memória/checkpoint/session usam tenant_id:agent_id:session_id.
|
||||
"""
|
||||
|
||||
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None, summary_memory=None):
|
||||
self.llm = llm
|
||||
self.memory = memory
|
||||
self.telemetry = telemetry
|
||||
self.analytics = analytics
|
||||
self.observer = observer or AgentObserver(analytics=analytics)
|
||||
self.settings = settings
|
||||
self.tool_router = tool_router
|
||||
self.summary_memory = summary_memory
|
||||
self.long_term_memory_manager = create_long_term_memory_manager(settings, telemetry=telemetry)
|
||||
self.guardrails = GuardrailPipeline(
|
||||
observer=self.observer,
|
||||
llm=llm,
|
||||
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
|
||||
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
|
||||
)
|
||||
self.output_supervisor_engine = OutputSupervisor(
|
||||
rails=[FrameworkOutputGuardrailRail(self.guardrails)],
|
||||
observer=self.observer,
|
||||
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
|
||||
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
|
||||
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
|
||||
)
|
||||
self.judges = JudgePipeline()
|
||||
self.supervisor = Supervisor()
|
||||
self.workflow_telemetry = WorkflowTelemetry(telemetry)
|
||||
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
|
||||
self.judge_telemetry = JudgeTelemetry(telemetry)
|
||||
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
|
||||
self.cache = create_cache(settings)
|
||||
self.embedding_provider = create_embedding_provider(settings)
|
||||
self.rag_service = RagService(settings, embedding_provider=self.embedding_provider, telemetry=telemetry)
|
||||
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
|
||||
agent_kwargs = {
|
||||
"telemetry": telemetry,
|
||||
"tool_router": getattr(self, "tool_router", None),
|
||||
"rag_service": self.rag_service,
|
||||
"cache": self.cache,
|
||||
"settings": settings,
|
||||
"observer": self.observer,
|
||||
"memory": memory,
|
||||
"summary_memory": summary_memory,
|
||||
"guardrail_pipeline": self.guardrails,
|
||||
}
|
||||
self.faturas = FaturasAgent(llm, **agent_kwargs)
|
||||
self.vas = VasAgent(llm, **agent_kwargs)
|
||||
self.contestacao = ContestacaoAgent(llm, **agent_kwargs)
|
||||
self.suporte_contas = SuporteContasAgent(llm, **agent_kwargs)
|
||||
|
||||
# Long-term memory is injected as a runtime capability after creation.
|
||||
for agent in (self.faturas, self.vas, self.contestacao, self.suporte_contas):
|
||||
agent.long_term_memory_manager = self.long_term_memory_manager
|
||||
self.graph = self._build_graph()
|
||||
|
||||
@staticmethod
|
||||
def _output_guardrail_context(state: dict) -> dict:
|
||||
"""Enriquece contexto de guardrail com evidência operacional real.
|
||||
|
||||
CMP/ANATEL precisa dos protocolos produzidos pelas tools; GND/ALUC precisam
|
||||
enxergar evidências MCP. O domínio não implementa rails, apenas devolve dados.
|
||||
"""
|
||||
ctx = dict(state.get("context", {}) or {})
|
||||
mcp_results = state.get("mcp_results") or []
|
||||
ctx["evidence"] = mcp_results or ctx.get("evidence")
|
||||
ctx["tool_result"] = mcp_results or ctx.get("tool_result")
|
||||
ctx["tool_executed"] = any(isinstance(r, dict) and r.get("ok") for r in mcp_results)
|
||||
|
||||
protocols: list[str] = []
|
||||
seen: set[str] = set()
|
||||
protocol_keys = {"protocol_number", "protocolo_id", "interactionProtocol", "protocolNumber", "finalizacao_protocol"}
|
||||
|
||||
def walk(value):
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if key in protocol_keys and item not in (None, ""):
|
||||
text = str(item).strip()
|
||||
if text and text not in seen:
|
||||
seen.add(text)
|
||||
protocols.append(text)
|
||||
elif isinstance(item, (dict, list, tuple)):
|
||||
walk(item)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
walk(item)
|
||||
|
||||
walk(mcp_results)
|
||||
if protocols:
|
||||
ctx["expected_protocols"] = protocols
|
||||
ctx["requer_protocolo"] = True
|
||||
ctx.setdefault("tipo_fluxo", "ajuste")
|
||||
return ctx
|
||||
|
||||
def _node(self, name, fn):
|
||||
async def _wrapped(state):
|
||||
async with self.langgraph_telemetry.node(name, state):
|
||||
return await fn(state)
|
||||
return _wrapped
|
||||
|
||||
def _build_graph(self):
|
||||
builder = FrameworkStateGraph(AgentState)
|
||||
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
|
||||
builder.add_node("load_long_term_memory", self._node("load_long_term_memory", self.load_long_term_memory))
|
||||
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
|
||||
builder.add_node("faturas_agent", self._node("faturas_agent", self.faturas_agent))
|
||||
builder.add_node("vas_agent", self._node("vas_agent", self.vas_agent))
|
||||
builder.add_node("contestacao_agent", self._node("contestacao_agent", self.contestacao_agent))
|
||||
builder.add_node("suporte_contas_agent", self._node("suporte_contas_agent", self.suporte_contas_agent))
|
||||
builder.add_node("handoff", self._node("handoff", self.handoff))
|
||||
builder.add_node("human_handoff", self._node("human_handoff", self.human_handoff))
|
||||
builder.add_node("end_session", self._node("end_session", self.end_session))
|
||||
builder.add_node("supervisor_agent", self._node("supervisor_agent", self.supervisor_agent))
|
||||
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
|
||||
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
|
||||
builder.add_node("judge", self._node("judge", self.judge))
|
||||
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
|
||||
builder.add_node("persist_long_term_memory", self._node("persist_long_term_memory", self.persist_long_term_memory))
|
||||
builder.add_node("persist", self._node("persist", self.persist))
|
||||
|
||||
builder.add_edge(START, "input_guardrails")
|
||||
builder.add_conditional_edges(
|
||||
"input_guardrails",
|
||||
self._after_input_guardrails,
|
||||
{"blocked": "persist", "continue": "load_long_term_memory"},
|
||||
)
|
||||
builder.add_edge("load_long_term_memory", "routing_decision")
|
||||
builder.add_conditional_edges(
|
||||
"routing_decision",
|
||||
lambda s: s.get("route", "faturas_agent"),
|
||||
{
|
||||
"faturas_agent": "faturas_agent",
|
||||
"vas_agent": "vas_agent",
|
||||
"contestacao_agent": "contestacao_agent",
|
||||
"suporte_contas_agent": "suporte_contas_agent",
|
||||
"handoff": "handoff",
|
||||
"human_handoff": "human_handoff",
|
||||
"end_session": "end_session",
|
||||
"supervisor_agent": "supervisor_agent",
|
||||
},
|
||||
)
|
||||
builder.add_edge("faturas_agent", "output_supervisor")
|
||||
builder.add_edge("vas_agent", "output_supervisor")
|
||||
builder.add_edge("contestacao_agent", "output_supervisor")
|
||||
builder.add_edge("suporte_contas_agent", "output_supervisor")
|
||||
builder.add_edge("handoff", "output_supervisor")
|
||||
builder.add_edge("human_handoff", "output_supervisor")
|
||||
builder.add_edge("end_session", "output_supervisor")
|
||||
builder.add_edge("supervisor_agent", "output_supervisor")
|
||||
builder.add_edge("output_supervisor", "output_guardrails")
|
||||
builder.add_edge("output_guardrails", "judge")
|
||||
builder.add_edge("judge", "supervisor_review")
|
||||
builder.add_edge("supervisor_review", "persist_long_term_memory")
|
||||
builder.add_edge("persist_long_term_memory", "persist")
|
||||
builder.add_edge("persist", END)
|
||||
|
||||
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
|
||||
|
||||
def _after_input_guardrails(self, state):
|
||||
return "blocked" if state.get("blocked") else "continue"
|
||||
|
||||
async def input_guardrails(self, state):
|
||||
if state.get("session_ended") is True:
|
||||
answer = str(getattr(
|
||||
self.settings,
|
||||
"SESSION_ALREADY_ENDED_MESSAGE",
|
||||
"Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.",
|
||||
))
|
||||
await self.telemetry.event(
|
||||
"session.message.rejected_after_end",
|
||||
{"session_id": state.get("conversation_key") or state.get("session_id")},
|
||||
)
|
||||
return {
|
||||
"answer": answer,
|
||||
"final_answer": answer,
|
||||
"blocked": True,
|
||||
"session_control": "END_SESSION",
|
||||
"session_ended": True,
|
||||
"next_state": "SESSION_ENDED",
|
||||
}
|
||||
async with self.telemetry.span(
|
||||
"workflow.input_guardrails",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input=state.get("user_text"),
|
||||
):
|
||||
history_texts = [m.get("content", "") for m in state.get("history", [])]
|
||||
await self.observer.emit_grl(
|
||||
"001",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "input",
|
||||
},
|
||||
component="workflow.input_guardrails.start",
|
||||
)
|
||||
sanitized, decisions = await self.guardrails.run_input(
|
||||
state["user_text"],
|
||||
{
|
||||
**(state.get("context") or {}),
|
||||
"history_texts": history_texts,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"agent_profile": state.get("agent_profile") or {},
|
||||
},
|
||||
)
|
||||
for _decision in decisions:
|
||||
await self.guardrail_telemetry.evaluated("input", _decision)
|
||||
await self.observer.emit_grl(
|
||||
"002" if _decision.allowed else "004",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "input",
|
||||
"rail_code": getattr(_decision, "code", None),
|
||||
"allowed": bool(_decision.allowed),
|
||||
"reason": getattr(_decision, "reason", None),
|
||||
},
|
||||
component="workflow.input_guardrails.decision",
|
||||
)
|
||||
if not _decision.allowed:
|
||||
await self.guardrail_telemetry.blocked("input", _decision)
|
||||
await self.telemetry.event(
|
||||
"guardrails.input.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"decisions": [d.model_dump() for d in decisions],
|
||||
},
|
||||
)
|
||||
await self.observer.emit_grl(
|
||||
"009",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "input",
|
||||
"blocked": any(not d.allowed for d in decisions),
|
||||
"decision_count": len(decisions),
|
||||
},
|
||||
component="workflow.input_guardrails.final",
|
||||
)
|
||||
if any(not d.allowed for d in decisions):
|
||||
return {
|
||||
"sanitized_input": sanitized,
|
||||
"answer": "Não consegui seguir com essa mensagem por regra de segurança.",
|
||||
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.",
|
||||
"guardrail_decisions": [d.model_dump() for d in decisions],
|
||||
"route": "blocked",
|
||||
"blocked": True,
|
||||
}
|
||||
return {
|
||||
"sanitized_input": sanitized,
|
||||
"guardrail_decisions": [d.model_dump() for d in decisions],
|
||||
"blocked": False,
|
||||
}
|
||||
|
||||
async def routing_decision(self, state):
|
||||
mode = getattr(self.settings, "ROUTING_MODE", "router")
|
||||
async with self.telemetry.span(
|
||||
"workflow.routing_decision",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={
|
||||
"mode": mode,
|
||||
"text": state.get("sanitized_input") or state.get("user_text"),
|
||||
"previous_state": state.get("next_state"),
|
||||
},
|
||||
):
|
||||
if mode == "supervisor":
|
||||
plan = await self.supervisor.route_plan(state)
|
||||
await self.langgraph_telemetry.edge("routing_decision", "supervisor_agent", state, {"method": "supervisor", "intent": plan.intent, "confidence": plan.confidence})
|
||||
return {
|
||||
"route": "supervisor_agent",
|
||||
"intent": plan.intent,
|
||||
"supervisor_plan": {
|
||||
"agents": plan.agents,
|
||||
"intent": plan.intent,
|
||||
"confidence": plan.confidence,
|
||||
"reason": plan.reason,
|
||||
"metadata": plan.metadata,
|
||||
},
|
||||
"route_decision": {
|
||||
"route": "supervisor_agent",
|
||||
"agent": "supervisor",
|
||||
"intent": plan.intent,
|
||||
"confidence": plan.confidence,
|
||||
"reason": plan.reason,
|
||||
"method": "supervisor",
|
||||
"metadata": plan.metadata,
|
||||
},
|
||||
}
|
||||
|
||||
decision = await self.router.route(state)
|
||||
await self.langgraph_telemetry.edge("routing_decision", decision.route, state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
|
||||
await self.observer.emit_ic(
|
||||
"ROUTE_SELECTED",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": decision.route,
|
||||
"intent": decision.intent,
|
||||
"confidence": decision.confidence,
|
||||
"method": getattr(decision, "method", None),
|
||||
},
|
||||
component="workflow.routing_decision",
|
||||
)
|
||||
return {
|
||||
"route": decision.route,
|
||||
"intent": decision.intent,
|
||||
"route_decision": decision.model_dump(mode="json"),
|
||||
"domain": decision.domain,
|
||||
"mcp_tools": decision.mcp_tools,
|
||||
"next_state": decision.next_state,
|
||||
"active_agent": decision.agent,
|
||||
"route_bypassed": decision.method == "continuity",
|
||||
"session_control": (decision.metadata or {}).get("session_control", ""),
|
||||
"human_handoff_requested": (decision.metadata or {}).get("session_control") == "HUMAN_HANDOFF",
|
||||
"session_ended": (decision.metadata or {}).get("session_control") == "END_SESSION",
|
||||
"continuity_signal": {
|
||||
"decision": (decision.metadata or {}).get("continuity_decision"),
|
||||
"confidence": decision.confidence if decision.method == "continuity" else None,
|
||||
"reason": decision.reason if decision.method == "continuity" else None,
|
||||
"profile": (decision.metadata or {}).get("continuity_profile"),
|
||||
} if decision.method == "continuity" else {},
|
||||
}
|
||||
|
||||
async def faturas_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.billing",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"intent": state.get("intent")},
|
||||
):
|
||||
return await self.faturas.run(state)
|
||||
|
||||
async def vas_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.product",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"intent": state.get("intent")},
|
||||
):
|
||||
return await self.vas.run(state)
|
||||
|
||||
async def contestacao_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.orders",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"intent": state.get("intent")},
|
||||
):
|
||||
return await self.contestacao.run(state)
|
||||
|
||||
async def suporte_contas_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.support",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"intent": state.get("intent")},
|
||||
):
|
||||
return await self.suporte_contas.run(state)
|
||||
|
||||
async def supervisor_agent(self, state):
|
||||
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
|
||||
|
||||
Este nó mantém o desenho de supervisor sem obrigar o restante do workflow
|
||||
a conhecer quantos agentes foram acionados. Cada execução especializada
|
||||
recebe o mesmo estado, mas com route/active_agent atualizados.
|
||||
"""
|
||||
plan = state.get("supervisor_plan") or {}
|
||||
agents = plan.get("agents") or ["faturas_agent"]
|
||||
handlers = {
|
||||
"faturas_agent": self.faturas.run,
|
||||
"vas_agent": self.vas.run,
|
||||
"contestacao_agent": self.contestacao.run,
|
||||
"suporte_contas_agent": self.suporte_contas.run,
|
||||
}
|
||||
partials = []
|
||||
mcp_results = []
|
||||
async with self.telemetry.span(
|
||||
"workflow.supervisor_agent",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"agents": agents, "intent": state.get("intent")},
|
||||
):
|
||||
for agent_name in agents:
|
||||
handler = handlers.get(agent_name)
|
||||
if handler is None:
|
||||
continue
|
||||
child_state = {**state, "route": agent_name, "active_agent": agent_name}
|
||||
result = await handler(child_state)
|
||||
partials.append({"agent": agent_name, "answer": result.get("answer", "")})
|
||||
mcp_results.extend(result.get("mcp_results") or [])
|
||||
|
||||
if len(partials) == 1:
|
||||
answer = partials[0]["answer"]
|
||||
else:
|
||||
joined = "\n\n".join(f"{p['agent']}: {p['answer']}" for p in partials)
|
||||
answer = (
|
||||
"[Supervisor] Consolidação de múltiplos agentes acionados.\n"
|
||||
f"{joined}"
|
||||
)
|
||||
return {
|
||||
"answer": answer,
|
||||
"supervisor_results": partials,
|
||||
"mcp_results": mcp_results,
|
||||
"next_state": "SUPERVISOR_ACTIVE",
|
||||
}
|
||||
|
||||
async def handoff(self, state):
|
||||
async with self.telemetry.span("workflow.handoff", session_id=state.get("session_id")):
|
||||
target = (state.get("route_decision") or {}).get("metadata", {}).get("target_agent")
|
||||
answer = (
|
||||
"Vou redirecionar sua solicitação para o especialista correto. "
|
||||
f"Destino sugerido: {target or 'agente especializado'}."
|
||||
)
|
||||
return {"answer": answer}
|
||||
|
||||
async def human_handoff(self, state):
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
async with self.telemetry.span("workflow.human_handoff", session_id=session_id):
|
||||
try:
|
||||
if self.tool_router:
|
||||
runtime_context = (state.get("context") or {}).get("business_context") or {}
|
||||
await self.tool_router.call(
|
||||
"finalizar_atendimento",
|
||||
{"status": "nao_resolvido", "summary": "Handoff humano solicitado pelo agent_framework_oci", "confirmed": True},
|
||||
business_context=runtime_context,
|
||||
original_context=state.get("context") or {},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
answer = str(getattr(self.settings, "HUMAN_HANDOFF_MESSAGE", "Vou encaminhar seu atendimento para uma pessoa."))
|
||||
await self.telemetry.event(
|
||||
"session.human_handoff.requested",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"reason": (state.get("route_decision") or {}).get("reason"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"answer": answer,
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"human_handoff_requested": True,
|
||||
"session_ended": True,
|
||||
"terminal_status": "nao_resolvido",
|
||||
"next_state": "HUMAN_HANDOFF_REQUESTED",
|
||||
}
|
||||
|
||||
async def end_session(self, state):
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
async with self.telemetry.span("workflow.end_session", session_id=session_id):
|
||||
# Preserva efeitos colaterais de negócio do Contas (protocolos/ICs)
|
||||
# sem devolver a orquestração ao runtime do framework. Falha aqui não impede
|
||||
# o encerramento controlado pelo framework.
|
||||
try:
|
||||
if self.tool_router:
|
||||
runtime_context = (state.get("context") or {}).get("business_context") or {}
|
||||
await self.tool_router.call(
|
||||
"finalizar_atendimento",
|
||||
{"status": "resolvido", "summary": "Encerramento solicitado pelo agent_framework_oci", "confirmed": True},
|
||||
business_context=runtime_context,
|
||||
original_context=state.get("context") or {},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
answer = str(getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
|
||||
await self.telemetry.event(
|
||||
"session.end.requested",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"reason": (state.get("route_decision") or {}).get("reason"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"answer": answer,
|
||||
"session_control": "END_SESSION",
|
||||
"session_ended": True,
|
||||
"terminal_status": "resolvido",
|
||||
"human_handoff_requested": False,
|
||||
"next_state": "SESSION_ENDED",
|
||||
}
|
||||
|
||||
async def output_supervisor(self, state):
|
||||
"""Valida a resposta candidata com o OutputSupervisor corporativo.
|
||||
|
||||
Este nó não substitui o roteador/supervisor multiagente. Ele roda após o
|
||||
agente gerar `answer` e antes dos judges/persistência, produzindo campos
|
||||
supervisor_* no state e eventos GRL.001..GRL.009 via AgentObserver.
|
||||
"""
|
||||
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
|
||||
return {
|
||||
"output_guardrails_already_applied": False,
|
||||
"supervisor_action": "disabled",
|
||||
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
|
||||
}
|
||||
|
||||
candidate = state.get("answer") or ""
|
||||
context = {
|
||||
**self._output_guardrail_context(state),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
|
||||
}
|
||||
async with self.telemetry.span(
|
||||
"workflow.output_supervisor",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input=candidate,
|
||||
):
|
||||
decision = await self.output_supervisor_engine.evaluate(candidate, context)
|
||||
action = decision.action.value
|
||||
await self.telemetry.event(
|
||||
"output_supervisor.completed",
|
||||
{
|
||||
"session_id": context["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"action": action,
|
||||
"approved": decision.approved,
|
||||
"guidance": decision.guidance,
|
||||
},
|
||||
)
|
||||
|
||||
await self.observer.emit_ic(
|
||||
"IC.OUTPUT_SUPERVISOR_COMPLETED",
|
||||
{
|
||||
"session_id": context["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"action": action,
|
||||
"approved": decision.approved,
|
||||
"result_count": len(decision.results),
|
||||
},
|
||||
component="workflow.output_supervisor",
|
||||
)
|
||||
|
||||
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
|
||||
final_answer = decision.candidate
|
||||
elif decision.action == RailAction.HANDOVER:
|
||||
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
|
||||
else:
|
||||
final_answer = decision.fallback_message
|
||||
|
||||
return {
|
||||
"answer": final_answer,
|
||||
"final_answer": final_answer,
|
||||
"supervisor_action": action,
|
||||
"supervisor_guidance": decision.guidance,
|
||||
"supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0),
|
||||
"supervisor_handover_reason": decision.handover_reason,
|
||||
"output_supervisor_results": [
|
||||
{
|
||||
"code": r.code,
|
||||
"action": r.action.value,
|
||||
"reason": r.reason,
|
||||
"guidance": r.guidance,
|
||||
"metadata": r.metadata,
|
||||
}
|
||||
for r in decision.results
|
||||
],
|
||||
"output_guardrails_already_applied": True,
|
||||
"guardrail_decisions": state.get("guardrail_decisions", [])
|
||||
+ [item for r in decision.results for item in (r.metadata or {}).get("framework_decisions", [])],
|
||||
}
|
||||
|
||||
async def output_guardrails(self, state):
|
||||
if state.get("output_guardrails_already_applied"):
|
||||
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
|
||||
|
||||
async with self.telemetry.span(
|
||||
"workflow.output_guardrails",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input=state.get("answer"),
|
||||
):
|
||||
await self.observer.emit_grl(
|
||||
"001",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "output",
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
},
|
||||
component="workflow.output_guardrails.start",
|
||||
)
|
||||
guardrail_context = self._output_guardrail_context(state)
|
||||
final, decisions = await self.guardrails.run_output(
|
||||
state["answer"], guardrail_context
|
||||
)
|
||||
for _decision in decisions:
|
||||
await self.guardrail_telemetry.evaluated("output", _decision)
|
||||
await self.observer.emit_grl(
|
||||
"002" if _decision.allowed else "004",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "output",
|
||||
"rail_code": getattr(_decision, "code", None),
|
||||
"allowed": bool(_decision.allowed),
|
||||
"reason": getattr(_decision, "reason", None),
|
||||
},
|
||||
component="workflow.output_guardrails.decision",
|
||||
)
|
||||
if not _decision.allowed:
|
||||
await self.guardrail_telemetry.blocked("output", _decision)
|
||||
await self.telemetry.event(
|
||||
"guardrails.output.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"decisions": [d.model_dump() for d in decisions],
|
||||
},
|
||||
)
|
||||
await self.observer.emit_grl(
|
||||
"009",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "output",
|
||||
"blocked": any(not d.allowed for d in decisions),
|
||||
"decision_count": len(decisions),
|
||||
},
|
||||
component="workflow.output_guardrails.final",
|
||||
)
|
||||
return {
|
||||
"final_answer": final,
|
||||
"guardrail_decisions": state.get("guardrail_decisions", [])
|
||||
+ [d.model_dump() for d in decisions],
|
||||
}
|
||||
|
||||
async def judge(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.judge",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"question": state.get("user_text"), "answer": state.get("final_answer")},
|
||||
):
|
||||
judge_context = dict(state.get("context", {}) or {})
|
||||
judge_context["mcp_results"] = state.get("mcp_results", [])
|
||||
judge_context["evidence"] = state.get("mcp_results", []) or judge_context.get("evidence")
|
||||
judge_context["route"] = state.get("route")
|
||||
judge_context["intent"] = state.get("intent")
|
||||
# Judge sampling must see the finalized transaction state. These
|
||||
# fields are populated by the agent/tool runtime before this node.
|
||||
for key in (
|
||||
"transaction_status",
|
||||
"confirmation_required",
|
||||
"confirmation_received",
|
||||
"tool_policy_result",
|
||||
"selected_tool_call",
|
||||
"pending_tool_call",
|
||||
):
|
||||
judge_context[key] = state.get(key)
|
||||
judge_context["transactional_tools"] = [
|
||||
result.get("tool_name")
|
||||
for result in state.get("mcp_results", [])
|
||||
if isinstance(result, dict)
|
||||
and (
|
||||
(result.get("metadata") or {}).get("operation_type") == "transactional"
|
||||
or result.get("awaiting_confirmation")
|
||||
or result.get("transaction_status")
|
||||
)
|
||||
]
|
||||
results = await self.judges.evaluate_all(
|
||||
state["user_text"], state["final_answer"], judge_context
|
||||
)
|
||||
for _result in results:
|
||||
await self.judge_telemetry.evaluated(_result)
|
||||
await self.telemetry.event(
|
||||
"judges.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"results": [r.model_dump() for r in results],
|
||||
},
|
||||
)
|
||||
return {"judge_results": [r.model_dump() for r in results]}
|
||||
|
||||
async def supervisor_review(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.supervisor_review",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input=state.get("final_answer"),
|
||||
):
|
||||
ok, answer = await self.supervisor.review(
|
||||
state["final_answer"], state.get("context", {})
|
||||
)
|
||||
await self.telemetry.event(
|
||||
"supervisor.review.completed",
|
||||
{"session_id": state.get("session_id"), "approved": ok},
|
||||
)
|
||||
return {"final_answer": answer if ok else answer}
|
||||
|
||||
async def load_long_term_memory(self, state):
|
||||
"""Carrega LTM antes do roteamento e mantém o resultado no estado.
|
||||
|
||||
A carga explícita evita depender apenas do agente selecionado para realizar
|
||||
a recuperação e facilita o diagnóstico de identidade/namespace.
|
||||
"""
|
||||
try:
|
||||
memories = await self.long_term_memory_manager.load(state)
|
||||
serialized = []
|
||||
context_lines = []
|
||||
for item in memories or []:
|
||||
if hasattr(item, "model_dump"):
|
||||
data = item.model_dump(mode="json")
|
||||
elif hasattr(item, "__dict__"):
|
||||
data = dict(item.__dict__)
|
||||
elif isinstance(item, dict):
|
||||
data = dict(item)
|
||||
else:
|
||||
data = {"value": str(item)}
|
||||
serialized.append(data)
|
||||
key = data.get("key") or data.get("memory_key") or data.get("category") or "memory"
|
||||
value = data.get("value") or data.get("memory_value")
|
||||
if value not in (None, ""):
|
||||
context_lines.append(f"- {key}: {value}")
|
||||
|
||||
return {
|
||||
"long_term_memories": serialized,
|
||||
"long_term_memory_context": "\n".join(context_lines),
|
||||
}
|
||||
except Exception as exc:
|
||||
await self.telemetry.event(
|
||||
"long_term_memory.load.failed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"subject_key": state.get("long_term_memory_subject_key"),
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"long_term_memories": [],
|
||||
"long_term_memory_context": "",
|
||||
"long_term_memory_load_error": str(exc),
|
||||
}
|
||||
|
||||
async def persist_long_term_memory(self, state):
|
||||
try:
|
||||
result = await self.long_term_memory_manager.persist_turn(state)
|
||||
await self.telemetry.event(
|
||||
"long_term_memory.persist.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"subject_key": state.get("long_term_memory_subject_key"),
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
return {"long_term_memory_write_result": result}
|
||||
except Exception as exc:
|
||||
await self.telemetry.event(
|
||||
"long_term_memory.persist.failed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"subject_key": state.get("long_term_memory_subject_key"),
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
return {"long_term_memory_write_result": {"saved": 0, "error": str(exc)}}
|
||||
|
||||
async def persist(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.persist",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"route": state.get("route"), "intent": state.get("intent")},
|
||||
):
|
||||
await self.observer.emit_ic(
|
||||
"AGENT_COMPLETED",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"route_decision": state.get("route_decision"),
|
||||
"judges": state.get("judge_results", []),
|
||||
"mcp_tools": state.get("mcp_tools", []),
|
||||
"mcp_results": state.get("mcp_results", []),
|
||||
},
|
||||
)
|
||||
|
||||
await self.observer.emit_noc(
|
||||
"006",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"answer_chars": len(state.get("final_answer") or ""),
|
||||
},
|
||||
component="workflow.persist",
|
||||
)
|
||||
|
||||
await self.telemetry.event(
|
||||
"agent.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"answer_chars": len(state.get("final_answer") or ""),
|
||||
},
|
||||
)
|
||||
return state
|
||||
|
||||
async def ainvoke(self, state):
|
||||
thread_id = state.get("conversation_key") or state["session_id"]
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
async with self.telemetry.span(
|
||||
"workflow.langgraph.ainvoke",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
user_id=state.get("context", {}).get("user_id"),
|
||||
input={"user_text": state.get("user_text")},
|
||||
tags=["langgraph", "agent-workflow", f"routing-mode:{getattr(self.settings, 'ROUTING_MODE', 'router')}",],
|
||||
):
|
||||
await self.workflow_telemetry.started("agent_workflow", state)
|
||||
await self.observer.emit_noc(
|
||||
"001",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"channel_id": (state.get("context") or {}).get("channel"),
|
||||
"message_id": (state.get("context") or {}).get("message_id"),
|
||||
"ura_call_id": (state.get("context") or {}).get("ura_call_id"),
|
||||
},
|
||||
component="workflow.ainvoke",
|
||||
)
|
||||
await self.observer.emit_ic(
|
||||
"AGENT_STARTED",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"channel_id": (state.get("context") or {}).get("channel"),
|
||||
"message_id": (state.get("context") or {}).get("message_id"),
|
||||
"user_text_chars": len(state.get("user_text") or ""),
|
||||
},
|
||||
component="workflow.ainvoke",
|
||||
)
|
||||
try:
|
||||
result = await self.graph.ainvoke(state, config=config)
|
||||
await self.workflow_telemetry.completed("agent_workflow", result)
|
||||
return result
|
||||
except Exception as exc:
|
||||
await self.workflow_telemetry.failed("agent_workflow", exc)
|
||||
await self.observer.emit_noc(
|
||||
"005",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"error": str(exc),
|
||||
"exception_type": exc.__class__.__name__,
|
||||
},
|
||||
component="workflow.ainvoke",
|
||||
)
|
||||
raise
|
||||
Reference in New Issue
Block a user