Ajustes conforme relatorio de testes 2026-08-27

This commit is contained in:
2026-08-29 10:03:42 -03:00
parent 1fd18531c0
commit 81f24d7357
699 changed files with 7989 additions and 2364 deletions

View File

@@ -32,7 +32,7 @@ A documentação possui três níveis:
1. **Tutorial principal:** este [`README.md`](README.md) — criação, configuração, execução e teste de um agente do início ao fim. 1. **Tutorial principal:** este [`README.md`](README.md) — criação, configuração, execução e teste de um agente do início ao fim.
2. **Arquitetura:** [01 — Arquitetura e Conceitos](docs/developer/pt/01_architecture_and_concepts.md) — componentes, responsabilidades e onde implementar cada coisa. 2. **Arquitetura:** [01 — Arquitetura e Conceitos](docs/developer/pt/01_architecture_and_concepts.md) — componentes, responsabilidades e onde implementar cada coisa.
3. **Referências especializadas:** manuais `02` a `11` — implementação profunda e troubleshooting por capacidade. 3. **Referências especializadas:** manuais `02` a `12` — implementação profunda e troubleshooting por capacidade.
Se você está começando um novo agente, siga este `README.md` desde o início. Para aprofundamento ou troubleshooting, use os links abaixo. Se você está começando um novo agente, siga este `README.md` desde o início. Para aprofundamento ou troubleshooting, use os links abaixo.
@@ -11217,6 +11217,9 @@ O conteúdo desta pasta deve ser tratado como uma extensão adicional do framewo
| Recebo 401 entre gateway/backend/MCP | Basic Auth, credenciais por hop | [Gateways e Auth](docs/developer/pt/05_agent_gateway_mcp_gateway_and_auth.md) | | Recebo 401 entre gateway/backend/MCP | Basic Auth, credenciais por hop | [Gateways e Auth](docs/developer/pt/05_agent_gateway_mcp_gateway_and_auth.md) |
| Preciso decidir se algo pertence ao framework ou ao agente | boundary core/agente | [Arquitetura e Conceitos](docs/developer/pt/01_architecture_and_concepts.md) | | Preciso decidir se algo pertence ao framework ou ao agente | boundary core/agente | [Arquitetura e Conceitos](docs/developer/pt/01_architecture_and_concepts.md) |
| Guardrail específico de um agente está quebrando outro | extensibilidade, imports de domínio no core | [Guardrails e Judges](docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md) | | Guardrail específico de um agente está quebrando outro | extensibilidade, imports de domínio no core | [Guardrails e Judges](docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md) |
| Uma frase incompleta recebe mensagem genérica de “regra de segurança” | feedback de input guardrail, `COER`, blocked-turn state | [Feedback de Guardrails de Entrada](docs/developer/pt/12_input_guardrail_feedback_and_blocked_turns.md) |
| `route=blocked` aparece junto com tools/resultados de outro turno | limpeza de estado do turno bloqueado | [Feedback de Guardrails de Entrada](docs/developer/pt/12_input_guardrail_feedback_and_blocked_turns.md) |
| Workflow conclui e gera protocolo, mas a resposta final vira mensagem de segurança | `expected_protocols`, `CMP`, `DLEX_OUT`, ordem de `output_guardrails` | [Guardrails e Judges](docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md) |
| Judge não roda em uma transação | sampling, `always_run_for_transactional`, sinais transacionais | [Guardrails e Judges](docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md) | | Judge não roda em uma transação | sampling, `always_run_for_transactional`, sinais transacionais | [Guardrails e Judges](docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md) |
| Groundedness está avaliando sem contexto correto | RAG context, MCP evidence, judge inputs | [RAG/Grounding](docs/developer/pt/07_rag_business_context_and_grounding.md) | | Groundedness está avaliando sem contexto correto | RAG context, MCP evidence, judge inputs | [RAG/Grounding](docs/developer/pt/07_rag_business_context_and_grounding.md) |
| RAG não encontra conteúdo | provider, ingestão, embeddings, configuração | [RAG/Grounding](docs/developer/pt/07_rag_business_context_and_grounding.md) | | RAG não encontra conteúdo | provider, ingestão, embeddings, configuração | [RAG/Grounding](docs/developer/pt/07_rag_business_context_and_grounding.md) |
@@ -11301,6 +11304,12 @@ O conteúdo desta pasta deve ser tratado como uma extensão adicional do framewo
**Use quando:** for necessário provar o caminho executado ou diagnosticar produção. **Use quando:** for necessário provar o caminho executado ou diagnosticar produção.
### [12 — Feedback de Guardrails de Entrada e Turnos Bloqueados](docs/developer/pt/12_input_guardrail_feedback_and_blocked_turns.md)
**O que é:** semântica de mensagens públicas para bloqueios de input, limpeza do estado do turno e passagem da resposta pelos guardrails de saída.
**Use quando:** um `COER`/guardrail de entrada gera mensagem genérica, `route=blocked` carrega resultados antigos ou há dúvida sobre a precedência entre input guardrails, routing e tools.
### Tutorial principal ### Tutorial principal
[`README.md`](README.md) continua sendo a referência para o passo a passo completo: [`README.md`](README.md) continua sendo a referência para o passo a passo completo:

View File

@@ -32,7 +32,7 @@ The documentation has three clear levels:
1. **Main tutorial:** this [`README_en.md`](README_en.md) — build, configure, run and test an agent end to end. 1. **Main tutorial:** this [`README_en.md`](README_en.md) — build, configure, run and test an agent end to end.
2. **Architecture:** [01 — Architecture and Concepts](docs/developer/en/01_architecture_and_concepts.md) — components, boundaries and implementation placement. 2. **Architecture:** [01 — Architecture and Concepts](docs/developer/en/01_architecture_and_concepts.md) — components, boundaries and implementation placement.
3. **Specialized references:** manuals `02` through `11` — deep implementation and troubleshooting by capability. 3. **Specialized references:** manuals `02` through `12` — deep implementation and troubleshooting by capability.
If you are creating a new agent, follow this `README_en.md` from the beginning. For deeper implementation details or troubleshooting, use the links below. If you are creating a new agent, follow this `README_en.md` from the beginning. For deeper implementation details or troubleshooting, use the links below.
@@ -11205,6 +11205,14 @@ The content of this folder should be treated as an additional framework extensio
**Use it when:** proving execution paths or diagnosing production behavior. **Use it when:** proving execution paths or diagnosing production behavior.
### [12 — Input Guardrail Feedback and Blocked-Turn Semantics](docs/developer/en/12_input_guardrail_feedback_and_blocked_turns.md)
**What it is:** user-facing semantics for input blocks, blocked-turn state cleanup, and output-guardrail validation of the generated feedback.
**Use it when:** `COER`/input guardrails generate generic messages, `route=blocked` carries stale results, or you need to reason about precedence between input guardrails, routing, and tools.
### Main tutorial ### Main tutorial
[`README_en.md`](README_en.md) remains the complete step-by-step guide. [`README_en.md`](README_en.md) remains the complete step-by-step guide.
| Workflow completes and generates a protocol, but the final response becomes a safety message | `expected_protocols`, `CMP`, `DLEX_OUT`, `output_guardrails` ordering | [Guardrails and Judges](docs/developer/en/06_guardrails_judges_and_transaction_evaluation.md) |

View File

@@ -160,7 +160,7 @@ class AgentWorkflow:
builder.add_conditional_edges( builder.add_conditional_edges(
"input_guardrails", "input_guardrails",
self._after_input_guardrails, self._after_input_guardrails,
{"blocked": "persist", "continue": "load_long_term_memory"}, {"blocked": "output_guardrails", "continue": "load_long_term_memory"},
) )
builder.add_edge("load_long_term_memory", "routing_decision") builder.add_edge("load_long_term_memory", "routing_decision")
builder.add_conditional_edges( builder.add_conditional_edges(
@@ -197,6 +197,31 @@ class AgentWorkflow:
def _after_input_guardrails(self, state): def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue" return "blocked" if state.get("blocked") else "continue"
@staticmethod
def _input_guardrail_user_message(decisions, state, sanitized_text):
# Keep the technical guardrail reason in telemetry, but expose only a
# safe, actionable message to the end user. The message is intentionally
# routed through output_guardrails before persistence/delivery.
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
first = blocked[0] if blocked else None
code = str(getattr(first, "code", "") or "").upper()
if code == "COER":
return (
"Não consegui entender sua última mensagem porque ela parece "
"incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?"
)
if code == "INPUT_SIZE":
return "Sua mensagem ficou muito longa para eu processar de uma vez. Pode resumir ou dividir em partes?"
if code == "DLEX_IN":
return "Não posso usar essa informação da forma solicitada. Reformule o pedido sem incluir dados ou conteúdo restrito."
if code == "PINJ":
return "Não posso seguir instruções que tentem alterar as regras do atendimento. Posso continuar ajudando com a sua solicitação."
if code == "TOX":
return "Não consegui prosseguir com essa mensagem. Pode reformular o pedido para continuarmos o atendimento?"
if code == "CMP":
return "Não posso prosseguir com essa solicitação dessa forma. Posso ajudar com uma alternativa permitida."
return "Não consegui processar essa mensagem. Pode reformular para eu continuar o atendimento?"
async def input_guardrails(self, state): async def input_guardrails(self, state):
if state.get("session_ended") is True: if state.get("session_ended") is True:
answer = str(getattr( answer = str(getattr(
@@ -281,12 +306,33 @@ class AgentWorkflow:
component="workflow.input_guardrails.final", component="workflow.input_guardrails.final",
) )
if any(not d.allowed for d in decisions): if any(not d.allowed for d in decisions):
# A blocking input guardrail stops the turn before routing/tools.
# Clear turn-local routing/tool state so stale data from a prior
# turn cannot appear as if it was executed after the block.
user_message = self._input_guardrail_user_message(decisions, state, sanitized)
return { return {
"sanitized_input": sanitized, "sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.", "answer": user_message,
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": None,
"guardrail_decisions": [d.model_dump() for d in decisions], "guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked", "route": "blocked",
"intent": "input_guardrail_blocked",
"route_decision": {
"route": "blocked",
"agent": None,
"intent": "input_guardrail_blocked",
"confidence": 1.0,
"reason": "Entrada interrompida por guardrail antes do roteamento.",
"method": "guardrail",
"next_state": state.get("next_state"),
"handoff": False,
"metadata": {},
"domain": state.get("domain"),
"mcp_tools": [],
},
"mcp_tools": [],
"mcp_results": [],
"judge_results": [],
"blocked": True, "blocked": True,
} }
return { return {

View File

@@ -7,6 +7,35 @@ router:
confidence_threshold: 0.65 confidence_threshold: 0.65
allow_handoff: true allow_handoff: true
transaction_confirmation:
# Explicit yes/no stays deterministic. Only inconclusive replies use this LLM fallback.
semantic_fallback:
enabled: true
allowed_values: [SIM, NAO, CONTINUAR]
confirm_values: [SIM]
reject_values: [NAO]
continue_values: [CONTINUAR]
include_relevant_context: true
profile_name: router
prompt: |
Você classifica a resposta do cliente a uma confirmação transacional pendente.
Considere a pergunta pendente, somente o histórico recente relacionado ao mesmo tema e a fala atual.
Não execute a ação e não invente fatos.
Classes permitidas: {{ allowed_values }}
- SIM: confirmação/aceite inequívoco, inclusive equivalentes como "isso mesmo", "pode confirmar", "é isso" quando o contexto tornar o aceite claro.
- NAO: recusa/cancelamento inequívoco da ação pendente.
- CONTINUAR: qualquer resposta que não confirme nem rejeite inequivocamente, incluindo pergunta adicional, correção, novo dado, ambiguidade ou possível mudança de assunto.
Pergunta pendente:
{{ pending_prompt }}
Histórico relevante:
{{ relevant_conversation_context }}
Resposta atual do cliente:
{{ user_input }}
state_policies: state_policies:
- state: WAITING_BILLING_CONFIRMATION - state: WAITING_BILLING_CONFIRMATION
agent: billing_agent agent: billing_agent

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em faturas.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade para responder somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente, como “contract_key”, “customer_key” ou “MSISDN”.\nPara consultas informativas de fatura, apresente somente dados de negócio necessários, como valor, vencimento, situação e itens cobrados.\nNão acrescente canais, telefones, códigos USSD, URLs, aplicativos, lojas, relatórios adicionais, procedimentos alternativos ou próximos passos que não tenham sido explicitamente retornados pela tool/RAG e solicitados pelo usuário.\nNão ofereça espontaneamente outras ações ou detalhamentos.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de pedidos de varejo.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso, alteração, troca, cancelamento ou qualquer mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em produtos, planos e serviços.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nEm consultas meramente informativas, não exponha flags ou capacidades transacionais internas como can.cancel e não informe espontaneamente que algo pode ser cancelado, alterado, contratado, removido ou trocado. Só mencione capacidade transacional quando o usuário tiver solicitado essa ação.\nNão faça oferta proativa e não execute nem simule mutações sem a confirmação exigida pelo framework.\nNão acrescente canais, procedimentos ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "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.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de suporte de varejo para troca, devolução e garantia.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso nem simule troca, devolução, garantia ou outra mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de suporte de varejo para troca, devolução e garantia.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -13,42 +13,7 @@ def _money_brl(value: Any) -> str:
def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
"""Renderiza somente campos de negócio seguros da fatura. return f"[{agent_label}] Fatura consultada: {result}."
Identificadores técnicos/PII presentes no payload MCP (por exemplo msisdn,
customer_id, document e business keys) não devem ser propagados ao usuário.
"""
lines = [f"[{agent_label}] Dados da sua fatura:"]
total = result.get("valor_total")
vencimento = result.get("vencimento")
status = result.get("status")
if total is not None:
lines.append(f"Valor total: R$ {_money_brl(total)}.")
if vencimento not in (None, ""):
lines.append(f"Vencimento: {vencimento}.")
if status not in (None, ""):
lines.append(f"Situação: {status}.")
items = result.get("itens") or []
rendered_items: list[str] = []
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
description = item.get("descricao") or item.get("nome")
value = item.get("valor")
if description in (None, ""):
continue
if value is None:
rendered_items.append(str(description))
else:
rendered_items.append(f"{description}: R$ {_money_brl(value)}")
if rendered_items:
lines.append("Itens: " + "; ".join(rendered_items) + ".")
# Se não houver nenhum campo de negócio seguro além do cabeçalho, deixe a
# composição pela LLM/guardrails em vez de despejar o payload bruto.
return " ".join(lines) if len(lines) > 1 else None
def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:

View File

@@ -160,7 +160,7 @@ class AgentWorkflow:
builder.add_conditional_edges( builder.add_conditional_edges(
"input_guardrails", "input_guardrails",
self._after_input_guardrails, self._after_input_guardrails,
{"blocked": "persist", "continue": "load_long_term_memory"}, {"blocked": "output_guardrails", "continue": "load_long_term_memory"},
) )
builder.add_edge("load_long_term_memory", "routing_decision") builder.add_edge("load_long_term_memory", "routing_decision")
builder.add_conditional_edges( builder.add_conditional_edges(
@@ -197,6 +197,31 @@ class AgentWorkflow:
def _after_input_guardrails(self, state): def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue" return "blocked" if state.get("blocked") else "continue"
@staticmethod
def _input_guardrail_user_message(decisions, state, sanitized_text):
# Keep the technical guardrail reason in telemetry, but expose only a
# safe, actionable message to the end user. The message is intentionally
# routed through output_guardrails before persistence/delivery.
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
first = blocked[0] if blocked else None
code = str(getattr(first, "code", "") or "").upper()
if code == "COER":
return (
"Não consegui entender sua última mensagem porque ela parece "
"incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?"
)
if code == "INPUT_SIZE":
return "Sua mensagem ficou muito longa para eu processar de uma vez. Pode resumir ou dividir em partes?"
if code == "DLEX_IN":
return "Não posso usar essa informação da forma solicitada. Reformule o pedido sem incluir dados ou conteúdo restrito."
if code == "PINJ":
return "Não posso seguir instruções que tentem alterar as regras do atendimento. Posso continuar ajudando com a sua solicitação."
if code == "TOX":
return "Não consegui prosseguir com essa mensagem. Pode reformular o pedido para continuarmos o atendimento?"
if code == "CMP":
return "Não posso prosseguir com essa solicitação dessa forma. Posso ajudar com uma alternativa permitida."
return "Não consegui processar essa mensagem. Pode reformular para eu continuar o atendimento?"
async def input_guardrails(self, state): async def input_guardrails(self, state):
if state.get("session_ended") is True: if state.get("session_ended") is True:
answer = str(getattr( answer = str(getattr(
@@ -281,12 +306,33 @@ class AgentWorkflow:
component="workflow.input_guardrails.final", component="workflow.input_guardrails.final",
) )
if any(not d.allowed for d in decisions): if any(not d.allowed for d in decisions):
# A blocking input guardrail stops the turn before routing/tools.
# Clear turn-local routing/tool state so stale data from a prior
# turn cannot appear as if it was executed after the block.
user_message = self._input_guardrail_user_message(decisions, state, sanitized)
return { return {
"sanitized_input": sanitized, "sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.", "answer": user_message,
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": None,
"guardrail_decisions": [d.model_dump() for d in decisions], "guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked", "route": "blocked",
"intent": "input_guardrail_blocked",
"route_decision": {
"route": "blocked",
"agent": None,
"intent": "input_guardrail_blocked",
"confidence": 1.0,
"reason": "Entrada interrompida por guardrail antes do roteamento.",
"method": "guardrail",
"next_state": state.get("next_state"),
"handoff": False,
"metadata": {},
"domain": state.get("domain"),
"mcp_tools": [],
},
"mcp_tools": [],
"mcp_results": [],
"judge_results": [],
"blocked": True, "blocked": True,
} }
return { return {
@@ -494,119 +540,6 @@ class AgentWorkflow:
"next_state": "SESSION_ENDED", "next_state": "SESSION_ENDED",
} }
@staticmethod
def _output_guardrail_context(state: dict) -> dict:
"""Monta o contexto operacional do turno para os guardrails de saída.
Mantém evidências/protocolos necessários aos rails, mas impede que uma
transação encerrada ou semanticamente interrompida governe o novo turno.
O histórico completo permanece no state/checkpoint para auditoria.
"""
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)
history = list(state.get("history") or [])
current_user_text = str(state.get("user_text") or "").strip()
if current_user_text:
if (
not history
or not isinstance(history[-1], dict)
or str(history[-1].get("content") or "") != current_user_text
or str(history[-1].get("role") or "") != "user"
):
history.append({"role": "user", "content": current_user_text})
route_decision = state.get("route_decision") or {}
route_metadata = route_decision.get("metadata") if isinstance(route_decision, dict) else {}
route_metadata = route_metadata if isinstance(route_metadata, dict) else {}
pre_validation = state.get("transaction_pre_validation") or {}
pre_validation = pre_validation if isinstance(pre_validation, dict) else {}
tx_status = str(
state.get("transaction_status") or pre_validation.get("status") or ""
).strip().upper()
terminal_tx = bool(pre_validation.get("terminal")) or tx_status in {
"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"
}
semantic_intent_shift = (
str(route_metadata.get("transaction_interruption") or "").strip().lower()
== "intent_shift"
)
stickiness_intent_shift = bool(route_metadata.get("route_stickiness_preempted"))
should_isolate_history = semantic_intent_shift or (terminal_tx and stickiness_intent_shift)
current_route = str(
state.get("route")
or (route_decision.get("route") if isinstance(route_decision, dict) else "")
or ""
).strip()
current_intent = str(
state.get("intent")
or (route_decision.get("intent") if isinstance(route_decision, dict) else "")
or ""
).strip()
ctx["current_user_message"] = current_user_text
ctx["current_route"] = current_route
ctx["current_intent"] = current_intent
if should_isolate_history:
operational_history = (
[{"role": "user", "content": current_user_text}]
if current_user_text else []
)
ctx["historical_transaction_ignored"] = True
ctx["historical_transaction_status"] = tx_status or (
"INTERRUPTED" if semantic_intent_shift else "TERMINAL"
)
if semantic_intent_shift:
ctx["historical_transaction_interruption"] = "intent_shift"
for stale_key in (
"transaction_pre_validation",
"transaction_status",
"active_transaction",
"transaction",
):
ctx.pop(stale_key, None)
else:
operational_history = history
ctx["conversation_history"] = operational_history
ctx["history_texts"] = [
str(item.get("content") or "")
for item in operational_history
if isinstance(item, dict) and item.get("content") not in (None, "")
]
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
async def output_supervisor(self, state): async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo. """Valida a resposta candidata com o OutputSupervisor corporativo.
@@ -622,15 +555,15 @@ class AgentWorkflow:
} }
candidate = state.get("answer") or "" candidate = state.get("answer") or ""
context = self._output_guardrail_context(state) context = {
context.update({ **(state.get("context") or {}),
"tenant_id": state.get("tenant_id"), "tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"), "agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"), "session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"), "route": state.get("route"),
"intent": state.get("intent"), "intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)), "supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}) }
async with self.telemetry.span( async with self.telemetry.span(
"workflow.output_supervisor", "workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"), session_id=state.get("conversation_key") or state.get("session_id"),
@@ -716,7 +649,7 @@ class AgentWorkflow:
component="workflow.output_guardrails.start", component="workflow.output_guardrails.start",
) )
final, decisions = await self.guardrails.run_output( final, decisions = await self.guardrails.run_output(
state["answer"], self._output_guardrail_context(state) state["answer"], state.get("context", {})
) )
for _decision in decisions: for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision) await self.guardrail_telemetry.evaluated("output", _decision)

View File

@@ -7,6 +7,35 @@ router:
confidence_threshold: 0.65 confidence_threshold: 0.65
allow_handoff: true allow_handoff: true
transaction_confirmation:
# Explicit yes/no stays deterministic. Only inconclusive replies use this LLM fallback.
semantic_fallback:
enabled: true
allowed_values: [SIM, NAO, CONTINUAR]
confirm_values: [SIM]
reject_values: [NAO]
continue_values: [CONTINUAR]
include_relevant_context: true
profile_name: router
prompt: |
Você classifica a resposta do cliente a uma confirmação transacional pendente.
Considere a pergunta pendente, somente o histórico recente relacionado ao mesmo tema e a fala atual.
Não execute a ação e não invente fatos.
Classes permitidas: {{ allowed_values }}
- SIM: confirmação/aceite inequívoco, inclusive equivalentes como "isso mesmo", "pode confirmar", "é isso" quando o contexto tornar o aceite claro.
- NAO: recusa/cancelamento inequívoco da ação pendente.
- CONTINUAR: qualquer resposta que não confirme nem rejeite inequivocamente, incluindo pergunta adicional, correção, novo dado, ambiguidade ou possível mudança de assunto.
Pergunta pendente:
{{ pending_prompt }}
Histórico relevante:
{{ relevant_conversation_context }}
Resposta atual do cliente:
{{ user_input }}
state_policies: state_policies:
- state: WAITING_BILLING_CONFIRMATION - state: WAITING_BILLING_CONFIRMATION
agent: billing_agent agent: billing_agent

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em faturas.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade para responder somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente, como “contract_key”, “customer_key” ou “MSISDN”.\nPara consultas informativas de fatura, apresente somente dados de negócio necessários, como valor, vencimento, situação e itens cobrados.\nNão acrescente canais, telefones, códigos USSD, URLs, aplicativos, lojas, relatórios adicionais, procedimentos alternativos ou próximos passos que não tenham sido explicitamente retornados pela tool/RAG e solicitados pelo usuário.\nNão ofereça espontaneamente outras ações ou detalhamentos.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de pedidos de varejo.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso, alteração, troca, cancelamento ou qualquer mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em produtos, planos e serviços.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nEm consultas meramente informativas, não exponha flags ou capacidades transacionais internas como can.cancel e não informe espontaneamente que algo pode ser cancelado, alterado, contratado, removido ou trocado. Só mencione capacidade transacional quando o usuário tiver solicitado essa ação.\nNão faça oferta proativa e não execute nem simule mutações sem a confirmação exigida pelo framework.\nNão acrescente canais, procedimentos ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "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.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de suporte de varejo para troca, devolução e garantia.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso nem simule troca, devolução, garantia ou outra mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de suporte de varejo para troca, devolução e garantia.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -13,42 +13,7 @@ def _money_brl(value: Any) -> str:
def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
"""Renderiza somente campos de negócio seguros da fatura. return f"[{agent_label}] Fatura consultada: {result}."
Identificadores técnicos/PII presentes no payload MCP (por exemplo msisdn,
customer_id, document e business keys) não devem ser propagados ao usuário.
"""
lines = [f"[{agent_label}] Dados da sua fatura:"]
total = result.get("valor_total")
vencimento = result.get("vencimento")
status = result.get("status")
if total is not None:
lines.append(f"Valor total: R$ {_money_brl(total)}.")
if vencimento not in (None, ""):
lines.append(f"Vencimento: {vencimento}.")
if status not in (None, ""):
lines.append(f"Situação: {status}.")
items = result.get("itens") or []
rendered_items: list[str] = []
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
description = item.get("descricao") or item.get("nome")
value = item.get("valor")
if description in (None, ""):
continue
if value is None:
rendered_items.append(str(description))
else:
rendered_items.append(f"{description}: R$ {_money_brl(value)}")
if rendered_items:
lines.append("Itens: " + "; ".join(rendered_items) + ".")
# Se não houver nenhum campo de negócio seguro além do cabeçalho, deixe a
# composição pela LLM/guardrails em vez de despejar o payload bruto.
return " ".join(lines) if len(lines) > 1 else None
def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:

View File

@@ -159,7 +159,7 @@ class AgentWorkflow:
builder.add_conditional_edges( builder.add_conditional_edges(
"input_guardrails", "input_guardrails",
self._after_input_guardrails, self._after_input_guardrails,
{"blocked": "persist", "continue": "routing_decision"}, {"blocked": "output_guardrails", "continue": "routing_decision"},
) )
builder.add_conditional_edges( builder.add_conditional_edges(
"routing_decision", "routing_decision",
@@ -195,6 +195,31 @@ class AgentWorkflow:
def _after_input_guardrails(self, state): def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue" return "blocked" if state.get("blocked") else "continue"
@staticmethod
def _input_guardrail_user_message(decisions, state, sanitized_text):
# Keep the technical guardrail reason in telemetry, but expose only a
# safe, actionable message to the end user. The message is intentionally
# routed through output_guardrails before persistence/delivery.
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
first = blocked[0] if blocked else None
code = str(getattr(first, "code", "") or "").upper()
if code == "COER":
return (
"Não consegui entender sua última mensagem porque ela parece "
"incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?"
)
if code == "INPUT_SIZE":
return "Sua mensagem ficou muito longa para eu processar de uma vez. Pode resumir ou dividir em partes?"
if code == "DLEX_IN":
return "Não posso usar essa informação da forma solicitada. Reformule o pedido sem incluir dados ou conteúdo restrito."
if code == "PINJ":
return "Não posso seguir instruções que tentem alterar as regras do atendimento. Posso continuar ajudando com a sua solicitação."
if code == "TOX":
return "Não consegui prosseguir com essa mensagem. Pode reformular o pedido para continuarmos o atendimento?"
if code == "CMP":
return "Não posso prosseguir com essa solicitação dessa forma. Posso ajudar com uma alternativa permitida."
return "Não consegui processar essa mensagem. Pode reformular para eu continuar o atendimento?"
async def input_guardrails(self, state): async def input_guardrails(self, state):
if state.get("session_ended") is True: if state.get("session_ended") is True:
answer = str(getattr( answer = str(getattr(
@@ -279,12 +304,33 @@ class AgentWorkflow:
component="workflow.input_guardrails.final", component="workflow.input_guardrails.final",
) )
if any(not d.allowed for d in decisions): if any(not d.allowed for d in decisions):
# A blocking input guardrail stops the turn before routing/tools.
# Clear turn-local routing/tool state so stale data from a prior
# turn cannot appear as if it was executed after the block.
user_message = self._input_guardrail_user_message(decisions, state, sanitized)
return { return {
"sanitized_input": sanitized, "sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.", "answer": user_message,
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": None,
"guardrail_decisions": [d.model_dump() for d in decisions], "guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked", "route": "blocked",
"intent": "input_guardrail_blocked",
"route_decision": {
"route": "blocked",
"agent": None,
"intent": "input_guardrail_blocked",
"confidence": 1.0,
"reason": "Entrada interrompida por guardrail antes do roteamento.",
"method": "guardrail",
"next_state": state.get("next_state"),
"handoff": False,
"metadata": {},
"domain": state.get("domain"),
"mcp_tools": [],
},
"mcp_tools": [],
"mcp_results": [],
"judge_results": [],
"blocked": True, "blocked": True,
} }
return { return {
@@ -492,119 +538,6 @@ class AgentWorkflow:
"next_state": "SESSION_ENDED", "next_state": "SESSION_ENDED",
} }
@staticmethod
def _output_guardrail_context(state: dict) -> dict:
"""Monta o contexto operacional do turno para os guardrails de saída.
Mantém evidências/protocolos necessários aos rails, mas impede que uma
transação encerrada ou semanticamente interrompida governe o novo turno.
O histórico completo permanece no state/checkpoint para auditoria.
"""
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)
history = list(state.get("history") or [])
current_user_text = str(state.get("user_text") or "").strip()
if current_user_text:
if (
not history
or not isinstance(history[-1], dict)
or str(history[-1].get("content") or "") != current_user_text
or str(history[-1].get("role") or "") != "user"
):
history.append({"role": "user", "content": current_user_text})
route_decision = state.get("route_decision") or {}
route_metadata = route_decision.get("metadata") if isinstance(route_decision, dict) else {}
route_metadata = route_metadata if isinstance(route_metadata, dict) else {}
pre_validation = state.get("transaction_pre_validation") or {}
pre_validation = pre_validation if isinstance(pre_validation, dict) else {}
tx_status = str(
state.get("transaction_status") or pre_validation.get("status") or ""
).strip().upper()
terminal_tx = bool(pre_validation.get("terminal")) or tx_status in {
"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"
}
semantic_intent_shift = (
str(route_metadata.get("transaction_interruption") or "").strip().lower()
== "intent_shift"
)
stickiness_intent_shift = bool(route_metadata.get("route_stickiness_preempted"))
should_isolate_history = semantic_intent_shift or (terminal_tx and stickiness_intent_shift)
current_route = str(
state.get("route")
or (route_decision.get("route") if isinstance(route_decision, dict) else "")
or ""
).strip()
current_intent = str(
state.get("intent")
or (route_decision.get("intent") if isinstance(route_decision, dict) else "")
or ""
).strip()
ctx["current_user_message"] = current_user_text
ctx["current_route"] = current_route
ctx["current_intent"] = current_intent
if should_isolate_history:
operational_history = (
[{"role": "user", "content": current_user_text}]
if current_user_text else []
)
ctx["historical_transaction_ignored"] = True
ctx["historical_transaction_status"] = tx_status or (
"INTERRUPTED" if semantic_intent_shift else "TERMINAL"
)
if semantic_intent_shift:
ctx["historical_transaction_interruption"] = "intent_shift"
for stale_key in (
"transaction_pre_validation",
"transaction_status",
"active_transaction",
"transaction",
):
ctx.pop(stale_key, None)
else:
operational_history = history
ctx["conversation_history"] = operational_history
ctx["history_texts"] = [
str(item.get("content") or "")
for item in operational_history
if isinstance(item, dict) and item.get("content") not in (None, "")
]
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
async def output_supervisor(self, state): async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo. """Valida a resposta candidata com o OutputSupervisor corporativo.
@@ -620,15 +553,15 @@ class AgentWorkflow:
} }
candidate = state.get("answer") or "" candidate = state.get("answer") or ""
context = self._output_guardrail_context(state) context = {
context.update({ **(state.get("context") or {}),
"tenant_id": state.get("tenant_id"), "tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"), "agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"), "session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"), "route": state.get("route"),
"intent": state.get("intent"), "intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)), "supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}) }
async with self.telemetry.span( async with self.telemetry.span(
"workflow.output_supervisor", "workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"), session_id=state.get("conversation_key") or state.get("session_id"),
@@ -714,7 +647,7 @@ class AgentWorkflow:
component="workflow.output_guardrails.start", component="workflow.output_guardrails.start",
) )
final, decisions = await self.guardrails.run_output( final, decisions = await self.guardrails.run_output(
state["answer"], self._output_guardrail_context(state) state["answer"], state.get("context", {})
) )
for _decision in decisions: for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision) await self.guardrail_telemetry.evaluated("output", _decision)

View File

@@ -7,6 +7,35 @@ router:
confidence_threshold: 0.65 confidence_threshold: 0.65
allow_handoff: true allow_handoff: true
transaction_confirmation:
# Explicit yes/no stays deterministic. Only inconclusive replies use this LLM fallback.
semantic_fallback:
enabled: true
allowed_values: [SIM, NAO, CONTINUAR]
confirm_values: [SIM]
reject_values: [NAO]
continue_values: [CONTINUAR]
include_relevant_context: true
profile_name: router
prompt: |
Você classifica a resposta do cliente a uma confirmação transacional pendente.
Considere a pergunta pendente, somente o histórico recente relacionado ao mesmo tema e a fala atual.
Não execute a ação e não invente fatos.
Classes permitidas: {{ allowed_values }}
- SIM: confirmação/aceite inequívoco, inclusive equivalentes como "isso mesmo", "pode confirmar", "é isso" quando o contexto tornar o aceite claro.
- NAO: recusa/cancelamento inequívoco da ação pendente.
- CONTINUAR: qualquer resposta que não confirme nem rejeite inequivocamente, incluindo pergunta adicional, correção, novo dado, ambiguidade ou possível mudança de assunto.
Pergunta pendente:
{{ pending_prompt }}
Histórico relevante:
{{ relevant_conversation_context }}
Resposta atual do cliente:
{{ user_input }}
state_policies: state_policies:
- state: WAITING_BILLING_CONFIRMATION - state: WAITING_BILLING_CONFIRMATION
agent: billing_agent agent: billing_agent

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em faturas.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade para responder somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente, como “contract_key”, “customer_key” ou “MSISDN”.\nPara consultas informativas de fatura, apresente somente dados de negócio necessários, como valor, vencimento, situação e itens cobrados.\nNão acrescente canais, telefones, códigos USSD, URLs, aplicativos, lojas, relatórios adicionais, procedimentos alternativos ou próximos passos que não tenham sido explicitamente retornados pela tool/RAG e solicitados pelo usuário.\nNão ofereça espontaneamente outras ações ou detalhamentos.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de pedidos de varejo.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso, alteração, troca, cancelamento ou qualquer mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em produtos, planos e serviços.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nEm consultas meramente informativas, não exponha flags ou capacidades transacionais internas como can.cancel e não informe espontaneamente que algo pode ser cancelado, alterado, contratado, removido ou trocado. Só mencione capacidade transacional quando o usuário tiver solicitado essa ação.\nNão faça oferta proativa e não execute nem simule mutações sem a confirmação exigida pelo framework.\nNão acrescente canais, procedimentos ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "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.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de suporte de varejo para troca, devolução e garantia.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso nem simule troca, devolução, garantia ou outra mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de suporte de varejo para troca, devolução e garantia.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -13,42 +13,7 @@ def _money_brl(value: Any) -> str:
def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
"""Renderiza somente campos de negócio seguros da fatura. return f"[{agent_label}] Fatura consultada: {result}."
Identificadores técnicos/PII presentes no payload MCP (por exemplo msisdn,
customer_id, document e business keys) não devem ser propagados ao usuário.
"""
lines = [f"[{agent_label}] Dados da sua fatura:"]
total = result.get("valor_total")
vencimento = result.get("vencimento")
status = result.get("status")
if total is not None:
lines.append(f"Valor total: R$ {_money_brl(total)}.")
if vencimento not in (None, ""):
lines.append(f"Vencimento: {vencimento}.")
if status not in (None, ""):
lines.append(f"Situação: {status}.")
items = result.get("itens") or []
rendered_items: list[str] = []
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
description = item.get("descricao") or item.get("nome")
value = item.get("valor")
if description in (None, ""):
continue
if value is None:
rendered_items.append(str(description))
else:
rendered_items.append(f"{description}: R$ {_money_brl(value)}")
if rendered_items:
lines.append("Itens: " + "; ".join(rendered_items) + ".")
# Se não houver nenhum campo de negócio seguro além do cabeçalho, deixe a
# composição pela LLM/guardrails em vez de despejar o payload bruto.
return " ".join(lines) if len(lines) > 1 else None
def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:

View File

@@ -160,7 +160,7 @@ class AgentWorkflow:
builder.add_conditional_edges( builder.add_conditional_edges(
"input_guardrails", "input_guardrails",
self._after_input_guardrails, self._after_input_guardrails,
{"blocked": "persist", "continue": "load_long_term_memory"}, {"blocked": "output_guardrails", "continue": "load_long_term_memory"},
) )
builder.add_edge("load_long_term_memory", "routing_decision") builder.add_edge("load_long_term_memory", "routing_decision")
builder.add_conditional_edges( builder.add_conditional_edges(
@@ -197,6 +197,31 @@ class AgentWorkflow:
def _after_input_guardrails(self, state): def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue" return "blocked" if state.get("blocked") else "continue"
@staticmethod
def _input_guardrail_user_message(decisions, state, sanitized_text):
# Keep the technical guardrail reason in telemetry, but expose only a
# safe, actionable message to the end user. The message is intentionally
# routed through output_guardrails before persistence/delivery.
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
first = blocked[0] if blocked else None
code = str(getattr(first, "code", "") or "").upper()
if code == "COER":
return (
"Não consegui entender sua última mensagem porque ela parece "
"incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?"
)
if code == "INPUT_SIZE":
return "Sua mensagem ficou muito longa para eu processar de uma vez. Pode resumir ou dividir em partes?"
if code == "DLEX_IN":
return "Não posso usar essa informação da forma solicitada. Reformule o pedido sem incluir dados ou conteúdo restrito."
if code == "PINJ":
return "Não posso seguir instruções que tentem alterar as regras do atendimento. Posso continuar ajudando com a sua solicitação."
if code == "TOX":
return "Não consegui prosseguir com essa mensagem. Pode reformular o pedido para continuarmos o atendimento?"
if code == "CMP":
return "Não posso prosseguir com essa solicitação dessa forma. Posso ajudar com uma alternativa permitida."
return "Não consegui processar essa mensagem. Pode reformular para eu continuar o atendimento?"
async def input_guardrails(self, state): async def input_guardrails(self, state):
if state.get("session_ended") is True: if state.get("session_ended") is True:
answer = str(getattr( answer = str(getattr(
@@ -281,12 +306,33 @@ class AgentWorkflow:
component="workflow.input_guardrails.final", component="workflow.input_guardrails.final",
) )
if any(not d.allowed for d in decisions): if any(not d.allowed for d in decisions):
# A blocking input guardrail stops the turn before routing/tools.
# Clear turn-local routing/tool state so stale data from a prior
# turn cannot appear as if it was executed after the block.
user_message = self._input_guardrail_user_message(decisions, state, sanitized)
return { return {
"sanitized_input": sanitized, "sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.", "answer": user_message,
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": None,
"guardrail_decisions": [d.model_dump() for d in decisions], "guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked", "route": "blocked",
"intent": "input_guardrail_blocked",
"route_decision": {
"route": "blocked",
"agent": None,
"intent": "input_guardrail_blocked",
"confidence": 1.0,
"reason": "Entrada interrompida por guardrail antes do roteamento.",
"method": "guardrail",
"next_state": state.get("next_state"),
"handoff": False,
"metadata": {},
"domain": state.get("domain"),
"mcp_tools": [],
},
"mcp_tools": [],
"mcp_results": [],
"judge_results": [],
"blocked": True, "blocked": True,
} }
return { return {
@@ -494,119 +540,6 @@ class AgentWorkflow:
"next_state": "SESSION_ENDED", "next_state": "SESSION_ENDED",
} }
@staticmethod
def _output_guardrail_context(state: dict) -> dict:
"""Monta o contexto operacional do turno para os guardrails de saída.
Mantém evidências/protocolos necessários aos rails, mas impede que uma
transação encerrada ou semanticamente interrompida governe o novo turno.
O histórico completo permanece no state/checkpoint para auditoria.
"""
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)
history = list(state.get("history") or [])
current_user_text = str(state.get("user_text") or "").strip()
if current_user_text:
if (
not history
or not isinstance(history[-1], dict)
or str(history[-1].get("content") or "") != current_user_text
or str(history[-1].get("role") or "") != "user"
):
history.append({"role": "user", "content": current_user_text})
route_decision = state.get("route_decision") or {}
route_metadata = route_decision.get("metadata") if isinstance(route_decision, dict) else {}
route_metadata = route_metadata if isinstance(route_metadata, dict) else {}
pre_validation = state.get("transaction_pre_validation") or {}
pre_validation = pre_validation if isinstance(pre_validation, dict) else {}
tx_status = str(
state.get("transaction_status") or pre_validation.get("status") or ""
).strip().upper()
terminal_tx = bool(pre_validation.get("terminal")) or tx_status in {
"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"
}
semantic_intent_shift = (
str(route_metadata.get("transaction_interruption") or "").strip().lower()
== "intent_shift"
)
stickiness_intent_shift = bool(route_metadata.get("route_stickiness_preempted"))
should_isolate_history = semantic_intent_shift or (terminal_tx and stickiness_intent_shift)
current_route = str(
state.get("route")
or (route_decision.get("route") if isinstance(route_decision, dict) else "")
or ""
).strip()
current_intent = str(
state.get("intent")
or (route_decision.get("intent") if isinstance(route_decision, dict) else "")
or ""
).strip()
ctx["current_user_message"] = current_user_text
ctx["current_route"] = current_route
ctx["current_intent"] = current_intent
if should_isolate_history:
operational_history = (
[{"role": "user", "content": current_user_text}]
if current_user_text else []
)
ctx["historical_transaction_ignored"] = True
ctx["historical_transaction_status"] = tx_status or (
"INTERRUPTED" if semantic_intent_shift else "TERMINAL"
)
if semantic_intent_shift:
ctx["historical_transaction_interruption"] = "intent_shift"
for stale_key in (
"transaction_pre_validation",
"transaction_status",
"active_transaction",
"transaction",
):
ctx.pop(stale_key, None)
else:
operational_history = history
ctx["conversation_history"] = operational_history
ctx["history_texts"] = [
str(item.get("content") or "")
for item in operational_history
if isinstance(item, dict) and item.get("content") not in (None, "")
]
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
async def output_supervisor(self, state): async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo. """Valida a resposta candidata com o OutputSupervisor corporativo.
@@ -622,15 +555,15 @@ class AgentWorkflow:
} }
candidate = state.get("answer") or "" candidate = state.get("answer") or ""
context = self._output_guardrail_context(state) context = {
context.update({ **(state.get("context") or {}),
"tenant_id": state.get("tenant_id"), "tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"), "agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"), "session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"), "route": state.get("route"),
"intent": state.get("intent"), "intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)), "supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}) }
async with self.telemetry.span( async with self.telemetry.span(
"workflow.output_supervisor", "workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"), session_id=state.get("conversation_key") or state.get("session_id"),
@@ -716,7 +649,7 @@ class AgentWorkflow:
component="workflow.output_guardrails.start", component="workflow.output_guardrails.start",
) )
final, decisions = await self.guardrails.run_output( final, decisions = await self.guardrails.run_output(
state["answer"], self._output_guardrail_context(state) state["answer"], state.get("context", {})
) )
for _decision in decisions: for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision) await self.guardrail_telemetry.evaluated("output", _decision)

View File

@@ -7,6 +7,35 @@ router:
confidence_threshold: 0.65 confidence_threshold: 0.65
allow_handoff: true allow_handoff: true
transaction_confirmation:
# Explicit yes/no stays deterministic. Only inconclusive replies use this LLM fallback.
semantic_fallback:
enabled: true
allowed_values: [SIM, NAO, CONTINUAR]
confirm_values: [SIM]
reject_values: [NAO]
continue_values: [CONTINUAR]
include_relevant_context: true
profile_name: router
prompt: |
Você classifica a resposta do cliente a uma confirmação transacional pendente.
Considere a pergunta pendente, somente o histórico recente relacionado ao mesmo tema e a fala atual.
Não execute a ação e não invente fatos.
Classes permitidas: {{ allowed_values }}
- SIM: confirmação/aceite inequívoco, inclusive equivalentes como "isso mesmo", "pode confirmar", "é isso" quando o contexto tornar o aceite claro.
- NAO: recusa/cancelamento inequívoco da ação pendente.
- CONTINUAR: qualquer resposta que não confirme nem rejeite inequivocamente, incluindo pergunta adicional, correção, novo dado, ambiguidade ou possível mudança de assunto.
Pergunta pendente:
{{ pending_prompt }}
Histórico relevante:
{{ relevant_conversation_context }}
Resposta atual do cliente:
{{ user_input }}
state_policies: state_policies:
- state: WAITING_BILLING_CONFIRMATION - state: WAITING_BILLING_CONFIRMATION
agent: billing_agent agent: billing_agent

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em faturas.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade para responder somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente, como “contract_key”, “customer_key” ou “MSISDN”.\nPara consultas informativas de fatura, apresente somente dados de negócio necessários, como valor, vencimento, situação e itens cobrados.\nNão acrescente canais, telefones, códigos USSD, URLs, aplicativos, lojas, relatórios adicionais, procedimentos alternativos ou próximos passos que não tenham sido explicitamente retornados pela tool/RAG e solicitados pelo usuário.\nNão ofereça espontaneamente outras ações ou detalhamentos.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de pedidos de varejo.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso, alteração, troca, cancelamento ou qualquer mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em produtos, planos e serviços.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nEm consultas meramente informativas, não exponha flags ou capacidades transacionais internas como can.cancel e não informe espontaneamente que algo pode ser cancelado, alterado, contratado, removido ou trocado. Só mencione capacidade transacional quando o usuário tiver solicitado essa ação.\nNão faça oferta proativa e não execute nem simule mutações sem a confirmação exigida pelo framework.\nNão acrescente canais, procedimentos ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "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.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de suporte de varejo para troca, devolução e garantia.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso nem simule troca, devolução, garantia ou outra mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de suporte de varejo para troca, devolução e garantia.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -13,42 +13,7 @@ def _money_brl(value: Any) -> str:
def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
"""Renderiza somente campos de negócio seguros da fatura. return f"[{agent_label}] Fatura consultada: {result}."
Identificadores técnicos/PII presentes no payload MCP (por exemplo msisdn,
customer_id, document e business keys) não devem ser propagados ao usuário.
"""
lines = [f"[{agent_label}] Dados da sua fatura:"]
total = result.get("valor_total")
vencimento = result.get("vencimento")
status = result.get("status")
if total is not None:
lines.append(f"Valor total: R$ {_money_brl(total)}.")
if vencimento not in (None, ""):
lines.append(f"Vencimento: {vencimento}.")
if status not in (None, ""):
lines.append(f"Situação: {status}.")
items = result.get("itens") or []
rendered_items: list[str] = []
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
description = item.get("descricao") or item.get("nome")
value = item.get("valor")
if description in (None, ""):
continue
if value is None:
rendered_items.append(str(description))
else:
rendered_items.append(f"{description}: R$ {_money_brl(value)}")
if rendered_items:
lines.append("Itens: " + "; ".join(rendered_items) + ".")
# Se não houver nenhum campo de negócio seguro além do cabeçalho, deixe a
# composição pela LLM/guardrails em vez de despejar o payload bruto.
return " ".join(lines) if len(lines) > 1 else None
def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:

View File

@@ -159,7 +159,7 @@ class AgentWorkflow:
builder.add_conditional_edges( builder.add_conditional_edges(
"input_guardrails", "input_guardrails",
self._after_input_guardrails, self._after_input_guardrails,
{"blocked": "persist", "continue": "routing_decision"}, {"blocked": "output_guardrails", "continue": "routing_decision"},
) )
builder.add_conditional_edges( builder.add_conditional_edges(
"routing_decision", "routing_decision",
@@ -195,6 +195,31 @@ class AgentWorkflow:
def _after_input_guardrails(self, state): def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue" return "blocked" if state.get("blocked") else "continue"
@staticmethod
def _input_guardrail_user_message(decisions, state, sanitized_text):
# Keep the technical guardrail reason in telemetry, but expose only a
# safe, actionable message to the end user. The message is intentionally
# routed through output_guardrails before persistence/delivery.
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
first = blocked[0] if blocked else None
code = str(getattr(first, "code", "") or "").upper()
if code == "COER":
return (
"Não consegui entender sua última mensagem porque ela parece "
"incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?"
)
if code == "INPUT_SIZE":
return "Sua mensagem ficou muito longa para eu processar de uma vez. Pode resumir ou dividir em partes?"
if code == "DLEX_IN":
return "Não posso usar essa informação da forma solicitada. Reformule o pedido sem incluir dados ou conteúdo restrito."
if code == "PINJ":
return "Não posso seguir instruções que tentem alterar as regras do atendimento. Posso continuar ajudando com a sua solicitação."
if code == "TOX":
return "Não consegui prosseguir com essa mensagem. Pode reformular o pedido para continuarmos o atendimento?"
if code == "CMP":
return "Não posso prosseguir com essa solicitação dessa forma. Posso ajudar com uma alternativa permitida."
return "Não consegui processar essa mensagem. Pode reformular para eu continuar o atendimento?"
async def input_guardrails(self, state): async def input_guardrails(self, state):
if state.get("session_ended") is True: if state.get("session_ended") is True:
answer = str(getattr( answer = str(getattr(
@@ -279,12 +304,33 @@ class AgentWorkflow:
component="workflow.input_guardrails.final", component="workflow.input_guardrails.final",
) )
if any(not d.allowed for d in decisions): if any(not d.allowed for d in decisions):
# A blocking input guardrail stops the turn before routing/tools.
# Clear turn-local routing/tool state so stale data from a prior
# turn cannot appear as if it was executed after the block.
user_message = self._input_guardrail_user_message(decisions, state, sanitized)
return { return {
"sanitized_input": sanitized, "sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.", "answer": user_message,
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": None,
"guardrail_decisions": [d.model_dump() for d in decisions], "guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked", "route": "blocked",
"intent": "input_guardrail_blocked",
"route_decision": {
"route": "blocked",
"agent": None,
"intent": "input_guardrail_blocked",
"confidence": 1.0,
"reason": "Entrada interrompida por guardrail antes do roteamento.",
"method": "guardrail",
"next_state": state.get("next_state"),
"handoff": False,
"metadata": {},
"domain": state.get("domain"),
"mcp_tools": [],
},
"mcp_tools": [],
"mcp_results": [],
"judge_results": [],
"blocked": True, "blocked": True,
} }
return { return {
@@ -492,119 +538,6 @@ class AgentWorkflow:
"next_state": "SESSION_ENDED", "next_state": "SESSION_ENDED",
} }
@staticmethod
def _output_guardrail_context(state: dict) -> dict:
"""Monta o contexto operacional do turno para os guardrails de saída.
Mantém evidências/protocolos necessários aos rails, mas impede que uma
transação encerrada ou semanticamente interrompida governe o novo turno.
O histórico completo permanece no state/checkpoint para auditoria.
"""
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)
history = list(state.get("history") or [])
current_user_text = str(state.get("user_text") or "").strip()
if current_user_text:
if (
not history
or not isinstance(history[-1], dict)
or str(history[-1].get("content") or "") != current_user_text
or str(history[-1].get("role") or "") != "user"
):
history.append({"role": "user", "content": current_user_text})
route_decision = state.get("route_decision") or {}
route_metadata = route_decision.get("metadata") if isinstance(route_decision, dict) else {}
route_metadata = route_metadata if isinstance(route_metadata, dict) else {}
pre_validation = state.get("transaction_pre_validation") or {}
pre_validation = pre_validation if isinstance(pre_validation, dict) else {}
tx_status = str(
state.get("transaction_status") or pre_validation.get("status") or ""
).strip().upper()
terminal_tx = bool(pre_validation.get("terminal")) or tx_status in {
"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"
}
semantic_intent_shift = (
str(route_metadata.get("transaction_interruption") or "").strip().lower()
== "intent_shift"
)
stickiness_intent_shift = bool(route_metadata.get("route_stickiness_preempted"))
should_isolate_history = semantic_intent_shift or (terminal_tx and stickiness_intent_shift)
current_route = str(
state.get("route")
or (route_decision.get("route") if isinstance(route_decision, dict) else "")
or ""
).strip()
current_intent = str(
state.get("intent")
or (route_decision.get("intent") if isinstance(route_decision, dict) else "")
or ""
).strip()
ctx["current_user_message"] = current_user_text
ctx["current_route"] = current_route
ctx["current_intent"] = current_intent
if should_isolate_history:
operational_history = (
[{"role": "user", "content": current_user_text}]
if current_user_text else []
)
ctx["historical_transaction_ignored"] = True
ctx["historical_transaction_status"] = tx_status or (
"INTERRUPTED" if semantic_intent_shift else "TERMINAL"
)
if semantic_intent_shift:
ctx["historical_transaction_interruption"] = "intent_shift"
for stale_key in (
"transaction_pre_validation",
"transaction_status",
"active_transaction",
"transaction",
):
ctx.pop(stale_key, None)
else:
operational_history = history
ctx["conversation_history"] = operational_history
ctx["history_texts"] = [
str(item.get("content") or "")
for item in operational_history
if isinstance(item, dict) and item.get("content") not in (None, "")
]
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
async def output_supervisor(self, state): async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo. """Valida a resposta candidata com o OutputSupervisor corporativo.
@@ -620,15 +553,15 @@ class AgentWorkflow:
} }
candidate = state.get("answer") or "" candidate = state.get("answer") or ""
context = self._output_guardrail_context(state) context = {
context.update({ **(state.get("context") or {}),
"tenant_id": state.get("tenant_id"), "tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"), "agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"), "session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"), "route": state.get("route"),
"intent": state.get("intent"), "intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)), "supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}) }
async with self.telemetry.span( async with self.telemetry.span(
"workflow.output_supervisor", "workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"), session_id=state.get("conversation_key") or state.get("session_id"),
@@ -714,7 +647,7 @@ class AgentWorkflow:
component="workflow.output_guardrails.start", component="workflow.output_guardrails.start",
) )
final, decisions = await self.guardrails.run_output( final, decisions = await self.guardrails.run_output(
state["answer"], self._output_guardrail_context(state) state["answer"], state.get("context", {})
) )
for _decision in decisions: for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision) await self.guardrail_telemetry.evaluated("output", _decision)

View File

@@ -7,6 +7,35 @@ router:
confidence_threshold: 0.65 confidence_threshold: 0.65
allow_handoff: true allow_handoff: true
transaction_confirmation:
# Explicit yes/no stays deterministic. Only inconclusive replies use this LLM fallback.
semantic_fallback:
enabled: true
allowed_values: [SIM, NAO, CONTINUAR]
confirm_values: [SIM]
reject_values: [NAO]
continue_values: [CONTINUAR]
include_relevant_context: true
profile_name: router
prompt: |
Você classifica a resposta do cliente a uma confirmação transacional pendente.
Considere a pergunta pendente, somente o histórico recente relacionado ao mesmo tema e a fala atual.
Não execute a ação e não invente fatos.
Classes permitidas: {{ allowed_values }}
- SIM: confirmação/aceite inequívoco, inclusive equivalentes como "isso mesmo", "pode confirmar", "é isso" quando o contexto tornar o aceite claro.
- NAO: recusa/cancelamento inequívoco da ação pendente.
- CONTINUAR: qualquer resposta que não confirme nem rejeite inequivocamente, incluindo pergunta adicional, correção, novo dado, ambiguidade ou possível mudança de assunto.
Pergunta pendente:
{{ pending_prompt }}
Histórico relevante:
{{ relevant_conversation_context }}
Resposta atual do cliente:
{{ user_input }}
state_policies: state_policies:
- state: WAITING_BILLING_CONFIRMATION - state: WAITING_BILLING_CONFIRMATION
agent: billing_agent agent: billing_agent

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em faturas.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade para responder somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente, como “contract_key”, “customer_key” ou “MSISDN”.\nPara consultas informativas de fatura, apresente somente dados de negócio necessários, como valor, vencimento, situação e itens cobrados.\nNão acrescente canais, telefones, códigos USSD, URLs, aplicativos, lojas, relatórios adicionais, procedimentos alternativos ou próximos passos que não tenham sido explicitamente retornados pela tool/RAG e solicitados pelo usuário.\nNão ofereça espontaneamente outras ações ou detalhamentos.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de pedidos de varejo.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso, alteração, troca, cancelamento ou qualquer mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em produtos, planos e serviços.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nEm consultas meramente informativas, não exponha flags ou capacidades transacionais internas como can.cancel e não informe espontaneamente que algo pode ser cancelado, alterado, contratado, removido ou trocado. Só mencione capacidade transacional quando o usuário tiver solicitado essa ação.\nNão faça oferta proativa e não execute nem simule mutações sem a confirmação exigida pelo framework.\nNão acrescente canais, procedimentos ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "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.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de suporte de varejo para troca, devolução e garantia.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso nem simule troca, devolução, garantia ou outra mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de suporte de varejo para troca, devolução e garantia.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -159,7 +159,7 @@ class AgentWorkflow:
builder.add_conditional_edges( builder.add_conditional_edges(
"input_guardrails", "input_guardrails",
self._after_input_guardrails, self._after_input_guardrails,
{"blocked": "persist", "continue": "routing_decision"}, {"blocked": "output_guardrails", "continue": "routing_decision"},
) )
builder.add_conditional_edges( builder.add_conditional_edges(
"routing_decision", "routing_decision",
@@ -195,6 +195,31 @@ class AgentWorkflow:
def _after_input_guardrails(self, state): def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue" return "blocked" if state.get("blocked") else "continue"
@staticmethod
def _input_guardrail_user_message(decisions, state, sanitized_text):
# Keep the technical guardrail reason in telemetry, but expose only a
# safe, actionable message to the end user. The message is intentionally
# routed through output_guardrails before persistence/delivery.
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
first = blocked[0] if blocked else None
code = str(getattr(first, "code", "") or "").upper()
if code == "COER":
return (
"Não consegui entender sua última mensagem porque ela parece "
"incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?"
)
if code == "INPUT_SIZE":
return "Sua mensagem ficou muito longa para eu processar de uma vez. Pode resumir ou dividir em partes?"
if code == "DLEX_IN":
return "Não posso usar essa informação da forma solicitada. Reformule o pedido sem incluir dados ou conteúdo restrito."
if code == "PINJ":
return "Não posso seguir instruções que tentem alterar as regras do atendimento. Posso continuar ajudando com a sua solicitação."
if code == "TOX":
return "Não consegui prosseguir com essa mensagem. Pode reformular o pedido para continuarmos o atendimento?"
if code == "CMP":
return "Não posso prosseguir com essa solicitação dessa forma. Posso ajudar com uma alternativa permitida."
return "Não consegui processar essa mensagem. Pode reformular para eu continuar o atendimento?"
async def input_guardrails(self, state): async def input_guardrails(self, state):
if state.get("session_ended") is True: if state.get("session_ended") is True:
answer = str(getattr( answer = str(getattr(
@@ -279,12 +304,33 @@ class AgentWorkflow:
component="workflow.input_guardrails.final", component="workflow.input_guardrails.final",
) )
if any(not d.allowed for d in decisions): if any(not d.allowed for d in decisions):
# A blocking input guardrail stops the turn before routing/tools.
# Clear turn-local routing/tool state so stale data from a prior
# turn cannot appear as if it was executed after the block.
user_message = self._input_guardrail_user_message(decisions, state, sanitized)
return { return {
"sanitized_input": sanitized, "sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.", "answer": user_message,
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": None,
"guardrail_decisions": [d.model_dump() for d in decisions], "guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked", "route": "blocked",
"intent": "input_guardrail_blocked",
"route_decision": {
"route": "blocked",
"agent": None,
"intent": "input_guardrail_blocked",
"confidence": 1.0,
"reason": "Entrada interrompida por guardrail antes do roteamento.",
"method": "guardrail",
"next_state": state.get("next_state"),
"handoff": False,
"metadata": {},
"domain": state.get("domain"),
"mcp_tools": [],
},
"mcp_tools": [],
"mcp_results": [],
"judge_results": [],
"blocked": True, "blocked": True,
} }
return { return {
@@ -492,119 +538,6 @@ class AgentWorkflow:
"next_state": "SESSION_ENDED", "next_state": "SESSION_ENDED",
} }
@staticmethod
def _output_guardrail_context(state: dict) -> dict:
"""Monta o contexto operacional do turno para os guardrails de saída.
Mantém evidências/protocolos necessários aos rails, mas impede que uma
transação encerrada ou semanticamente interrompida governe o novo turno.
O histórico completo permanece no state/checkpoint para auditoria.
"""
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)
history = list(state.get("history") or [])
current_user_text = str(state.get("user_text") or "").strip()
if current_user_text:
if (
not history
or not isinstance(history[-1], dict)
or str(history[-1].get("content") or "") != current_user_text
or str(history[-1].get("role") or "") != "user"
):
history.append({"role": "user", "content": current_user_text})
route_decision = state.get("route_decision") or {}
route_metadata = route_decision.get("metadata") if isinstance(route_decision, dict) else {}
route_metadata = route_metadata if isinstance(route_metadata, dict) else {}
pre_validation = state.get("transaction_pre_validation") or {}
pre_validation = pre_validation if isinstance(pre_validation, dict) else {}
tx_status = str(
state.get("transaction_status") or pre_validation.get("status") or ""
).strip().upper()
terminal_tx = bool(pre_validation.get("terminal")) or tx_status in {
"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"
}
semantic_intent_shift = (
str(route_metadata.get("transaction_interruption") or "").strip().lower()
== "intent_shift"
)
stickiness_intent_shift = bool(route_metadata.get("route_stickiness_preempted"))
should_isolate_history = semantic_intent_shift or (terminal_tx and stickiness_intent_shift)
current_route = str(
state.get("route")
or (route_decision.get("route") if isinstance(route_decision, dict) else "")
or ""
).strip()
current_intent = str(
state.get("intent")
or (route_decision.get("intent") if isinstance(route_decision, dict) else "")
or ""
).strip()
ctx["current_user_message"] = current_user_text
ctx["current_route"] = current_route
ctx["current_intent"] = current_intent
if should_isolate_history:
operational_history = (
[{"role": "user", "content": current_user_text}]
if current_user_text else []
)
ctx["historical_transaction_ignored"] = True
ctx["historical_transaction_status"] = tx_status or (
"INTERRUPTED" if semantic_intent_shift else "TERMINAL"
)
if semantic_intent_shift:
ctx["historical_transaction_interruption"] = "intent_shift"
for stale_key in (
"transaction_pre_validation",
"transaction_status",
"active_transaction",
"transaction",
):
ctx.pop(stale_key, None)
else:
operational_history = history
ctx["conversation_history"] = operational_history
ctx["history_texts"] = [
str(item.get("content") or "")
for item in operational_history
if isinstance(item, dict) and item.get("content") not in (None, "")
]
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
async def output_supervisor(self, state): async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo. """Valida a resposta candidata com o OutputSupervisor corporativo.
@@ -620,15 +553,15 @@ class AgentWorkflow:
} }
candidate = state.get("answer") or "" candidate = state.get("answer") or ""
context = self._output_guardrail_context(state) context = {
context.update({ **(state.get("context") or {}),
"tenant_id": state.get("tenant_id"), "tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"), "agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"), "session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"), "route": state.get("route"),
"intent": state.get("intent"), "intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)), "supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}) }
async with self.telemetry.span( async with self.telemetry.span(
"workflow.output_supervisor", "workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"), session_id=state.get("conversation_key") or state.get("session_id"),
@@ -714,7 +647,7 @@ class AgentWorkflow:
component="workflow.output_guardrails.start", component="workflow.output_guardrails.start",
) )
final, decisions = await self.guardrails.run_output( final, decisions = await self.guardrails.run_output(
state["answer"], self._output_guardrail_context(state) state["answer"], state.get("context", {})
) )
for _decision in decisions: for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision) await self.guardrail_telemetry.evaluated("output", _decision)

View File

@@ -7,6 +7,35 @@ router:
confidence_threshold: 0.65 confidence_threshold: 0.65
allow_handoff: true allow_handoff: true
transaction_confirmation:
# Explicit yes/no stays deterministic. Only inconclusive replies use this LLM fallback.
semantic_fallback:
enabled: true
allowed_values: [SIM, NAO, CONTINUAR]
confirm_values: [SIM]
reject_values: [NAO]
continue_values: [CONTINUAR]
include_relevant_context: true
profile_name: router
prompt: |
Você classifica a resposta do cliente a uma confirmação transacional pendente.
Considere a pergunta pendente, somente o histórico recente relacionado ao mesmo tema e a fala atual.
Não execute a ação e não invente fatos.
Classes permitidas: {{ allowed_values }}
- SIM: confirmação/aceite inequívoco, inclusive equivalentes como "isso mesmo", "pode confirmar", "é isso" quando o contexto tornar o aceite claro.
- NAO: recusa/cancelamento inequívoco da ação pendente.
- CONTINUAR: qualquer resposta que não confirme nem rejeite inequivocamente, incluindo pergunta adicional, correção, novo dado, ambiguidade ou possível mudança de assunto.
Pergunta pendente:
{{ pending_prompt }}
Histórico relevante:
{{ relevant_conversation_context }}
Resposta atual do cliente:
{{ user_input }}
state_policies: state_policies:
- state: WAITING_BILLING_CONFIRMATION - state: WAITING_BILLING_CONFIRMATION
agent: billing_agent agent: billing_agent

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -13,3 +13,46 @@ O `WorkflowRuntime` também preserva o último snapshot persistido do LangGraph
Isso é necessário para workflows transacionais: por exemplo, se um protocolo foi criado e uma chamada posterior falha, o chamador ainda recebe o `protocol_number` persistido e pode executar recuperação/idempotência sem repetir o primeiro side effect. Isso é necessário para workflows transacionais: por exemplo, se um protocolo foi criado e uma chamada posterior falha, o chamador ainda recebe o `protocol_number` persistido e pode executar recuperação/idempotência sem repetir o primeiro side effect.
O runtime não transforma falha em sucesso e não reexecuta automaticamente a action; ele apenas preserva a evidência durável já existente no checkpointer. O runtime não transforma falha em sucesso e não reexecuta automaticamente a action; ele apenas preserva a evidência durável já existente no checkpointer.
## Tratamento genérico de entrada fora das opções (`unmatched`)
`expected_input` mantém compatibilidade com o comportamento anterior.
Sem `semantic_classifier`, qualquer entrada que não pertença literalmente a
`allowed_values` permanece no workflow e recebe o `reprompt`.
Quando o agente precisa aceitar linguagem natural, ele declara um prompt
classificatório cujo resultado deve ser uma das próprias opções dinâmicas:
```yaml
expected_input:
key: resposta_usuario
allowed_values: [SIM, NAO]
normalize: upper_strip
reprompt: "Não entendi. Responda sim ou não."
semantic_classifier:
enabled: true
prompt: |
Classifique a fala em exatamente uma opção de {{ allowed_values }}.
Pergunta pendente: {{ pending_prompt }}
Fala do usuário: {{ user_input }}
Retorne somente uma opção de {{ allowed_values }}.
```
O framework não possui classes fixas. `allowed_values` pode conter duas, três ou
mais opções; o prompt do agente define a semântica de cada uma. O framework
renderiza os placeholders, chama a LLM e rejeita qualquer saída que não pertença
à allowlist, usando `reprompt` nesse caso. O texto original do usuário é mantido
nos metadados da decisão para auditoria.
Nesse modo, `COER` delega a interpretação semântica ao classificador configurado.
Rails de segurança independentes — por exemplo PINJ, toxicidade, PII e limites
de tamanho — continuam podendo bloquear o turno normalmente.
O exemplo executável está em `agent_template_backend/` e, por compatibilidade
com a estrutura histórica desta feature, também em
`agent_template_backend_pause_resume/`.
### Reentrada contextual por opção
Uma opção do `semantic_classifier` pode declarar `option_actions.<OPCAO>.action: contextual_reentry`. Nesse caso o workflow pausado não é retomado: o framework libera a pausa e reexecuta o roteamento usando somente o contexto conversacional ancorado que originou a decisão mais a fala atual. A fala original é preservada para auditoria e o contexto reconstruído não vira evidência de negócio; parâmetros candidatos continuam sujeitos a validação e confirmação normais.

View File

@@ -32,3 +32,49 @@ O exemplo usa `MemorySaver` apenas para ser autocontido. Em aplicações reais u
## Regra arquitetural ## Regra arquitetural
Código de domínio não deve importar `langgraph.graph.StateGraph`. Para grafos de agentes use `FrameworkStateGraph`; para workflows determinísticos de negócio use `WorkflowRuntime`. Código de domínio não deve importar `langgraph.graph.StateGraph`. Para grafos de agentes use `FrameworkStateGraph`; para workflows determinísticos de negócio use `WorkflowRuntime`.
## Entrada enumerada, reprompt e `semantic_classifier`
O contrato `expected_input` pode declarar qualquer conjunto de opções em
`allowed_values`. O match literal continua determinístico; quando a resposta não
coincide literalmente com uma opção, o agente pode habilitar um classificador
semântico com prompt próprio.
```yaml
expected_input:
key: resposta_usuario
allowed_values: [SIM, NAO]
normalize: upper_strip
reprompt: "Não entendi. Responda sim ou não."
semantic_classifier:
enabled: true
prompt: |
Classifique {{ user_input }} em exatamente uma opção de {{ allowed_values }}.
Para este workflow, aceitação/entendimento => SIM; negação, nova pergunta
ou hipótese factual a validar => NAO.
Retorne somente uma opção de {{ allowed_values }}.
```
O framework não conhece o significado de `SIM`, `NAO` nem de nenhuma outra
opção. Ele apenas injeta `allowed_values`, `pending_prompt` e `user_input`, chama
a LLM e valida estritamente se a saída pertence à lista declarada. Uma saída
fora da lista usa o `reprompt`.
O mesmo mecanismo funciona sem alteração do framework para, por exemplo,
`[CONFIRMAR, ALTERAR, CANCELAR]` ou qualquer outra lista configurada pelo agente.
O rail `COER` delega a semântica ao `semantic_classifier` nesse modo; PINJ,
toxicidade, PII e os demais rails de segurança continuam independentes.
Há dois diretórios equivalentes para facilitar comparação com os demais
cenários de Tuning-Performance:
- `agent_template_backend/` — nome padrão de template;
- `agent_template_backend_pause_resume/` — nome histórico deste exemplo.
Ambos contêm o mesmo workflow `confirmacao.v1.yaml`.
### Reentrada contextual por opção
Uma opção do `semantic_classifier` pode declarar `option_actions.<OPCAO>.action: contextual_reentry`. Nesse caso o workflow pausado não é retomado: o framework libera a pausa e reexecuta o roteamento usando somente o contexto conversacional ancorado que originou a decisão mais a fala atual. A fala original é preservada para auditoria e o contexto reconstruído não vira evidência de negócio; parâmetros candidatos continuam sujeitos a validação e confirmação normais.

View File

@@ -0,0 +1,26 @@
# Agent Template Backend — Pause/Resume Workflow
Exemplo autocontido de um agente que usa o motor genérico de workflows do
`agent_framework_oci`.
O arquivo `workflows/confirmacao.v1.yaml` demonstra:
- `pause`;
- `expected_input`;
- `allowed_values`;
- `normalize`;
- `reprompt`;
- `semantic_classifier`;
- `resume_from`.
O framework não conhece `SIM`, `NAO` nem a regra de negócio. O agente declara
os valores e o prompt no YAML. Quando a fala não corresponde literalmente a uma
opção, `semantic_classifier` classifica usando o prompt do agente e o framework
aceita somente uma saída presente em `allowed_values`; qualquer outra saída usa
o `reprompt`. O mesmo mecanismo funciona com qualquer quantidade de opções.
Execute os testes a partir desta pasta:
```bash
pytest -q
```

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from agent_framework.workflows import FileWorkflowRepository, WorkflowActionRegistry, WorkflowRuntime
ROOT = Path(__file__).resolve().parents[1]
def build_runtime(*, offline_test_fallback: bool = False) -> WorkflowRuntime:
actions = WorkflowActionRegistry()
async def preparar(params, state):
return {"assunto": params.get("assunto") or "operação"}
async def perguntar(params, state):
return {"mensagem": f"Deseja confirmar {params['assunto']}?"}
async def decidir(params, state):
return {
"mensagem": "Operação confirmada." if params["resposta"] == "SIM" else "Operação cancelada.",
"confirmado": params["resposta"] == "SIM",
}
actions.register("preparar_operacao", preparar)
actions.register("montar_pergunta", perguntar)
actions.register("registrar_decisao", decidir)
checkpointer = None
if not offline_test_fallback:
# Produção/exemplo real continua usando LangGraph + checkpointer. O import
# fica aqui para que a regressão offline do repositório não dependa de rede.
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
return WorkflowRuntime(
FileWorkflowRepository(ROOT / "workflows"),
actions=actions,
checkpointer=checkpointer,
allow_deterministic_fallback=offline_test_fallback,
)
async def main() -> None:
runtime = build_runtime()
first = await runtime.arun("confirmacao", {"assunto": "a alteração do plano"})
print(first.model_dump(mode="json"))
assert first.status == "PAUSED"
resumed = await runtime.aresume("confirmacao", first.execution_id, {"resposta_usuario": "sim"})
print(resumed.model_dump(mode="json"))
assert resumed.status == "COMPLETED"
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import pytest
from app.demo import build_runtime
@pytest.mark.asyncio
async def test_pause_resume_does_not_repeat_previous_action():
# Regressão offline: exercita a mesma DSL/WorkflowRuntime sem exigir download
# de LangGraph no builder. Produção continua usando build_runtime() default.
runtime = build_runtime(offline_test_fallback=True)
first = await runtime.arun("confirmacao", {"assunto": "o cancelamento"})
assert first.status == "PAUSED"
assert first.pause["expected_input"]["key"] == "resposta_usuario"
before = [item for item in first.trace if item.get("action") == "preparar_operacao"]
assert len(before) == 1
resumed = await runtime.aresume("confirmacao", first.execution_id, {"resposta_usuario": "SIM"})
assert resumed.status == "COMPLETED"
after = [item for item in resumed.trace if item.get("action") == "preparar_operacao"]
assert len(after) == 1
assert resumed.state["vars"]["decidir"]["confirmado"] is True
def test_pause_resume_example_documents_semantic_classifier():
from pathlib import Path
import yaml
project = Path(__file__).resolve().parents[1]
data = yaml.safe_load((project / "workflows" / "confirmacao.v1.yaml").read_text(encoding="utf-8"))
perguntar = next(node for node in data["nodes"] if node["id"] == "perguntar")
expected = perguntar["pause"]["expected_input"]
assert expected["reprompt"] == "Não entendi. Responda sim ou não."
assert expected["semantic_classifier"]["enabled"] is True
assert "{{ allowed_values }}" in expected["semantic_classifier"]["prompt"]

View File

@@ -0,0 +1,45 @@
name: confirmacao
version: 1
start: preparar
nodes:
- id: preparar
action: preparar_operacao
input:
assunto: $.input.assunto
- id: perguntar
action: montar_pergunta
input:
assunto: $.vars.preparar.assunto
pause:
enabled: true
return_from: $.output.mensagem
expected_input:
key: resposta_usuario
allowed_values: [SIM, NAO]
normalize: upper_strip
reprompt: "Não entendi. Responda sim ou não."
semantic_classifier:
include_relevant_context: true
enabled: true
prompt: |
Classifique a resposta em exatamente uma opção de {{ allowed_values }}.
Pergunta pendente: {{ pending_prompt }}
Contexto relevante:
{{ relevant_conversation_context }}
Resposta do usuário: {{ user_input }}
Neste exemplo, concordância/aceitação corresponde a SIM e recusa, dúvida
adicional ou nova condição corresponde a NAO.
Retorne somente uma opção de {{ allowed_values }}.
resume_from: decidir
- id: decidir
action: registrar_decisao
input:
resposta: $.input.resposta_usuario
assunto: $.vars.preparar.assunto
edges:
- from: preparar
to: perguntar
- from: perguntar
to: END
- from: decidir
to: END

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -20,3 +20,16 @@ async def test_pause_resume_does_not_repeat_previous_action():
after = [item for item in resumed.trace if item.get("action") == "preparar_operacao"] after = [item for item in resumed.trace if item.get("action") == "preparar_operacao"]
assert len(after) == 1 assert len(after) == 1
assert resumed.state["vars"]["decidir"]["confirmado"] is True assert resumed.state["vars"]["decidir"]["confirmado"] is True
def test_pause_resume_example_documents_semantic_classifier():
from pathlib import Path
import yaml
project = Path(__file__).resolve().parents[1]
data = yaml.safe_load((project / "workflows" / "confirmacao.v1.yaml").read_text(encoding="utf-8"))
perguntar = next(node for node in data["nodes"] if node["id"] == "perguntar")
expected = perguntar["pause"]["expected_input"]
assert expected["reprompt"] == "Não entendi. Responda sim ou não."
assert expected["semantic_classifier"]["enabled"] is True
assert "{{ allowed_values }}" in expected["semantic_classifier"]["prompt"]

View File

@@ -17,6 +17,19 @@ nodes:
key: resposta_usuario key: resposta_usuario
allowed_values: [SIM, NAO] allowed_values: [SIM, NAO]
normalize: upper_strip normalize: upper_strip
reprompt: "Não entendi. Responda sim ou não."
semantic_classifier:
include_relevant_context: true
enabled: true
prompt: |
Classifique a resposta em exatamente uma opção de {{ allowed_values }}.
Pergunta pendente: {{ pending_prompt }}
Contexto relevante:
{{ relevant_conversation_context }}
Resposta do usuário: {{ user_input }}
Neste exemplo, concordância/aceitação corresponde a SIM e recusa, dúvida
adicional ou nova condição corresponde a NAO.
Retorne somente uma opção de {{ allowed_values }}.
resume_from: decidir resume_from: decidir
- id: decidir - id: decidir
action: registrar_decisao action: registrar_decisao

View File

@@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em faturas.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade para responder somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente, como “contract_key”, “customer_key” ou “MSISDN”.\nPara consultas informativas de fatura, apresente somente dados de negócio necessários, como valor, vencimento, situação e itens cobrados.\nNão acrescente canais, telefones, códigos USSD, URLs, aplicativos, lojas, relatórios adicionais, procedimentos alternativos ou próximos passos que não tenham sido explicitamente retornados pela tool/RAG e solicitados pelo usuário.\nNão ofereça espontaneamente outras ações ou detalhamentos.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de pedidos de varejo.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso, alteração, troca, cancelamento ou qualquer mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em produtos, planos e serviços.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nEm consultas meramente informativas, não exponha flags ou capacidades transacionais internas como can.cancel e não informe espontaneamente que algo pode ser cancelado, alterado, contratado, removido ou trocado. Só mencione capacidade transacional quando o usuário tiver solicitado essa ação.\nNão faça oferta proativa e não execute nem simule mutações sem a confirmação exigida pelo framework.\nNão acrescente canais, procedimentos ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "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.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente de suporte de varejo para troca, devolução e garantia.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade e responda somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente.\nNão declare sucesso nem simule troca, devolução, garantia ou outra mutação se a tool não tiver confirmado a execução.\nNão acrescente canais, procedimentos, ofertas ou próximos passos não solicitados.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não invente alternativa e encerre a resposta.", "Você é um agente de suporte de varejo para troca, devolução e garantia.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

View File

@@ -13,42 +13,7 @@ def _money_brl(value: Any) -> str:
def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
"""Renderiza somente campos de negócio seguros da fatura. return f"[{agent_label}] Fatura consultada: {result}."
Identificadores técnicos/PII presentes no payload MCP (por exemplo msisdn,
customer_id, document e business keys) não devem ser propagados ao usuário.
"""
lines = [f"[{agent_label}] Dados da sua fatura:"]
total = result.get("valor_total")
vencimento = result.get("vencimento")
status = result.get("status")
if total is not None:
lines.append(f"Valor total: R$ {_money_brl(total)}.")
if vencimento not in (None, ""):
lines.append(f"Vencimento: {vencimento}.")
if status not in (None, ""):
lines.append(f"Situação: {status}.")
items = result.get("itens") or []
rendered_items: list[str] = []
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
description = item.get("descricao") or item.get("nome")
value = item.get("valor")
if description in (None, ""):
continue
if value is None:
rendered_items.append(str(description))
else:
rendered_items.append(f"{description}: R$ {_money_brl(value)}")
if rendered_items:
lines.append("Itens: " + "; ".join(rendered_items) + ".")
# Se não houver nenhum campo de negócio seguro além do cabeçalho, deixe a
# composição pela LLM/guardrails em vez de despejar o payload bruto.
return " ".join(lines) if len(lines) > 1 else None
def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:

View File

@@ -159,7 +159,7 @@ class AgentWorkflow:
builder.add_conditional_edges( builder.add_conditional_edges(
"input_guardrails", "input_guardrails",
self._after_input_guardrails, self._after_input_guardrails,
{"blocked": "persist", "continue": "routing_decision"}, {"blocked": "output_guardrails", "continue": "routing_decision"},
) )
builder.add_conditional_edges( builder.add_conditional_edges(
"routing_decision", "routing_decision",
@@ -195,6 +195,31 @@ class AgentWorkflow:
def _after_input_guardrails(self, state): def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue" return "blocked" if state.get("blocked") else "continue"
@staticmethod
def _input_guardrail_user_message(decisions, state, sanitized_text):
# Keep the technical guardrail reason in telemetry, but expose only a
# safe, actionable message to the end user. The message is intentionally
# routed through output_guardrails before persistence/delivery.
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
first = blocked[0] if blocked else None
code = str(getattr(first, "code", "") or "").upper()
if code == "COER":
return (
"Não consegui entender sua última mensagem porque ela parece "
"incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?"
)
if code == "INPUT_SIZE":
return "Sua mensagem ficou muito longa para eu processar de uma vez. Pode resumir ou dividir em partes?"
if code == "DLEX_IN":
return "Não posso usar essa informação da forma solicitada. Reformule o pedido sem incluir dados ou conteúdo restrito."
if code == "PINJ":
return "Não posso seguir instruções que tentem alterar as regras do atendimento. Posso continuar ajudando com a sua solicitação."
if code == "TOX":
return "Não consegui prosseguir com essa mensagem. Pode reformular o pedido para continuarmos o atendimento?"
if code == "CMP":
return "Não posso prosseguir com essa solicitação dessa forma. Posso ajudar com uma alternativa permitida."
return "Não consegui processar essa mensagem. Pode reformular para eu continuar o atendimento?"
async def input_guardrails(self, state): async def input_guardrails(self, state):
if state.get("session_ended") is True: if state.get("session_ended") is True:
answer = str(getattr( answer = str(getattr(
@@ -279,12 +304,33 @@ class AgentWorkflow:
component="workflow.input_guardrails.final", component="workflow.input_guardrails.final",
) )
if any(not d.allowed for d in decisions): if any(not d.allowed for d in decisions):
# A blocking input guardrail stops the turn before routing/tools.
# Clear turn-local routing/tool state so stale data from a prior
# turn cannot appear as if it was executed after the block.
user_message = self._input_guardrail_user_message(decisions, state, sanitized)
return { return {
"sanitized_input": sanitized, "sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.", "answer": user_message,
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": None,
"guardrail_decisions": [d.model_dump() for d in decisions], "guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked", "route": "blocked",
"intent": "input_guardrail_blocked",
"route_decision": {
"route": "blocked",
"agent": None,
"intent": "input_guardrail_blocked",
"confidence": 1.0,
"reason": "Entrada interrompida por guardrail antes do roteamento.",
"method": "guardrail",
"next_state": state.get("next_state"),
"handoff": False,
"metadata": {},
"domain": state.get("domain"),
"mcp_tools": [],
},
"mcp_tools": [],
"mcp_results": [],
"judge_results": [],
"blocked": True, "blocked": True,
} }
return { return {
@@ -492,119 +538,6 @@ class AgentWorkflow:
"next_state": "SESSION_ENDED", "next_state": "SESSION_ENDED",
} }
@staticmethod
def _output_guardrail_context(state: dict) -> dict:
"""Monta o contexto operacional do turno para os guardrails de saída.
Mantém evidências/protocolos necessários aos rails, mas impede que uma
transação encerrada ou semanticamente interrompida governe o novo turno.
O histórico completo permanece no state/checkpoint para auditoria.
"""
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)
history = list(state.get("history") or [])
current_user_text = str(state.get("user_text") or "").strip()
if current_user_text:
if (
not history
or not isinstance(history[-1], dict)
or str(history[-1].get("content") or "") != current_user_text
or str(history[-1].get("role") or "") != "user"
):
history.append({"role": "user", "content": current_user_text})
route_decision = state.get("route_decision") or {}
route_metadata = route_decision.get("metadata") if isinstance(route_decision, dict) else {}
route_metadata = route_metadata if isinstance(route_metadata, dict) else {}
pre_validation = state.get("transaction_pre_validation") or {}
pre_validation = pre_validation if isinstance(pre_validation, dict) else {}
tx_status = str(
state.get("transaction_status") or pre_validation.get("status") or ""
).strip().upper()
terminal_tx = bool(pre_validation.get("terminal")) or tx_status in {
"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"
}
semantic_intent_shift = (
str(route_metadata.get("transaction_interruption") or "").strip().lower()
== "intent_shift"
)
stickiness_intent_shift = bool(route_metadata.get("route_stickiness_preempted"))
should_isolate_history = semantic_intent_shift or (terminal_tx and stickiness_intent_shift)
current_route = str(
state.get("route")
or (route_decision.get("route") if isinstance(route_decision, dict) else "")
or ""
).strip()
current_intent = str(
state.get("intent")
or (route_decision.get("intent") if isinstance(route_decision, dict) else "")
or ""
).strip()
ctx["current_user_message"] = current_user_text
ctx["current_route"] = current_route
ctx["current_intent"] = current_intent
if should_isolate_history:
operational_history = (
[{"role": "user", "content": current_user_text}]
if current_user_text else []
)
ctx["historical_transaction_ignored"] = True
ctx["historical_transaction_status"] = tx_status or (
"INTERRUPTED" if semantic_intent_shift else "TERMINAL"
)
if semantic_intent_shift:
ctx["historical_transaction_interruption"] = "intent_shift"
for stale_key in (
"transaction_pre_validation",
"transaction_status",
"active_transaction",
"transaction",
):
ctx.pop(stale_key, None)
else:
operational_history = history
ctx["conversation_history"] = operational_history
ctx["history_texts"] = [
str(item.get("content") or "")
for item in operational_history
if isinstance(item, dict) and item.get("content") not in (None, "")
]
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
async def output_supervisor(self, state): async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo. """Valida a resposta candidata com o OutputSupervisor corporativo.
@@ -620,15 +553,15 @@ class AgentWorkflow:
} }
candidate = state.get("answer") or "" candidate = state.get("answer") or ""
context = self._output_guardrail_context(state) context = {
context.update({ **(state.get("context") or {}),
"tenant_id": state.get("tenant_id"), "tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"), "agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"), "session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"), "route": state.get("route"),
"intent": state.get("intent"), "intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)), "supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}) }
async with self.telemetry.span( async with self.telemetry.span(
"workflow.output_supervisor", "workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"), session_id=state.get("conversation_key") or state.get("session_id"),
@@ -714,7 +647,7 @@ class AgentWorkflow:
component="workflow.output_guardrails.start", component="workflow.output_guardrails.start",
) )
final, decisions = await self.guardrails.run_output( final, decisions = await self.guardrails.run_output(
state["answer"], self._output_guardrail_context(state) state["answer"], state.get("context", {})
) )
for _decision in decisions: for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision) await self.guardrail_telemetry.evaluated("output", _decision)

View File

@@ -7,6 +7,35 @@ router:
confidence_threshold: 0.65 confidence_threshold: 0.65
allow_handoff: true allow_handoff: true
transaction_confirmation:
# Explicit yes/no stays deterministic. Only inconclusive replies use this LLM fallback.
semantic_fallback:
enabled: true
allowed_values: [SIM, NAO, CONTINUAR]
confirm_values: [SIM]
reject_values: [NAO]
continue_values: [CONTINUAR]
include_relevant_context: true
profile_name: router
prompt: |
Você classifica a resposta do cliente a uma confirmação transacional pendente.
Considere a pergunta pendente, somente o histórico recente relacionado ao mesmo tema e a fala atual.
Não execute a ação e não invente fatos.
Classes permitidas: {{ allowed_values }}
- SIM: confirmação/aceite inequívoco, inclusive equivalentes como "isso mesmo", "pode confirmar", "é isso" quando o contexto tornar o aceite claro.
- NAO: recusa/cancelamento inequívoco da ação pendente.
- CONTINUAR: qualquer resposta que não confirme nem rejeite inequivocamente, incluindo pergunta adicional, correção, novo dado, ambiguidade ou possível mudança de assunto.
Pergunta pendente:
{{ pending_prompt }}
Histórico relevante:
{{ relevant_conversation_context }}
Resposta atual do cliente:
{{ user_input }}
state_policies: state_policies:
- state: WAITING_BILLING_CONFIRMATION - state: WAITING_BILLING_CONFIRMATION
agent: billing_agent agent: billing_agent

View File

@@ -0,0 +1,11 @@
# Confirmação Transacional Semântica
Este template suporta confirmação transacional em duas camadas: primeiro um parser determinístico para `sim`/`não` e equivalentes explícitos; somente quando ele não consegue decidir, o framework usa um classificador semântico configurado em `config/routing.yaml`.
A configuração `router.transaction_confirmation.semantic_fallback` usa três classes: `SIM`, `NAO` e `CONTINUAR`. O prompt pode usar `{{ pending_prompt }}`, `{{ relevant_conversation_context }}`, `{{ user_input }}` e `{{ allowed_values }}`. O histórico injetado é apenas contexto de interpretação; não substitui validação de negócio ou evidência MCP.
Exemplo: após `Você confirma o cancelamento do serviço Tamboro Mensal?`, a frase `isso mesmo, pode confirmar` pode ser classificada como `SIM`. Já `mas qual é o valor?` deve ser `CONTINUAR`, portanto não executa a ação por confirmação.
Entradas explícitas já suportadas continuam no caminho determinístico e não geram custo adicional de LLM. Em observabilidade, o fallback usa `transaction.confirmation.semantic_classifier` e o `route_decision.metadata` informa `transaction_confirmation_source: semantic`.
Consulte `docs/developer/pt/03_transaction_workflows_and_state.md` do framework para o contrato completo e exemplos.

View File

@@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin):
state, state,
system_prompt=apply_agent_profile_prompt( system_prompt=apply_agent_profile_prompt(
state, state,
"Você é um agente especialista em faturas.\n\nUse dados de tools/MCP e RAG autorizados como fonte de verdade para responder somente à solicitação atual.\nNunca exponha identificadores técnicos ou de identidade presentes no estado, contexto ou MCP, incluindo customer_key, contract_key, account_key, resource_key, session_key, customer_id, document, message_id, ura_call_id ou MSISDN/telefone completo.\nNão transforme nomes internos de campos em rótulos para o cliente, como “contract_key”, “customer_key” ou “MSISDN”.\nPara consultas informativas de fatura, apresente somente dados de negócio necessários, como valor, vencimento, situação e itens cobrados.\nNão acrescente canais, telefones, códigos USSD, URLs, aplicativos, lojas, relatórios adicionais, procedimentos alternativos ou próximos passos que não tenham sido explicitamente retornados pela tool/RAG e solicitados pelo usuário.\nNão ofereça espontaneamente outras ações ou detalhamentos.\nSe uma tool retornar BLOCKED, OUT_OF_SCOPE, NOT_ALLOWED, FAILED ou outro resultado terminal, explique somente o motivo retornado, não declare sucesso, não invente alternativa e encerre a resposta.", "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
), ),
mcp_results=tool_context, mcp_results=tool_context,
rag_context=rag_context, rag_context=rag_context,

Some files were not shown because too many files have changed in this diff Show More