diff --git a/README.md b/README.md index b91aab56..1b431526 100644 --- a/README.md +++ b/README.md @@ -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. 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. @@ -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) | | 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) | +| 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) | | 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) | @@ -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. +### [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 [`README.md`](README.md) continua sendo a referência para o passo a passo completo: diff --git a/README_en.md b/README_en.md index 834af657..46603afe 100644 --- a/README_en.md +++ b/README_en.md @@ -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. 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. @@ -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. +### [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 [`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) | diff --git a/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/__pycache__/agent_graph.cpython-313.pyc index dbe6f098..70ba3b26 100644 Binary files a/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/agent_graph.py b/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/agent_graph.py index b8ed7bcb..ed17e04b 100644 --- a/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/agent_graph.py +++ b/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/agent_graph.py @@ -160,7 +160,7 @@ class AgentWorkflow: builder.add_conditional_edges( "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_conditional_edges( @@ -197,6 +197,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -281,12 +306,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { diff --git a/Tuning-Performance/Authentication/agent_template_backend_authentication/config/routing.yaml b/Tuning-Performance/Authentication/agent_template_backend_authentication/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Authentication/agent_template_backend_authentication/config/routing.yaml +++ b/Tuning-Performance/Authentication/agent_template_backend_authentication/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Authentication/agent_template_backend_authentication/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Authentication/agent_template_backend_authentication/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Authentication/agent_template_backend_authentication/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/billing_agent.py b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/billing_agent.py +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/orders_agent.py b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/orders_agent.py +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/product_agent.py b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/product_agent.py +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/support_agent.py b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/support_agent.py +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc index 79a8274a..009b7c12 100644 Binary files a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc and b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc differ diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/presentation/tool_renderers.py b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/presentation/tool_renderers.py index cb442023..f77c47a4 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/presentation/tool_renderers.py +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/presentation/tool_renderers.py @@ -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: - """Renderiza somente campos de negócio seguros da fatura. - - 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 + return f"[{agent_label}] Fatura consultada: {result}." def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 192132da..49ad6af0 100644 Binary files a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/agent_graph.py index 303bf510..ed17e04b 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/agent_graph.py @@ -160,7 +160,7 @@ class AgentWorkflow: builder.add_conditional_edges( "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_conditional_edges( @@ -197,6 +197,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -281,12 +306,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -494,119 +540,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -622,15 +555,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -716,7 +649,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/config/routing.yaml b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/config/routing.yaml +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/billing_agent.py b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/billing_agent.py +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/orders_agent.py b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/orders_agent.py +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/product_agent.py b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/product_agent.py +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/support_agent.py b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/support_agent.py +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/presentation/tool_renderers.py b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/presentation/tool_renderers.py index cb442023..f77c47a4 100644 --- a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/presentation/tool_renderers.py +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/presentation/tool_renderers.py @@ -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: - """Renderiza somente campos de negócio seguros da fatura. - - 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 + return f"[{agent_label}] Fatura consultada: {result}." def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 7e370465..d345c8b9 100644 Binary files a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/agent_graph.py index 2cb86e12..fc292459 100644 --- a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/agent_graph.py @@ -159,7 +159,7 @@ class AgentWorkflow: builder.add_conditional_edges( "input_guardrails", self._after_input_guardrails, - {"blocked": "persist", "continue": "routing_decision"}, + {"blocked": "output_guardrails", "continue": "routing_decision"}, ) builder.add_conditional_edges( "routing_decision", @@ -195,6 +195,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -279,12 +304,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -492,119 +538,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -620,15 +553,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -714,7 +647,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/config/routing.yaml b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/config/routing.yaml +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc index 823d2841..884409ed 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/main.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/main.cpython-313.pyc index baf57767..7f097b78 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/main.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc index 71b13395..d6621835 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/state.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/state.cpython-313.pyc index 7d103e65..e5055fe3 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/state.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/__pycache__/state.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/billing_agent.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/billing_agent.cpython-313.pyc index abd23f2b..0e9f1e1c 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/billing_agent.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/billing_agent.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/orders_agent.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/orders_agent.cpython-313.pyc index 3a5ff2f4..f05ef2c6 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/orders_agent.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/orders_agent.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/product_agent.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/product_agent.cpython-313.pyc index cfd8819c..2b363379 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/product_agent.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/product_agent.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/prompting.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/prompting.cpython-313.pyc index 0ab1a52b..27c5dd67 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/prompting.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/prompting.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/runtime.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/runtime.cpython-313.pyc index 10892912..321fc089 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/runtime.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/runtime.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/support_agent.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/support_agent.cpython-313.pyc index 66abc541..bcd05596 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/support_agent.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/__pycache__/support_agent.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/billing_agent.py b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/billing_agent.py +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/orders_agent.py b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/orders_agent.py +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/product_agent.py b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/product_agent.py +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/support_agent.py b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/support_agent.py +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/__init__.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/__init__.cpython-313.pyc index 82f6849c..e011e2c1 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/__init__.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/__init__.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/grl_examples.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/grl_examples.cpython-313.pyc index 5ce24803..417fdeaf 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/grl_examples.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/grl_examples.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/ic_examples.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/ic_examples.cpython-313.pyc index 636a41ab..43c28410 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/ic_examples.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/ic_examples.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/mcp_examples.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/mcp_examples.cpython-313.pyc index 0f860a4a..8684f23b 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/mcp_examples.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/mcp_examples.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/noc_examples.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/noc_examples.cpython-313.pyc index 4e5f533a..19cee2f2 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/noc_examples.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/noc_examples.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/observer_examples.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/observer_examples.cpython-313.pyc index 063a9b9c..5919f9d0 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/observer_examples.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/examples/__pycache__/observer_examples.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/observability/__pycache__/__init__.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/observability/__pycache__/__init__.cpython-313.pyc index e06cfd22..60f4328a 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/observability/__pycache__/__init__.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/observability/__pycache__/telemetry_observer.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/observability/__pycache__/telemetry_observer.cpython-313.pyc index 70a39579..a01c7f1e 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/observability/__pycache__/telemetry_observer.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/observability/__pycache__/telemetry_observer.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc index 2c6e20ef..32cbdf80 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/presentation/tool_renderers.py b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/presentation/tool_renderers.py index cb442023..f77c47a4 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/presentation/tool_renderers.py +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/presentation/tool_renderers.py @@ -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: - """Renderiza somente campos de negócio seguros da fatura. - - 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 + return f"[{agent_label}] Fatura consultada: {result}." def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 8adf49f3..0807ab7c 100644 Binary files a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/agent_graph.py index 303bf510..ed17e04b 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/agent_graph.py @@ -160,7 +160,7 @@ class AgentWorkflow: builder.add_conditional_edges( "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_conditional_edges( @@ -197,6 +197,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -281,12 +306,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -494,119 +540,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -622,15 +555,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -716,7 +649,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/config/routing.yaml b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/config/routing.yaml +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/billing_agent.py b/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/billing_agent.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/orders_agent.py b/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/orders_agent.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/product_agent.py b/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/product_agent.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/support_agent.py b/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/support_agent.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc b/Tuning-Performance/Normal/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc index 970b809f..23b52e13 100644 Binary files a/Tuning-Performance/Normal/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc and b/Tuning-Performance/Normal/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc differ diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/presentation/tool_renderers.py b/Tuning-Performance/Normal/templates/agent_template_backend/app/presentation/tool_renderers.py index cb442023..f77c47a4 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/app/presentation/tool_renderers.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend/app/presentation/tool_renderers.py @@ -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: - """Renderiza somente campos de negócio seguros da fatura. - - 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 + return f"[{agent_label}] Fatura consultada: {result}." def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 114625a7..7293dd3a 100644 Binary files a/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/agent_graph.py index 2cb86e12..fc292459 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/agent_graph.py @@ -159,7 +159,7 @@ class AgentWorkflow: builder.add_conditional_edges( "input_guardrails", self._after_input_guardrails, - {"blocked": "persist", "continue": "routing_decision"}, + {"blocked": "output_guardrails", "continue": "routing_decision"}, ) builder.add_conditional_edges( "routing_decision", @@ -195,6 +195,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -279,12 +304,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -492,119 +538,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -620,15 +553,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -714,7 +647,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/config/routing.yaml b/Tuning-Performance/Normal/templates/agent_template_backend/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/config/routing.yaml +++ b/Tuning-Performance/Normal/templates/agent_template_backend/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Normal/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Normal/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/billing_agent.py b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/billing_agent.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/orders_agent.py b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/orders_agent.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/product_agent.py b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/product_agent.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/support_agent.py b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/support_agent.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 3c19c45a..ce2047c5 100644 Binary files a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py index 2cb86e12..fc292459 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py @@ -159,7 +159,7 @@ class AgentWorkflow: builder.add_conditional_edges( "input_guardrails", self._after_input_guardrails, - {"blocked": "persist", "continue": "routing_decision"}, + {"blocked": "output_guardrails", "continue": "routing_decision"}, ) builder.add_conditional_edges( "routing_decision", @@ -195,6 +195,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -279,12 +304,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -492,119 +538,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -620,15 +553,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -714,7 +647,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/config/routing.yaml b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/config/routing.yaml +++ b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Pause_Resume_Workflow/IMPLEMENTACAO_PAUSE_RESUME_LANGGRAPH.md b/Tuning-Performance/Pause_Resume_Workflow/IMPLEMENTACAO_PAUSE_RESUME_LANGGRAPH.md index 9b317c86..53682d6e 100644 --- a/Tuning-Performance/Pause_Resume_Workflow/IMPLEMENTACAO_PAUSE_RESUME_LANGGRAPH.md +++ b/Tuning-Performance/Pause_Resume_Workflow/IMPLEMENTACAO_PAUSE_RESUME_LANGGRAPH.md @@ -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. 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..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. diff --git a/Tuning-Performance/Pause_Resume_Workflow/README.md b/Tuning-Performance/Pause_Resume_Workflow/README.md index 13d92b45..1efe5af1 100644 --- a/Tuning-Performance/Pause_Resume_Workflow/README.md +++ b/Tuning-Performance/Pause_Resume_Workflow/README.md @@ -32,3 +32,49 @@ O exemplo usa `MemorySaver` apenas para ser autocontido. Em aplicações reais u ## 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`. + +## 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..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. diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/README.md b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/README.md new file mode 100644 index 00000000..6952974e --- /dev/null +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/README.md @@ -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 +``` diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/__init__.py b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..41f7f0a9 Binary files /dev/null and b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/__pycache__/demo.cpython-313.pyc b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/__pycache__/demo.cpython-313.pyc new file mode 100644 index 00000000..4bb04c5e Binary files /dev/null and b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/__pycache__/demo.cpython-313.pyc differ diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/demo.py b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/demo.py new file mode 100644 index 00000000..7e96bd42 --- /dev/null +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/app/demo.py @@ -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()) diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/tests/__pycache__/test_pause_resume.cpython-313-pytest-9.0.2.pyc b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/tests/__pycache__/test_pause_resume.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..96739456 Binary files /dev/null and b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/tests/__pycache__/test_pause_resume.cpython-313-pytest-9.0.2.pyc differ diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/tests/test_pause_resume.py b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/tests/test_pause_resume.py new file mode 100644 index 00000000..52a5922c --- /dev/null +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/tests/test_pause_resume.py @@ -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"] diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/workflows/confirmacao.active.yaml b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/workflows/confirmacao.active.yaml new file mode 100644 index 00000000..b8255184 --- /dev/null +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/workflows/confirmacao.active.yaml @@ -0,0 +1 @@ +version: 1 diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/workflows/confirmacao.v1.yaml b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/workflows/confirmacao.v1.yaml new file mode 100644 index 00000000..2e723ce9 --- /dev/null +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend/workflows/confirmacao.v1.yaml @@ -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 diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/app/__pycache__/__init__.cpython-313.pyc b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..a8d37181 Binary files /dev/null and b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/app/__pycache__/demo.cpython-313.pyc b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/app/__pycache__/demo.cpython-313.pyc new file mode 100644 index 00000000..aca91b75 Binary files /dev/null and b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/app/__pycache__/demo.cpython-313.pyc differ diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/tests/__pycache__/test_pause_resume.cpython-313-pytest-9.0.2.pyc b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/tests/__pycache__/test_pause_resume.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..2d339c6d Binary files /dev/null and b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/tests/__pycache__/test_pause_resume.cpython-313-pytest-9.0.2.pyc differ diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/tests/test_pause_resume.py b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/tests/test_pause_resume.py index d7649983..52a5922c 100644 --- a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/tests/test_pause_resume.py +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/tests/test_pause_resume.py @@ -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"] 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"] diff --git a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/workflows/confirmacao.v1.yaml b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/workflows/confirmacao.v1.yaml index 923d17f1..2e723ce9 100644 --- a/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/workflows/confirmacao.v1.yaml +++ b/Tuning-Performance/Pause_Resume_Workflow/agent_template_backend_pause_resume/workflows/confirmacao.v1.yaml @@ -17,6 +17,19 @@ nodes: 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 diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/billing_agent.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/billing_agent.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/orders_agent.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/orders_agent.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/product_agent.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/product_agent.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/support_agent.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/support_agent.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc index 8c88b043..567eb4dc 100644 Binary files a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc and b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc differ diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/presentation/tool_renderers.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/presentation/tool_renderers.py index cb442023..f77c47a4 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/presentation/tool_renderers.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/presentation/tool_renderers.py @@ -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: - """Renderiza somente campos de negócio seguros da fatura. - - 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 + return f"[{agent_label}] Fatura consultada: {result}." def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 91f49fc4..03dc5e99 100644 Binary files a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/agent_graph.py index 2cb86e12..fc292459 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/agent_graph.py @@ -159,7 +159,7 @@ class AgentWorkflow: builder.add_conditional_edges( "input_guardrails", self._after_input_guardrails, - {"blocked": "persist", "continue": "routing_decision"}, + {"blocked": "output_guardrails", "continue": "routing_decision"}, ) builder.add_conditional_edges( "routing_decision", @@ -195,6 +195,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -279,12 +304,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -492,119 +538,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -620,15 +553,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -714,7 +647,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/config/routing.yaml b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/config/routing.yaml +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/billing_agent.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/billing_agent.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/orders_agent.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/orders_agent.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/product_agent.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/product_agent.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/support_agent.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/support_agent.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc index d268a633..e22859d1 100644 Binary files a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py index 2cb86e12..fc292459 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py @@ -159,7 +159,7 @@ class AgentWorkflow: builder.add_conditional_edges( "input_guardrails", self._after_input_guardrails, - {"blocked": "persist", "continue": "routing_decision"}, + {"blocked": "output_guardrails", "continue": "routing_decision"}, ) builder.add_conditional_edges( "routing_decision", @@ -195,6 +195,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -279,12 +304,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -492,119 +538,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -620,15 +553,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -714,7 +647,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/config/routing.yaml b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/config/routing.yaml +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/billing_agent.py b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/billing_agent.py +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/orders_agent.py b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/orders_agent.py +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/product_agent.py b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/product_agent.py +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/support_agent.py b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/support_agent.py +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/presentation/tool_renderers.py b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/presentation/tool_renderers.py index cb442023..f77c47a4 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/presentation/tool_renderers.py +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/presentation/tool_renderers.py @@ -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: - """Renderiza somente campos de negócio seguros da fatura. - - 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 + return f"[{agent_label}] Fatura consultada: {result}." def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 515e583d..a4df9969 100644 Binary files a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/agent_graph.py index fef0791c..e22cf447 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/agent_graph.py @@ -160,7 +160,7 @@ class AgentWorkflow: builder.add_conditional_edges( "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_conditional_edges( @@ -197,6 +197,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -281,12 +306,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -494,119 +540,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -622,15 +555,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -716,7 +649,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/config/routing.yaml b/Tuning-Performance/Transaction_Evidence/agent_template_backend/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/config/routing.yaml +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Transaction_Evidence/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/billing_agent.py b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/billing_agent.py +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/orders_agent.py b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/orders_agent.py +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/product_agent.py b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/product_agent.py +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/support_agent.py b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/support_agent.py +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/presentation/tool_renderers.py b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/presentation/tool_renderers.py index cb442023..f77c47a4 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/presentation/tool_renderers.py +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/presentation/tool_renderers.py @@ -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: - """Renderiza somente campos de negócio seguros da fatura. - - 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 + return f"[{agent_label}] Fatura consultada: {result}." def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 8604cd4c..f8da2411 100644 Binary files a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/agent_graph.py index fef0791c..e22cf447 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/agent_graph.py @@ -160,7 +160,7 @@ class AgentWorkflow: builder.add_conditional_edges( "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_conditional_edges( @@ -197,6 +197,31 @@ class AgentWorkflow: def _after_input_guardrails(self, state): 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): if state.get("session_ended") is True: answer = str(getattr( @@ -281,12 +306,33 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) 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 { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -494,119 +540,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -622,15 +555,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -716,7 +649,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/config/routing.yaml b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/config/routing.yaml +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/docs/FIX_PAUSED_WORKFLOW_HUMAN_HANDOFF_PRECEDENCE_20260829.md b/docs/FIX_PAUSED_WORKFLOW_HUMAN_HANDOFF_PRECEDENCE_20260829.md new file mode 100644 index 00000000..9fb0b13d --- /dev/null +++ b/docs/FIX_PAUSED_WORKFLOW_HUMAN_HANDOFF_PRECEDENCE_20260829.md @@ -0,0 +1,49 @@ +# Correção: precedência de handoff sobre workflow pausado + +## Problema + +Quando um workflow conversacional estava em `WORKFLOW_PAUSED` com `expected_input` +enumerado e `semantic_classifier`, uma solicitação explícita de atendimento humano podia +ser absorvida pelo classificador local do workflow (por exemplo `SIM/NAO/CONTINUAR`). + +Exemplo de regressão: + +1. cliente pede explicação de fatura; +2. workflow pausa perguntando se a dúvida foi resolvida; +3. cliente diz `quero falar com um atendente`; +4. a frase era classificada como valor do `expected_input`, em vez de acionar handoff. + +## Regra de precedência corrigida + +A ordem passa a ser: + +1. `expected_input` determinístico continua com precedência absoluta (`sim`, `não`, etc.); +2. se não houver match determinístico, o framework verifica exclusivamente o controle global + `HUMAN_HANDOFF` usando o classificador semântico de continuidade já existente; +3. se não houver handoff, o `semantic_classifier` declarativo do workflow continua sendo a + autoridade sobre a mensagem; +4. `CONTINUE`, `ROUTE` e `END_SESSION` encontrados no probe global são ignorados nessa etapa; +5. as regras normais de transação e intent shift permanecem inalteradas. + +Assim, `quero falar com um atendente` não é tratado como `intent_shift`: é um comando global +de controle de sessão. A correção não cria lista de palavras nem regex de handoff. + +## Observabilidade + +Quando o handoff preempta um workflow pausado, a decisão contém: + +- `session_control=HUMAN_HANDOFF`; +- `global_control_preempted_workflow=true`; +- `workflow_interruption=human_handoff`; +- `interrupted_workflow_name`; +- `interrupted_workflow_execution_id`. + +## Testes + +Foram adicionados testes para garantir que: + +- pedido explícito de atendente preempta `expected_input.semantic_classifier`; +- resposta determinística `sim` continua retomando o workflow e não é roubada pelo probe global. + +Também foram executadas as suítes de regressão de transação/intent shift para confirmar que a +mudança não altera a precedência existente de coleta de parâmetros e confirmação transacional. diff --git a/docs/FIX_POST_FINALIZATION_SOFT_RESET_20260829.md b/docs/FIX_POST_FINALIZATION_SOFT_RESET_20260829.md new file mode 100644 index 00000000..cc669d04 --- /dev/null +++ b/docs/FIX_POST_FINALIZATION_SOFT_RESET_20260829.md @@ -0,0 +1,15 @@ +# Soft reset operacional após finalização de workflow + +Quando um workflow de domínio termina, a sessão conversacional permanece a mesma, mas o próximo turno deve iniciar uma nova interação operacional. + +A correção introduz um marcador `operational_context_boundary_pending` no fechamento do workflow. No primeiro turno subsequente, o marcador é consumido e o framework: + +- mantém `session_id`, `session_key`, `conversation_key`, identidade e BusinessContext; +- preserva o histórico durável/checkpoint para auditoria; +- mantém Long-Term Memory; +- limpa `pending_domain_workflow`, `pending_tool_clarification`, `active_transaction`, tool calls, parâmetros pendentes, confirmação, pre-validation, route/intent/active_agent e demais latches operacionais; +- não executa route continuity do fluxo encerrado; +- não injeta ConversationSummaryMemory nem mensagens recentes do fluxo encerrado no primeiro turno após a fronteira; +- entrega apenas a nova mensagem ao contexto operacional desse turno. + +O marcador de reset é de uso único e é desligado no `persist` do novo turno. A partir do turno seguinte, a nova interação pode novamente acumular seu próprio contexto curto, usando o mesmo identificador de sessão. diff --git a/docs/FIX_WORKFLOW_FINAL_STATUS_NORMALIZATION_20260829.md b/docs/FIX_WORKFLOW_FINAL_STATUS_NORMALIZATION_20260829.md new file mode 100644 index 00000000..bcc646bd --- /dev/null +++ b/docs/FIX_WORKFLOW_FINAL_STATUS_NORMALIZATION_20260829.md @@ -0,0 +1,7 @@ +# Workflow final status normalization + +A resumed domain workflow may be returned by a legacy adapter with `status=PAUSED` even after its terminal node has emitted `workflow_response_final=true`. + +The framework now treats `workflow_response_final=true` as the authoritative interaction-lifecycle signal and normalizes that stale adapter status to `COMPLETED` before capturing the workflow latch. This clears the paused workflow/expected-input state, persists an operational-context boundary for the next user turn, and keeps the same session identifiers. + +Contextual re-entry routing now also degrades safely to the configured fallback when an LLM router response cannot be parsed, rather than propagating a structured-output exception to the HTTP endpoint. diff --git a/docs/FIX_WORKFLOW_TERMINAL_LIFECYCLE_SAME_SESSION_20260829.md b/docs/FIX_WORKFLOW_TERMINAL_LIFECYCLE_SAME_SESSION_20260829.md new file mode 100644 index 00000000..c9fba0a5 --- /dev/null +++ b/docs/FIX_WORKFLOW_TERMINAL_LIFECYCLE_SAME_SESSION_20260829.md @@ -0,0 +1,25 @@ +# Fix: terminal workflow lifecycle in the same conversation session + +## Problem +A conversational workflow could return `status=COMPLETED` and a final response (`workflow_response_final=true`), while a stale `pending_domain_workflow` / `expected_input` latch remained durable in LangGraph state. A later user message in the same `session_id` could therefore be interpreted as a resume of the already completed workflow. + +Scenario 22 reproduces the issue: after invoice explanation is accepted and protocol is returned, `ah espera` must be treated as a new interaction in the same conversation session, not as SIM/NAO/CONTINUAR for the old workflow. + +## Semantics after the fix +- Conversation identifiers are preserved (`session_id`, `session_key`, `conversation_key`, `user_id`, `msisdn`, customer/contract keys). +- The completed workflow is terminal only as an interaction/workflow, not as the user session. +- `pending_domain_workflow`, `pending_tool_clarification`, `workflow_input_reprompt`, active transaction latches and `next_state` are cleared. +- `transaction_status` is materialized as `COMPLETED` (or `FAILED`) so terminal state wins over stale checkpoints. +- The next message is routed as a new interaction in the same session. +- Router and input-guardrail layers defensively ignore stale paused-workflow contracts when transaction status is already terminal. + +## Main files +- `libs/agent_framework/src/agent_framework/runtime/agent_runtime.py` +- `libs/agent_framework/src/agent_framework/routing/enterprise_router.py` +- `../app/workflows/agent_graph.py` +- `tests/test_paused_workflow_resume_precedence.py` + +## Validation +- Framework transactional/routing suites: 119 passed. +- Focused migration suites: 12 passed. +- Full `tests/migration`: 789 passed. diff --git a/docs/FIX_WORKFLOW_TERMINAL_SNAPSHOT_SEMANTICS_20260829.md b/docs/FIX_WORKFLOW_TERMINAL_SNAPSHOT_SEMANTICS_20260829.md new file mode 100644 index 00000000..80dd6dea --- /dev/null +++ b/docs/FIX_WORKFLOW_TERMINAL_SNAPSHOT_SEMANTICS_20260829.md @@ -0,0 +1,31 @@ +# Correção: snapshot terminal não deve virar PAUSED sem interrupt real + +## Problema + +O `WorkflowRuntime` tratava qualquer `snapshot.next` truthy do LangGraph como evidência de pausa. Em alguns snapshots/checkpointers, o último nó de ação já havia terminado e sua transição ativa apontava para `END`, porém `snapshot.next` ainda continha trabalho estrutural interno. Como não havia `interrupt()`, o runtime fabricava um `pause={"node": current_node}` e devolvia `PAUSED`. + +Efeito observado em `contestacao_tool`: `atualizar_status_sr` estava `COMPLETED`, mas o workflow era exposto como `PAUSED`, com `resume_tool=retomar_workflow`, impedindo o fechamento normal da evidência transacional. + +## Regra corrigida + +A precedência agora é: + +1. Se existem payloads reais de `interrupt()` no snapshot: `PAUSED`. +2. Se não há interrupt e o `current_node` possui uma transição ativa para `END`: `COMPLETED`, mesmo que `snapshot.next` esteja truthy. +3. Se não há interrupt, o estado não é estruturalmente terminal e ainda existe `snapshot.next`: fail-closed (`FAILED`) com diagnóstico, em vez de inventar uma pausa. +4. Sem interrupt, sem pending work e sem anomalia: conclusão normal. + +A mesma regra foi aplicada em `WorkflowRuntime.arun()` e `WorkflowRuntime.aresume()`. + +## Por que não há hardcode + +A detecção terminal usa exclusivamente a `WorkflowDefinition`, o `current_node` e as condições das edges. Não conhece `contestacao_tool`, `atualizar_status_sr`, TIM ou qualquer agente específico. + +## Testes de regressão + +`tests/unit/test_workflow_terminal_snapshot_semantics.py` cobre: + +- `arun`: `snapshot.next` truthy + sem interrupt + edge ativa para `END` => `COMPLETED`; +- `aresume`: mesma condição => `COMPLETED`; +- interrupt real tem precedência e continua retornando `PAUSED`; +- pending work não terminal sem interrupt retorna `FAILED`, nunca uma pausa falsa. diff --git a/docs/developer/en/03_transaction_workflows_and_state.md b/docs/developer/en/03_transaction_workflows_and_state.md index 86a3cd92..4adc0bb5 100644 --- a/docs/developer/en/03_transaction_workflows_and_state.md +++ b/docs/developer/en/03_transaction_workflows_and_state.md @@ -557,3 +557,82 @@ The files below were consolidated into this manual: ### Maintenance rule New fixes or evolutions for this subject should update this consolidated document. Release notes may continue to exist as history, but they should not be required to understand or implement the feature. + + +## Canonical resolution and domain revalidation before execution + +When pre-validation resolves a user reference to a canonical entity, the framework **must not blindly overwrite the parameter and execute the originally selected tool**. The contract keeps requested, resolved and execution values distinct. + +A domain validator may return `transaction_decision` with `resolved_arguments`, `target_tool`, `action_changed`, `requires_reconfirmation`, and an optional customer-facing `confirmation_message`. + +Responsibilities: + +- **Framework:** preserve the requested arguments, apply only canonical arguments declared by the validator, update the transaction to the effective `target_tool`, honor reconfirmation, and retain the decision in pre-validation evidence. +- **Agent/domain:** decide business class, policy and effective tool. The framework must not know rules such as “Youtube Premium is strategic”. +- **MCP/backend:** execute the final operation chosen by the domain. + +If canonicalization does not change the action, the current tool may remain valid. If entity resolution changes business class/policy/tool, domain revalidation must happen **before confirmation and execution**. Ambiguous or low-confidence resolution must request clarification instead of silently promoting a candidate. + +### Troubleshooting: resolved_subject is correct but execution receives the original text + +If pre-validation records `resolved_subject="Youtube Premium"` while execution still receives `subject="youtube"`, verify that the validator returns `transaction_decision.resolved_arguments` and that the runtime applies the decision before freezing `pending_tool_call` / `confirmation_snapshot`. If the canonical entity is correct but the final tool is wrong, inspect `transaction_decision.target_tool`; that business reclassification belongs to the domain validator, not to the framework. + +For domains that expose an authoritative business classification in backend detail, revalidation should use that evidence before aggregated categories. In Contas, for example, `invoice_detail.parsed_content` preserves `classe=avulso|estrategico|bundle`, while `billing_analysis` may group the same item into broader sections such as `streaming` or partner services. Canonical entity discovery may use any authorized evidence, but the **business decision** should prioritize the source that preserves the domain classification. If classification evidence conflicts, do not silently change the action; preserve the current operation or request clarification according to the agent policy. + + +## Semantic transactional confirmation: SIM / NAO / CONTINUAR + +Transactions in `AWAITING_CONFIRMATION` use two layers, in this order: + +1. **Deterministic parser** for explicit confirmations/rejections (`sim`, `não`, `confirmo`, `pode fazer`, etc.). This remains the cheapest and safest path and **does not call an LLM**. +2. **LLM semantic fallback** only when the deterministic parser is inconclusive. The fallback reuses the same declarative semantic-classifier engine used by paused workflow `expected_input`, injecting the pending prompt, recent context related to the same topic, and the current user utterance. + +Configuration lives in `config/routing.yaml` under `router.transaction_confirmation.semantic_fallback`: + +```yaml +router: + transaction_confirmation: + 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: | + Allowed classes: {{ allowed_values }} + Pending prompt: + {{ pending_prompt }} + Relevant context: + {{ relevant_conversation_context }} + Current user input: + {{ user_input }} +``` + +`SIM` means an unambiguous acceptance, `NAO` an unambiguous rejection, and `CONTINUAR` means the utterance does not safely confirm or reject the pending action. Example: after `Você confirma o cancelamento do serviço Tamboro Mensal?`, the reply `isso mesmo, pode confirmar` can be classified as `SIM` without hardcoding that exact sentence. + +When semantic confirmation succeeds, the router records: + +```json +{ + "transaction_turn_consumed": true, + "transaction_confirmation_decision": "confirm", + "transaction_confirmation_source": "semantic" +} +``` + +`AgentRuntime` reuses this routed decision instead of re-running the deterministic parser. The change is additive: existing explicit yes/no inputs continue through the deterministic path with no extra LLM call. Semantic generations are named `transaction.confirmation.semantic_classifier` for observability. + +### Durable interrupt compatibility in pause/resume + +The runtime does not use `snapshot.next` alone to decide whether a workflow is paused. A truthy `next` may represent LangGraph helper work, including framework-generated synthetic nodes such as `__pause` and `__continue`. + +A pause is recognized only from a real interrupt. Depending on the LangGraph/checkpointer version, that interrupt may be exposed through `task.interrupts` or persisted in `snapshot.values["__interrupt__"]`. The runtime supports both shapes and deduplicates the payload when both are present. + +This prevents two false diagnoses: + +- treating `snapshot.next` as `PAUSED` when no real interrupt exists; +- treating `next=("__pause",)` as invalid pending work when the real interrupt is persisted under `__interrupt__`. + +For workflows using `expected_input.semantic_classifier`, internal tokens such as `SIM`, `NAO`, and `CONTINUAR` remain resume control values and must not be confused with customer-facing output. diff --git a/docs/developer/en/06_guardrails_judges_and_transaction_evaluation.md b/docs/developer/en/06_guardrails_judges_and_transaction_evaluation.md index 7a831797..e98fa518 100644 --- a/docs/developer/en/06_guardrails_judges_and_transaction_evaluation.md +++ b/docs/developer/en/06_guardrails_judges_and_transaction_evaluation.md @@ -57,6 +57,69 @@ This version adds a pragmatic guardrail layer to `agent_framework`, inspired by - `RET_REL` — validates retrieval-chunk relevance using a minimum score. - `TOOL_VAL` — validates MCP/tool name, required arguments, negative values, and allowlist. +### Contract for authorized protocols in output guardrails + +When a workflow or tool produces a **protocol/reference number that must be shown to the same customer**, the agent integration code must register that value in the output context before output guardrails run: + +```python +ctx["expected_protocols"] = [protocol_number] +``` + +This field is a **framework contract**. It declares that those exact values were produced or validated by the current flow and may therefore be used by output guardrails as authorization evidence. + +Expected flow: + +```text +workflow/tool produces protocol + ↓ +agent registers it in expected_protocols + ↓ +CMP validates that the displayed protocol belongs to the expected values + ↓ +DLEX_OUT does not block that protocol merely because it is an identifier + ↓ +response may disclose the protocol to the customer +``` + +Important rules: + +- `expected_protocols` must contain **only protocols actually produced/expected in the current turn or transaction**. +- Do not use `expected_protocols` to allow tokens, credentials, arbitrary internal IDs, or third-party data. +- Authorization applies only to listed values; any other identifier remains subject to normal `DLEX_OUT` rules. +- The value must be propagated **before `output_guardrails`**. Adding it later has no effect. +- For transactional responses, keep protocol evidence in the tool/workflow result so `CMP`, `GND`, and observability can correlate the value. + +Example: + +```python +result = await execute_workflow(...) +protocol_number = result.get("protocol_number") or result.get("protocolo_id") +if protocol_number: + ctx["expected_protocols"] = [str(protocol_number)] +``` + +#### Troubleshooting: workflow completed but the response was replaced by a safety message + +Typical symptom: + +```text +workflow = COMPLETED +CMP = allowed +DLEX_OUT = blocked because of "internal protocol" +final response = "I could not safely validate this response..." +``` + +Check, in this order: + +1. Is the generated protocol present in the tool/workflow result or evidence? +2. Did the agent propagate the same value in `ctx["expected_protocols"]`? +3. Was `expected_protocols` populated before `output_guardrails`? +4. Is the protocol shown in the response exactly one of the expected values? +5. Is `DLEX_OUT` actually blocking another real issue such as a secret, token, or third-party data? + +If `expected_protocols` is absent, the framework must not assume that an arbitrary textual identifier is safe to disclose. + + ### Files changed - `agent_framework/src/agent_framework/guardrails/rails.py` diff --git a/docs/developer/en/12_input_guardrail_feedback_and_blocked_turns.md b/docs/developer/en/12_input_guardrail_feedback_and_blocked_turns.md new file mode 100644 index 00000000..88bb3129 --- /dev/null +++ b/docs/developer/en/12_input_guardrail_feedback_and_blocked_turns.md @@ -0,0 +1,157 @@ +# 12 — Input Guardrail Feedback and Blocked-Turn Semantics + +## Goal + +This document describes how `AgentWorkflow`, implemented in `app/workflows/agent_graph.py`, should handle a turn interrupted by an input guardrail without turning every interruption into a generic “security rule” message. + +The core rule is to keep three concerns separate: + +1. **the guardrail technical decision**, used by the runtime and observability; +2. **the user-facing message**, appropriate to the type of block or clarification need; +3. **the turn state**, which must not carry routing, tool, or judge data from a turn that was interrupted before those stages. + +## Expected flow + +```text +user message + ↓ +input_guardrails + ↓ +allowed? + ├─ yes → routing → tools/agent → composition → output_guardrails + │ + └─ no + ↓ + select public handling + ↓ + clear routing/tools/judges state for this turn + ↓ + build a safe user-facing message + ↓ + output_guardrails + ↓ + persistence/response +``` + +A blocking input guardrail must be decided **before any side-effecting tool is executed**. + +## Internal `reason` is not the user response + +The `reason` field should remain available to logs, traces, events, and diagnostics. It should not be exposed verbatim when it may reveal internal mechanisms or when the technical wording is not appropriate for the end user. + +Example: + +```text +COER.reason = "utterance is incomprehensible or contains an ambiguous negation" +``` + +A public response may be: + +```text +"I could not fully understand your last message because it seems incomplete or ambiguous. Could you rephrase or complete what you meant?" +``` + +## Handling by guardrail type + +Exact behavior remains configurable, but the expected semantics are: + +| Guardrail | Recommended public handling | +|---|---| +| `COER` | ask for clarification/rephrasing; do not frame ordinary ambiguity as a security incident | +| `PINJ` | block safely without describing the internal mechanism | +| `DLEX_IN` | block or request reformulation without exposing internal/sensitive data | +| `INPUT_SIZE` | ask the user to reduce the input | +| `TOX` | apply the configured policy for inappropriate content | +| `CMP` | respond according to the compliance policy | +| unknown | use a safe generic fallback | + +## Clearing blocked-turn state + +When input is blocked before routing, the final state for that turn must not reuse residual data from the previous turn. + +At a minimum, the workflow should avoid presenting these as current: + +```text +route_decision +mcp_tools +mcp_results +judge_results +``` + +Metadata should clearly indicate that the turn was interrupted at the input-guardrail stage. + +This prevents misleading diagnostics such as: + +```text +route = blocked +mcp_results = [tool executed] +``` + +when the tool result actually belongs to the previous turn. + +## The public message also goes through output guardrails + +A response created because of an input block is still agent output. Therefore it should follow the same output-validation pipeline before reaching the user. + +This allows `DLEX_OUT`, `PINJ`, `TOXOUT`, Output Supervisor, and other policies to remove or sanitize information that should not be exposed. + +## Relationship with `agent_graph.py` + +This feature belongs to template orchestration because it defines precedence between graph nodes and blocked-turn state semantics. + +When changing `app/workflows/agent_graph.py`, preserve these invariants: + +- `input_guardrails` runs before routing/tools; +- an input block does not execute a transactional action after the block; +- the public response is not the raw guardrail `reason`; +- residual routing/tools/judges state does not survive as the blocked turn result; +- the public message passes through `output_guardrails` before persistence/response. + +The same semantics must be preserved in the official templates and equivalent variants under `Tuning-Performance`. + +## Troubleshooting + +### The user receives “I could not continue because of a security rule” for a merely incomplete phrase + +Check: + +1. which guardrail returned `allowed=false`; +2. whether `COER` is handled as clarification rather than a generic security block; +3. whether the blocked branch builds a guardrail-specific public message; +4. whether the generic fallback is used only when no specific handling exists. + +### Metadata shows a tool as executed while `route=blocked` + +Check whether the blocked branch clears transient turn state before returning. Also confirm that the tool was not executed in the same turn before input-guardrail evaluation. + +### The block response exposes internal details + +Do not use `reason` directly as user-facing text. Generate the public message and keep `reason` for observability only. + +### The block response skips output guardrails + +Check the graph edge. The expected path is: + +```text +input_guardrails blocked +→ build public response +→ output_guardrails +→ persist +``` + +not: + +```text +input_guardrails blocked +→ persist +``` + +## Recommended regression tests + +Cover at least: + +- `COER=false` asks for clarification instead of returning a generic security message; +- blocked branch does not retain previous-turn `mcp_results`/routing; +- no transactional tool executes after an input block; +- the public message passes through output guardrails; +- an unknown guardrail still has a safe generic fallback. diff --git a/docs/developer/en/INDEX_DEVELOPER_GUIDE.md b/docs/developer/en/INDEX_DEVELOPER_GUIDE.md index e88a907b..20714e01 100644 --- a/docs/developer/en/INDEX_DEVELOPER_GUIDE.md +++ b/docs/developer/en/INDEX_DEVELOPER_GUIDE.md @@ -6,7 +6,7 @@ The documentation has three clear levels: 1. **Main tutorial:** [`README_en.md`](README_en.md) — creation, configuration, execution, and testing of an agent from start to finish. 2. **Architecture:** [01 — Architecture and Concepts](docs/developer/en/01_architecture_and_concepts.md) — components, responsibilities, and where to implement each concern. -3. **Specialized references:** manuals `02` through `11` — in-depth implementation and troubleshooting by capability. +3. **Specialized references:** manuals `02` through `12` — in-depth implementation and troubleshooting by capability. If you are starting a new agent, begin with `README_en.md`. @@ -31,7 +31,10 @@ If something is not working, use **Search by problem** below. | I receive 401 between gateway/backend/MCP | Basic Auth, credentials per hop | [Gateways and Auth](docs/developer/en/05_agent_gateway_mcp_gateway_and_auth.md) | | I need to decide whether something belongs to the framework or the agent | core/agent boundary | [Architecture and Concepts](docs/developer/en/01_architecture_and_concepts.md) | | An agent-specific guardrail is breaking another agent | extensibility, domain imports in the core | [Guardrails and Judges](docs/developer/en/06_guardrails_judges_and_transaction_evaluation.md) | +| An incomplete phrase receives a generic “security rule” message | input-guardrail feedback, `COER`, blocked-turn state | [Input Guardrail Feedback](./12_input_guardrail_feedback_and_blocked_turns.md) | +| `route=blocked` appears together with tools/results from another turn | blocked-turn state cleanup | [Input Guardrail Feedback](./12_input_guardrail_feedback_and_blocked_turns.md) | | A judge does not run in a transaction | sampling, `always_run_for_transactional`, transaction signals | [Guardrails and Judges](docs/developer/en/06_guardrails_judges_and_transaction_evaluation.md) | +| 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](./06_guardrails_judges_and_transaction_evaluation.md) | | Groundedness is evaluating without the correct context | RAG context, MCP evidence, judge inputs | [RAG/Grounding](docs/developer/en/07_rag_business_context_and_grounding.md) | | RAG does not find content | provider, ingestion, embeddings, configuration | [RAG/Grounding](docs/developer/en/07_rag_business_context_and_grounding.md) | | I do not know whether to use RAG, memory, or a tool | separation of responsibilities | [Architecture and Concepts](docs/developer/en/01_architecture_and_concepts.md) and [RAG/Grounding](docs/developer/en/07_rag_business_context_and_grounding.md) | @@ -115,6 +118,12 @@ If something is not working, use **Search by problem** below. **Use when:** it is necessary to prove the executed path or diagnose production. +### [12 — Input Guardrail Feedback and Blocked-Turn Semantics](./12_input_guardrail_feedback_and_blocked_turns.md) + +**What it is:** public handling of input blocks, blocked-turn state cleanup, and output-guardrail validation of generated feedback. + +**Use when:** block messages are generic, `COER` should ask for clarification, or blocked-turn metadata contains stale routing/tool results. + ### Main tutorial [`README_en.md`](README_en.md) remains the reference for the complete step-by-step flow: @@ -131,3 +140,4 @@ When evolving a feature: - update the specialized manual with behavior, configuration, examples, and troubleshooting; - update SPECs if the contract changed; - keep release notes as history, not as the only current documentation. + diff --git a/docs/developer/pt/03_transaction_workflows_and_state.md b/docs/developer/pt/03_transaction_workflows_and_state.md index 601f0c14..aa753ba3 100644 --- a/docs/developer/pt/03_transaction_workflows_and_state.md +++ b/docs/developer/pt/03_transaction_workflows_and_state.md @@ -558,3 +558,136 @@ Os arquivos abaixo foram consolidados neste manual: ### Regra de manutenção Novas correções ou evoluções deste tema devem atualizar este documento consolidado. Release notes podem continuar existindo como histórico, mas não devem ser necessárias para compreender ou implementar a funcionalidade. + + +## Resolução canônica e revalidação de domínio antes da execução + +Quando uma pré-validação resolve uma referência do usuário para uma entidade canônica, o framework **não deve simplesmente sobrescrever o parâmetro e executar a tool originalmente escolhida**. O contrato separa três valores: + +```text +requested_subject = "youtube" +resolved_subject = "Youtube Premium" +execution_subject = "Youtube Premium" +``` + +O validador de domínio pode devolver `transaction_decision` com: + +```json +{ + "resolved_arguments": {"subject": "Youtube Premium"}, + "target_tool": "tratar_vas_estrategico", + "action_changed": true, + "requires_reconfirmation": true, + "confirmation_message": "Identifiquei o serviço Youtube Premium. Esse serviço possui tratamento específico. Você deseja prosseguir?" +} +``` + +Responsabilidades: + +- **Framework:** preserva argumentos solicitados, aplica apenas os argumentos canônicos declarados pelo validador, atualiza a transação para a `target_tool`, respeita `requires_reconfirmation` e mantém a decisão na evidência de pré-validação. +- **Agente/domínio:** decide classe, política e tool efetiva. O framework não conhece regras como “Youtube Premium é estratégico”. +- **MCP/backend:** executa a operação final já decidida pelo domínio. + +Se a canonicalização não alterar a ação (`Tamboro` → `Tamboro Mensal`, por exemplo), a tool pode permanecer a mesma. Se a resolução alterar classe/política/tool, a decisão de domínio precisa ocorrer **antes da confirmação e da execução**. Em caso de ambiguidade ou baixa confiança, o validador deve pedir nova coleta/clarificação em vez de promover silenciosamente um candidato. + +### Troubleshooting: resolved_subject correto, mas tool recebe o texto original + +Sintoma: a pré-validação registra `resolved_subject="Youtube Premium"`, porém a execução ainda recebe `subject="youtube"`. Verifique se o validador retorna `transaction_decision.resolved_arguments` e se o runtime aplicou a decisão antes de congelar `pending_tool_call`/`confirmation_snapshot`. + +Sintoma: a entidade foi resolvida corretamente, mas a tool final continua inadequada. Verifique `transaction_decision.target_tool`; a reclassificação de domínio pertence ao agente/validador, não ao framework. + +Para domínios que possuem uma classificação autoritativa no detalhe do backend, a revalidação deve usar essa evidência antes de categorias agregadas. No Contas, por exemplo, `invoice_detail.parsed_content` preserva `classe=avulso|estrategico|bundle`; `billing_analysis` pode agrupar o mesmo item em seções mais amplas como `streaming` ou serviços de parceiros. A entidade canônica pode ser descoberta por qualquer evidência autorizada, mas a **decisão de negócio** deve priorizar a fonte que preserva a classificação de domínio. Se houver conflito de classificação, não troque a ação silenciosamente: mantenha a operação original ou peça esclarecimento conforme a política do agente. + + +## Confirmação transacional semântica: SIM / NAO / CONTINUAR + +Transações em `AWAITING_CONFIRMATION` usam duas camadas, nesta ordem: + +1. **Parser determinístico** para confirmações/recusas explícitas (`sim`, `não`, `confirmo`, `pode fazer`, etc.). Esse caminho continua sendo o mais barato, rápido e seguro e **não chama LLM**. +2. **Fallback semântico por LLM** somente quando o parser determinístico retorna inconclusivo. O fallback reutiliza o mesmo mecanismo declarativo de `expected_input.semantic_classifier` dos workflows pausados e injeta a pergunta pendente, o histórico recente relacionado ao mesmo tema e a fala atual. + +A configuração fica em `config/routing.yaml`, sob `router.transaction_confirmation.semantic_fallback`: + +```yaml +router: + transaction_confirmation: + 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: | + Classes permitidas: {{ allowed_values }} + Pergunta pendente: + {{ pending_prompt }} + Histórico relevante: + {{ relevant_conversation_context }} + Resposta atual: + {{ user_input }} +``` + +### Significado das classes + +- `SIM`: aceite inequívoco da ação pendente. Exemplos: `isso mesmo, pode confirmar`, `é isso`, `pode seguir`, quando o contexto torna o aceite claro. +- `NAO`: recusa inequívoca da ação pendente. Exemplos: `melhor não`, `não quero mais`, `cancela isso`. +- `CONTINUAR`: a fala não confirma nem rejeita de forma inequívoca. Exemplos: pergunta adicional, correção de parâmetro, informação nova, ambiguidade ou possível mudança de assunto. Nesse caso a tool não é executada por confirmação. + +### Exemplo + +Contexto: + +```text +Cliente: quero cancelar o Tamboro Mensal +Agente: Você confirma o cancelamento do serviço Tamboro Mensal? +Cliente: isso mesmo, pode confirmar +``` + +O parser determinístico não precisa conhecer literalmente `isso mesmo, pode confirmar`. O fallback recebe: + +```text +pending_prompt = "Você confirma o cancelamento do serviço Tamboro Mensal?" +relevant_conversation_context = histórico recente do mesmo fluxo +user_input = "isso mesmo, pode confirmar" +``` + +e deve retornar apenas: + +```text +SIM +``` + +O router então publica em `route_decision.metadata`: + +```json +{ + "transaction_turn_consumed": true, + "transaction_confirmation_decision": "confirm", + "transaction_confirmation_source": "semantic" +} +``` + +O `AgentRuntime` reutiliza essa decisão e **não tenta reclassificar a mesma fala com o parser determinístico**. Isso evita a regressão em que o router entende semanticamente a confirmação, mas o runtime volta a tratá-la como inconclusiva. + +### Precedência e compatibilidade + +A funcionalidade é aditiva. Entradas determinísticas já suportadas continuam com o mesmo comportamento e sem custo adicional de LLM. O fallback semântico só roda quando a primeira camada não consegue decidir. Assim, `sim` e `não` continuam tendo precedência absoluta sobre `intent_shift`. Uma saída `CONTINUAR` não confirma nem rejeita automaticamente a transação; o fluxo normal pode então avaliar continuação contextual ou mudança de intenção conforme as políticas existentes. + +### Observabilidade + +Para confirmações semânticas, o framework registra a geração como `transaction.confirmation.semantic_classifier` e acrescenta ao metadata do roteamento a fonte `semantic`, a classificação retornada e o contexto conversacional relevante utilizado. Para confirmações literais, a fonte permanece `deterministic`. + +### Compatibilidade de interrupts duráveis no pause/resume + +O runtime não usa `snapshot.next` isoladamente para decidir se um workflow está pausado. Um `next` pode representar trabalho auxiliar do LangGraph, inclusive nós sintéticos criados pelo framework como `__pause` e `__continue`. + +A pausa é reconhecida por um interrupt real. Dependendo da versão do LangGraph/checkpointer, esse interrupt pode aparecer em `task.interrupts` ou persistido em `snapshot.values["__interrupt__"]`. O runtime aceita ambas as formas e deduplica o payload quando as duas são expostas simultaneamente. + +Isso evita dois falsos diagnósticos: + +- considerar `snapshot.next` como `PAUSED` quando não existe interrupt real; +- considerar um `next=("__pause",)` como erro de trabalho pendente quando o interrupt está persistido em `__interrupt__`. + +Em workflows com `expected_input.semantic_classifier`, os tokens internos `SIM`, `NAO` e `CONTINUAR` continuam sendo valores de controle do resume e não devem ser confundidos com resposta final ao cliente. diff --git a/docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md b/docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md index b56c07da..18801e65 100644 --- a/docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md +++ b/docs/developer/pt/06_guardrails_judges_and_transaction_evaluation.md @@ -58,6 +58,69 @@ Esta versão adiciona uma camada pragmática de guardrails ao `agent_framework`, - `RET_REL` — valida relevância de chunks de retrieval por score mínimo. - `TOOL_VAL` — valida ferramenta MCP/tool, argumentos obrigatórios, valores negativos e allowlist. +### Contrato para protocolos autorizados em guardrails de saída + +Quando um workflow ou tool produz um **protocolo que deve ser exibido ao próprio cliente**, o código de integração do agente deve registrar esse valor no contexto de saída antes da execução dos guardrails: + +```python +ctx["expected_protocols"] = [protocol_number] +``` + +Esse campo é um **contrato do framework**. Ele informa que aqueles valores específicos foram produzidos ou validados pelo fluxo atual e, portanto, podem ser usados pelos guardrails de saída como evidência de autorização. + +Fluxo esperado: + +```text +workflow/tool gera protocolo + ↓ +agente registra em expected_protocols + ↓ +CMP valida que o protocolo exibido pertence aos valores esperados + ↓ +DLEX_OUT não bloqueia esse protocolo apenas por classificá-lo como identificador + ↓ +resposta pode informar o protocolo ao cliente +``` + +Regras importantes: + +- `expected_protocols` deve conter **somente protocolos realmente produzidos/esperados no turno ou transação atual**. +- Não use `expected_protocols` para liberar tokens, credenciais, IDs internos arbitrários ou dados de terceiros. +- A autorização vale somente para os valores listados; outro identificador continua sujeito às regras normais de `DLEX_OUT`. +- O valor deve ser propagado **antes de `output_guardrails`**. Se o protocolo só for adicionado depois, a autorização não terá efeito. +- Em respostas transacionais, mantenha a evidência do protocolo no resultado da tool/workflow para que `CMP`, `GND` e observabilidade consigam correlacionar o valor. + +Exemplo: + +```python +result = await executar_workflow(...) +protocol_number = result.get("protocol_number") or result.get("protocolo_id") +if protocol_number: + ctx["expected_protocols"] = [str(protocol_number)] +``` + +#### Troubleshooting: workflow concluiu, mas a resposta foi substituída por mensagem de segurança + +Sintoma típico: + +```text +workflow = COMPLETED +CMP = allowed +DLEX_OUT = blocked por "protocolo interno" +resposta final = "Não consegui validar essa resposta com segurança..." +``` + +Verifique, nesta ordem: + +1. O protocolo gerado está presente no resultado/evidência da tool ou workflow? +2. O agente propagou o mesmo valor em `ctx["expected_protocols"]`? +3. `expected_protocols` foi preenchido antes de `output_guardrails`? +4. O protocolo presente na resposta é exatamente um dos valores esperados? +5. O `DLEX_OUT` está bloqueando por outro motivo real, como segredo, token ou dado de terceiro? + +Se `expected_protocols` estiver ausente, o framework não deve presumir que qualquer identificador textual é seguro para divulgação. + + ### Arquivos alterados - `agent_framework/src/agent_framework/guardrails/rails.py` diff --git a/docs/developer/pt/12_input_guardrail_feedback_and_blocked_turns.md b/docs/developer/pt/12_input_guardrail_feedback_and_blocked_turns.md new file mode 100644 index 00000000..ed9d8a65 --- /dev/null +++ b/docs/developer/pt/12_input_guardrail_feedback_and_blocked_turns.md @@ -0,0 +1,157 @@ +# 12 — Feedback de Guardrails de Entrada e Semântica de Turno Bloqueado + +## Objetivo + +Este documento descreve como o `AgentWorkflow`, implementado em `app/workflows/agent_graph.py`, deve tratar um turno interrompido por guardrail de entrada sem transformar toda interrupção em uma mensagem genérica de “regra de segurança”. + +A regra central é separar três coisas: + +1. **decisão técnica do guardrail**, usada pelo runtime e pela observabilidade; +2. **mensagem pública ao usuário**, adequada ao tipo de bloqueio ou necessidade de esclarecimento; +3. **estado do turno**, que não pode carregar routing, tools ou judges de um turno que foi interrompido antes dessas etapas. + +## Fluxo esperado + +```text +mensagem do usuário + ↓ +input_guardrails + ↓ +allowed? + ├─ sim → routing → tools/agente → composição → output_guardrails + │ + └─ não + ↓ + classificar tratamento público + ↓ + limpar estado de routing/tools/judges do turno + ↓ + construir mensagem pública segura + ↓ + output_guardrails + ↓ + persistência/resposta +``` + +Um guardrail de entrada bloqueante deve ser decidido **antes de qualquer tool com efeito colateral**. + +## `reason` interno não é a resposta ao usuário + +O campo `reason` deve permanecer disponível para logs, traces, eventos e diagnóstico. Ele não deve ser exibido literalmente quando puder revelar mecanismo interno ou quando a frase técnica não for apropriada ao usuário final. + +Exemplo: + +```text +COER.reason = "fala incompreensível ou negação ambígua na transcrição" +``` + +A resposta pública pode ser: + +```text +"Não consegui entender sua última mensagem porque ela parece incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?" +``` + +## Tratamento por tipo de guardrail + +O comportamento exato continua configurável, mas a semântica esperada é: + +| Guardrail | Tratamento público recomendado | +|---|---| +| `COER` | solicitar esclarecimento/reformulação; não tratar ambiguidade como incidente de segurança | +| `PINJ` | bloquear com mensagem segura sem explicar o mecanismo interno | +| `DLEX_IN` | bloquear ou orientar reformulação sem expor dado interno/sensível | +| `INPUT_SIZE` | solicitar redução da entrada | +| `TOX` | aplicar a política configurada para conteúdo inadequado | +| `CMP` | responder segundo a política de compliance | +| desconhecido | usar fallback seguro e genérico | + +## Limpeza do estado do turno bloqueado + +Quando o input é bloqueado antes do routing, o estado final daquele turno não deve reutilizar dados residuais do turno anterior. + +No mínimo, o workflow deve evitar apresentar como atuais: + +```text +route_decision +mcp_tools +mcp_results +judge_results +``` + +O metadata deve deixar explícito que o turno foi interrompido no estágio de input guardrails. + +Isso evita um diagnóstico falso como: + +```text +route = blocked +mcp_results = [tool executada] +``` + +quando a tool na realidade pertence ao turno anterior. + +## Mensagem pública também passa pelos guardrails de saída + +Uma resposta criada em função de um bloqueio de entrada ainda é uma saída do agente. Portanto ela deve seguir o mesmo pipeline de validação de saída antes de chegar ao usuário. + +Isso permite que `DLEX_OUT`, `PINJ`, `TOXOUT`, Output Supervisor e outras políticas removam ou sanitizem informação que não deva ser apresentada. + +## Relação com `agent_graph.py` + +Esta feature é responsabilidade da orquestração do template, porque define a precedência entre nós do grafo e o estado do turno. + +Ao alterar `app/workflows/agent_graph.py`, preserve estas invariantes: + +- `input_guardrails` antecede routing/tools; +- um bloqueio de input não executa ação transacional depois do bloqueio; +- resposta pública não é o `reason` bruto do guardrail; +- estado residual de routing/tools/judges não sobrevive como resultado do turno bloqueado; +- a resposta pública passa por `output_guardrails` antes da persistência/resposta. + +A mesma semântica deve ser mantida nos templates oficiais e nas variantes equivalentes em `Tuning-Performance`. + +## Troubleshooting + +### O usuário recebe “Não consegui seguir com essa mensagem por regra de segurança” para uma frase apenas incompleta + +Verifique: + +1. qual guardrail retornou `allowed=false`; +2. se `COER` está sendo tratado como esclarecimento e não como bloqueio genérico; +3. se o caminho de bloqueio usa uma mensagem pública específica; +4. se o fallback genérico está sendo usado somente quando não existe tratamento específico. + +### O metadata mostra tool executada mesmo com `route=blocked` + +Verifique se o ramo de bloqueio limpa o estado transitório do turno antes de retornar a resposta. Confirme também se a tool não foi executada no mesmo turno antes do guardrail de entrada. + +### A mensagem de bloqueio expõe detalhes internos + +Não use `reason` diretamente como texto público. Gere a mensagem pública e deixe o `reason` apenas em observabilidade. + +### A resposta de bloqueio ignora guardrails de saída + +Verifique a aresta do grafo. O fluxo esperado é: + +```text +input_guardrails bloqueou +→ construir resposta pública +→ output_guardrails +→ persist +``` + +não: + +```text +input_guardrails bloqueou +→ persist +``` + +## Testes de regressão recomendados + +Cubra pelo menos: + +- `COER=false` gera solicitação de esclarecimento, não mensagem genérica de segurança; +- ramo bloqueado não conserva `mcp_results`/routing de turno anterior; +- nenhuma tool transacional é executada depois de um bloqueio de input; +- mensagem pública passa pelos guardrails de saída; +- guardrail desconhecido ainda possui fallback seguro. diff --git a/docs/developer/pt/INDEX_DEVELOPER_GUIDE.md b/docs/developer/pt/INDEX_DEVELOPER_GUIDE.md index 91b9e9a1..079cf671 100644 --- a/docs/developer/pt/INDEX_DEVELOPER_GUIDE.md +++ b/docs/developer/pt/INDEX_DEVELOPER_GUIDE.md @@ -7,7 +7,7 @@ A documentação possui três níveis claros: 1. **Tutorial principal:** [`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](./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, comece pelo `README.md`. @@ -32,6 +32,9 @@ Se algo não está funcionando, use **Buscar pelo problema** abaixo. | Recebo 401 entre gateway/backend/MCP | Basic Auth, credenciais por hop | [Gateways e Auth](./05_agent_gateway_mcp_gateway_and_auth.md) | | Preciso decidir se algo pertence ao framework ou ao agente | boundary core/agente | [Arquitetura e Conceitos](./01_architecture_and_concepts.md) | | Guardrail específico de um agente está quebrando outro | extensibilidade, imports de domínio no core | [Guardrails e Judges](./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](./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](./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](./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](./06_guardrails_judges_and_transaction_evaluation.md) | | Groundedness está avaliando sem contexto correto | RAG context, MCP evidence, judge inputs | [RAG/Grounding](./07_rag_business_context_and_grounding.md) | | RAG não encontra conteúdo | provider, ingestão, embeddings, configuração | [RAG/Grounding](./07_rag_business_context_and_grounding.md) | @@ -116,6 +119,12 @@ Se algo não está funcionando, use **Buscar pelo problema** abaixo. **Use quando:** for necessário provar o caminho executado ou diagnosticar produção. +### [12 — Feedback de Guardrails de Entrada e Turnos Bloqueados](./12_input_guardrail_feedback_and_blocked_turns.md) + +**O que é:** tratamento público de bloqueios de input, limpeza do estado do turno e validação da mensagem gerada pelos guardrails de saída. + +**Use quando:** mensagens de bloqueio são genéricas, `COER` deveria pedir esclarecimento ou o metadata de um turno bloqueado contém routing/tools antigos. + ### Tutorial principal [`README.md`](../../../README.md) continua sendo a referência para o passo a passo completo: diff --git a/libs/agent_framework/build/lib/agent_framework/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..393fb8bf Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/__pycache__/extensions.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/__pycache__/extensions.cpython-313.pyc new file mode 100644 index 00000000..6bcfde5d Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/__pycache__/extensions.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/__pycache__/gateway_policy_context.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/__pycache__/gateway_policy_context.cpython-313.pyc new file mode 100644 index 00000000..8f935c61 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/__pycache__/gateway_policy_context.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/__pycache__/idempotency.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/__pycache__/idempotency.cpython-313.pyc new file mode 100644 index 00000000..d7cfedbb Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/__pycache__/idempotency.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/__pycache__/observer.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/__pycache__/observer.cpython-313.pyc new file mode 100644 index 00000000..54827815 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/__pycache__/observer.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/__pycache__/runtime_mcp_gateway_adapter.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/__pycache__/runtime_mcp_gateway_adapter.cpython-313.pyc new file mode 100644 index 00000000..38238133 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/__pycache__/runtime_mcp_gateway_adapter.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..91e613ef Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/composite_publisher.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/composite_publisher.cpython-313.pyc new file mode 100644 index 00000000..98c3131c Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/composite_publisher.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/event_builder.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/event_builder.cpython-313.pyc new file mode 100644 index 00000000..4446a3ff Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/event_builder.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/factory.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/factory.cpython-313.pyc new file mode 100644 index 00000000..8a3e728d Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/factory.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/publisher.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/publisher.cpython-313.pyc new file mode 100644 index 00000000..39a07288 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/publisher.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/tim_payload_mapper.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/tim_payload_mapper.cpython-313.pyc new file mode 100644 index 00000000..3710122e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/tim_payload_mapper.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/tim_sequence.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/tim_sequence.cpython-313.pyc new file mode 100644 index 00000000..025f7710 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/__pycache__/tim_sequence.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..9a843496 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/kafka.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/kafka.cpython-313.pyc new file mode 100644 index 00000000..b2e4a9a5 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/kafka.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/langfuse.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/langfuse.cpython-313.pyc new file mode 100644 index 00000000..eb3c2eae Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/langfuse.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/oci_streaming.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/oci_streaming.cpython-313.pyc new file mode 100644 index 00000000..850581c1 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/oci_streaming.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/pubsub.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/pubsub.cpython-313.pyc new file mode 100644 index 00000000..91e65961 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/analytics/providers/__pycache__/pubsub.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/billing/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/billing/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..e9e66d67 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/billing/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/billing/__pycache__/usage_repository.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/billing/__pycache__/usage_repository.cpython-313.pyc new file mode 100644 index 00000000..a67530c2 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/billing/__pycache__/usage_repository.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/cache/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/cache/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..04b86877 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/cache/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/cache/__pycache__/cache.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/cache/__pycache__/cache.cpython-313.pyc new file mode 100644 index 00000000..e47f0c9e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/cache/__pycache__/cache.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..37b3d894 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/adapters.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/adapters.cpython-313.pyc new file mode 100644 index 00000000..432b7d64 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/adapters.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/base.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/base.cpython-313.pyc new file mode 100644 index 00000000..53e0fb60 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/base.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/gateway.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/gateway.cpython-313.pyc new file mode 100644 index 00000000..161d2324 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/gateway.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/interruption.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/interruption.cpython-313.pyc new file mode 100644 index 00000000..5c310dad Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/interruption.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/transcription.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/transcription.cpython-313.pyc new file mode 100644 index 00000000..07cf4ede Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/channels/__pycache__/transcription.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..f7d3dbc8 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/checkpoint_repository.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/checkpoint_repository.cpython-313.pyc new file mode 100644 index 00000000..9b48baaf Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/checkpoint_repository.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/langgraph_saver.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/langgraph_saver.cpython-313.pyc new file mode 100644 index 00000000..f3c63174 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/checkpoints/__pycache__/langgraph_saver.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/config/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..a6db1bb1 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/config/__pycache__/agent_registry.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/config/__pycache__/agent_registry.cpython-313.pyc new file mode 100644 index 00000000..c1db452a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/config/__pycache__/agent_registry.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/config/__pycache__/settings.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/config/__pycache__/settings.cpython-313.pyc new file mode 100644 index 00000000..88305648 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/config/__pycache__/settings.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/events/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/events/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..dfe6ced0 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/events/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/events/__pycache__/oci_streaming.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/events/__pycache__/oci_streaming.cpython-313.pyc new file mode 100644 index 00000000..6a517098 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/events/__pycache__/oci_streaming.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..1497c7b0 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc new file mode 100644 index 00000000..938c58d3 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..13d24b84 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/client.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/client.cpython-313.pyc new file mode 100644 index 00000000..3ee859c0 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/client.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/config.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/config.cpython-313.pyc new file mode 100644 index 00000000..370f6f6e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/config.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/models.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..2a6c75c3 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/router.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/router.cpython-313.pyc new file mode 100644 index 00000000..a99fd417 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/router.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/session_store.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/session_store.cpython-313.pyc new file mode 100644 index 00000000..063978d9 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/global_supervisor/__pycache__/session_store.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..9503cb79 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/base.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/base.cpython-313.pyc new file mode 100644 index 00000000..ad115454 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/base.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/config_loader.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/config_loader.cpython-313.pyc new file mode 100644 index 00000000..4a6373b9 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/config_loader.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/custom_rails.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/custom_rails.cpython-313.pyc new file mode 100644 index 00000000..5f16e14b Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/custom_rails.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/executor.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/executor.cpython-313.pyc new file mode 100644 index 00000000..76a0ce34 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/executor.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/framework_llm_client.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/framework_llm_client.cpython-313.pyc new file mode 100644 index 00000000..005475ba Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/framework_llm_client.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/langgraph_adapters.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/langgraph_adapters.cpython-313.pyc new file mode 100644 index 00000000..53ac82d8 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/langgraph_adapters.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/llm_rails.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/llm_rails.cpython-313.pyc new file mode 100644 index 00000000..36676c00 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/llm_rails.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/output_supervisor.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/output_supervisor.cpython-313.pyc new file mode 100644 index 00000000..6fe0384e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/output_supervisor.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/parallel_executor.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/parallel_executor.cpython-313.pyc new file mode 100644 index 00000000..6e5a9dc8 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/parallel_executor.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/pipeline.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/pipeline.cpython-313.pyc new file mode 100644 index 00000000..2faecbb2 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/pipeline.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_action.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_action.cpython-313.pyc new file mode 100644 index 00000000..de06bc82 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_action.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_decision.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_decision.cpython-313.pyc new file mode 100644 index 00000000..a9334338 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_decision.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_result.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_result.cpython-313.pyc new file mode 100644 index 00000000..3d3b39bf Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rail_result.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rails.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rails.cpython-313.pyc new file mode 100644 index 00000000..03f36cfc Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/__pycache__/rails.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..5a8663ca Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/_compat.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/_compat.cpython-313.pyc new file mode 100644 index 00000000..a1da7169 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/_compat.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/config.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/config.cpython-313.pyc new file mode 100644 index 00000000..009d915e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/config.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/contestation_validation.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/contestation_validation.cpython-313.pyc new file mode 100644 index 00000000..69fe1a5b Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/contestation_validation.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/contracts.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/contracts.cpython-313.pyc new file mode 100644 index 00000000..9abed83d Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/contracts.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/input_size.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/input_size.cpython-313.pyc new file mode 100644 index 00000000..f18e5d15 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/input_size.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_adapter.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_adapter.cpython-313.pyc new file mode 100644 index 00000000..ab6cbe50 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_adapter.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_client.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_client.cpython-313.pyc new file mode 100644 index 00000000..8f10fdc3 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_client.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_rails.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_rails.cpython-313.pyc new file mode 100644 index 00000000..d6e600ef Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/llm_rails.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/output_sanitization.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/output_sanitization.cpython-313.pyc new file mode 100644 index 00000000..1b2dff7a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/output_sanitization.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/pipeline.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/pipeline.cpython-313.pyc new file mode 100644 index 00000000..c2af6f63 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/__pycache__/pipeline.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..9e4ba3ee Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/_context.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/_context.cpython-313.pyc new file mode 100644 index 00000000..e0c2e93d Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/_context.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc new file mode 100644 index 00000000..053dc694 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/coerencia.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/coerencia.cpython-313.pyc new file mode 100644 index 00000000..08b46a44 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/coerencia.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_in.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_in.cpython-313.pyc new file mode 100644 index 00000000..023ef961 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_in.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_out.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_out.cpython-313.pyc new file mode 100644 index 00000000..77bac8f9 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_out.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/fallback.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/fallback.cpython-313.pyc new file mode 100644 index 00000000..d5cd6332 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/fallback.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/fraseologia.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/fraseologia.cpython-313.pyc new file mode 100644 index 00000000..4bf4d612 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/fraseologia.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/out_of_scope.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/out_of_scope.cpython-313.pyc new file mode 100644 index 00000000..e21f5956 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/out_of_scope.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/pinj.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/pinj.cpython-313.pyc new file mode 100644 index 00000000..97277c86 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/pinj.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/ragsec.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/ragsec.cpython-313.pyc new file mode 100644 index 00000000..33e30180 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/ragsec.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/revprec.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/revprec.cpython-313.pyc new file mode 100644 index 00000000..334e62b3 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/revprec.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/safe_out.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/safe_out.cpython-313.pyc new file mode 100644 index 00000000..ea56658d Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/safe_out.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/tox.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/tox.cpython-313.pyc new file mode 100644 index 00000000..afce58b5 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/tox.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/toxicidade_output.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/toxicidade_output.cpython-313.pyc new file mode 100644 index 00000000..aacec91d Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/__pycache__/toxicidade_output.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/dlex_out.py b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/dlex_out.py index b88caefc..09fb7a66 100644 --- a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/dlex_out.py +++ b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/dlex_out.py @@ -20,6 +20,7 @@ SÃO PERMITIDOS: - Expor valores de planos, itens de planos Não marque DLEX_OUT para explicação pública de atendimento, protocolo, plano, fatura ou dados autorizados do próprio cliente. +Se o contexto indicar authorized_customer_protocol=true ou que o protocolo veio de expected_protocols do workflow atual, esse protocolo é explicitamente autorizado para divulgação ao próprio cliente e NÃO é vazamento. Responda apenas JSON: {{"allowed": true/false, "label": "DLEX_OUT/OK", "reason": "Explicação curta da razão"}} diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..903f4e91 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/supervision_template.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/supervision_template.cpython-313.pyc new file mode 100644 index 00000000..e7111a6b Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/supervision_template.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/tts_rules.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/tts_rules.cpython-313.pyc new file mode 100644 index 00000000..12e38f60 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/tts_rules.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..e4547ab7 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/alcada.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/alcada.cpython-313.pyc new file mode 100644 index 00000000..9cd565fc Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/alcada.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/anatel.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/anatel.cpython-313.pyc new file mode 100644 index 00000000..7ebd55ba Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/anatel.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/confirmation.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/confirmation.cpython-313.pyc new file mode 100644 index 00000000..ca1905ea Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/confirmation.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_in.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_in.cpython-313.pyc new file mode 100644 index 00000000..bf674c16 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_in.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_out.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_out.cpython-313.pyc new file mode 100644 index 00000000..516d9ba2 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_out.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/ragsec.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/ragsec.cpython-313.pyc new file mode 100644 index 00000000..aa71f5f4 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/ragsec.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/revprec.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/revprec.cpython-313.pyc new file mode 100644 index 00000000..a26fce76 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/revprec.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/tox.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/tox.cpython-313.pyc new file mode 100644 index 00000000..c8f9c0fb Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/__pycache__/tox.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..dae0fca7 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/correspondencia_item.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/correspondencia_item.cpython-313.pyc new file mode 100644 index 00000000..25203986 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/correspondencia_item.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/groundedness.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/groundedness.cpython-313.pyc new file mode 100644 index 00000000..3f7c6819 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/groundedness.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/intencao_cancelar.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/intencao_cancelar.cpython-313.pyc new file mode 100644 index 00000000..94012421 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/intencao_cancelar.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/quantidade_coerente.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/quantidade_coerente.cpython-313.pyc new file mode 100644 index 00000000..ce058caa Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/quantidade_coerente.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/servico_correto.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/servico_correto.cpython-313.pyc new file mode 100644 index 00000000..ab875b40 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/servico_correto.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/verbalizacao_prematura.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/verbalizacao_prematura.cpython-313.pyc new file mode 100644 index 00000000..26d97a2a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/verbalizacao_prematura.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..63716a33 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/alcada.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/alcada.cpython-313.pyc new file mode 100644 index 00000000..82c85db4 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/alcada.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/oos_blocklist.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/oos_blocklist.cpython-313.pyc new file mode 100644 index 00000000..64cb2564 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/oos_blocklist.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/pinj_patterns.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/pinj_patterns.cpython-313.pyc new file mode 100644 index 00000000..47569e8f Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/pinj_patterns.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/tox_blocklist.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/tox_blocklist.cpython-313.pyc new file mode 100644 index 00000000..ca1fa168 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/guardrails/calibrated/rules/__pycache__/tox_blocklist.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/rails.py b/libs/agent_framework/build/lib/agent_framework/guardrails/rails.py index f8c70c44..187e5570 100644 --- a/libs/agent_framework/build/lib/agent_framework/guardrails/rails.py +++ b/libs/agent_framework/build/lib/agent_framework/guardrails/rails.py @@ -25,6 +25,7 @@ from .calibrated.output_sanitization import mascarar_pii_output, sanitizar_toxic from .calibrated.rules.pinj_patterns import _PINJ_PATTERNS, is_obvious_injection from .calibrated.rules.tox_blocklist import _EXPLICIT_TERMS, _THREAT_PATTERNS, is_obvious_toxic from .framework_llm_client import classify_with_framework_llm +from agent_framework.workflows.input_contract import has_meaningful_unmatched_policy, has_semantic_classifier def _lower(text: str) -> str: @@ -220,8 +221,6 @@ class OutputToxicitySanitizationRail(Guardrail): class OutOfScopeRail(Guardrail): - """OOS calibrado: classificador LLM para escopo de domínio de atendimento configurado.""" - code = "OOS" stage = "input" @@ -251,6 +250,79 @@ class CoherenceRail(Guardrail): async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: ctx = _ctx(context) + transaction_status = str(ctx.get("transaction_status") or "").strip().upper() + missing_parameters = [str(x) for x in (ctx.get("missing_parameters") or []) if str(x).strip()] + if transaction_status == "COLLECTING_PARAMETERS" and missing_parameters: + return RailDecision( + code=self.code, + allowed=True, + reason="Coerência delegada ao contrato de parâmetros da transação ativa", + sanitized_text=text, + metadata={ + "mechanism": "transaction_parameter_contract", + "calibrated": True, + "delegated": True, + "transaction_status": transaction_status, + "missing_parameters": missing_parameters, + }, + ) + expected_input = ctx.get("expected_input") + if isinstance(expected_input, dict) and expected_input.get("allowed_values"): + # Backward-compatible default: enumerated contracts without an + # explicit unmatched policy own coherence deterministically and + # reprompt every value outside allowed_values. + if has_semantic_classifier(expected_input): + return RailDecision( + code=self.code, + allowed=True, + reason="Coerência e semântica delegadas ao semantic_classifier do expected_input", + sanitized_text=text, + metadata={ + "mechanism": "expected_input_semantic_classifier", + "calibrated": True, + "delegated": True, + }, + ) + if not has_meaningful_unmatched_policy(expected_input): + return RailDecision( + code=self.code, + allowed=True, + reason="Coerência delegada ao contrato expected_input do workflow pausado", + sanitized_text=text, + metadata={ + "mechanism": "expected_input_contract", + "calibrated": True, + "delegated": True, + }, + ) + + # Opt-in semantic unmatched handling: COER still classifies the + # free-text reply, but does NOT block the graph. Its underlying + # signal is consumed by expected_input to choose reprompt vs the + # workflow-declared meaningful_input action. Other safety rails + # continue to execute and may block independently. + out = await classify_with_framework_llm( + _llm(ctx), "COER", {"text": text or "", "context": ctx}, + profile_name="guardrail", component_name="guardrail.coer", generation_name="guardrail.coer", + ) + semantic_coherent = bool(out.get("allowed", True)) + return RailDecision( + code=self.code, + allowed=True, + reason=( + "Entrada coerente; decisão delegada à política unmatched do expected_input" + if semantic_coherent + else "Entrada incoerente; decisão delegada ao reprompt do expected_input" + ), + sanitized_text=text, + metadata={ + "mechanism": "expected_input_contract", + "calibrated": True, + "delegated": True, + "semantic_coherent": semantic_coherent, + "data": out, + }, + ) out = await classify_with_framework_llm( _llm(ctx), "COER", {"text": text or "", "context": ctx}, profile_name="guardrail", component_name="guardrail.coer", generation_name="guardrail.coer", @@ -565,6 +637,49 @@ class DataLeakageInputRail(Guardrail): return RailDecision(code=self.code, allowed=bool(out.get("allowed", True)), reason=str(out.get("reason") or out.get("label") or "DLEX_IN avaliado"), sanitized_text=text, metadata={"mechanism": "llm_rail", "data": out, "calibrated": True}) +def _mask_authorized_protocol_values(value: Any, protocols: list[str]) -> Any: + """Mask only protocol values explicitly authorized for the current turn. + + This function is used only to build the DLEX_OUT classifier payload. It does + not mutate the runtime state or the user-visible response. Unrelated values + remain untouched and therefore continue to be evaluated normally by DLEX. + """ + + if isinstance(value, str): + masked = value + for protocol in protocols: + if protocol: + masked = masked.replace(protocol, "") + return masked + if isinstance(value, dict): + return {key: _mask_authorized_protocol_values(item, protocols) for key, item in value.items()} + if isinstance(value, list): + return [_mask_authorized_protocol_values(item, protocols) for item in value] + if isinstance(value, tuple): + return tuple(_mask_authorized_protocol_values(item, protocols) for item in value) + return value + + +def _dlex_block_may_be_authorized_protocol(out: dict[str, Any]) -> bool: + """Return True only when DLEX appears to object to the protocol itself. + + The recheck must not run for unrelated leakage (tokens, credentials, + prompts, third-party data, etc.), because those violations remain blocking. + """ + + reason = str(out.get("reason") or out.get("label") or "").lower() + protocol_terms = ("protocolo", "protocol", "identificador", "identifier") + unrelated_terms = ( + "token", "secret", "segredo", "api key", "api_key", "chave", + "senha", "password", "credencial", "credential", "prompt", + "instrução interna", "instrucoes internas", "instruções internas", + "terceiro", "third-party", "outro cliente", + ) + return any(term in reason for term in protocol_terms) and not any( + term in reason for term in unrelated_terms + ) + + class DataLeakageOutputRail(Guardrail): code = "DLEX_OUT" stage = "output" @@ -573,8 +688,110 @@ class DataLeakageOutputRail(Guardrail): ctx = _ctx(context) if not ctx.get("__guardrails_yaml_controlled") and not _truthy(os.getenv("GUARDRAIL_DLEX_OUT_ENABLED"), False): return RailDecision(code=self.code, allowed=True, metadata={"skipped": "covered_by_OOS_and_MSK", "calibrated": True}) - out = await classify_with_framework_llm(_llm(ctx), "DLEX_OUT", {"text": text or "", "context": ctx}, profile_name="grl", component_name="guardrail.dlex_out", generation_name="guardrail.dlex_out") - return RailDecision(code=self.code, allowed=bool(out.get("allowed", True)), reason=str(out.get("reason") or out.get("label") or "DLEX_OUT avaliado"), sanitized_text=text, metadata={"mechanism": "llm_rail", "data": out, "calibrated": True}) + + original_text = text or "" + expected_protocols = [ + str(value).strip() + for value in (ctx.get("expected_protocols") or []) + if str(value).strip() + ] + matched_expected_protocols = [ + protocol for protocol in expected_protocols if protocol in original_text + ] + + # Protocols explicitly produced/expected by the current workflow are + # authorized output values. Mask only those exact values before DLEX + # classification so that the LLM cannot mistake them for leaked internal + # identifiers. Any other number/identifier remains visible to DLEX. + classifier_text = original_text + classifier_ctx: dict[str, Any] = ctx + if matched_expected_protocols: + classifier_text = _mask_authorized_protocol_values( + original_text, matched_expected_protocols + ) + classifier_ctx = _mask_authorized_protocol_values( + ctx, matched_expected_protocols + ) + + out = await classify_with_framework_llm( + _llm(ctx), + "DLEX_OUT", + {"text": classifier_text, "context": classifier_ctx}, + profile_name="grl", + component_name="guardrail.dlex_out", + generation_name="guardrail.dlex_out", + ) + + # A workflow-generated protocol listed in ``expected_protocols`` is an + # explicitly authorized customer-facing value. Some LLM classifiers can + # still reject the neutral placeholder merely because the surrounding + # sentence contains the word "protocolo". When that happens, re-run the + # classifier with the exact authorized value replaced by plain public + # wording. This second pass preserves every other part of the response + # (tokens, credentials, third-party data, internal instructions, etc.), + # so unrelated leakage continues to be blocked. Only if the response is + # safe without the authorized identifier do we override the false + # positive from the first pass. + protocol_authorization_verified = False + protocol_recheck = None + if ( + matched_expected_protocols + and not bool(out.get("allowed", True)) + and _dlex_block_may_be_authorized_protocol(out) + ): + recheck_text = original_text + recheck_ctx: dict[str, Any] = ctx + for protocol in matched_expected_protocols: + recheck_text = recheck_text.replace( + protocol, "referência pública autorizada para este cliente" + ) + recheck_ctx = _mask_authorized_protocol_values( + ctx, matched_expected_protocols + ) + recheck_ctx = dict(recheck_ctx) + recheck_ctx["authorized_customer_protocol"] = True + recheck_ctx["authorization_rule"] = ( + "Protocolos presentes em expected_protocols foram produzidos " + "pelo workflow atual e são autorizados para divulgação ao próprio cliente." + ) + protocol_recheck = await classify_with_framework_llm( + _llm(ctx), + "DLEX_OUT", + {"text": recheck_text, "context": recheck_ctx}, + profile_name="grl", + component_name="guardrail.dlex_out.protocol_authorization_recheck", + generation_name="guardrail.dlex_out.protocol_authorization_recheck", + ) + if bool(protocol_recheck.get("allowed", True)): + out = { + "allowed": True, + "label": "OK", + "reason": "protocolo esperado pelo workflow explicitamente autorizado", + "protocol_recheck": protocol_recheck, + } + protocol_authorization_verified = True + + metadata = { + "mechanism": "llm_rail", + "data": out, + "calibrated": True, + } + if matched_expected_protocols: + metadata.update( + { + "protocol_authorization": "expected_values", + "authorized_protocols_masked": len(matched_expected_protocols), + "protocol_authorization_verified": protocol_authorization_verified, + "protocol_recheck": protocol_recheck, + } + ) + return RailDecision( + code=self.code, + allowed=bool(out.get("allowed", True)), + reason=str(out.get("reason") or out.get("label") or "DLEX_OUT avaliado"), + sanitized_text=text, + metadata=metadata, + ) class RetrievalRelevanceRail(Guardrail): diff --git a/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..3693bdb4 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc new file mode 100644 index 00000000..28329f0c Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/models.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..0506d819 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/resolver.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/resolver.cpython-313.pyc new file mode 100644 index 00000000..e760e2a7 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/identity/__pycache__/resolver.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..3f398b48 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/__pycache__/judge.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/__pycache__/judge.cpython-313.pyc new file mode 100644 index 00000000..49d40ba5 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/__pycache__/judge.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..6d6627d7 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/_compat.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/_compat.cpython-313.pyc new file mode 100644 index 00000000..da779fef Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/_compat.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/llm_client.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/llm_client.cpython-313.pyc new file mode 100644 index 00000000..d8673447 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/llm_client.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/models.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..f014ca02 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..6713dee6 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/aluc.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/aluc.cpython-313.pyc new file mode 100644 index 00000000..9b1920ed Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/aluc.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/csi.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/csi.cpython-313.pyc new file mode 100644 index 00000000..8d25a7eb Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/csi.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/fallback.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/fallback.cpython-313.pyc new file mode 100644 index 00000000..ac88701c Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/fallback.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/rqlt.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/rqlt.cpython-313.pyc new file mode 100644 index 00000000..23cbc9f1 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/rqlt.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/vctn.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/vctn.cpython-313.pyc new file mode 100644 index 00000000..ce953403 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/judges/calibrated/prompts/__pycache__/vctn.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..f53408a7 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/base.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/base.cpython-313.pyc new file mode 100644 index 00000000..c2723909 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/base.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/profile_resolver.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/profile_resolver.cpython-313.pyc new file mode 100644 index 00000000..240d8a2a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/profile_resolver.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/providers.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/providers.cpython-313.pyc new file mode 100644 index 00000000..0e46e899 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/providers.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/types.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/types.cpython-313.pyc new file mode 100644 index 00000000..fac84663 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/llm/__pycache__/types.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..5671eaa5 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/client.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/client.cpython-313.pyc new file mode 100644 index 00000000..e1051427 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/client.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/models.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..ab343571 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/registry.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/registry.cpython-313.pyc new file mode 100644 index 00000000..4a2f72c4 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/registry.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc new file mode 100644 index 00000000..ff81f2fa Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc new file mode 100644 index 00000000..a83ed8fc Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..dfc32d2c Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_extractor.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_extractor.cpython-313.pyc new file mode 100644 index 00000000..75617bc9 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_extractor.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_memory.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_memory.cpython-313.pyc new file mode 100644 index 00000000..e0542ae4 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_memory.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_models.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_models.cpython-313.pyc new file mode 100644 index 00000000..ab1f4386 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_models.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_store.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_store.cpython-313.pyc new file mode 100644 index 00000000..bceeeb72 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/long_term_store.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/message_history.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/message_history.cpython-313.pyc new file mode 100644 index 00000000..8a4baf1e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/message_history.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc new file mode 100644 index 00000000..f2e6d50a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc new file mode 100644 index 00000000..ea2e3022 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/models/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..62370613 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/models/__pycache__/identity.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/models/__pycache__/identity.cpython-313.pyc new file mode 100644 index 00000000..1a6b4d01 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/models/__pycache__/identity.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/models/__pycache__/session.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/models/__pycache__/session.cpython-313.pyc new file mode 100644 index 00000000..ecdbb999 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/models/__pycache__/session.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..e5922853 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/code_mapper.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/code_mapper.cpython-313.pyc new file mode 100644 index 00000000..6bb7356d Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/code_mapper.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/context.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/context.cpython-313.pyc new file mode 100644 index 00000000..24187ed6 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/context.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/control_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/control_events.cpython-313.pyc new file mode 100644 index 00000000..8a51c87f Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/control_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/decorators.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/decorators.cpython-313.pyc new file mode 100644 index 00000000..0eb8dc72 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/decorators.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/event_bus.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/event_bus.cpython-313.pyc new file mode 100644 index 00000000..0d54e8b2 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/event_bus.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/grl_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/grl_events.cpython-313.pyc new file mode 100644 index 00000000..edbd6b54 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/grl_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/guardrail_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/guardrail_events.cpython-313.pyc new file mode 100644 index 00000000..31552cdf Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/guardrail_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/ic_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/ic_events.cpython-313.pyc new file mode 100644 index 00000000..50c66aee Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/ic_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/informational_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/informational_events.cpython-313.pyc new file mode 100644 index 00000000..1af4354b Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/informational_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/judge_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/judge_events.cpython-313.pyc new file mode 100644 index 00000000..fb64d4f9 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/judge_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/langfuse_enterprise.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/langfuse_enterprise.cpython-313.pyc new file mode 100644 index 00000000..f1dbfa32 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/langfuse_enterprise.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/langgraph_telemetry.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/langgraph_telemetry.cpython-313.pyc new file mode 100644 index 00000000..e3f93f8f Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/langgraph_telemetry.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/llm_advisors.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/llm_advisors.cpython-313.pyc new file mode 100644 index 00000000..acd379cd Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/llm_advisors.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_contract.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_contract.cpython-313.pyc new file mode 100644 index 00000000..30ac0c22 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_contract.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_events.cpython-313.pyc new file mode 100644 index 00000000..d37ab17a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_otel.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_otel.cpython-313.pyc new file mode 100644 index 00000000..47c1def9 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/noc_otel.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/observer.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/observer.cpython-313.pyc new file mode 100644 index 00000000..4871e78e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/observer.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/otel.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/otel.cpython-313.pyc new file mode 100644 index 00000000..0c0e1644 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/otel.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/streaming_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/streaming_events.cpython-313.pyc new file mode 100644 index 00000000..346186b3 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/streaming_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/streaming_exporter.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/streaming_exporter.cpython-313.pyc new file mode 100644 index 00000000..e46d02e9 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/streaming_exporter.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/telemetry.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/telemetry.cpython-313.pyc new file mode 100644 index 00000000..77915f0e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/telemetry.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/tim_backoffice_contract.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/tim_backoffice_contract.cpython-313.pyc new file mode 100644 index 00000000..bd3eedbb Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/tim_backoffice_contract.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/token_cost.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/token_cost.cpython-313.pyc new file mode 100644 index 00000000..eb020acd Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/token_cost.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/workflow_events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/workflow_events.cpython-313.pyc new file mode 100644 index 00000000..de1a0127 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/observability/__pycache__/workflow_events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/oci/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/oci/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..8a331fb0 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/oci/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/oci/__pycache__/auth.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/oci/__pycache__/auth.cpython-313.pyc new file mode 100644 index 00000000..a8cca2ad Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/oci/__pycache__/auth.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..0393bae6 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/mongodb_store.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/mongodb_store.cpython-313.pyc new file mode 100644 index 00000000..546cc342 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/mongodb_store.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/oracle_store.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/oracle_store.cpython-313.pyc new file mode 100644 index 00000000..836b1e2e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/oracle_store.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc new file mode 100644 index 00000000..28d590e8 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/presentation/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/presentation/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..2ea8209c Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/presentation/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/presentation/__pycache__/renderers.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/presentation/__pycache__/renderers.cpython-313.pyc new file mode 100644 index 00000000..966f6d4a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/presentation/__pycache__/renderers.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..b06d83ac Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/embedding_provider.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/embedding_provider.cpython-313.pyc new file mode 100644 index 00000000..f0027235 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/embedding_provider.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/graph_store.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/graph_store.cpython-313.pyc new file mode 100644 index 00000000..e9db1f82 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/graph_store.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/ingest.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/ingest.cpython-313.pyc new file mode 100644 index 00000000..35ac6bf3 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/ingest.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/rag_service.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/rag_service.cpython-313.pyc new file mode 100644 index 00000000..871c7d84 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/rag_service.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/vector_store.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/vector_store.cpython-313.pyc new file mode 100644 index 00000000..7a3ea018 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/rag/__pycache__/vector_store.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/repositories/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/repositories/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..6f1fb7aa Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/repositories/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/repositories/__pycache__/session_repository.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/repositories/__pycache__/session_repository.cpython-313.pyc new file mode 100644 index 00000000..b8815bbb Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/repositories/__pycache__/session_repository.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..222d34ec Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc new file mode 100644 index 00000000..17ab49de Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/continuity.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/continuity.cpython-313.pyc new file mode 100644 index 00000000..a3edd9e0 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/continuity.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc new file mode 100644 index 00000000..b805d8d4 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/models.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..52d1888a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/routing/enterprise_router.py b/libs/agent_framework/build/lib/agent_framework/routing/enterprise_router.py index 501e2bfd..ea5188d1 100644 --- a/libs/agent_framework/build/lib/agent_framework/routing/enterprise_router.py +++ b/libs/agent_framework/build/lib/agent_framework/routing/enterprise_router.py @@ -9,7 +9,16 @@ from typing import Any from .config_loader import load_intents, load_router_defaults, load_state_policies from .continuity import SemanticRouteContinuity from .models import IntentDefinition, RouteDecision, RouterStatePolicy +from agent_framework.llm.structured_output import parse_json_object from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation +from agent_framework.workflows.input_contract import ( + expected_input_reprompt, + has_semantic_classifier, + match_expected_input, + match_semantic_classifier_output, + meaningful_unmatched_resume_value, + semantic_coherence_from_guardrails, +) logger = logging.getLogger("agent_framework.routing") @@ -37,6 +46,7 @@ class EnterpriseRouter: self.defaults = load_router_defaults(self.config_path) self.fallback_agent = self.defaults.get("fallback_agent", "billing_agent") self.intent_shift_threshold = float(self.defaults.get("confidence_threshold", 0.7)) + self.transaction_confirmation = dict(self.defaults.get("transaction_confirmation") or {}) self.enable_llm_router = bool(getattr(settings, "ENABLE_LLM_ROUTER", False)) self.continuity = SemanticRouteContinuity(settings, llm, telemetry) logger.info( @@ -53,6 +63,301 @@ class EnterpriseRouter: self.continuity.confidence_threshold, ) + @staticmethod + def _history_message_intent(item: dict[str, Any]) -> str: + metadata = item.get("metadata") if isinstance(item, dict) else {} + metadata = metadata if isinstance(metadata, dict) else {} + direct = str(metadata.get("intent") or "").strip() + if direct: + return direct + decision = metadata.get("route_decision") + if isinstance(decision, dict): + return str(decision.get("intent") or "").strip() + return "" + + @classmethod + def _collect_relevant_conversation_context( + cls, + *, + state: dict[str, Any], + pending_workflow: dict[str, Any], + current_text: str, + ) -> str: + """Return the contiguous conversational suffix relevant to the paused workflow. + + The preferred anchor is the user turn that produced the current PAUSED + workflow state. From there we keep the contiguous conversation through the + immediately preceding assistant prompt. For legacy checkpoints without an + anchor id, we walk backwards and stop at the first assistant turn whose + recorded intent differs from the workflow owner intent. Transaction state, + snapshots and tool evidence are deliberately not injected here: this context + is only for understanding unresolved conversational requests, never for + treating user claims as business evidence. + """ + history = [x for x in (state.get("history") or []) if isinstance(x, dict)] + if history: + last = history[-1] + if ( + str(last.get("role") or "") == "user" + and str(last.get("content") or "").strip() == str(current_text or "").strip() + ): + history = history[:-1] + if not history: + return "" + + # Preferred boundary: the exact user message that produced the current + # pause. This is refreshed on every PAUSED result, so a new decision does + # not inherit unrelated older requests, even when they share the same + # route/intent. + anchor_message_id = str(pending_workflow.get("context_anchor_message_id") or "").strip() + if anchor_message_id: + for index, item in enumerate(history): + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + if str(metadata.get("message_id") or "").strip() == anchor_message_id: + history = history[index:] + break + + target_intent = str( + pending_workflow.get("owner_intent") + or (state.get("route_decision") or {}).get("intent") + or state.get("intent") + or "" + ).strip() + + selected: list[dict[str, Any]] = [] + anchor_seen = False + for item in reversed(history): + role = str(item.get("role") or "").strip().lower() + content = str(item.get("content") or "").strip() + if not content: + continue + + if role == "assistant": + item_intent = cls._history_message_intent(item) + if anchor_seen and target_intent and item_intent and item_intent != target_intent: + break + anchor_seen = True + + # Ignore everything before the first assistant anchor. This keeps a + # malformed/incomplete history from pulling unrelated old user turns. + if anchor_seen: + selected.append(item) + + selected.reverse() + rendered = [] + for item in selected: + role = str(item.get("role") or "unknown").strip().lower() + content = str(item.get("content") or "").strip() + rendered.append(f"{role}: {content}") + return "\n".join(rendered) + + @staticmethod + def _collect_transaction_parameter_context( + *, state: dict[str, Any], current_text: str, max_messages: int = 6 + ) -> str: + """Render a bounded recent history only to resolve parameter references. + + This context is deliberately non-authoritative. It may help the extractor + resolve references such as "a de 14,99" to an entity named in the recent + assistant/tool-grounded conversation, but business pre-validation remains + responsible for proving the candidate before confirmation/execution. + """ + history = [item for item in (state.get("history") or []) if isinstance(item, dict)] + if history: + last = history[-1] + if ( + str(last.get("role") or "").strip().lower() == "user" + and str(last.get("content") or "").strip() == str(current_text or "").strip() + ): + history = history[:-1] + selected = history[-max(1, int(max_messages or 1)):] + rendered: list[str] = [] + for item in selected: + role = str(item.get("role") or "unknown").strip().lower() + content = str(item.get("content") or "").strip() + if content: + rendered.append(f"{role}: {content}") + return "\n".join(rendered) + + async def _classify_expected_input_semantically( + self, + *, + text: str, + expected_input: dict[str, Any], + pause_prompt: str, + relevant_conversation_context: str = "", + profile_name: str = "router", + component_name: str = "workflow.expected_input", + generation_name: str = "workflow.expected_input.semantic_classifier", + ) -> tuple[str | None, str | None]: + """Run an agent-defined classifier and constrain its output to allowed_values. + + The framework does not know what any option means. It only renders the + workflow prompt, invokes the configured LLM and rejects every value not + declared in ``allowed_values``. + """ + if not has_semantic_classifier(expected_input) or self.llm is None: + return None, None + classifier = expected_input.get("semantic_classifier") or {} + allowed = [str(x) for x in (expected_input.get("allowed_values") or [])] + prompt = str(classifier.get("prompt") or "") + rendered = ( + prompt.replace("{{ allowed_values }}", json.dumps(allowed, ensure_ascii=False)) + .replace("{{ pending_prompt }}", str(pause_prompt or "")) + .replace("{{ relevant_conversation_context }}", str(relevant_conversation_context or "")) + .replace("{{ user_input }}", str(text or "")) + ) + protocol = ( + "\n\nPROTOCOLO OBRIGATÓRIO DO FRAMEWORK: responda somente com UMA das " + f"opções permitidas, sem explicação adicional: {json.dumps(allowed, ensure_ascii=False)}." + ) + try: + answer = await self.llm.ainvoke( + [ + {"role": "system", "content": rendered + protocol}, + {"role": "user", "content": str(text or "")}, + ], + profile_name=profile_name, + component_name=component_name, + generation_name=generation_name, + ) + except Exception as exc: + logger.warning("Falha no semantic_classifier do expected_input: %s", exc) + return None, None + raw = str(answer or "").strip() + matched = match_semantic_classifier_output(raw, expected_input) + if matched is not None: + return matched, raw + # Tolerate a tiny structured wrapper while still validating its value. + try: + data = parse_json_object(raw) + except Exception: + data = {} + for key in ("value", "option", "choice", "classification", "result"): + if key in data: + matched = match_semantic_classifier_output(str(data.get(key) or ""), expected_input) + if matched is not None: + return matched, raw + return None, raw + + @staticmethod + def _last_assistant_prompt(state: dict[str, Any], current_text: str) -> str: + history = [item for item in (state.get("history") or []) if isinstance(item, dict)] + if history and str(history[-1].get("role") or "").lower() == "user" and str(history[-1].get("content") or "").strip() == str(current_text or "").strip(): + history = history[:-1] + for item in reversed(history): + if str(item.get("role") or "").strip().lower() == "assistant": + content = str(item.get("content") or "").strip() + if content: + return content + return "" + + async def _classify_transaction_confirmation_semantically( + self, *, state: dict[str, Any], text: str + ) -> tuple[str | None, str | None, str]: + """Classify a non-literal confirmation using the existing workflow semantic engine. + + The deterministic parser remains authoritative for explicit yes/no. This + fallback is only reached when that parser returns ``None``. Configuration + is declarative under ``router.transaction_confirmation`` in routing.yaml. + """ + cfg = self.transaction_confirmation if isinstance(self.transaction_confirmation, dict) else {} + semantic = cfg.get("semantic_fallback") if isinstance(cfg.get("semantic_fallback"), dict) else {} + if not bool(semantic.get("enabled", False)) or self.llm is None: + return None, None, "" + + allowed = [str(x) for x in (semantic.get("allowed_values") or ["SIM", "NAO", "CONTINUAR"])] + prompt = str(semantic.get("prompt") or "").strip() + if not prompt: + return None, None, "" + expected_input = { + "allowed_values": allowed, + "semantic_classifier": { + "enabled": True, + "include_relevant_context": bool(semantic.get("include_relevant_context", True)), + "prompt": prompt, + }, + } + relevant_context = "" + if bool(semantic.get("include_relevant_context", True)): + previous = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {} + synthetic_pending = { + "owner_intent": str(previous.get("intent") or state.get("intent") or "").strip(), + "context_anchor_message_id": str((state.get("active_transaction") or {}).get("context_anchor_message_id") or "").strip() if isinstance(state.get("active_transaction"), dict) else "", + } + relevant_context = self._collect_relevant_conversation_context( + state=state, pending_workflow=synthetic_pending, current_text=str(text) + ) + pending_prompt = self._last_assistant_prompt(state, str(text)) + classified, raw = await self._classify_expected_input_semantically( + text=str(text), + expected_input=expected_input, + pause_prompt=pending_prompt, + relevant_conversation_context=relevant_context, + profile_name=str(semantic.get("profile_name") or "router"), + component_name="transaction.confirmation", + generation_name="transaction.confirmation.semantic_classifier", + ) + return classified, raw, relevant_context + + async def _route_contextual_reentry( + self, + *, + state: dict[str, Any], + original_input: str, + relevant_context: str, + classifier_output: str, + raw_classifier: str | None, + allowed_values: list[Any], + ) -> RouteDecision: + """Re-enter normal routing using bounded conversational context. + + This is deliberately a routing aid, not business evidence. The original + utterance remains available separately for audit, while the effective + text is used only to understand the unresolved request and extract + candidate transaction parameters that must still pass normal validation + and confirmation policies. + """ + contextual_input = ( + "CONTEXTO DA SOLICITAÇÃO IMEDIATAMENTE ANTERIOR:\n" + f"{str(relevant_context or '').strip()}\n\n" + "CONTINUAÇÃO ATUAL DO CLIENTE:\n" + f"{str(original_input or '').strip()}" + ).strip() + + reentry_state = dict(state) + reentry_state["pending_domain_workflow"] = None + reentry_state["transaction_status"] = None + + # Contextual reentry is semantically richer than substring matching. + # Prefer the configured LLM router when available; deterministic routing + # remains the fallback for deployments that disable semantic routing. + if self.enable_llm_router and self.llm is not None: + decision = await self._route_by_llm(contextual_input, reentry_state) + else: + decision = self._route_by_keyword(contextual_input) or RouteDecision( + route=self.fallback_agent, + agent=self.fallback_agent, + intent="fallback", + confidence=0.3, + reason="Fallback após reentrada contextual.", + method="fallback", + ) + + decision.metadata = { + **dict(decision.metadata or {}), + "contextual_reentry": True, + "contextual_reentry_input": contextual_input, + "original_input": str(original_input or ""), + "classifier_output": classifier_output, + "classifier_raw_output": raw_classifier, + "allowed_values": list(allowed_values or []), + "relevant_conversation_context": str(relevant_context or ""), + "user_claims_are_evidence": False, + "previous_workflow_cancel_reason": "contextual_reentry", + } + return decision + async def route(self, state: dict[str, Any]) -> RouteDecision: session = (state.get("context") or {}).get("session", {}) or {} explicit_next_state = state.get("next_state") @@ -71,6 +376,191 @@ class EnterpriseRouter: current_state = explicit_next_state or session.get("metadata", {}).get("workflow_state") text = state.get("sanitized_input") or state.get("user_text") or "" + # A paused conversational workflow owns the next turn when the current + # input satisfies its declarative ``expected_input`` contract. This check + # must happen before route continuity; otherwise a generic reply such as + # "sim" can be misread as END_SESSION instead of resuming the workflow. + pending_workflow = state.get("pending_domain_workflow") + if isinstance(pending_workflow, dict) and pending_workflow.get("execution_id"): + pause = pending_workflow.get("pause") if isinstance(pending_workflow.get("pause"), dict) else {} + expected_input = pause.get("expected_input") if isinstance(pause, dict) else None + matched = match_expected_input(str(text), expected_input) + if matched is not None: + previous = state.get("route_decision") or {} + owner_agent = str( + pending_workflow.get("owner_agent") + or state.get("active_agent") + or previous.get("agent") + or state.get("route") + or self.fallback_agent + ).strip() + owner_intent = str( + pending_workflow.get("owner_intent") + or previous.get("intent") + or state.get("intent") + or f"workflow_resume:{pending_workflow.get('workflow_name') or 'paused'}" + ).strip() + decision = RouteDecision( + route=owner_agent, + agent=owner_agent, + intent=owner_intent, + confidence=1.0, + reason="Entrada consumida pelo contrato expected_input do workflow pausado.", + method="state", + domain=previous.get("domain") or state.get("domain"), + mcp_tools=[str(pending_workflow.get("resume_tool") or "retomar_workflow")], + metadata={ + "route_bypassed": True, + "workflow_resume": True, + "workflow_name": pending_workflow.get("workflow_name"), + "workflow_execution_id": pending_workflow.get("execution_id"), + "normalized_input": matched, + }, + ) + await self._emit(decision, state) + return decision + + # Enumerated contracts retain workflow ownership for unmatched + # replies. A workflow may explicitly opt in to semantic handling: + # coherent free text can be resumed as a workflow-declared value, + # while incoherent input still receives the declarative reprompt. + if isinstance(expected_input, dict) and expected_input.get("allowed_values"): + previous = state.get("route_decision") or {} + owner_agent = str( + pending_workflow.get("owner_agent") + or state.get("active_agent") + or previous.get("agent") + or state.get("route") + or self.fallback_agent + ).strip() + owner_intent = str( + pending_workflow.get("owner_intent") + or previous.get("intent") + or state.get("intent") + or f"workflow_resume:{pending_workflow.get('workflow_name') or 'paused'}" + ).strip() + raw_classifier = None + relevant_context = "" + + # Preferred path: the agent provides a prompt whose output must + # be one of the dynamic allowed_values. The framework adds no + # SIM/NAO or other domain semantics. + if has_semantic_classifier(expected_input): + classifier_cfg = expected_input.get("semantic_classifier") or {} + relevant_context = "" + if bool(classifier_cfg.get("include_relevant_context")): + relevant_context = self._collect_relevant_conversation_context( + state=state, + pending_workflow=pending_workflow, + current_text=str(text), + ) + classified, raw_classifier = await self._classify_expected_input_semantically( + text=str(text), + expected_input=expected_input, + pause_prompt=str(pause.get("prompt") or ""), + relevant_conversation_context=relevant_context, + ) + if classified is not None: + option_actions = classifier_cfg.get("option_actions") if isinstance(classifier_cfg, dict) else {} + option_actions = option_actions if isinstance(option_actions, dict) else {} + action_cfg = option_actions.get(str(classified)) or option_actions.get(str(classified).upper()) + action_cfg = action_cfg if isinstance(action_cfg, dict) else {} + if str(action_cfg.get("action") or "").strip().lower() == "contextual_reentry": + decision = await self._route_contextual_reentry( + state=state, + original_input=str(text), + relevant_context=relevant_context, + classifier_output=str(classified), + raw_classifier=raw_classifier, + allowed_values=list(expected_input.get("allowed_values") or []), + ) + await self._emit(decision, state) + return decision + + decision = RouteDecision( + route=owner_agent, + agent=owner_agent, + intent=owner_intent, + confidence=1.0, + reason="Entrada classificada pelo semantic_classifier do expected_input.", + method="state", + domain=previous.get("domain") or state.get("domain"), + mcp_tools=[str(pending_workflow.get("resume_tool") or "retomar_workflow")], + metadata={ + "route_bypassed": True, + "workflow_resume": True, + "workflow_semantic_classifier": True, + "workflow_name": pending_workflow.get("workflow_name"), + "workflow_execution_id": pending_workflow.get("execution_id"), + "normalized_input": classified, + "classifier_output": classified, + "classifier_raw_output": raw_classifier, + "allowed_values": list(expected_input.get("allowed_values") or []), + "original_input": str(text), + "relevant_conversation_context": relevant_context, + }, + ) + await self._emit(decision, state) + return decision + + # Legacy compatibility for workflows that still use the older + # coherent-unmatched -> resume_as contract. + semantic_coherent = semantic_coherence_from_guardrails(state) + resume_as = meaningful_unmatched_resume_value( + expected_input, + semantic_coherent=semantic_coherent, + ) + if resume_as is not None: + decision = RouteDecision( + route=owner_agent, + agent=owner_agent, + intent=owner_intent, + confidence=1.0, + reason="Entrada coerente fora das opções; aplicando política unmatched legada do workflow pausado.", + method="state", + domain=previous.get("domain") or state.get("domain"), + mcp_tools=[str(pending_workflow.get("resume_tool") or "retomar_workflow")], + metadata={ + "route_bypassed": True, + "workflow_resume": True, + "workflow_unmatched": True, + "workflow_unmatched_action": "resume_as", + "workflow_name": pending_workflow.get("workflow_name"), + "workflow_execution_id": pending_workflow.get("execution_id"), + "normalized_input": resume_as, + "original_input": str(text), + }, + ) + await self._emit(decision, state) + return decision + + decision = RouteDecision( + route=owner_agent, + agent=owner_agent, + intent=owner_intent, + confidence=1.0, + reason="Entrada inválida para o contrato expected_input do workflow pausado; mantendo posse do workflow.", + method="state", + domain=previous.get("domain") or state.get("domain"), + mcp_tools=[], + metadata={ + "route_bypassed": True, + "workflow_input_invalid": True, + "workflow_name": pending_workflow.get("workflow_name"), + "workflow_execution_id": pending_workflow.get("execution_id"), + "workflow_reprompt": expected_input_reprompt( + expected_input, pause_prompt=str(pause.get("prompt") or "") + ), + "workflow_semantic_classifier": bool(has_semantic_classifier(expected_input)), + "classifier_raw_output": raw_classifier if has_semantic_classifier(expected_input) else None, + "allowed_values": list(expected_input.get("allowed_values") or []), + "original_input": str(text), + "relevant_conversation_context": relevant_context if has_semantic_classifier(expected_input) else "", + }, + ) + await self._emit(decision, state) + return decision + # Estados transacionais preservam continuidade para respostas curtas # (parâmetros, "sim", "não"), mas NÃO podem aprisionar a sessão. Antes # de aplicar a política de estado, procuramos uma mudança explícita de @@ -79,18 +569,39 @@ class EnterpriseRouter: # pendente antes de executar a nova intent. state_decision = self._route_by_state(current_state) if state_decision: - consumed = await self._transaction_parameter_precedence( - state, text=str(text), state_decision=state_decision - ) - if consumed is not None: - await self._emit(consumed, state) - return consumed + tx_status = str(state.get("transaction_status") or "").strip().upper() + + # Confirmation is the only transaction input with absolute precedence: + # an explicit yes/no answers the confirmation contract itself. + if tx_status == "AWAITING_CONFIRMATION": + consumed = await self._transaction_parameter_precedence( + state, text=str(text), state_decision=state_decision + ) + if consumed is not None: + await self._emit(consumed, state) + return consumed + + # Transaction parameter precedence is absolute while collecting: + # first let the active transaction try to consume the current turn. + # Only when NO pending parameter can be extracted do we ask the + # semantic classifier whether the user changed goals. This prevents + # value/name/reference answers (for example "a de 14,99") from being + # stolen by a semantically plausible but incompatible intent. + if tx_status == "COLLECTING_PARAMETERS": + consumed = await self._transaction_parameter_precedence( + state, text=str(text), state_decision=state_decision + ) + if consumed is not None: + await self._emit(consumed, state) + return consumed + interruption = await self._transaction_state_interruption_candidate( state, text=str(text), state_decision=state_decision ) if interruption is not None: await self._emit(interruption, state) return interruption + await self._emit(state_decision, state) return state_decision @@ -116,16 +627,29 @@ class EnterpriseRouter: method="state", next_state=tx_status, ) - consumed = await self._transaction_parameter_precedence( - state, text=str(text), state_decision=synthetic - ) - if consumed is not None: - consumed.metadata = { - **(consumed.metadata or {}), - "transaction_state_recovered": True, - } - await self._emit(consumed, state) - return consumed + if tx_status == "AWAITING_CONFIRMATION": + consumed = await self._transaction_parameter_precedence( + state, text=str(text), state_decision=synthetic + ) + if consumed is not None: + consumed.metadata = { + **(consumed.metadata or {}), + "transaction_state_recovered": True, + } + await self._emit(consumed, state) + return consumed + + if tx_status == "COLLECTING_PARAMETERS": + consumed = await self._transaction_parameter_precedence( + state, text=str(text), state_decision=synthetic + ) + if consumed is not None: + consumed.metadata = { + **(consumed.metadata or {}), + "transaction_state_recovered": True, + } + await self._emit(consumed, state) + return consumed interruption = await self._transaction_state_interruption_candidate( state, text=str(text), state_decision=synthetic @@ -174,10 +698,17 @@ class EnterpriseRouter: await self._emit(keyword_candidate, state) return keyword_candidate - decision = await self.continuity.evaluate(state, intents=self.intents) - if decision: - await self._emit(decision, state) - return decision + # Uma transação terminal encerra também a elegibilidade de route + # stickiness/continuity herdada daquele fluxo no próximo roteamento. + # O histórico conversacional continua intacto, mas o agente/intenção + # anterior não pode capturar uma nova mensagem depois de COMPLETED, + # FAILED, CANCELLED, BLOCKED ou OUT_OF_SCOPE. Nesses casos a mensagem + # volta ao roteamento normal (keyword/LLM/fallback). + if not terminal_tx: + decision = await self.continuity.evaluate(state, intents=self.intents) + if decision: + await self._emit(decision, state) + return decision decision = self._route_by_keyword(text) if decision: @@ -211,23 +742,51 @@ class EnterpriseRouter: text: str, state_decision: RouteDecision, ) -> RouteDecision | None: - """Consume a turn as transaction parameters before evaluating intent shift. + """Try to consume the turn under the active transaction contract first. - Only COLLECTING_PARAMETERS participates. The LLM extracts values for the - currently missing parameters; if at least one value is found, the state - route wins deterministically and intent-shift classification is skipped. + AWAITING_CONFIRMATION consumes an explicit confirmation before any shift + classification. COLLECTING_PARAMETERS also has precedence: if at least one + pending parameter can be extracted, the active transaction keeps ownership + of the turn. Semantic intent-shift is evaluated only when extraction returns + no usable pending parameter. """ tx_status = str(state.get("transaction_status") or "").strip().upper() if tx_status == "AWAITING_CONFIRMATION": confirmation = parse_transaction_confirmation(text) + source = "deterministic" + classifier_output = None + raw_classifier = None + relevant_context = "" if confirmation is None: - return None + classified, raw_classifier, relevant_context = await self._classify_transaction_confirmation_semantically( + state=state, text=str(text) + ) + classifier_output = classified + semantic_cfg = self.transaction_confirmation.get("semantic_fallback") if isinstance(self.transaction_confirmation, dict) else {} + semantic_cfg = semantic_cfg if isinstance(semantic_cfg, dict) else {} + confirm_values = {str(x).strip().upper() for x in (semantic_cfg.get("confirm_values") or ["SIM"])} + reject_values = {str(x).strip().upper() for x in (semantic_cfg.get("reject_values") or ["NAO"])} + normalized = str(classified or "").strip().upper() + if normalized in confirm_values: + confirmation = "confirm" + source = "semantic" + elif normalized in reject_values: + confirmation = "reject" + source = "semantic" + else: + return None state_decision.metadata = { **(state_decision.metadata or {}), "transaction_turn_consumed": True, "transaction_confirmation_decision": confirmation, - "transaction_confirmation_source": "deterministic", + "transaction_confirmation_source": source, } + if source == "semantic": + state_decision.metadata.update({ + "transaction_confirmation_classifier_output": classifier_output, + "transaction_confirmation_classifier_raw_output": raw_classifier, + "relevant_conversation_context": relevant_context, + }) return state_decision if tx_status != "COLLECTING_PARAMETERS": return None @@ -241,6 +800,11 @@ class EnterpriseRouter: known = dict(active.get("arguments") or {}) schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else {} description = str(active.get("tool_description") or "") + conversational_context = str(active.get("parameter_conversational_context") or "").strip() + if not conversational_context: + conversational_context = self._collect_transaction_parameter_context( + state=state, current_text=text + ) values = await extract_transaction_parameters( self.llm, text=text, @@ -249,6 +813,7 @@ class EnterpriseRouter: known_arguments=known, parameter_schema=schema, tool_description=description, + conversational_context=conversational_context, ) if not values: return None @@ -280,25 +845,33 @@ class EnterpriseRouter: previous = state.get("route_decision") or {} previous_intent = str(previous.get("intent") or state.get("intent") or started_intent).strip() - candidate = self._route_by_keyword(text) - if candidate is not None: + configured_candidate = self._route_by_keyword(text) + if configured_candidate is not None: different = ( - candidate.agent != state_decision.agent - or (started_intent and candidate.intent != started_intent) - or (previous_intent and not previous_intent.startswith("state:") and candidate.intent != previous_intent) + configured_candidate.agent != state_decision.agent + or (started_intent and configured_candidate.intent != started_intent) + or (previous_intent and not previous_intent.startswith("state:") and configured_candidate.intent != previous_intent) ) - if different: - candidate.metadata = { - **(candidate.metadata or {}), + if not different: + return None + + # During parameter collection, a configured keyword may be present in + # a perfectly valid parameter answer (for example an order identifier + # utterance containing the generic word "pedido"). When semantic + # classification is available, use the configured route only as a + # candidate hint and let the LLM decide CONTINUE vs SHIFT. This avoids + # both failure modes: parameter extraction cannot hide a real new goal, + # and a broad keyword cannot steal a legitimate parameter turn. + if not (self.enable_llm_router and self.llm is not None): + configured_candidate.metadata = { + **(configured_candidate.metadata or {}), "transaction_interruption": "intent_shift", "interrupted_state": state_decision.next_state, "interrupted_agent": state_decision.agent, "interrupted_intent": started_intent or previous_intent, "interruption_source": "configured_routing", } - return candidate - else: - return None + return configured_candidate if not (self.enable_llm_router and self.llm is not None): return None @@ -320,12 +893,22 @@ class EnterpriseRouter: "transaction_status": state.get("transaction_status"), "tool_name": active_tx.get("tool_name"), "missing_parameters": list(state.get("missing_parameters") or []), + "configured_candidate": ( + { + "intent": configured_candidate.intent, + "agent": configured_candidate.agent, + "confidence": configured_candidate.confidence, + } + if configured_candidate is not None + else None + ), } system = ( "Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " "Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " - "Se a mensagem responde ao dado/confirmacao pendente, retorne CONTINUE. " - "Se o usuário passou a perseguir outro objetivo, retorne SHIFT e a nova intent permitida. " + "A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " + "Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " + "Se o usuário passou claramente a perseguir outro objetivo, retorne SHIFT e a nova intent permitida. " "Retorne somente JSON válido com decision, intent, agent, confidence, reason." ) user = { @@ -377,6 +960,9 @@ class EnterpriseRouter: "interrupted_agent": state_decision.agent, "interrupted_intent": started_intent or previous_intent, "interruption_source": "semantic_classifier", + "configured_routing_hint": ( + configured_candidate.intent if configured_candidate is not None else None + ), "raw_llm_answer": answer[:1000], }, domain=self._domain_for_intent(intent_name), @@ -622,19 +1208,7 @@ class EnterpriseRouter: return None def _parse_json(self, text: str) -> dict[str, Any]: - text = text.strip() - if text.startswith("```"): - text = text.strip("`") - if text.lower().startswith("json"): - text = text[4:].strip() - try: - return json.loads(text) - except Exception: - start = text.find("{") - end = text.rfind("}") - if start >= 0 and end > start: - return json.loads(text[start : end + 1]) - raise + return parse_json_object(text) async def _emit(self, decision: RouteDecision, state: dict[str, Any]) -> None: if self.telemetry: diff --git a/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..14725d45 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc new file mode 100644 index 00000000..5030833e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc new file mode 100644 index 00000000..f7d5ff86 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc new file mode 100644 index 00000000..0df895b3 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/runtime/agent_runtime.py b/libs/agent_framework/build/lib/agent_framework/runtime/agent_runtime.py index f5aa1ef2..7363992c 100644 --- a/libs/agent_framework/build/lib/agent_framework/runtime/agent_runtime.py +++ b/libs/agent_framework/build/lib/agent_framework/runtime/agent_runtime.py @@ -1,5 +1,7 @@ from __future__ import annotations +from agent_framework.llm.structured_output import parse_json_object + import hashlib import json import logging @@ -11,6 +13,7 @@ from typing import Any, Iterable, Mapping from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation +from agent_framework.workflows.input_contract import match_expected_input logger = logging.getLogger(__name__) @@ -344,6 +347,23 @@ class AgentRuntimeMixin: deduped = list(dict.fromkeys(queries)) return required, "\n".join(deduped) + @classmethod + def _mcp_rag_sufficient(cls, mcp_results: list[dict[str, Any]]) -> bool: + """Retorna True somente quando o domínio declara que MCP basta para este turno. + + Um tool result bem-sucedido não é, por si só, evidência de suficiência + semântica. Para pular retrieval, a tool/workflow deve declarar + ``rag_sufficient=true`` ou ``knowledge_sufficient=true`` em seu payload. + Isso evita que o framework conheça nomes de tools ou termos de negócio. + """ + for item in mcp_results or []: + if not isinstance(item, dict) or not item.get("ok"): + continue + for mapping in cls._iter_mapping_values(item.get("result")): + if bool(mapping.get("rag_sufficient")) or bool(mapping.get("knowledge_sufficient")): + return True + return False + @classmethod def _mcp_llm_composition_directive(cls, mcp_results: list[dict[str, Any]]) -> tuple[bool, list[str]]: @@ -372,20 +392,31 @@ class AgentRuntimeMixin: async def _retrieve_rag_context(self, state: dict[str, Any]) -> tuple[str, dict[str, Any]]: rag_service = getattr(self, "rag_service", None) - if not rag_service: - return "", {"enabled": False} settings = getattr(self, "settings", None) + if not rag_service: + return "", { + "enabled": False, + "attempted": False, + "status": "no_service", + "reason": "rag_service_not_configured", + "provider": getattr(settings, "RAG_PROVIDER", "standard"), + } mcp_results = state.get("mcp_results") or [] requires_rag, rag_query_override = self._mcp_rag_directive(mcp_results) + explicit_mcp_sufficient = self._mcp_rag_sufficient(mcp_results) if ( not requires_rag and bool(getattr(settings, "SKIP_RAG_WHEN_MCP_SUFFICIENT", True)) - and any(r.get("ok") and r.get("result") for r in mcp_results) + and explicit_mcp_sufficient ): - text = str(state.get("sanitized_input") or state.get("user_text") or "").lower() - policy_terms = ("política", "politica", "regra", "prazo", "como funciona", "por que", "porque") - if not any(term in text for term in policy_terms): - return "", {"enabled": False, "skipped": True, "reason": "mcp_sufficient"} + return "", { + "enabled": False, + "skipped": True, + "reason": "mcp_explicitly_sufficient", + "required_by_tool": False, + "mcp_explicitly_sufficient": True, + "provider": getattr(settings, "RAG_PROVIDER", "standard"), + } runtime = self.get_runtime_context(state) namespace = ( (state.get("agent_profile") or {}).get("rag_namespace") @@ -411,10 +442,13 @@ class AgentRuntimeMixin: # observabilidade e para decisões posteriores. return "", { "enabled": False, + "attempted": True, "failed": True, "technical_error": True, "technical_error_in_rag": True, + "status": "error", "error": str(exc), + "provider": getattr(settings, "RAG_PROVIDER", "standard"), "namespace": namespace, "query": rag_query, "query_overridden_by_tool": bool(rag_query_override), @@ -442,24 +476,41 @@ class AgentRuntimeMixin: if any(not bool(getattr(d, "allowed", True)) for d in decisions): return "", { "enabled": False, + "attempted": True, "blocked": True, + "status": "blocked", "reason": "retrieval_guardrail", + "provider": result.metadata.get("provider") or getattr(settings, "RAG_PROVIDER", "standard"), + "namespace": namespace, + "query": rag_query, + "document_count": len(result.documents), "guardrails": retrieval_decisions, } context = guarded_context + document_count = len(result.documents) + provider = result.metadata.get("provider") or getattr(settings, "RAG_PROVIDER", "standard") + status = "executed" if context and document_count else "empty" return context, { "enabled": True, + "attempted": True, + "status": status, + "provider": provider, "namespace": namespace, "query": rag_query, "query_overridden_by_tool": bool(rag_query_override), "required_by_tool": bool(requires_rag), + "mcp_explicitly_sufficient": explicit_mcp_sufficient, "latency_ms": result.latency_ms, - "document_count": len(result.documents), + "document_count": document_count, "graph_neighbors": len(result.graph_neighbors), "top_document_ids": [d.id for d in result.documents[:5]], "top_scores": [d.score for d in result.documents[:5]], "rewritten": result.metadata.get("rewritten"), "effective_query": result.query, + "confidence": result.metadata.get("confidence"), + "low_confidence": result.metadata.get("low_confidence"), + "fallback_reason": result.metadata.get("fallback_reason"), + "warnings": result.metadata.get("warnings") or [], "guardrails": retrieval_decisions, } @@ -661,10 +712,8 @@ class AgentRuntimeMixin: max_tokens=80, ) raw = self._llm_response_text(response).strip() - if raw.startswith("```"): - raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE | re.DOTALL).strip() - payload = json.loads(raw) - value = payload.get(field_name) if isinstance(payload, dict) else None + payload = parse_json_object(raw) + value = payload.get(field_name) except Exception as exc: logger.warning( "mcp.parameter.llm_extract_failed tool=%s field=%s error=%s", @@ -752,13 +801,45 @@ class AgentRuntimeMixin: payload = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result eligible = payload.get("eligible") if isinstance(payload, dict) else None if eligible is True: + # Generic domain-decision contract. A validator may canonicalize + # transaction arguments and may also decide that the canonical entity + # belongs to another domain-owned action/tool. The framework does not + # interpret business classes; it only applies the declarative decision. + decision = payload.get("transaction_decision") if isinstance(payload, dict) else None + decision = decision if isinstance(decision, dict) else {} + resolved_arguments = decision.get("resolved_arguments") + resolved_arguments = resolved_arguments if isinstance(resolved_arguments, dict) else {} + requested_arguments = dict(arguments or {}) + for key, value in resolved_arguments.items(): + if value not in (None, "", [], {}): + arguments[str(key)] = value + + effective_tool = str(decision.get("target_tool") or tool_name).strip() or tool_name + action_changed = bool(decision.get("action_changed")) or effective_tool != tool_name + requires_reconfirmation = bool(decision.get("requires_reconfirmation")) + confirmation_message = str(decision.get("confirmation_message") or "").strip() + state["transaction_pre_validation"] = { - "tool_name": tool_name, "validator_tool": validator, "eligible": True, "result": result + "tool_name": tool_name, + "validator_tool": validator, + "eligible": True, + "result": result, + "requested_arguments": requested_arguments, + "resolved_arguments": dict(resolved_arguments), + "effective_tool_name": effective_tool, + "action_changed": action_changed, + "requires_reconfirmation": requires_reconfirmation, + "confirmation_message": confirmation_message or None, } if emit_events: await self._emit_ic( "IC.TRANSACTION_PREVALIDATION_PASSED", state, - {"tool_name": tool_name, "validator_tool": validator}, + { + "tool_name": tool_name, + "validator_tool": validator, + "effective_tool_name": effective_tool, + "action_changed": action_changed, + }, component="agent_runtime.tool_policy", ) return None @@ -766,6 +847,55 @@ class AgentRuntimeMixin: if transport_failed and bool(cfg.get("fail_open")): return None status = str((payload or {}).get("status") or ("PREVALIDATION_ERROR" if transport_failed else "OUT_OF_SCOPE")) + + # Generic recoverable validation contract. A domain validator may determine + # that one previously extracted parameter does not identify a valid entity + # and request that only this parameter be collected again. The framework + # does not know what the parameter means; it merely honors the declarative + # ``NEEDS_PARAMETER`` + ``parameter`` contract and preserves every other + # argument already collected in the transaction. + if status == "NEEDS_PARAMETER" and isinstance(payload, dict): + parameter = str(payload.get("parameter") or "").strip() + if parameter: + recovered_arguments = dict(arguments or {}) + recovered_arguments.pop(parameter, None) + recovered_policy = self._resolve_tool_execution_policy(tool_name, recovered_arguments) + missing = self._missing_required_arguments(recovered_policy, recovered_arguments) + if parameter not in missing: + missing = [parameter, *[name for name in missing if name != parameter]] + self._set_collecting_parameters( + state, + tool_name=tool_name, + arguments=recovered_arguments, + policy=recovered_policy, + missing=missing, + ) + state["transaction_pre_validation"] = { + "tool_name": tool_name, + "validator_tool": validator, + "eligible": False, + "status": status, + "parameter": parameter, + "terminal": False, + "result": result, + } + if emit_events: + await self._emit_ic( + "IC.TRANSACTION_PREVALIDATION_PARAMETER_REJECTED", + state, + {"tool_name": tool_name, "validator_tool": validator, "parameter": parameter}, + component="agent_runtime.tool_policy", + ) + enriched = dict(result or {}) + enriched.update({ + "pre_validation": True, + "target_tool": tool_name, + "collecting_parameters": True, + "missing_parameters": missing, + "transaction_status": "COLLECTING_PARAMETERS", + }) + return enriched + state["transaction_pre_validation"] = { "tool_name": tool_name, "validator_tool": validator, @@ -795,6 +925,33 @@ class AgentRuntimeMixin: enriched["transaction_status"] = "OUT_OF_SCOPE" return enriched + def _apply_prevalidated_transaction_decision( + self, + state: dict[str, Any], + *, + tool_name: str, + arguments: dict[str, Any], + policy: dict[str, Any], + ) -> tuple[str, dict[str, Any], bool]: + """Apply a generic domain decision produced by transaction pre-validation. + + The framework never derives domain semantics here. It only consumes the + validator contract: canonical arguments, effective target tool and whether + the resulting action needs explicit confirmation. + """ + pv = state.get("transaction_pre_validation") + pv = pv if isinstance(pv, dict) and pv.get("eligible") is True else {} + effective_tool = str(pv.get("effective_tool_name") or tool_name).strip() or tool_name + effective_policy = policy + if effective_tool != tool_name: + effective_policy = self._resolve_tool_execution_policy(effective_tool, arguments) + force_confirmation = bool(pv.get("requires_reconfirmation")) + if pv.get("confirmation_message"): + state["transaction_confirmation_message_override"] = str(pv.get("confirmation_message")) + else: + state.pop("transaction_confirmation_message_override", None) + return effective_tool, effective_policy, force_confirmation + def _validate_tool_execution_policy(self, tool_name: str, arguments: dict[str, Any]) -> tuple[bool, str | None]: """Aplica a mesma política central usada pelo MCPToolRouter.""" router = getattr(self, "tool_router", None) @@ -1241,13 +1398,58 @@ class AgentRuntimeMixin: return parse_transaction_confirmation(text) def _transaction_parameter_schema(self, tool_name: str, policy: dict[str, Any] | None = None) -> dict[str, Any]: - """Return generic schema metadata for transactional required parameters.""" + """Return schema metadata for transactional required parameters. + + Backward compatibility is intentional: + - legacy ``args_schema: {field: string}`` remains valid; + - enriched ``args_schema`` entries may provide ``type``/``description``; + - when a legacy entry has no description, a declarative description from + ``mcp_parameter_mapping.yaml`` is used when available. + + The framework never assigns domain meaning to a parameter name. It only + forwards metadata declared by the agent so the generic LLM extractor can + interpret the user's wording more accurately. + """ cfg = self._tool_config(tool_name) raw_schema = dict(getattr(cfg, "args_schema", {}) or {}) if cfg is not None else {} required = [str(name) for name in ((policy or {}).get("requires") or getattr(cfg, "requires", []) or [])] - if not required: - return raw_schema - return {name: raw_schema.get(name, "string") for name in required} + + # Optional semantic metadata already declared by the agent for MCP + # extraction. This is only a fallback; args_schema remains authoritative. + extract_rules: dict[str, dict[str, Any]] = {} + router = getattr(self, "tool_router", None) + if router is not None and hasattr(router, "parameter_extract_rules"): + try: + extract_rules = dict(router.parameter_extract_rules(tool_name) or {}) + except Exception: + # Schema construction must never break legacy agents merely + # because optional descriptive metadata cannot be loaded. + extract_rules = {} + + names = required or [str(name) for name in raw_schema.keys()] + normalized: dict[str, Any] = {} + for name in names: + raw = raw_schema.get(name, "string") + rule = extract_rules.get(name) if isinstance(extract_rules.get(name), dict) else {} + fallback_description = str((rule or {}).get("description") or "").strip() or None + + if isinstance(raw, dict): + entry = dict(raw) + entry.setdefault("type", "string") + if not entry.get("description") and fallback_description: + entry["description"] = fallback_description + normalized[name] = entry + elif fallback_description: + normalized[name] = { + "type": raw or "string", + "description": fallback_description, + } + else: + # Preserve the exact legacy representation when there is no + # additional metadata to contribute. + normalized[name] = raw or "string" + + return normalized def _transaction_tool_description(self, tool_name: str) -> str: cfg = self._tool_config(tool_name) @@ -1269,11 +1471,24 @@ class AgentRuntimeMixin: """ route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} cached = route_meta.get("transaction_parameter_values") + allowed = set(str(x) for x in missing_parameters) + reused: dict[str, Any] = {} if isinstance(cached, dict): - allowed = set(str(x) for x in missing_parameters) reused = {str(k): v for k, v in cached.items() if str(k) in allowed and v not in _EMPTY_VALUES} - if reused: - return reused + + # Router-side extraction is an optimization, not an authoritative final + # extraction. If it only filled a subset of the pending contract, keep + # those candidates and continue extracting the remaining fields instead + # of returning early. This is especially important after contextual + # reentry, where a short follow-up may identify the entity while the + # bounded prior context carries an associated value that still requires + # domain pre-validation. + remaining_parameters = [ + str(name) for name in missing_parameters + if str(name) not in reused + ] + if not remaining_parameters: + return reused active = self._active_transaction(state) or {} schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else None @@ -1281,16 +1496,45 @@ class AgentRuntimeMixin: policy = self._resolve_tool_execution_policy(tool_name, known_arguments or {}) schema = self._transaction_parameter_schema(tool_name, policy) description = str(active.get("tool_description") or self._transaction_tool_description(tool_name) or "") - text = state.get("sanitized_input") or state.get("user_text") or "" - return await extract_transaction_parameters( + route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} + contextual_reentry = bool(route_meta.get("contextual_reentry")) + # In contextual reentry keep the current utterance separate from prior + # conversation. The prior context can resolve references, but remains + # non-authoritative and is never promoted to business evidence. + text = ( + route_meta.get("original_input") + if contextual_reentry + else None + ) or state.get("sanitized_input") or state.get("user_text") or "" + conversational_context = ( + route_meta.get("relevant_conversation_context") + if contextual_reentry + else None + ) + # Once a contextual reentry opens a transaction, preserve only its + # bounded conversational context as an interpretation aid for subsequent + # COLLECTING_PARAMETERS turns. It is explicitly non-authoritative: the + # domain pre-validation step must still prove every candidate against + # backend/MCP evidence before confirmation/execution. + if not str(conversational_context or "").strip(): + conversational_context = active.get("parameter_conversational_context") + if contextual_reentry and not str(conversational_context or "").strip(): + effective = str(route_meta.get("contextual_reentry_input") or "") + prefix = "CONTEXTO DA SOLICITAÇÃO IMEDIATAMENTE ANTERIOR:\n" + suffix = "\n\nCONTINUAÇÃO ATUAL DO CLIENTE:\n" + if prefix in effective and suffix in effective: + conversational_context = effective.split(prefix, 1)[1].split(suffix, 1)[0].strip() + extracted = await extract_transaction_parameters( getattr(self, "llm", None), text=str(text), tool_name=tool_name, - missing_parameters=list(missing_parameters or []), - known_arguments=known_arguments or {}, + missing_parameters=remaining_parameters, + known_arguments={**dict(known_arguments or {}), **reused}, parameter_schema=schema, tool_description=description, + conversational_context=str(conversational_context or ""), ) + return {**reused, **extracted} def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None: """Detecta solicitação transacional usando metadados de tools.yaml. @@ -1343,17 +1587,50 @@ class AgentRuntimeMixin: return f"WAITING_{self._agent_state_prefix(current)}_CONFIRMATION" @staticmethod - def _workflow_resume_decision(text: str) -> str: + def _workflow_resume_decision(text: str, pending: dict[str, Any] | None = None) -> str: + # Prefer the workflow's declarative input contract. This removes domain + # semantics from the framework: SIM/NAO, numeric choices or free text are + # interpreted only from ``expected_input`` persisted by the paused flow. + pause = (pending or {}).get("pause") if isinstance(pending, dict) else None + expected = pause.get("expected_input") if isinstance(pause, dict) else None + matched = match_expected_input(text, expected) + if matched is not None: + return matched + + # Backward compatibility for old checkpoints that predate expected_input. + # This branch is intentionally limited to the previous generic yes/no + # behavior and is not used when the workflow provides a contract. + if isinstance(expected, dict): + return "OUTRO" normalized = " ".join((text or "").strip().lower().split()) normalized = re.sub(r"[.!?]+$", "", normalized).strip() yes = {"sim", "s", "claro", "isso", "correto", "pode", "pode sim", "entendi", "conseguiu", "resolveu"} - no = {"não", "nao", "n", "não resolveu", "nao resolveu", "não entendi", "nao entendi", "não", "negativo"} + no = {"não", "nao", "n", "não resolveu", "nao resolveu", "não entendi", "nao entendi", "negativo"} if normalized in yes or normalized.startswith("sim "): return "SIM" if normalized in no or normalized.startswith("não ") or normalized.startswith("nao "): return "NAO" return "OUTRO" + @staticmethod + def _workflow_pause_descriptor(workflow: dict[str, Any]) -> dict[str, Any]: + """Recover the complete pause descriptor from a workflow result. + + Runtime v2 exposes ``pause`` as a compact public summary, while the + LangGraph interrupt carries ``expected_input`` and ``resume_from``. Keep + both forms compatible without coupling this logic to a domain workflow. + """ + descriptor = dict(workflow.get("pause") or {}) if isinstance(workflow.get("pause"), dict) else {} + state = workflow.get("state") if isinstance(workflow.get("state"), dict) else {} + interrupts = state.get("__interrupt__") if isinstance(state, dict) else None + if isinstance(interrupts, list) and interrupts: + first = interrupts[0] + value = first.get("value") if isinstance(first, dict) else None + if isinstance(value, dict): + for key, item in value.items(): + descriptor.setdefault(key, item) + return descriptor + @staticmethod def _workflow_payload_from_tool_result(result: dict[str, Any]) -> dict[str, Any] | None: data = result.get("result") if isinstance(result, dict) else None @@ -1379,12 +1656,34 @@ class AgentRuntimeMixin: executed.append(workflow_name) state["business_workflows_executed"] = executed if workflow.get("status") != "PAUSED": + # Clearing must be materialized in the graph-state patch. ``pop``/absence + # is not enough with LangGraph state merging: an older latch can survive + # into the next turn and incorrectly resume a workflow that already + # completed. Only clear the currently owned execution (or an unlabeled + # legacy latch); never clear a different concurrently tracked workflow. + pending = state.get("pending_domain_workflow") + pending_execution = (pending or {}).get("execution_id") if isinstance(pending, dict) else None + workflow_execution = metadata.get("workflow_execution_id") or workflow.get("execution_id") + if not pending_execution or not workflow_execution or str(pending_execution) == str(workflow_execution): + state["pending_domain_workflow"] = None + if state.get("transaction_status") == "WORKFLOW_PAUSED": + state["transaction_status"] = None return state["pending_domain_workflow"] = { "workflow_name": metadata.get("workflow_name") or workflow.get("workflow_name"), "execution_id": metadata.get("workflow_execution_id") or workflow.get("execution_id"), "resume_tool": metadata.get("resume_tool") or "retomar_workflow", - "pause": workflow.get("pause") or {}, + "owner_agent": state.get("active_agent") or state.get("route"), + "owner_intent": state.get("intent"), + # Anchor the conversational context to the user turn that produced + # this exact pause. On a later pause/resume cycle this value is + # refreshed, preventing old same-intent topics from leaking into the + # next expected_input decision. + "context_anchor_message_id": ( + (state.get("context") or {}).get("message_id") + or state.get("message_id") + ), + "pause": self._workflow_pause_descriptor(workflow), } state["transaction_status"] = "WORKFLOW_PAUSED" @@ -1393,10 +1692,20 @@ class AgentRuntimeMixin: if not isinstance(pending, dict) or not pending.get("execution_id"): return None tool_name = str(pending.get("resume_tool") or "retomar_workflow") + route_metadata = (state.get("route_decision") or {}).get("metadata") or {} + routed_resume_value = ( + route_metadata.get("normalized_input") + if route_metadata.get("workflow_resume") + else None + ) arguments = { "workflow_name": pending.get("workflow_name"), "execution_id": pending.get("execution_id"), - "resposta_usuario": self._workflow_resume_decision(text), + "resposta_usuario": ( + str(routed_resume_value) + if routed_resume_value is not None + else self._workflow_resume_decision(text, pending) + ), } result = await self._call_mcp_tool(tool_name, arguments, state) workflow = self._workflow_payload_from_tool_result(result) @@ -1404,7 +1713,10 @@ class AgentRuntimeMixin: if workflow and workflow.get("status") == "PAUSED": pass else: - state.pop("pending_domain_workflow", None) + # Explicit tombstone: transaction_state_patch() must carry the clear + # through LangGraph's state merge. Removing the key locally would let + # the previous PAUSED latch remain durable in the graph state. + state["pending_domain_workflow"] = None if state.get("transaction_status") == "WORKFLOW_PAUSED": state["transaction_status"] = None return result @@ -1534,9 +1846,29 @@ class AgentRuntimeMixin: current = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} txid = transaction_id or current.get("transaction_id") or str(uuid.uuid4()) if str(current.get("tool_name") or "") != str(tool_name): - state["transaction_pre_validation"] = None + pre_validation = state.get("transaction_pre_validation") + pre_validation = pre_validation if isinstance(pre_validation, dict) else {} + effective_prevalidated_tool = str(pre_validation.get("effective_tool_name") or "").strip() + # Preserve the validator decision when the active transaction is being + # moved to the exact tool selected by that decision. Any unrelated tool + # shift still invalidates stale pre-validation evidence. + if effective_prevalidated_tool != str(tool_name): + state["transaction_pre_validation"] = None cfg = self._tool_config(tool_name) policy = self._resolve_tool_execution_policy(tool_name, arguments or {}) + route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} + parameter_context = current.get("parameter_conversational_context") + if route_meta.get("contextual_reentry"): + bounded = str(route_meta.get("relevant_conversation_context") or "").strip() + prior_claim = str(route_meta.get("original_input") or "").strip() + if bounded and prior_claim: + parameter_context = ( + bounded + + "\nprevious_user_continuation_non_authoritative: " + + prior_claim + ) + else: + parameter_context = bounded or prior_claim or parameter_context tx = { "transaction_id": txid, "tool_name": tool_name, @@ -1546,6 +1878,10 @@ class AgentRuntimeMixin: "requires": list(policy.get("requires") or getattr(cfg, "requires", []) or []), "parameter_schema": self._transaction_parameter_schema(tool_name, policy), "tool_description": self._transaction_tool_description(tool_name), + # Conversation context is only an interpretation aid. Never expose + # it through transaction_evidence or treat user claims as proof. + "parameter_conversational_context": parameter_context or "", + "user_claims_are_evidence": False if parameter_context else current.get("user_claims_are_evidence", False), } state["active_transaction"] = tx return tx @@ -1669,11 +2005,13 @@ class AgentRuntimeMixin: state["active_transaction"] = None state["selected_tool_call"] = {} state["pending_tool_call"] = {} + state["confirmation_snapshot"] = None state["missing_parameters"] = [] state["confirmation_required"] = False state["confirmation_received"] = status == "COMPLETED" state["next_state"] = None state["transaction_status"] = status + state.pop("transaction_confirmation_message_override", None) def _normalize_transaction_lifecycle(self, state: dict[str, Any]) -> None: """Ensure closed transactions cannot leak into a later user turn.""" @@ -1695,28 +2033,99 @@ class AgentRuntimeMixin: state["active_transaction"] = None state["selected_tool_call"] = {} state["pending_tool_call"] = {} + state["confirmation_snapshot"] = None state["missing_parameters"] = [] state["confirmation_required"] = False state["confirmation_received"] = False state["next_state"] = None + state.pop("transaction_confirmation_message_override", None) return if self._transaction_is_active(state): self._active_transaction(state) + def _freeze_confirmation_snapshot( + self, + state: dict[str, Any], + *, + tool_name: str, + arguments: dict[str, Any], + ) -> dict[str, Any]: + """Freeze the exact tool call that the user is being asked to confirm. + + Confirmation is a control boundary. Once the runtime exposes a confirmation + prompt, later turns must not re-extract/re-resolve arguments before execution. + The immutable snapshot is therefore the source of truth for an explicit + confirmation. The active transaction may continue carrying presentation/audit + metadata, but execution consumes this snapshot only. + """ + active = self._active_transaction(state) or {} + snapshot = { + "transaction_id": active.get("transaction_id") or str(uuid.uuid4()), + "tool_name": str(tool_name or ""), + "arguments": dict(arguments or {}), + "started_from_intent": active.get("started_from_intent") or state.get("intent"), + } + state["confirmation_snapshot"] = snapshot + return snapshot + + @staticmethod + def _confirmation_snapshot(state: dict[str, Any]) -> dict[str, Any] | None: + snapshot = state.get("confirmation_snapshot") + if not isinstance(snapshot, dict) or not snapshot.get("tool_name"): + return None + return { + **snapshot, + "arguments": dict(snapshot.get("arguments") or {}), + } + + def _transaction_user_prompt( + self, + state: dict[str, Any], + *, + parameter: str, + ) -> str: + """Render a user-facing prompt without exposing implementation names. + + Domain semantics are declared by the agent in ``args_schema``. Supported + optional keys are ``user_prompt`` (preferred), ``label`` and ``description``. + Legacy schemas remain valid; when no semantic metadata exists the framework + uses a neutral prompt rather than leaking the technical parameter key. + """ + active = self._active_transaction(state) or {} + schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else {} + raw = schema.get(parameter) + entry = raw if isinstance(raw, dict) else {} + explicit = str(entry.get("user_prompt") or "").strip() + if explicit: + return explicit + label = str(entry.get("label") or "").strip() + if label: + return f"Para prosseguir, informe {label}." + description = str(entry.get("description") or "").strip() + if description: + # Descriptions can be long/extractor-oriented. Keep user output concise. + sentence = description.split(".", 1)[0].strip() + if sentence: + return f"Para prosseguir, informe {sentence[0].lower() + sentence[1:] if len(sentence) > 1 else sentence.lower()}." + return "Para prosseguir, preciso de mais uma informação para continuar com a solicitação." + def transaction_state_patch(self, state: dict[str, Any]) -> dict[str, Any]: keys = ( "available_mcp_tools", "selected_tool_call", "pending_tool_call", "transaction_status", "confirmation_required", "confirmation_received", "tool_policy_result", "missing_parameters", "next_state", "pending_domain_workflow", "pending_tool_clarification", - "business_workflows_executed", "active_transaction", "last_transaction", + "business_workflows_executed", "active_transaction", "last_transaction", "confirmation_snapshot", "transaction_evidence", "last_transaction_evidence", "relevant_transaction_evidence", - "transaction_pre_validation", + "transaction_pre_validation", "tool_terminal_result", "transaction_confirmation_message_override", ) return {key: state.get(key) for key in keys if key in state} def transaction_clarification_message(self, state: dict[str, Any]) -> str | None: """Retorna pergunta determinística para parâmetros ou resultado ambíguo.""" + workflow_reprompt = str(state.get("workflow_input_reprompt") or "").strip() + if workflow_reprompt: + return workflow_reprompt if state.get("transaction_status") == "TOOL_RESULT_CLARIFICATION": pending = state.get("pending_tool_clarification") or {} question = str(pending.get("question") or "Qual opção você quis dizer?").strip() @@ -1729,17 +2138,10 @@ class AgentRuntimeMixin: missing = list(state.get("missing_parameters") or []) if not missing: return None - labels = { - "order_id": "o número do pedido", - "reason": "o motivo da solicitação", - "customer_id": "a identificação do cliente", - } - friendly = [labels.get(name, str(name).replace("_", " ")) for name in missing] - if len(friendly) == 1: - detail = friendly[0] - else: - detail = ", ".join(friendly[:-1]) + " e " + friendly[-1] - return f"Para prosseguir, informe {detail}." + # Ask one semantic question at a time. The LLM extractor can still consume + # multiple values when the user volunteers them in the same turn. This keeps + # the conversation natural and, critically, never exposes internal field names. + return self._transaction_user_prompt(state, parameter=str(missing[0])) @staticmethod def _missing_required_arguments(policy: dict[str, Any], arguments: dict[str, Any]) -> list[str]: @@ -1775,6 +2177,9 @@ class AgentRuntimeMixin: def transaction_confirmation_message(self, state: dict[str, Any]) -> str | None: if state.get("transaction_status") != "AWAITING_CONFIRMATION": return None + override = str(state.get("transaction_confirmation_message_override") or "").strip() + if override: + return override pending = state.get("pending_tool_call") or {} tool_name = pending.get("tool_name") or "a operação solicitada" args = pending.get("arguments") or {} @@ -1999,12 +2404,148 @@ class AgentRuntimeMixin: return None return None + @staticmethod + def _terminal_tool_payload(tool_result: dict[str, Any] | None) -> dict[str, Any] | None: + """Return an explicitly terminal application payload, if present. + + The framework deliberately does not know domain status codes. A tool may + stop the current tool chain only by declaring ``terminal=true`` either + on the normalized result wrapper or on its application ``result`` body. + """ + if not isinstance(tool_result, dict): + return None + nested = tool_result.get("result") + candidates = [nested, tool_result] if isinstance(nested, dict) else [tool_result] + for payload in candidates: + if isinstance(payload, dict) and payload.get("terminal") is True: + return payload + return None + + def _apply_terminal_tool_result(self, state: dict[str, Any], tool_result: dict[str, Any]) -> None: + payload = self._terminal_tool_payload(tool_result) or {} + self._finish_active_transaction(state, "BLOCKED", result=tool_result) + state["tool_terminal_result"] = tool_result + state["tool_policy_result"] = { + "action": "terminal_tool_result", + "tool_name": tool_result.get("tool_name") or tool_result.get("tool"), + "reason": payload.get("reason") or tool_result.get("error"), + "terminal_action": payload.get("terminal_action") or "block", + } + + def _terminal_workflow_payload(self, tool_result: dict[str, Any] | None) -> dict[str, Any] | None: + """Return a terminal COMPLETED workflow payload using only generic signals. + + Workflow terminality must take precedence over RAG/LLM composition. The + framework intentionally does not know workflow names or domain status + codes; it recognizes only structural terminal contracts. + """ + if not isinstance(tool_result, dict): + return None + workflow = self._workflow_payload_from_tool_result(tool_result) + if not workflow or workflow.get("status") != "COMPLETED": + return None + + candidates: list[dict[str, Any]] = [workflow] + state = workflow.get("state") if isinstance(workflow.get("state"), dict) else {} + outputs = workflow.get("output") if isinstance(workflow.get("output"), dict) else {} + terminal_node = str(state.get("current_node") or "").strip() + if terminal_node and isinstance(outputs.get(terminal_node), dict): + candidates.insert(0, outputs[terminal_node]) + + for payload in candidates: + session_control = str(payload.get("session_control") or "").strip().upper() + terminal_status = str(payload.get("terminal_status") or "").strip() + if ( + payload.get("terminal") is True + or payload.get("session_ended") is True + or payload.get("handoff") is True + or bool(terminal_status) + or session_control in {"HUMAN_HANDOFF", "END_SESSION"} + ): + return payload + return None + + def _final_workflow_response_payload(self, tool_result: dict[str, Any] | None) -> dict[str, Any] | None: + """Return the final response payload of a COMPLETED workflow. + + This contract is intentionally different from session terminality. A + workflow may finish its own response while keeping the user session open. + Domain nodes opt in with ``workflow_response_final=true``. This prevents + directives emitted by an earlier pause node (for example + ``requires_llm_composition``) from being replayed after the user has + already completed the workflow. + """ + if not isinstance(tool_result, dict): + return None + workflow = self._workflow_payload_from_tool_result(tool_result) + if not workflow or workflow.get("status") != "COMPLETED": + return None + state = workflow.get("state") if isinstance(workflow.get("state"), dict) else {} + outputs = workflow.get("output") if isinstance(workflow.get("output"), dict) else {} + final_node = str(state.get("current_node") or "").strip() + candidates: list[dict[str, Any]] = [] + if final_node and isinstance(outputs.get(final_node), dict): + candidates.append(outputs[final_node]) + # Some workflow adapters promote the final node output to the workflow + # root. Support that generic shape as well. + candidates.append(workflow) + for payload in candidates: + if payload.get("workflow_response_final") is True: + return payload + return None + def build_direct_mcp_answer(self, state: dict[str, Any], mcp_results: list[dict[str, Any]], *, agent_label: str) -> str | None: - """Resposta determinística para consultas estruturadas simples.""" + """Retorna resposta MCP direta somente quando a aplicação declarar isso explicitamente. + + Um resultado de tool não implica, por si só, que a pergunta do usuário foi + respondida. O core do framework não conhece nomes de tools nem formatos de + domínio. Para encerrar o fluxo antes de RAG/LLM, a configuração da tool deve + declarar ``response.direct: true`` e fornecer uma política de apresentação + válida. Sem essa declaração, o fluxo continua para retrieval/composição. + """ + # Explicit terminal results own the turn regardless of normal response + # composition directives. A completed terminal workflow must therefore be + # checked BEFORE requires_rag/requires_llm_composition; otherwise an + # instruction emitted by an earlier workflow node can resurrect LLM + # composition after the workflow has already handed off/ended the session. + for item in mcp_results or []: + payload = self._terminal_tool_payload(item) + if payload: + message = str(payload.get("user_message") or payload.get("message") or payload.get("mensagem") or "").strip() + if message: + return message + + workflow_terminal = self._terminal_workflow_payload(item) + if workflow_terminal: + workflow = self._workflow_payload_from_tool_result(item) or {} + message = str( + workflow_terminal.get("user_message") + or workflow_terminal.get("message") + or workflow_terminal.get("mensagem") + or workflow.get("user_message") + or workflow.get("message") + or workflow.get("mensagem") + or "" + ).strip() + if message: + return message + + workflow_final = self._final_workflow_response_payload(item) + if workflow_final: + message = str( + workflow_final.get("user_message") + or workflow_final.get("message") + or workflow_final.get("mensagem") + or "" + ).strip() + if message: + return message + requires_rag, _ = self._mcp_rag_directive(mcp_results) requires_llm_composition, _ = self._mcp_llm_composition_directive(mcp_results) if requires_rag or requires_llm_composition: return None + ok = [r for r in mcp_results if r.get("ok") and isinstance(r.get("result"), dict)] for item in ok: workflow = self._workflow_payload_from_tool_result(item) @@ -2014,11 +2555,18 @@ class AgentRuntimeMixin: if prompt: return str(prompt) if workflow and workflow.get("status") == "COMPLETED": + # A completed workflow is not automatically a direct answer. Only + # the terminal node may provide an explicit message. Searching + # backwards for any prior ``mensagem`` can replay a prompt emitted + # before a pause (e.g. the question the user has just answered) and + # suppress the normal LLM/orchestrator composition of terminal data. nodes = workflow.get("output") if isinstance(workflow.get("output"), dict) else {} - # prefer last business message emitted by a workflow action - for value in reversed(list(nodes.values())): - if isinstance(value, dict) and str(value.get("mensagem") or "").strip(): - return str(value["mensagem"]).strip() + workflow_state = workflow.get("state") if isinstance(workflow.get("state"), dict) else {} + terminal_node = str(workflow_state.get("current_node") or "").strip() + terminal_output = nodes.get(terminal_node) if terminal_node else None + if isinstance(terminal_output, dict) and str(terminal_output.get("mensagem") or "").strip(): + return str(terminal_output["mensagem"]).strip() + text = state.get("sanitized_input") or state.get("user_text") or "" if ( len(ok) != 1 @@ -2026,30 +2574,79 @@ class AgentRuntimeMixin: or self._transactional_action_match(str(text)) is not None ): return None + tool = ok[0].get("tool_name") data = ok[0]["result"] + router = getattr(self, "tool_router", None) + registry = getattr(router, "registry", None) + cfg = registry.get_tool(str(tool)) if registry and tool else None + policy = dict(getattr(cfg, "response", None) or {}) if cfg else {} - # Primeiro tenta o contrato genérico e declarativo de apresentação. - # Se a aplicação não o configurou, preserva exatamente o fallback legado - # abaixo para não quebrar projetos existentes. - declared = self._render_declared_tool_response(tool, data, agent_label=agent_label, state=state) - if declared is not None: - return declared + # Importante: renderer/template descreve COMO apresentar uma resposta; + # somente ``direct: true`` declara que ela é semanticamente suficiente + # para encerrar o turno antes de RAG/LLM. + if not bool(policy.get("direct", False)): + return None - if tool == "consultar_pedido": - oid=data.get("order_id"); status=data.get("status"); total=data.get("valor_total") - lines=[f"[{agent_label}] Pedido {oid}: status {status}."] - if total is not None: lines.append(f"Valor total: R$ {float(total):.2f}.".replace('.', ',')) - items=data.get("itens") or [] - if items: lines.append("Itens: " + "; ".join(str(i.get("descricao") or i.get("nome") or i.get("sku")) for i in items) + ".") - return " ".join(lines) - if tool == "consultar_entrega": - return f"[{agent_label}] Entrega do pedido {data.get('order_id')}: transportadora {data.get('transportadora')}, rastreio {data.get('codigo_rastreio')}, previsão {data.get('previsao_entrega')}." - if tool == "consultar_plano": - return f"[{agent_label}] Seu plano é {data.get('plano')}, com {data.get('internet_gb')} GB e status {data.get('status')}." - if tool == "consultar_fatura": - return f"[{agent_label}] Fatura consultada: {data}." - return None + return self._render_declared_tool_response(tool, data, agent_label=agent_label, state=state) + + def _clear_active_interaction_context_on_route_shift(self, state: dict[str, Any]) -> bool: + """Invalidate active conversational latches when routing leaves their owner. + + This is deliberately generic. It compares the current route decision with + the owner recorded by a paused workflow; it does not inspect domain, tool, + workflow or intent names. Durable checkpoints/history remain intact. + """ + pending_workflow = state.get("pending_domain_workflow") + if not isinstance(pending_workflow, dict) or not pending_workflow.get("execution_id"): + return False + + route_decision = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {} + route_metadata = route_decision.get("metadata") if isinstance(route_decision.get("metadata"), dict) else {} + if route_metadata.get("workflow_resume"): + return False + + current_intent = str(route_decision.get("intent") or state.get("intent") or "").strip() + current_agent = str(route_decision.get("agent") or route_decision.get("route") or state.get("route") or "").strip() + owner_intent = str(pending_workflow.get("owner_intent") or "").strip() + owner_agent = str(pending_workflow.get("owner_agent") or "").strip() + + intent_changed = bool(owner_intent and current_intent and owner_intent != current_intent) + agent_changed = bool(owner_agent and current_agent and owner_agent != current_agent) + if not (intent_changed or agent_changed): + return False + + state["last_interrupted_domain_workflow"] = { + **pending_workflow, + "status": "CANCELLED", + "reason": "intent_shift", + } + state["pending_domain_workflow"] = None + + # The active interaction owns all operational latches, not the durable + # audit trail. Clear only live state so a new semantic route starts clean. + active_tx = self._active_transaction(state) + if isinstance(active_tx, dict) and active_tx.get("tool_name"): + self._finish_active_transaction(state, "CANCELLED") + else: + state["active_transaction"] = None + state["selected_tool_call"] = {} + state["pending_tool_call"] = {} + state["missing_parameters"] = [] + state["confirmation_required"] = False + state["confirmation_received"] = False + state["next_state"] = None + + if state.get("transaction_status") in {"WORKFLOW_PAUSED", "COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "CANCELLED"}: + state["transaction_status"] = None + state["transaction_pre_validation"] = None + state["pending_tool_clarification"] = None + state["tool_policy_result"] = { + "action": "cleared_by_intent_shift", + "workflow_execution_id": pending_workflow.get("execution_id"), + } + state["mcp_results"] = [] + return True async def execute_tools_for_intent( self, @@ -2069,7 +2666,12 @@ class AgentRuntimeMixin: results: list[dict[str, Any]] = [] available_tools = list(tools if tools is not None else (state.get("mcp_tools") or [])) state["available_mcp_tools"] = available_tools - text = state.get("sanitized_input") or state.get("user_text") or "" + route_meta = (state.get("route_decision") or {}).get("metadata") or {} + text = ( + route_meta.get("contextual_reentry_input") + if route_meta.get("contextual_reentry") + else None + ) or state.get("sanitized_input") or state.get("user_text") or "" self._normalize_transaction_lifecycle(state) # Uma transação em coleta/confirmação não pode aprisionar a sessão. O @@ -2077,7 +2679,6 @@ class AgentRuntimeMixin: # Não existe interpretação lexical de desistência no runtime: mudou a # intent, a transação anterior é encerrada e seus latches são limpos. active_before_interruption = self._active_transaction(state) - route_meta = (state.get("route_decision") or {}).get("metadata") or {} interruption = str(route_meta.get("transaction_interruption") or "").strip().lower() if active_before_interruption and interruption == "intent_shift": interrupted_tool = active_before_interruption.get("tool_name") @@ -2088,6 +2689,8 @@ class AgentRuntimeMixin: "tool_name": interrupted_tool, } + self._clear_active_interaction_context_on_route_shift(state) + # Clarificação de resultado de tool tem precedência: reutiliza a mesma tool # e argumentos, alterando apenas o parâmetro escolhido pelo usuário. if state.get("pending_tool_clarification"): @@ -2096,6 +2699,13 @@ class AgentRuntimeMixin: # Workflows conversacionais pausados têm precedência sobre novo roteamento/tool selection. # O domínio informa apenas workflow/execution_id; a retomada é uma capability genérica. + # Invalid enumerated replies remain owned by the paused workflow and are + # answered with a declarative reprompt; the resume tool is not called. + if route_meta.get("workflow_input_invalid") and state.get("pending_domain_workflow"): + state["workflow_input_reprompt"] = str(route_meta.get("workflow_reprompt") or "").strip() + state["transaction_status"] = "WORKFLOW_PAUSED" + return [] + state["workflow_input_reprompt"] = None if state.get("pending_domain_workflow"): resumed = await self._resume_pending_domain_workflow(state, str(text)) return [resumed] if resumed else [] @@ -2110,13 +2720,19 @@ class AgentRuntimeMixin: missing_before = self._missing_required_arguments(policy, previous_args) # Parâmetros TRANSACIONAIS são interpretados exclusivamente pelo - # extrator LLM genérico. Não existem regexes/nome de entidade - # hardcoded no framework. O extrator recebe apenas os parâmetros - # ainda pendentes da policy e pode consumir um ou vários no turno. + # extrator LLM genérico. Durante COLLECTING_PARAMETERS, a fala atual + # também pode CORRIGIR um required field já coletado em turno anterior + # (ex.: valor=19,99 e o cliente diz "desculpa, é 14,99" enquanto + # subject ainda está pendente). Por isso o contrato editável do turno + # é o conjunto completo de ``requires``; somente as chaves realmente + # extraídas pela LLM sobrescrevem ``previous_args``. Campos não citados + # permanecem intactos. Isso preserva parameter-before-intent-shift sem + # tornar valores antigos imutáveis por acidente. + editable_required = [str(name) for name in (policy.get("requires") or [])] extracted = await self._extract_transaction_parameters( state, tool_name=tool_name, - missing_parameters=missing_before, + missing_parameters=editable_required, known_arguments=previous_args, ) arguments = {**previous_args, **extracted} @@ -2168,7 +2784,13 @@ class AgentRuntimeMixin: if pre_validation_result is not None: return [pre_validation_result] - if policy.get("require_confirmation"): + tool_name, policy, force_confirmation = self._apply_prevalidated_transaction_decision( + state, tool_name=tool_name, arguments=arguments, policy=policy + ) + selected = {"tool_name": tool_name, "arguments": arguments} + state["selected_tool_call"] = selected + + if policy.get("require_confirmation") or force_confirmation: waiting_state = self._waiting_state_name(state) state.update({ "pending_tool_call": selected, @@ -2181,6 +2803,9 @@ class AgentRuntimeMixin: self._set_active_transaction( state, tool_name=tool_name, arguments=arguments, status="AWAITING_CONFIRMATION" ) + self._freeze_confirmation_snapshot( + state, tool_name=tool_name, arguments=arguments + ) return [{ "ok": True, "executed": False, @@ -2194,7 +2819,7 @@ class AgentRuntimeMixin: result = await self._call_mcp_tool(tool_name, arguments, state) self._capture_pending_domain_workflow(state, result) self._capture_pending_tool_clarification(state, result, tool_name=tool_name, arguments=arguments) - final_status = ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED"))) + final_status = ("BLOCKED" if self._terminal_tool_payload(result) else ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED")))) if final_status in _TERMINAL_TRANSACTION_STATUSES: self._finish_active_transaction(state, final_status, result=result) else: @@ -2211,9 +2836,13 @@ class AgentRuntimeMixin: return [result] active_tx = self._active_transaction(state) - pending = (active_tx if isinstance(active_tx, dict) and active_tx.get("status") == "AWAITING_CONFIRMATION" else state.get("pending_tool_call")) or {} + frozen_confirmation = self._confirmation_snapshot(state) + pending = frozen_confirmation or (active_tx if isinstance(active_tx, dict) and active_tx.get("status") == "AWAITING_CONFIRMATION" else state.get("pending_tool_call")) or {} if pending: - decision = self._confirmation_decision(text) + route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} + routed_decision = str(route_meta.get("transaction_confirmation_decision") or "").strip().lower() + routed_consumed = bool(route_meta.get("transaction_turn_consumed")) + decision = routed_decision if routed_consumed and routed_decision in {"confirm", "reject"} else self._confirmation_decision(text) if decision == "reject": state["tool_policy_result"] = {"action": "cancelled", "tool_name": pending.get("tool_name")} self._finish_active_transaction(state, "CANCELLED") @@ -2226,7 +2855,7 @@ class AgentRuntimeMixin: result = await self._call_mcp_tool(tool_name, arguments, state) self._capture_pending_domain_workflow(state, result) self._capture_pending_tool_clarification(state, result, tool_name=tool_name, arguments=arguments) - final_status = ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED"))) + final_status = ("BLOCKED" if self._terminal_tool_payload(result) else ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED")))) state["tool_policy_result"] = {"action": "executed_after_confirmation", "tool_name": tool_name} if final_status in _TERMINAL_TRANSACTION_STATUSES: self._finish_active_transaction(state, final_status, result=result) @@ -2246,6 +2875,12 @@ class AgentRuntimeMixin: self._set_active_transaction( state, tool_name=str(pending.get("tool_name") or ""), arguments=dict(pending.get("arguments") or {}), status="AWAITING_CONFIRMATION" ) + if self._confirmation_snapshot(state) is None: + self._freeze_confirmation_snapshot( + state, + tool_name=str(pending.get("tool_name") or ""), + arguments=dict(pending.get("arguments") or {}), + ) return [{"ok": False, "tool_name": pending.get("tool_name"), "awaiting_confirmation": True, "transaction_status": "AWAITING_CONFIRMATION"}] read_only_tools = [ @@ -2268,6 +2903,16 @@ class AgentRuntimeMixin: self._capture_pending_domain_workflow(state, result) self._capture_pending_tool_clarification(state, result, tool_name=tool, arguments=args) results.append(result) + if self._terminal_tool_payload(result): + self._apply_terminal_tool_result(state, result) + if emit_events: + await self._emit_ic( + "IC.TOOL_CHAIN_TERMINATED", + state, + {"tool_name": tool, "reason": (self._terminal_tool_payload(result) or {}).get("reason")}, + component="agent_runtime.tool_policy", + ) + return results if emit_events: await self._emit_ic( "IC.TOOL_CALLED", @@ -2371,7 +3016,13 @@ class AgentRuntimeMixin: results.append(pre_validation_result) return results - if policy.get("require_confirmation"): + selected_action, policy, force_confirmation = self._apply_prevalidated_transaction_decision( + state, tool_name=selected_action, arguments=action_args, policy=policy + ) + selected = {"tool_name": selected_action, "arguments": action_args} + state["selected_tool_call"] = selected + + if policy.get("require_confirmation") or force_confirmation: state.update({ "pending_tool_call": selected, "transaction_status": "AWAITING_CONFIRMATION", @@ -2381,6 +3032,9 @@ class AgentRuntimeMixin: self._set_active_transaction( state, tool_name=selected_action, arguments=action_args, status="AWAITING_CONFIRMATION" ) + self._freeze_confirmation_snapshot( + state, tool_name=selected_action, arguments=action_args + ) state["next_state"] = self._waiting_state_name(state) if emit_events: await self._emit_ic("IC.TRANSACTION_CONFIRMATION_REQUIRED", state, {"tool_name": selected_action, **policy}, component="agent_runtime.tool_policy") @@ -2390,7 +3044,7 @@ class AgentRuntimeMixin: action_args["confirmed"] = True result = await self._call_mcp_tool(selected_action, action_args, state) self._capture_pending_domain_workflow(state, result) - final_status = ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED"))) + final_status = ("BLOCKED" if self._terminal_tool_payload(result) else ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED")))) if final_status in _TERMINAL_TRANSACTION_STATUSES: self._finish_active_transaction(state, final_status, result=result) else: @@ -2573,9 +3227,21 @@ class AgentRuntimeMixin: f"(persistidas pelo framework, não inferidas pela memória conversacional):\n{transaction_evidence}" ) if rag_context is not None: - sections.append(f"Contexto RAG nativo do framework:\n{rag_context or '[sem contexto RAG]'}") + sections.append(f"Contexto de conhecimento (RAG):\n{rag_context or '[sem contexto RAG]'}") if rag_metadata is not None: sections.append(f"Metadados RAG:\n{rag_metadata}") + provider = str(rag_metadata.get("provider") or getattr(getattr(self, "settings", None), "RAG_PROVIDER", "standard")) + grounded_only = bool(getattr(getattr(self, "settings", None), "RAG_GROUNDED_ONLY", False)) + if provider == "kbdb": + grounded_only = bool(getattr(getattr(self, "settings", None), "KBDB_GROUNDED_ONLY", True)) + if grounded_only: + sections.append( + "Política de grounding obrigatória:\n" + "- Use como fatos somente evidências presentes nos resultados MCP, no contexto RAG e no business context fornecido.\n" + "- Não complete lacunas usando conhecimento paramétrico do modelo, memória geral ou suposições.\n" + "- Se a informação pedida não estiver sustentada pelas evidências disponíveis, diga explicitamente que não há informação suficiente na base consultada.\n" + "- Se o RAG estiver vazio, bloqueado ou com erro, ainda é permitido responder apenas a partes comprovadas por MCP/business context; não invente a parte documental ausente." + ) for title, value in (extra_sections or {}).items(): sections.append(f"{title}:\n{value}") return MessageBuilder(state).system(system_prompt).user("\n\n".join(sections)).build() diff --git a/libs/agent_framework/build/lib/agent_framework/runtime/transaction_parameters.py b/libs/agent_framework/build/lib/agent_framework/runtime/transaction_parameters.py index b79bc087..1a394f79 100644 --- a/libs/agent_framework/build/lib/agent_framework/runtime/transaction_parameters.py +++ b/libs/agent_framework/build/lib/agent_framework/runtime/transaction_parameters.py @@ -1,5 +1,7 @@ from __future__ import annotations +from agent_framework.llm.structured_output import parse_json_object + import json import logging import re @@ -76,6 +78,7 @@ async def extract_transaction_parameters( known_arguments: Mapping[str, Any] | None = None, parameter_schema: Mapping[str, Any] | None = None, tool_description: str | None = None, + conversational_context: str | None = None, ) -> dict[str, Any]: """Extract values for pending transactional parameters using the LLM only. @@ -114,14 +117,20 @@ async def extract_transaction_parameters( "3. Se nenhum parâmetro pendente foi realmente informado, devolva null para todos.\n" "4. Se houver apenas um parâmetro pendente, uma resposta contendo apenas um valor pode ser associada a ele quando isso for semanticamente inequívoco.\n" "5. Se houver vários parâmetros pendentes, extraia todos os que estiverem presentes no mesmo turno.\n" - "6. O nome do parâmetro não precisa aparecer literalmente na fala; use a semântica, o nome da transação e o schema para associar valores.\n" - "7. Em caso de dúvida, prefira null.\n" - "8. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n" + "6. O nome do parâmetro não precisa aparecer literalmente na fala. Associe semanticamente o valor usando o nome da transação e os metadados disponíveis para cada campo.\n" + "7. Para cada parâmetro, considere o nome técnico, o tipo quando disponível e principalmente a descrição semântica quando disponível. A ausência de tipo ou descrição NÃO impede a extração.\n" + "8. Se a mensagem deixar clara a correspondência entre um trecho e um parâmetro, preencha-o mesmo que o usuário não cite o nome técnico do campo.\n" + "9. Não use conhecimento externo para completar valores ausentes e não transforme aproximações ou suposições em fatos.\n" + "10. conversational_context, quando presente, serve SOMENTE para resolver referências da mensagem atual (por exemplo: 'a de 14,99' apontando para um item citado imediatamente antes). Não trate texto do contexto como uma nova afirmação do cliente nem como evidência de negócio.\n" + "11. Quando a mensagem atual identifica um valor OU nome e o contexto imediatamente anterior contém uma única entidade compatível, você pode preencher essa entidade e os atributos pendentes inequivocamente associados a ela como CANDIDATOS. Exemplo genérico: se a fala identifica uma entidade e o contexto associa unicamente essa entidade a um valor requerido, o valor pode ser retornado como candidato. A validação autoritativa ocorrerá depois; não invente se houver ambiguidade.\n" + "12. Em caso de dúvida razoável sobre a correspondência ou o valor, prefira null.\n" + "13. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n" f"transaction_tool: {tool_name}\n" f"transaction_description: {tool_description or ''}\n" f"pending_parameters: {json.dumps(pending, ensure_ascii=False)}\n" f"parameter_schema: {json.dumps(field_spec, ensure_ascii=False, default=str)}\n" f"known_arguments: {json.dumps(known, ensure_ascii=False, default=str)}\n" + f"conversational_context: {str(conversational_context or '').strip()}\n" f"user_message: {message}\n" f"Formato obrigatório: {json.dumps(output_shape, ensure_ascii=False)}" ) @@ -133,7 +142,6 @@ async def extract_transaction_parameters( component_name="transaction_parameter_extraction", generation_name="llm.transaction_parameter_extraction", temperature=0.0, - max_tokens=max(120, min(500, 80 + 60 * len(pending))), ) except TypeError: # Compatibilidade com doubles/testes e providers mínimos que aceitam @@ -149,13 +157,11 @@ async def extract_transaction_parameters( return {} raw = _response_text(response).strip() - if raw.startswith("```"): - raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE | re.DOTALL).strip() try: - payload = json.loads(raw) - except (TypeError, ValueError, json.JSONDecodeError): + payload = parse_json_object(raw) + except (TypeError, ValueError): logger.warning( - "transaction.parameter.llm_invalid_json tool=%s pending=%s raw=%r", + "transaction.parameter.llm_invalid_structured_output tool=%s pending=%s raw=%r", tool_name, pending, raw[:240], diff --git a/libs/agent_framework/build/lib/agent_framework/security/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..8d437d4d Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/security/__pycache__/authentication.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/authentication.cpython-313.pyc new file mode 100644 index 00000000..4e932231 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/authentication.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/security/__pycache__/factory.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/factory.cpython-313.pyc new file mode 100644 index 00000000..e79d4ac9 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/factory.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/security/__pycache__/installer.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/installer.cpython-313.pyc new file mode 100644 index 00000000..4b60c06b Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/installer.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/security/__pycache__/middleware.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/middleware.cpython-313.pyc new file mode 100644 index 00000000..7ac290cf Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/security/__pycache__/middleware.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/sse/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/sse/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..6cccf5f5 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/sse/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/sse/__pycache__/events.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/sse/__pycache__/events.cpython-313.pyc new file mode 100644 index 00000000..4f40a31a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/sse/__pycache__/events.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..ab585f86 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/router_supervisor.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/router_supervisor.cpython-313.pyc new file mode 100644 index 00000000..02b57518 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/router_supervisor.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/supervisor.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/supervisor.cpython-313.pyc new file mode 100644 index 00000000..790fbed3 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/supervisor/__pycache__/supervisor.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..662e3cc8 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/graph.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/graph.cpython-313.pyc new file mode 100644 index 00000000..ecf06f78 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/graph.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc new file mode 100644 index 00000000..9280ab9b Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/models.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..e3648446 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/registry.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/registry.cpython-313.pyc new file mode 100644 index 00000000..ac7534a1 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/registry.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/repository.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/repository.cpython-313.pyc new file mode 100644 index 00000000..4ba84943 Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/repository.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc new file mode 100644 index 00000000..f739c63a Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc new file mode 100644 index 00000000..7714ab8e Binary files /dev/null and b/libs/agent_framework/build/lib/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/input_contract.py b/libs/agent_framework/build/lib/agent_framework/workflows/input_contract.py new file mode 100644 index 00000000..15b1a926 --- /dev/null +++ b/libs/agent_framework/build/lib/agent_framework/workflows/input_contract.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from typing import Any + + +def normalize_expected_input(text: str, expected_input: dict[str, Any] | None) -> str: + """Normalize a workflow reply according to the declarative pause contract. + + The framework intentionally supports only explicit, deterministic normalizers. + Unknown normalizers fall back to ``strip`` instead of guessing semantics. + """ + rule = str((expected_input or {}).get("normalize") or "strip").strip().lower() + value = str(text or "") + if rule == "upper_strip": + return value.strip().upper() + if rule == "lower_strip": + return value.strip().lower() + return value.strip() + + +def match_expected_input(text: str, expected_input: dict[str, Any] | None) -> str | None: + """Return the normalized value only when it satisfies the workflow contract. + + With no ``allowed_values`` the normalized value is accepted when non-empty. + This keeps the capability generic for free-text pause contracts while making + enumerated contracts (SIM/NAO, choices, etc.) deterministic. + """ + if not isinstance(expected_input, dict): + return None + normalized = normalize_expected_input(text, expected_input) + if not normalized: + return None + allowed = expected_input.get("allowed_values") + if not allowed: + return normalized + allowed_normalized = { + normalize_expected_input(str(item), expected_input) + for item in allowed + if item is not None + } + return normalized if normalized in allowed_normalized else None + +def expected_input_reprompt(expected_input: dict[str, Any] | None, *, pause_prompt: str | None = None) -> str: + """Return a user-facing retry prompt for an invalid paused-workflow reply. + + Domains may declare ``reprompt`` in the workflow contract. When absent, the + framework builds a neutral message from ``allowed_values`` without guessing + domain semantics. + """ + contract = expected_input if isinstance(expected_input, dict) else {} + declared = str(contract.get("reprompt") or "").strip() + if declared: + return declared + allowed = [str(x).strip() for x in (contract.get("allowed_values") or []) if str(x).strip()] + if allowed: + rendered = ", ".join(allowed) + return f"Não entendi. Responda com uma das opções: {rendered}." + prompt = str(pause_prompt or "").strip() + if prompt: + return f"Não entendi. {prompt}" + return "Não entendi sua resposta. Por favor, tente novamente." + +def has_semantic_classifier(expected_input: dict[str, Any] | None) -> bool: + """Whether an enumerated contract opts in to agent-defined semantic classification.""" + if not isinstance(expected_input, dict) or not expected_input.get("allowed_values"): + return False + classifier = expected_input.get("semantic_classifier") + return ( + isinstance(classifier, dict) + and classifier.get("enabled", True) is not False + and bool(str(classifier.get("prompt") or "").strip()) + ) + + +def match_semantic_classifier_output( + output: str, expected_input: dict[str, Any] | None +) -> str | None: + """Validate classifier output strictly against dynamic ``allowed_values``. + + No option semantics live in the framework. The returned value is the same + normalized representation used by deterministic ``match_expected_input``. + """ + if not isinstance(expected_input, dict): + return None + candidate = str(output or "").strip().strip("` \n\r\t\"'") + if not candidate: + return None + allowed = expected_input.get("allowed_values") or [] + allowed_map = { + normalize_expected_input(str(item), expected_input): normalize_expected_input(str(item), expected_input) + for item in allowed + if item is not None + } + normalized = normalize_expected_input(candidate, expected_input) + return allowed_map.get(normalized) + + +def has_meaningful_unmatched_policy(expected_input: dict[str, Any] | None) -> bool: + """Whether the contract explicitly opts in to semantic handling of unmatched text.""" + if not isinstance(expected_input, dict): + return False + unmatched = expected_input.get("unmatched") + if not isinstance(unmatched, dict): + return False + meaningful = unmatched.get("meaningful_input") + return ( + isinstance(meaningful, dict) + and str(meaningful.get("action") or "").strip().lower() == "resume_as" + and meaningful.get("value") is not None + ) + + +def meaningful_unmatched_resume_value( + expected_input: dict[str, Any] | None, + *, + semantic_coherent: bool | None, +) -> str | None: + """Resolve a configured ``resume_as`` value for coherent unmatched input. + + The framework never invents domain semantics here. It only applies the + value declared by the workflow after the coherence rail classified the + free-text reply as meaningful. + """ + if semantic_coherent is not True or not has_meaningful_unmatched_policy(expected_input): + return None + unmatched = expected_input.get("unmatched") or {} + meaningful = unmatched.get("meaningful_input") or {} + raw = meaningful.get("value") + if raw is None: + return None + return normalize_expected_input(str(raw), expected_input) + + +def semantic_coherence_from_guardrails(state: dict[str, Any]) -> bool | None: + """Read the non-blocking COER signal emitted for a paused workflow contract.""" + decisions = state.get("guardrail_decisions") or state.get("guardrails") or [] + if not isinstance(decisions, list): + return None + for decision in reversed(decisions): + if hasattr(decision, "model_dump"): + decision = decision.model_dump() + if not isinstance(decision, dict) or str(decision.get("code") or "").upper() != "COER": + continue + metadata = decision.get("metadata") or {} + if isinstance(metadata, dict) and isinstance(metadata.get("semantic_coherent"), bool): + return metadata["semantic_coherent"] + data = metadata.get("data") if isinstance(metadata, dict) else None + if isinstance(data, dict) and isinstance(data.get("allowed"), bool): + return data["allowed"] + return None + diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/models.py b/libs/agent_framework/build/lib/agent_framework/workflows/models.py index 0dd996f5..fd8ca93e 100644 --- a/libs/agent_framework/build/lib/agent_framework/workflows/models.py +++ b/libs/agent_framework/build/lib/agent_framework/workflows/models.py @@ -4,10 +4,53 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator +class WorkflowMeaningfulInputAction(BaseModel): + """Legacy action for coherent unmatched input (kept for compatibility).""" + + action: Literal["resume_as"] = "resume_as" + value: Any + + +class WorkflowExpectedInputUnmatched(BaseModel): + meaningful_input: WorkflowMeaningfulInputAction | None = None + + +class WorkflowSemanticOptionAction(BaseModel): + """Optional generic action attached to one classified option. + + ``contextual_reentry`` releases the paused workflow and asks the normal + router/runtime to reinterpret the current utterance together with the + bounded conversational context that produced the pause. It never confirms + user-provided facts by itself. + """ + + action: Literal["contextual_reentry"] + + +class WorkflowSemanticClassifier(BaseModel): + """Agent-defined semantic classifier constrained by ``allowed_values``. + + The framework provides only execution/validation. The prompt defines the + domain meaning of every allowed option and may reference the runtime + placeholders ``{{ allowed_values }}``, ``{{ pending_prompt }}``, + ``{{ relevant_conversation_context }}`` and ``{{ user_input }}``. + Per-option actions are also agent configuration; the framework knows only + their generic mechanics. + """ + + enabled: bool = True + include_relevant_context: bool = False + prompt: str = Field(min_length=1) + option_actions: dict[str, WorkflowSemanticOptionAction] = Field(default_factory=dict) + + class WorkflowExpectedInput(BaseModel): key: str = Field(min_length=1) allowed_values: list[Any] = Field(default_factory=list) normalize: Literal["none", "upper_strip", "lower_strip", "strip"] = "none" + reprompt: str | None = None + semantic_classifier: WorkflowSemanticClassifier | None = None + unmatched: WorkflowExpectedInputUnmatched | None = None class WorkflowPause(BaseModel): diff --git a/libs/agent_framework/build/lib/agent_framework/workflows/runtime.py b/libs/agent_framework/build/lib/agent_framework/workflows/runtime.py index d721fb6c..c171010c 100644 --- a/libs/agent_framework/build/lib/agent_framework/workflows/runtime.py +++ b/libs/agent_framework/build/lib/agent_framework/workflows/runtime.py @@ -459,6 +459,68 @@ class WorkflowRuntime: self._compiled[key] = graph return graph + def _snapshot_interrupts(self, snapshot: Any) -> list[Any]: + """Return real LangGraph interrupt payloads from a durable snapshot. + + ``snapshot.next`` only means that LangGraph still exposes pending graph + work. It is *not* proof that execution is waiting for user input. + Pause semantics belong exclusively to real ``interrupt()`` payloads. + + LangGraph/checkpointer versions expose durable interrupts in more than + one shape. Newer snapshots normally attach them to ``task.interrupts``; + other supported versions persist them in ``snapshot.values`` under the + reserved ``__interrupt__`` key. Accept both representations so a real + pause is never mistaken for generic pending work and failed closed. + """ + interrupts: list[Any] = [] + + def append_interrupt(item: Any) -> None: + if isinstance(item, dict) and "value" in item: + value = item.get("value") + else: + value = getattr(item, "value", item) + # Avoid duplicating the same payload when a LangGraph version + # exposes it through both task metadata and durable state values. + if value not in interrupts: + interrupts.append(value) + + for task in getattr(snapshot, "tasks", ()) or (): + for item in getattr(task, "interrupts", ()) or (): + append_interrupt(item) + + # Compatibility with LangGraph/checkpointer snapshots where interrupts + # are durable state values instead of task metadata. This is the shape + # observed with pause nodes such as ``formatar__pause``. + values = getattr(snapshot, "values", None) + if isinstance(values, dict): + persisted = values.get("__interrupt__") + if isinstance(persisted, (list, tuple)): + for item in persisted: + append_interrupt(item) + elif persisted is not None: + append_interrupt(persisted) + + # Be tolerant of versions/adapters that expose a top-level collection. + for item in getattr(snapshot, "interrupts", ()) or (): + append_interrupt(item) + + return interrupts + + def _is_structurally_terminal(self, definition: WorkflowDefinition, state: dict[str, Any]) -> bool: + """Return True when the current completed node has an active edge to END. + + This intentionally evaluates the workflow definition rather than relying + on ``snapshot.next``. Some LangGraph/checkpointer combinations may leave + a truthy ``next`` after the final action node has already completed. + """ + current_node = state.get("current_node") + if not isinstance(current_node, str) or not current_node: + return False + for edge in self._outgoing(definition).get(current_node, []): + if _matches(edge.when, state): + return edge.target in {"END", "__end__"} + return False + def _result_from_state(self, definition: WorkflowDefinition, eid: str, state: dict[str, Any]) -> WorkflowRunResult: return WorkflowRunResult( execution_id=eid, @@ -505,12 +567,9 @@ class WorkflowRuntime: state = await graph.ainvoke(initial, config=config) phase = "aget_state" snapshot = await graph.aget_state(config) - if getattr(snapshot, "next", None): - interrupts = [] - for task in getattr(snapshot, "tasks", ()) or (): - for item in getattr(task, "interrupts", ()) or (): - interrupts.append(getattr(item, "value", item)) - pause = interrupts[-1] if interrupts else {"node": state.get("current_node")} + interrupts = self._snapshot_interrupts(snapshot) + if interrupts: + pause = interrupts[-1] return WorkflowRunResult( execution_id=eid, workflow_name=name, @@ -521,6 +580,14 @@ class WorkflowRuntime: pause=pause if isinstance(pause, dict) else {"value": pause}, trace=list(state.get("trace") or []), ) + if self._is_structurally_terminal(definition, state): + return self._result_from_state(definition, eid, state) + if getattr(snapshot, "next", None): + raise RuntimeError( + "LangGraph retornou trabalho pendente sem interrupt real em estado não terminal; " + f"workflow={definition.name!r} current_node={state.get('current_node')!r} " + f"next={getattr(snapshot, 'next', None)!r}" + ) return self._result_from_state(definition, eid, state) except Exception as exc: # Preserve the last durable LangGraph snapshot instead of discarding @@ -582,12 +649,9 @@ class WorkflowRuntime: state = await graph.ainvoke(Command(resume=resume_value), config=config) phase = "aget_state_resume" snapshot = await graph.aget_state(config) - if getattr(snapshot, "next", None): - interrupts = [] - for task in getattr(snapshot, "tasks", ()) or (): - for item in getattr(task, "interrupts", ()) or (): - interrupts.append(getattr(item, "value", item)) - pause = interrupts[-1] if interrupts else {"node": state.get("current_node")} + interrupts = self._snapshot_interrupts(snapshot) + if interrupts: + pause = interrupts[-1] return WorkflowRunResult( execution_id=execution_id, workflow_name=name, @@ -598,6 +662,14 @@ class WorkflowRuntime: pause=pause if isinstance(pause, dict) else {"value": pause}, trace=list(state.get("trace") or []), ) + if self._is_structurally_terminal(definition, state): + return self._result_from_state(definition, execution_id, state) + if getattr(snapshot, "next", None): + raise RuntimeError( + "LangGraph retornou trabalho pendente sem interrupt real em estado não terminal; " + f"workflow={definition.name!r} current_node={state.get('current_node')!r} " + f"next={getattr(snapshot, 'next', None)!r}" + ) return self._result_from_state(definition, execution_id, state) except Exception as exc: partial: dict[str, Any] = {} diff --git a/libs/agent_framework/src/agent_framework/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/__pycache__/__init__.cpython-313.pyc index de09ac23..e1a2af2b 100644 Binary files a/libs/agent_framework/src/agent_framework/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/__pycache__/extensions.cpython-313.pyc b/libs/agent_framework/src/agent_framework/__pycache__/extensions.cpython-313.pyc index a7262048..c8ae879c 100644 Binary files a/libs/agent_framework/src/agent_framework/__pycache__/extensions.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/__pycache__/extensions.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/__pycache__/gateway_policy_context.cpython-313.pyc b/libs/agent_framework/src/agent_framework/__pycache__/gateway_policy_context.cpython-313.pyc index 83fa4b8b..133a5dd8 100644 Binary files a/libs/agent_framework/src/agent_framework/__pycache__/gateway_policy_context.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/__pycache__/gateway_policy_context.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/__pycache__/idempotency.cpython-313.pyc b/libs/agent_framework/src/agent_framework/__pycache__/idempotency.cpython-313.pyc index d548db52..3b82cb6f 100644 Binary files a/libs/agent_framework/src/agent_framework/__pycache__/idempotency.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/__pycache__/idempotency.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/__pycache__/observer.cpython-313.pyc b/libs/agent_framework/src/agent_framework/__pycache__/observer.cpython-313.pyc index 69bd22ba..c5eab8c5 100644 Binary files a/libs/agent_framework/src/agent_framework/__pycache__/observer.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/__pycache__/observer.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/__pycache__/runtime_mcp_gateway_adapter.cpython-313.pyc b/libs/agent_framework/src/agent_framework/__pycache__/runtime_mcp_gateway_adapter.cpython-313.pyc index 562f4db5..5f69ea04 100644 Binary files a/libs/agent_framework/src/agent_framework/__pycache__/runtime_mcp_gateway_adapter.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/__pycache__/runtime_mcp_gateway_adapter.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/__pycache__/__init__.cpython-313.pyc index 48f7d5ae..11ba6485 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/__pycache__/composite_publisher.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/__pycache__/composite_publisher.cpython-313.pyc index 4bcd451b..c8c9645c 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/__pycache__/composite_publisher.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/__pycache__/composite_publisher.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/__pycache__/event_builder.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/__pycache__/event_builder.cpython-313.pyc index c4901cdd..b63884a8 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/__pycache__/event_builder.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/__pycache__/event_builder.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/__pycache__/factory.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/__pycache__/factory.cpython-313.pyc index 8c60d8f7..4e2f8a9a 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/__pycache__/factory.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/__pycache__/factory.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/__pycache__/publisher.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/__pycache__/publisher.cpython-313.pyc index 5fd44afd..5438b73e 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/__pycache__/publisher.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/__pycache__/publisher.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/__pycache__/tim_payload_mapper.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/__pycache__/tim_payload_mapper.cpython-313.pyc index 384343e1..14baae3a 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/__pycache__/tim_payload_mapper.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/__pycache__/tim_payload_mapper.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/__pycache__/tim_sequence.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/__pycache__/tim_sequence.cpython-313.pyc index ce3475cb..556155f1 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/__pycache__/tim_sequence.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/__pycache__/tim_sequence.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/__init__.cpython-313.pyc index 20cb3fa3..e2414368 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/kafka.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/kafka.cpython-313.pyc index 89213503..bc82faa8 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/kafka.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/kafka.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/langfuse.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/langfuse.cpython-313.pyc index 1458ff0b..c9b5e885 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/langfuse.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/langfuse.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/oci_streaming.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/oci_streaming.cpython-313.pyc index 5c315167..8590014b 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/oci_streaming.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/oci_streaming.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/pubsub.cpython-313.pyc b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/pubsub.cpython-313.pyc index 5285deb7..7f7a9910 100644 Binary files a/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/pubsub.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/analytics/providers/__pycache__/pubsub.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/billing/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/billing/__pycache__/__init__.cpython-313.pyc index 13881e79..24078c2c 100644 Binary files a/libs/agent_framework/src/agent_framework/billing/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/billing/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/billing/__pycache__/usage_repository.cpython-313.pyc b/libs/agent_framework/src/agent_framework/billing/__pycache__/usage_repository.cpython-313.pyc index 66072c94..42b0651b 100644 Binary files a/libs/agent_framework/src/agent_framework/billing/__pycache__/usage_repository.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/billing/__pycache__/usage_repository.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/cache/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/cache/__pycache__/__init__.cpython-313.pyc index b4b3fde8..969a666e 100644 Binary files a/libs/agent_framework/src/agent_framework/cache/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/cache/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/cache/__pycache__/cache.cpython-313.pyc b/libs/agent_framework/src/agent_framework/cache/__pycache__/cache.cpython-313.pyc index a3076638..a6d77107 100644 Binary files a/libs/agent_framework/src/agent_framework/cache/__pycache__/cache.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/cache/__pycache__/cache.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/channels/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/channels/__pycache__/__init__.cpython-313.pyc index fd5ec415..e3a60be8 100644 Binary files a/libs/agent_framework/src/agent_framework/channels/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/channels/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/channels/__pycache__/adapters.cpython-313.pyc b/libs/agent_framework/src/agent_framework/channels/__pycache__/adapters.cpython-313.pyc index a84f951a..484e6087 100644 Binary files a/libs/agent_framework/src/agent_framework/channels/__pycache__/adapters.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/channels/__pycache__/adapters.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/channels/__pycache__/base.cpython-313.pyc b/libs/agent_framework/src/agent_framework/channels/__pycache__/base.cpython-313.pyc index 5ad662bc..5de22cb7 100644 Binary files a/libs/agent_framework/src/agent_framework/channels/__pycache__/base.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/channels/__pycache__/base.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/channels/__pycache__/gateway.cpython-313.pyc b/libs/agent_framework/src/agent_framework/channels/__pycache__/gateway.cpython-313.pyc index a898bfe7..bb06c97a 100644 Binary files a/libs/agent_framework/src/agent_framework/channels/__pycache__/gateway.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/channels/__pycache__/gateway.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/channels/__pycache__/interruption.cpython-313.pyc b/libs/agent_framework/src/agent_framework/channels/__pycache__/interruption.cpython-313.pyc index 333f20d8..dc7c612e 100644 Binary files a/libs/agent_framework/src/agent_framework/channels/__pycache__/interruption.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/channels/__pycache__/interruption.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/channels/__pycache__/transcription.cpython-313.pyc b/libs/agent_framework/src/agent_framework/channels/__pycache__/transcription.cpython-313.pyc index 47f24637..b0e2a206 100644 Binary files a/libs/agent_framework/src/agent_framework/channels/__pycache__/transcription.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/channels/__pycache__/transcription.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/__init__.cpython-313.pyc index f2302562..74169e80 100644 Binary files a/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/checkpoint_repository.cpython-313.pyc b/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/checkpoint_repository.cpython-313.pyc index 69e908a3..89bc20f0 100644 Binary files a/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/checkpoint_repository.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/checkpoint_repository.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/langgraph_saver.cpython-313.pyc b/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/langgraph_saver.cpython-313.pyc index 5210e7ca..06f182d7 100644 Binary files a/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/langgraph_saver.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/checkpoints/__pycache__/langgraph_saver.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/config/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/config/__pycache__/__init__.cpython-313.pyc index 4dc9805c..7297e96b 100644 Binary files a/libs/agent_framework/src/agent_framework/config/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/config/__pycache__/agent_registry.cpython-313.pyc b/libs/agent_framework/src/agent_framework/config/__pycache__/agent_registry.cpython-313.pyc index 3cae7c9c..b4ebf5d5 100644 Binary files a/libs/agent_framework/src/agent_framework/config/__pycache__/agent_registry.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/config/__pycache__/agent_registry.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/config/__pycache__/settings.cpython-313.pyc b/libs/agent_framework/src/agent_framework/config/__pycache__/settings.cpython-313.pyc index 2b66521f..eb093bc2 100644 Binary files a/libs/agent_framework/src/agent_framework/config/__pycache__/settings.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/config/__pycache__/settings.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/events/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/events/__pycache__/__init__.cpython-313.pyc index 25db31fd..4be3b308 100644 Binary files a/libs/agent_framework/src/agent_framework/events/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/events/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/events/__pycache__/oci_streaming.cpython-313.pyc b/libs/agent_framework/src/agent_framework/events/__pycache__/oci_streaming.cpython-313.pyc index 6f0b4bf4..28c590a6 100644 Binary files a/libs/agent_framework/src/agent_framework/events/__pycache__/oci_streaming.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/events/__pycache__/oci_streaming.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc index 656d136a..480279af 100644 Binary files a/libs/agent_framework/src/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc b/libs/agent_framework/src/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc index 7909a8a0..7bcb3bae 100644 Binary files a/libs/agent_framework/src/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/__init__.cpython-313.pyc index 6bfab5ac..6a97e1d7 100644 Binary files a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/client.cpython-313.pyc b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/client.cpython-313.pyc index 7c4d8561..b90fd8f1 100644 Binary files a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/client.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/client.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/config.cpython-313.pyc b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/config.cpython-313.pyc index 16c4b376..885a41e3 100644 Binary files a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/config.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/config.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/models.cpython-313.pyc index 527a4a1f..000c9f63 100644 Binary files a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/router.cpython-313.pyc b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/router.cpython-313.pyc index ef4b2a9a..a93b618f 100644 Binary files a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/router.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/router.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/session_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/session_store.cpython-313.pyc index 43f44539..5df19dfc 100644 Binary files a/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/session_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/global_supervisor/__pycache__/session_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/__init__.cpython-313.pyc index 4734a86a..0deff967 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/base.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/base.cpython-313.pyc index 06f9eae6..5301e0b5 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/base.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/base.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/config_loader.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/config_loader.cpython-313.pyc index db931dbe..bc08ea9f 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/config_loader.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/config_loader.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/custom_rails.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/custom_rails.cpython-313.pyc index 112d959b..faf31222 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/custom_rails.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/custom_rails.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/executor.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/executor.cpython-313.pyc index d2a59de3..9f64e656 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/executor.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/executor.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/framework_llm_client.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/framework_llm_client.cpython-313.pyc index f18abd07..2955e5da 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/framework_llm_client.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/framework_llm_client.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/langgraph_adapters.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/langgraph_adapters.cpython-313.pyc index 359c587e..0b3b8716 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/langgraph_adapters.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/langgraph_adapters.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/llm_rails.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/llm_rails.cpython-313.pyc index edb612d0..3e07f681 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/llm_rails.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/llm_rails.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/output_supervisor.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/output_supervisor.cpython-313.pyc index 67c24457..27717120 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/output_supervisor.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/output_supervisor.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/parallel_executor.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/parallel_executor.cpython-313.pyc index 75411936..928a7d42 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/parallel_executor.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/parallel_executor.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/pipeline.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/pipeline.cpython-313.pyc index d9e53cb8..19579a2a 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/pipeline.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/pipeline.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_action.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_action.cpython-313.pyc index 59f52bba..739c6b0e 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_action.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_action.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_decision.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_decision.cpython-313.pyc index f758829e..af2b3572 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_decision.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_decision.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_result.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_result.cpython-313.pyc index 66f0d1e6..72369970 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_result.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rail_result.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rails.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rails.cpython-313.pyc index 2c248c30..00a3c382 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rails.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/__pycache__/rails.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/__init__.cpython-313.pyc index 7ef5a80e..34613036 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/_compat.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/_compat.cpython-313.pyc index c7c88fae..50840c9c 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/_compat.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/_compat.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/config.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/config.cpython-313.pyc index 1d50f432..46bd8d0a 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/config.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/config.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/contestation_validation.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/contestation_validation.cpython-313.pyc index 908bd598..73ed1bd8 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/contestation_validation.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/contestation_validation.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/contracts.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/contracts.cpython-313.pyc index b12fd643..705b3d7d 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/contracts.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/contracts.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/input_size.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/input_size.cpython-313.pyc index 293a24ca..c5a389cb 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/input_size.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/input_size.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_adapter.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_adapter.cpython-313.pyc index f7ce8151..19d6195d 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_adapter.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_adapter.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_client.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_client.cpython-313.pyc index d7e938b1..7485f644 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_client.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_client.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_rails.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_rails.cpython-313.pyc index 8d8604f9..31898926 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_rails.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/llm_rails.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/output_sanitization.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/output_sanitization.cpython-313.pyc index 9fcf3a34..8dd7cafe 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/output_sanitization.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/output_sanitization.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/pipeline.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/pipeline.cpython-313.pyc index 529052cb..5b2fc1a0 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/pipeline.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/__pycache__/pipeline.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/__init__.cpython-313.pyc index 07e87cec..65047540 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/_context.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/_context.cpython-313.pyc index 251de0cb..6e6fc85c 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/_context.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/_context.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc index b9fd59f1..33bebb8f 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/coerencia.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/coerencia.cpython-313.pyc index ada751df..d25d8aae 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/coerencia.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/coerencia.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_in.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_in.cpython-313.pyc index b4a792a8..803649c9 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_in.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_in.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_out.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_out.cpython-313.pyc index 8905a4e0..c16f49a4 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_out.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/dlex_out.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/fallback.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/fallback.cpython-313.pyc index 31ddc96a..80432b43 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/fallback.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/fallback.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/fraseologia.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/fraseologia.cpython-313.pyc index 68f9cbd8..46dd726a 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/fraseologia.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/fraseologia.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/out_of_scope.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/out_of_scope.cpython-313.pyc index b7877566..0a85a80b 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/out_of_scope.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/out_of_scope.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/pinj.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/pinj.cpython-313.pyc index 62ca20f2..92279318 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/pinj.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/pinj.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/ragsec.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/ragsec.cpython-313.pyc index 300c22a8..fb8381fe 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/ragsec.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/ragsec.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/revprec.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/revprec.cpython-313.pyc index 5e31e725..0e1ea717 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/revprec.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/revprec.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/safe_out.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/safe_out.cpython-313.pyc index 0c08d834..d1787ac5 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/safe_out.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/safe_out.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/tox.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/tox.cpython-313.pyc index 1fff83b7..6b599423 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/tox.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/tox.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/toxicidade_output.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/toxicidade_output.cpython-313.pyc index 74f244a6..949da14f 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/toxicidade_output.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/__pycache__/toxicidade_output.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/dlex_out.py b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/dlex_out.py index b88caefc..09fb7a66 100644 --- a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/dlex_out.py +++ b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/dlex_out.py @@ -20,6 +20,7 @@ SÃO PERMITIDOS: - Expor valores de planos, itens de planos Não marque DLEX_OUT para explicação pública de atendimento, protocolo, plano, fatura ou dados autorizados do próprio cliente. +Se o contexto indicar authorized_customer_protocol=true ou que o protocolo veio de expected_protocols do workflow atual, esse protocolo é explicitamente autorizado para divulgação ao próprio cliente e NÃO é vazamento. Responda apenas JSON: {{"allowed": true/false, "label": "DLEX_OUT/OK", "reason": "Explicação curta da razão"}} diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/__init__.cpython-313.pyc index eb1d9a31..d5a6bac6 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/supervision_template.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/supervision_template.cpython-313.pyc index 4d96758e..545c4152 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/supervision_template.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/supervision_template.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/tts_rules.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/tts_rules.cpython-313.pyc index bbf441d3..8708f30e 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/tts_rules.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/shared/__pycache__/tts_rules.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/__init__.cpython-313.pyc index 92b74c06..0862eef5 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/alcada.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/alcada.cpython-313.pyc index 96af157e..8cb1eafc 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/alcada.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/alcada.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/anatel.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/anatel.cpython-313.pyc index a6393b44..27b0d378 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/anatel.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/anatel.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/confirmation.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/confirmation.cpython-313.pyc index ec87619e..9af57931 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/confirmation.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/confirmation.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_in.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_in.cpython-313.pyc index b764e2a7..f8495a89 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_in.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_in.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_out.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_out.cpython-313.pyc index ca914133..0e971491 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_out.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/dlex_out.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/ragsec.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/ragsec.cpython-313.pyc index e3e74e40..0b9d0834 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/ragsec.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/ragsec.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/revprec.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/revprec.cpython-313.pyc index 02973ae3..8b49f3a7 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/revprec.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/revprec.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/tox.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/tox.cpython-313.pyc index ad81f20e..a7c7249d 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/tox.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/__pycache__/tox.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/__init__.cpython-313.pyc index 6d92edd7..6cc55089 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/correspondencia_item.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/correspondencia_item.cpython-313.pyc index 693e8a87..aeec1850 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/correspondencia_item.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/correspondencia_item.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/groundedness.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/groundedness.cpython-313.pyc index ab54fd4b..2a76c072 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/groundedness.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/groundedness.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/intencao_cancelar.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/intencao_cancelar.cpython-313.pyc index 4372f5e4..fefa284a 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/intencao_cancelar.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/intencao_cancelar.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/quantidade_coerente.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/quantidade_coerente.cpython-313.pyc index 8a5d4ee3..6c3b8f92 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/quantidade_coerente.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/quantidade_coerente.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/servico_correto.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/servico_correto.cpython-313.pyc index 8bdf382e..95255367 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/servico_correto.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/servico_correto.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/verbalizacao_prematura.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/verbalizacao_prematura.cpython-313.pyc index d2b7d24c..110ac503 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/verbalizacao_prematura.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rails/supervision/__pycache__/verbalizacao_prematura.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/__init__.cpython-313.pyc index c70f960c..63aabca0 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/alcada.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/alcada.cpython-313.pyc index d5664d67..a7ea241e 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/alcada.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/alcada.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/oos_blocklist.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/oos_blocklist.cpython-313.pyc index ef9f3f39..4f7fe94d 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/oos_blocklist.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/oos_blocklist.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/pinj_patterns.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/pinj_patterns.cpython-313.pyc index 0243e602..6860d9f4 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/pinj_patterns.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/pinj_patterns.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/tox_blocklist.cpython-313.pyc b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/tox_blocklist.cpython-313.pyc index 0fd52f7a..3e11b805 100644 Binary files a/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/tox_blocklist.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/guardrails/calibrated/rules/__pycache__/tox_blocklist.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/guardrails/rails.py b/libs/agent_framework/src/agent_framework/guardrails/rails.py index f8c70c44..187e5570 100644 --- a/libs/agent_framework/src/agent_framework/guardrails/rails.py +++ b/libs/agent_framework/src/agent_framework/guardrails/rails.py @@ -25,6 +25,7 @@ from .calibrated.output_sanitization import mascarar_pii_output, sanitizar_toxic from .calibrated.rules.pinj_patterns import _PINJ_PATTERNS, is_obvious_injection from .calibrated.rules.tox_blocklist import _EXPLICIT_TERMS, _THREAT_PATTERNS, is_obvious_toxic from .framework_llm_client import classify_with_framework_llm +from agent_framework.workflows.input_contract import has_meaningful_unmatched_policy, has_semantic_classifier def _lower(text: str) -> str: @@ -220,8 +221,6 @@ class OutputToxicitySanitizationRail(Guardrail): class OutOfScopeRail(Guardrail): - """OOS calibrado: classificador LLM para escopo de domínio de atendimento configurado.""" - code = "OOS" stage = "input" @@ -251,6 +250,79 @@ class CoherenceRail(Guardrail): async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: ctx = _ctx(context) + transaction_status = str(ctx.get("transaction_status") or "").strip().upper() + missing_parameters = [str(x) for x in (ctx.get("missing_parameters") or []) if str(x).strip()] + if transaction_status == "COLLECTING_PARAMETERS" and missing_parameters: + return RailDecision( + code=self.code, + allowed=True, + reason="Coerência delegada ao contrato de parâmetros da transação ativa", + sanitized_text=text, + metadata={ + "mechanism": "transaction_parameter_contract", + "calibrated": True, + "delegated": True, + "transaction_status": transaction_status, + "missing_parameters": missing_parameters, + }, + ) + expected_input = ctx.get("expected_input") + if isinstance(expected_input, dict) and expected_input.get("allowed_values"): + # Backward-compatible default: enumerated contracts without an + # explicit unmatched policy own coherence deterministically and + # reprompt every value outside allowed_values. + if has_semantic_classifier(expected_input): + return RailDecision( + code=self.code, + allowed=True, + reason="Coerência e semântica delegadas ao semantic_classifier do expected_input", + sanitized_text=text, + metadata={ + "mechanism": "expected_input_semantic_classifier", + "calibrated": True, + "delegated": True, + }, + ) + if not has_meaningful_unmatched_policy(expected_input): + return RailDecision( + code=self.code, + allowed=True, + reason="Coerência delegada ao contrato expected_input do workflow pausado", + sanitized_text=text, + metadata={ + "mechanism": "expected_input_contract", + "calibrated": True, + "delegated": True, + }, + ) + + # Opt-in semantic unmatched handling: COER still classifies the + # free-text reply, but does NOT block the graph. Its underlying + # signal is consumed by expected_input to choose reprompt vs the + # workflow-declared meaningful_input action. Other safety rails + # continue to execute and may block independently. + out = await classify_with_framework_llm( + _llm(ctx), "COER", {"text": text or "", "context": ctx}, + profile_name="guardrail", component_name="guardrail.coer", generation_name="guardrail.coer", + ) + semantic_coherent = bool(out.get("allowed", True)) + return RailDecision( + code=self.code, + allowed=True, + reason=( + "Entrada coerente; decisão delegada à política unmatched do expected_input" + if semantic_coherent + else "Entrada incoerente; decisão delegada ao reprompt do expected_input" + ), + sanitized_text=text, + metadata={ + "mechanism": "expected_input_contract", + "calibrated": True, + "delegated": True, + "semantic_coherent": semantic_coherent, + "data": out, + }, + ) out = await classify_with_framework_llm( _llm(ctx), "COER", {"text": text or "", "context": ctx}, profile_name="guardrail", component_name="guardrail.coer", generation_name="guardrail.coer", @@ -565,6 +637,49 @@ class DataLeakageInputRail(Guardrail): return RailDecision(code=self.code, allowed=bool(out.get("allowed", True)), reason=str(out.get("reason") or out.get("label") or "DLEX_IN avaliado"), sanitized_text=text, metadata={"mechanism": "llm_rail", "data": out, "calibrated": True}) +def _mask_authorized_protocol_values(value: Any, protocols: list[str]) -> Any: + """Mask only protocol values explicitly authorized for the current turn. + + This function is used only to build the DLEX_OUT classifier payload. It does + not mutate the runtime state or the user-visible response. Unrelated values + remain untouched and therefore continue to be evaluated normally by DLEX. + """ + + if isinstance(value, str): + masked = value + for protocol in protocols: + if protocol: + masked = masked.replace(protocol, "") + return masked + if isinstance(value, dict): + return {key: _mask_authorized_protocol_values(item, protocols) for key, item in value.items()} + if isinstance(value, list): + return [_mask_authorized_protocol_values(item, protocols) for item in value] + if isinstance(value, tuple): + return tuple(_mask_authorized_protocol_values(item, protocols) for item in value) + return value + + +def _dlex_block_may_be_authorized_protocol(out: dict[str, Any]) -> bool: + """Return True only when DLEX appears to object to the protocol itself. + + The recheck must not run for unrelated leakage (tokens, credentials, + prompts, third-party data, etc.), because those violations remain blocking. + """ + + reason = str(out.get("reason") or out.get("label") or "").lower() + protocol_terms = ("protocolo", "protocol", "identificador", "identifier") + unrelated_terms = ( + "token", "secret", "segredo", "api key", "api_key", "chave", + "senha", "password", "credencial", "credential", "prompt", + "instrução interna", "instrucoes internas", "instruções internas", + "terceiro", "third-party", "outro cliente", + ) + return any(term in reason for term in protocol_terms) and not any( + term in reason for term in unrelated_terms + ) + + class DataLeakageOutputRail(Guardrail): code = "DLEX_OUT" stage = "output" @@ -573,8 +688,110 @@ class DataLeakageOutputRail(Guardrail): ctx = _ctx(context) if not ctx.get("__guardrails_yaml_controlled") and not _truthy(os.getenv("GUARDRAIL_DLEX_OUT_ENABLED"), False): return RailDecision(code=self.code, allowed=True, metadata={"skipped": "covered_by_OOS_and_MSK", "calibrated": True}) - out = await classify_with_framework_llm(_llm(ctx), "DLEX_OUT", {"text": text or "", "context": ctx}, profile_name="grl", component_name="guardrail.dlex_out", generation_name="guardrail.dlex_out") - return RailDecision(code=self.code, allowed=bool(out.get("allowed", True)), reason=str(out.get("reason") or out.get("label") or "DLEX_OUT avaliado"), sanitized_text=text, metadata={"mechanism": "llm_rail", "data": out, "calibrated": True}) + + original_text = text or "" + expected_protocols = [ + str(value).strip() + for value in (ctx.get("expected_protocols") or []) + if str(value).strip() + ] + matched_expected_protocols = [ + protocol for protocol in expected_protocols if protocol in original_text + ] + + # Protocols explicitly produced/expected by the current workflow are + # authorized output values. Mask only those exact values before DLEX + # classification so that the LLM cannot mistake them for leaked internal + # identifiers. Any other number/identifier remains visible to DLEX. + classifier_text = original_text + classifier_ctx: dict[str, Any] = ctx + if matched_expected_protocols: + classifier_text = _mask_authorized_protocol_values( + original_text, matched_expected_protocols + ) + classifier_ctx = _mask_authorized_protocol_values( + ctx, matched_expected_protocols + ) + + out = await classify_with_framework_llm( + _llm(ctx), + "DLEX_OUT", + {"text": classifier_text, "context": classifier_ctx}, + profile_name="grl", + component_name="guardrail.dlex_out", + generation_name="guardrail.dlex_out", + ) + + # A workflow-generated protocol listed in ``expected_protocols`` is an + # explicitly authorized customer-facing value. Some LLM classifiers can + # still reject the neutral placeholder merely because the surrounding + # sentence contains the word "protocolo". When that happens, re-run the + # classifier with the exact authorized value replaced by plain public + # wording. This second pass preserves every other part of the response + # (tokens, credentials, third-party data, internal instructions, etc.), + # so unrelated leakage continues to be blocked. Only if the response is + # safe without the authorized identifier do we override the false + # positive from the first pass. + protocol_authorization_verified = False + protocol_recheck = None + if ( + matched_expected_protocols + and not bool(out.get("allowed", True)) + and _dlex_block_may_be_authorized_protocol(out) + ): + recheck_text = original_text + recheck_ctx: dict[str, Any] = ctx + for protocol in matched_expected_protocols: + recheck_text = recheck_text.replace( + protocol, "referência pública autorizada para este cliente" + ) + recheck_ctx = _mask_authorized_protocol_values( + ctx, matched_expected_protocols + ) + recheck_ctx = dict(recheck_ctx) + recheck_ctx["authorized_customer_protocol"] = True + recheck_ctx["authorization_rule"] = ( + "Protocolos presentes em expected_protocols foram produzidos " + "pelo workflow atual e são autorizados para divulgação ao próprio cliente." + ) + protocol_recheck = await classify_with_framework_llm( + _llm(ctx), + "DLEX_OUT", + {"text": recheck_text, "context": recheck_ctx}, + profile_name="grl", + component_name="guardrail.dlex_out.protocol_authorization_recheck", + generation_name="guardrail.dlex_out.protocol_authorization_recheck", + ) + if bool(protocol_recheck.get("allowed", True)): + out = { + "allowed": True, + "label": "OK", + "reason": "protocolo esperado pelo workflow explicitamente autorizado", + "protocol_recheck": protocol_recheck, + } + protocol_authorization_verified = True + + metadata = { + "mechanism": "llm_rail", + "data": out, + "calibrated": True, + } + if matched_expected_protocols: + metadata.update( + { + "protocol_authorization": "expected_values", + "authorized_protocols_masked": len(matched_expected_protocols), + "protocol_authorization_verified": protocol_authorization_verified, + "protocol_recheck": protocol_recheck, + } + ) + return RailDecision( + code=self.code, + allowed=bool(out.get("allowed", True)), + reason=str(out.get("reason") or out.get("label") or "DLEX_OUT avaliado"), + sanitized_text=text, + metadata=metadata, + ) class RetrievalRelevanceRail(Guardrail): diff --git a/libs/agent_framework/src/agent_framework/identity/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/identity/__pycache__/__init__.cpython-313.pyc index 8523e2fa..fdf2a693 100644 Binary files a/libs/agent_framework/src/agent_framework/identity/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/identity/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc b/libs/agent_framework/src/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc index 4965994c..2a8d295b 100644 Binary files a/libs/agent_framework/src/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/identity/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/identity/__pycache__/models.cpython-313.pyc index ce757d7c..6549426d 100644 Binary files a/libs/agent_framework/src/agent_framework/identity/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/identity/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/identity/__pycache__/resolver.cpython-313.pyc b/libs/agent_framework/src/agent_framework/identity/__pycache__/resolver.cpython-313.pyc index 2a481a84..11567c71 100644 Binary files a/libs/agent_framework/src/agent_framework/identity/__pycache__/resolver.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/identity/__pycache__/resolver.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/__pycache__/__init__.cpython-313.pyc index 65d88711..106c8e5a 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/__pycache__/judge.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/__pycache__/judge.cpython-313.pyc index 22b3d707..b282e70d 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/__pycache__/judge.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/__pycache__/judge.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/__init__.cpython-313.pyc index 7e6b5e89..4a8a15fe 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/_compat.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/_compat.cpython-313.pyc index 24ca7be0..80394059 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/_compat.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/_compat.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/llm_client.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/llm_client.cpython-313.pyc index 03efe846..95f8d640 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/llm_client.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/llm_client.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/models.cpython-313.pyc index ca311053..198f6bfb 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/__init__.cpython-313.pyc index 2c6e05da..0bee7235 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/aluc.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/aluc.cpython-313.pyc index 7eeb1422..e3638cff 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/aluc.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/aluc.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/csi.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/csi.cpython-313.pyc index 7dd5a209..fd40f937 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/csi.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/csi.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/fallback.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/fallback.cpython-313.pyc index 95a4f911..f28f3018 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/fallback.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/fallback.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/rqlt.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/rqlt.cpython-313.pyc index 0ca27c9c..23858e37 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/rqlt.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/rqlt.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/vctn.cpython-313.pyc b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/vctn.cpython-313.pyc index c35712e6..50ef1f1f 100644 Binary files a/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/vctn.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/judges/calibrated/prompts/__pycache__/vctn.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/__init__.cpython-313.pyc index c0dafc30..cc9fa8c1 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/base.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/base.cpython-313.pyc index ae721c45..aaf2b9db 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/base.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/base.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/profile_resolver.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/profile_resolver.cpython-313.pyc index 6fe3ec35..49f54605 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/profile_resolver.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/profile_resolver.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/providers.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/providers.cpython-313.pyc index 72a3a3f5..fe57b103 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/providers.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/providers.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/structured_output.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/structured_output.cpython-313.pyc index 9664a079..f72d54c3 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/structured_output.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/structured_output.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/types.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/types.cpython-313.pyc index 50a78169..26337df7 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/types.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/types.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc index b683e477..0e1e45f1 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/client.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/client.cpython-313.pyc index cc89f5e4..6ef32f0a 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/client.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/client.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/models.cpython-313.pyc index 0f9e4af8..5f455f0d 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/registry.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/registry.cpython-313.pyc index 62cf5815..653ae2f9 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/registry.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/registry.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc index 41f361c4..d2f75101 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc index a2184ab2..bd2b61db 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/__init__.cpython-313.pyc index 1455ef5f..93dff6cd 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_extractor.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_extractor.cpython-313.pyc index bf72c59f..04a09115 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_extractor.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_extractor.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_memory.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_memory.cpython-313.pyc index 79c43f7c..2bcc27cc 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_memory.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_memory.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_models.cpython-313.pyc index 4cade824..e047e208 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_store.cpython-313.pyc index bcdd6942..737f434b 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/long_term_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/message_history.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/message_history.cpython-313.pyc index 34726766..e53b148a 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/message_history.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/message_history.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc index 837ee366..ee594a60 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc index 37d834a6..bc025fb3 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/models/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/models/__pycache__/__init__.cpython-313.pyc index 58daae4e..c47826bc 100644 Binary files a/libs/agent_framework/src/agent_framework/models/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/models/__pycache__/identity.cpython-313.pyc b/libs/agent_framework/src/agent_framework/models/__pycache__/identity.cpython-313.pyc index 806a5437..2867784c 100644 Binary files a/libs/agent_framework/src/agent_framework/models/__pycache__/identity.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/models/__pycache__/identity.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/models/__pycache__/session.cpython-313.pyc b/libs/agent_framework/src/agent_framework/models/__pycache__/session.cpython-313.pyc index 4a12ed17..83ef853f 100644 Binary files a/libs/agent_framework/src/agent_framework/models/__pycache__/session.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/models/__pycache__/session.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/__init__.cpython-313.pyc index 1f1d73f0..742f9a87 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/code_mapper.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/code_mapper.cpython-313.pyc index 8e925166..3b6093d4 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/code_mapper.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/code_mapper.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/context.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/context.cpython-313.pyc index 7a83068a..3ac77c01 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/context.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/context.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/control_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/control_events.cpython-313.pyc index 3e8308d7..5bb1e55a 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/control_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/control_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/decorators.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/decorators.cpython-313.pyc index 3e59d703..0566f5fa 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/decorators.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/decorators.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/event_bus.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/event_bus.cpython-313.pyc index 45b240ca..f82b25b6 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/event_bus.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/event_bus.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/grl_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/grl_events.cpython-313.pyc index a7c7b457..278d210e 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/grl_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/grl_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/guardrail_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/guardrail_events.cpython-313.pyc index 5ca30979..a29bf67f 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/guardrail_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/guardrail_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/ic_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/ic_events.cpython-313.pyc index f469a9d6..ca9dccdb 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/ic_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/ic_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/informational_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/informational_events.cpython-313.pyc index bcdbacf7..b22ea7b0 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/informational_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/informational_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/judge_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/judge_events.cpython-313.pyc index 36dc5768..036e5e9a 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/judge_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/judge_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/langfuse_enterprise.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/langfuse_enterprise.cpython-313.pyc index 1a3b13ed..447bf413 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/langfuse_enterprise.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/langfuse_enterprise.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/langgraph_telemetry.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/langgraph_telemetry.cpython-313.pyc index fe0bd9c2..5604b3ac 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/langgraph_telemetry.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/langgraph_telemetry.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/llm_advisors.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/llm_advisors.cpython-313.pyc index fce47995..bb8ff14f 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/llm_advisors.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/llm_advisors.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_contract.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_contract.cpython-313.pyc index fd18fc0d..7e937fe9 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_contract.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_contract.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_events.cpython-313.pyc index 8de88ae5..6f66900d 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_otel.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_otel.cpython-313.pyc index ccb1323e..6ac24ada 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_otel.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/noc_otel.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/observer.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/observer.cpython-313.pyc index a3401174..60edf473 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/observer.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/observer.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/otel.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/otel.cpython-313.pyc index 185c1fda..5809ce84 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/otel.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/otel.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/streaming_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/streaming_events.cpython-313.pyc index 5a623de4..9be96197 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/streaming_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/streaming_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/streaming_exporter.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/streaming_exporter.cpython-313.pyc index 5fcb1d7a..b40b78b2 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/streaming_exporter.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/streaming_exporter.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/telemetry.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/telemetry.cpython-313.pyc index 95e89493..d024ff2a 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/telemetry.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/telemetry.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/tim_backoffice_contract.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/tim_backoffice_contract.cpython-313.pyc index 34add0f4..42851b9e 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/tim_backoffice_contract.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/tim_backoffice_contract.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/token_cost.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/token_cost.cpython-313.pyc index 42a053ea..ff48d4e2 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/token_cost.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/token_cost.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/observability/__pycache__/workflow_events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/observability/__pycache__/workflow_events.cpython-313.pyc index 6bc687d2..d65da67f 100644 Binary files a/libs/agent_framework/src/agent_framework/observability/__pycache__/workflow_events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/observability/__pycache__/workflow_events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/oci/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/oci/__pycache__/__init__.cpython-313.pyc index cf48e749..46ad29eb 100644 Binary files a/libs/agent_framework/src/agent_framework/oci/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/oci/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/oci/__pycache__/auth.cpython-313.pyc b/libs/agent_framework/src/agent_framework/oci/__pycache__/auth.cpython-313.pyc index d30f51a6..4695d5d5 100644 Binary files a/libs/agent_framework/src/agent_framework/oci/__pycache__/auth.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/oci/__pycache__/auth.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc index 7b26f45c..e6747568 100644 Binary files a/libs/agent_framework/src/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/persistence/__pycache__/mongodb_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/persistence/__pycache__/mongodb_store.cpython-313.pyc index 1f4357f0..6d9acc93 100644 Binary files a/libs/agent_framework/src/agent_framework/persistence/__pycache__/mongodb_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/persistence/__pycache__/mongodb_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/persistence/__pycache__/oracle_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/persistence/__pycache__/oracle_store.cpython-313.pyc index b5e162ee..d6bb4fbe 100644 Binary files a/libs/agent_framework/src/agent_framework/persistence/__pycache__/oracle_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/persistence/__pycache__/oracle_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc index 63c06a1f..aace1fa0 100644 Binary files a/libs/agent_framework/src/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/presentation/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/presentation/__pycache__/__init__.cpython-313.pyc index 99dd036f..b3829488 100644 Binary files a/libs/agent_framework/src/agent_framework/presentation/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/presentation/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/presentation/__pycache__/renderers.cpython-313.pyc b/libs/agent_framework/src/agent_framework/presentation/__pycache__/renderers.cpython-313.pyc index b6895c74..35d97c82 100644 Binary files a/libs/agent_framework/src/agent_framework/presentation/__pycache__/renderers.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/presentation/__pycache__/renderers.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/rag/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/rag/__pycache__/__init__.cpython-313.pyc index 4a1a73df..78ad4c36 100644 Binary files a/libs/agent_framework/src/agent_framework/rag/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/rag/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/rag/__pycache__/embedding_provider.cpython-313.pyc b/libs/agent_framework/src/agent_framework/rag/__pycache__/embedding_provider.cpython-313.pyc index 962fa827..28993120 100644 Binary files a/libs/agent_framework/src/agent_framework/rag/__pycache__/embedding_provider.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/rag/__pycache__/embedding_provider.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/rag/__pycache__/graph_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/rag/__pycache__/graph_store.cpython-313.pyc index 5c31d32b..c7569447 100644 Binary files a/libs/agent_framework/src/agent_framework/rag/__pycache__/graph_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/rag/__pycache__/graph_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/rag/__pycache__/ingest.cpython-313.pyc b/libs/agent_framework/src/agent_framework/rag/__pycache__/ingest.cpython-313.pyc index ee7273b4..ca931131 100644 Binary files a/libs/agent_framework/src/agent_framework/rag/__pycache__/ingest.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/rag/__pycache__/ingest.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/rag/__pycache__/kbdb_service.cpython-313.pyc b/libs/agent_framework/src/agent_framework/rag/__pycache__/kbdb_service.cpython-313.pyc index bb2200e0..9db7b40e 100644 Binary files a/libs/agent_framework/src/agent_framework/rag/__pycache__/kbdb_service.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/rag/__pycache__/kbdb_service.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/rag/__pycache__/rag_service.cpython-313.pyc b/libs/agent_framework/src/agent_framework/rag/__pycache__/rag_service.cpython-313.pyc index 2fbeaed2..fce5015e 100644 Binary files a/libs/agent_framework/src/agent_framework/rag/__pycache__/rag_service.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/rag/__pycache__/rag_service.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/rag/__pycache__/vector_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/rag/__pycache__/vector_store.cpython-313.pyc index c7a1f003..811c5988 100644 Binary files a/libs/agent_framework/src/agent_framework/rag/__pycache__/vector_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/rag/__pycache__/vector_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/repositories/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/repositories/__pycache__/__init__.cpython-313.pyc index f3a89d5b..27bf2a50 100644 Binary files a/libs/agent_framework/src/agent_framework/repositories/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/repositories/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/repositories/__pycache__/session_repository.cpython-313.pyc b/libs/agent_framework/src/agent_framework/repositories/__pycache__/session_repository.cpython-313.pyc index b11c48d2..eeb85f87 100644 Binary files a/libs/agent_framework/src/agent_framework/repositories/__pycache__/session_repository.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/repositories/__pycache__/session_repository.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/__init__.cpython-313.pyc index be1d14f2..38db495a 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc index 417d540b..e0ac9a0e 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/continuity.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/continuity.cpython-313.pyc index 0f8e9ff6..413590b4 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/continuity.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/continuity.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc index 34028e52..fab4d4f0 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/models.cpython-313.pyc index dcd5ec72..0d134a42 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/continuity.py b/libs/agent_framework/src/agent_framework/routing/continuity.py index 84125b24..34d1cd6e 100644 --- a/libs/agent_framework/src/agent_framework/routing/continuity.py +++ b/libs/agent_framework/src/agent_framework/routing/continuity.py @@ -44,6 +44,103 @@ class SemanticRouteContinuity: 1, int(getattr(settings, "ROUTE_STICKINESS_HISTORY_TURNS", 2)) ) + async def evaluate_global_control( + self, + state: dict[str, Any], + *, + intents: list[IntentDefinition], + allowed_controls: set[str] | None = None, + ) -> RouteDecision | None: + """Classify only global conversation controls before local workflow ownership. + + This probe exists for turns that did not satisfy a paused workflow's + deterministic ``expected_input`` contract. It deliberately returns only + explicitly allowed global controls (currently HUMAN_HANDOFF and/or + END_SESSION) and ignores CONTINUE/ROUTE, so ordinary workflow answers are + still resolved by the workflow's own semantic classifier. + + No linguistic keyword/regex rules are introduced here; the existing + route-continuity semantic classifier remains the single semantic source. + """ + controls = {str(x).strip().upper() for x in (allowed_controls or {"HUMAN_HANDOFF"})} + controls &= {"HUMAN_HANDOFF", "END_SESSION"} + if not controls or not self.enabled or self.llm is None: + return None + + active_agent = str(state.get("active_agent") or "").strip() + enabled_intents = [intent for intent in intents if intent.enabled] + known_agents = {intent.agent for intent in enabled_intents} + if active_agent and active_agent not in known_agents: + active_agent = "" + + text = str(state.get("sanitized_input") or state.get("user_text") or "").strip() + if not text: + return None + + try: + evaluation = await self._classify( + state, + text=text, + active_agent=active_agent, + intents=enabled_intents, + ) + except Exception as exc: + logger.warning("Global session-control probe failed: %s", exc) + return None + + accepted = evaluation.confidence >= self.confidence_threshold + await self._emit( + state, + { + "decision": evaluation.decision, + "confidence": evaluation.confidence, + "reason": evaluation.reason, + "active_agent": active_agent, + "route_bypassed": accepted and evaluation.decision in controls, + "profile_name": self.profile_name, + "global_control_probe": True, + "allowed_controls": sorted(controls), + }, + ) + if not accepted or evaluation.decision not in controls: + return None + + if evaluation.decision == "HUMAN_HANDOFF": + return RouteDecision( + route="human_handoff", + agent="human_handoff", + intent="human_handoff", + confidence=evaluation.confidence, + reason=evaluation.reason or "O usuário solicitou atendimento humano.", + method="continuity", + handoff=True, + metadata={ + "route_bypassed": True, + "continuity_decision": evaluation.decision, + "continuity_profile": self.profile_name, + "session_control": "HUMAN_HANDOFF", + "global_control_preempted_workflow": True, + "raw_llm_answer": evaluation.raw[:1000], + }, + ) + + return RouteDecision( + route="end_session", + agent="end_session", + intent="end_session", + confidence=evaluation.confidence, + reason=evaluation.reason or "O usuário solicitou o encerramento do atendimento.", + method="continuity", + metadata={ + "route_bypassed": True, + "continuity_decision": evaluation.decision, + "continuity_profile": self.profile_name, + "session_control": "END_SESSION", + "global_control_preempted_workflow": True, + "raw_llm_answer": evaluation.raw[:1000], + }, + ) + async def evaluate( self, state: dict[str, Any], diff --git a/libs/agent_framework/src/agent_framework/routing/enterprise_router.py b/libs/agent_framework/src/agent_framework/routing/enterprise_router.py index 06a92f8f..8f3758e6 100644 --- a/libs/agent_framework/src/agent_framework/routing/enterprise_router.py +++ b/libs/agent_framework/src/agent_framework/routing/enterprise_router.py @@ -11,7 +11,14 @@ from .continuity import SemanticRouteContinuity from .models import IntentDefinition, RouteDecision, RouterStatePolicy from agent_framework.llm.structured_output import parse_json_object from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation -from agent_framework.workflows.input_contract import match_expected_input +from agent_framework.workflows.input_contract import ( + expected_input_reprompt, + has_semantic_classifier, + match_expected_input, + match_semantic_classifier_output, + meaningful_unmatched_resume_value, + semantic_coherence_from_guardrails, +) logger = logging.getLogger("agent_framework.routing") @@ -39,6 +46,7 @@ class EnterpriseRouter: self.defaults = load_router_defaults(self.config_path) self.fallback_agent = self.defaults.get("fallback_agent", "billing_agent") self.intent_shift_threshold = float(self.defaults.get("confidence_threshold", 0.7)) + self.transaction_confirmation = dict(self.defaults.get("transaction_confirmation") or {}) self.enable_llm_router = bool(getattr(settings, "ENABLE_LLM_ROUTER", False)) self.continuity = SemanticRouteContinuity(settings, llm, telemetry) logger.info( @@ -55,11 +63,327 @@ class EnterpriseRouter: self.continuity.confidence_threshold, ) + @staticmethod + def _history_message_intent(item: dict[str, Any]) -> str: + metadata = item.get("metadata") if isinstance(item, dict) else {} + metadata = metadata if isinstance(metadata, dict) else {} + direct = str(metadata.get("intent") or "").strip() + if direct: + return direct + decision = metadata.get("route_decision") + if isinstance(decision, dict): + return str(decision.get("intent") or "").strip() + return "" + + @classmethod + def _collect_relevant_conversation_context( + cls, + *, + state: dict[str, Any], + pending_workflow: dict[str, Any], + current_text: str, + ) -> str: + """Return the contiguous conversational suffix relevant to the paused workflow. + + The preferred anchor is the user turn that produced the current PAUSED + workflow state. From there we keep the contiguous conversation through the + immediately preceding assistant prompt. For legacy checkpoints without an + anchor id, we walk backwards and stop at the first assistant turn whose + recorded intent differs from the workflow owner intent. Transaction state, + snapshots and tool evidence are deliberately not injected here: this context + is only for understanding unresolved conversational requests, never for + treating user claims as business evidence. + """ + history = [x for x in (state.get("history") or []) if isinstance(x, dict)] + if history: + last = history[-1] + if ( + str(last.get("role") or "") == "user" + and str(last.get("content") or "").strip() == str(current_text or "").strip() + ): + history = history[:-1] + if not history: + return "" + + # Preferred boundary: the exact user message that produced the current + # pause. This is refreshed on every PAUSED result, so a new decision does + # not inherit unrelated older requests, even when they share the same + # route/intent. + anchor_message_id = str(pending_workflow.get("context_anchor_message_id") or "").strip() + if anchor_message_id: + for index, item in enumerate(history): + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + if str(metadata.get("message_id") or "").strip() == anchor_message_id: + history = history[index:] + break + + target_intent = str( + pending_workflow.get("owner_intent") + or (state.get("route_decision") or {}).get("intent") + or state.get("intent") + or "" + ).strip() + + selected: list[dict[str, Any]] = [] + anchor_seen = False + for item in reversed(history): + role = str(item.get("role") or "").strip().lower() + content = str(item.get("content") or "").strip() + if not content: + continue + + if role == "assistant": + item_intent = cls._history_message_intent(item) + if anchor_seen and target_intent and item_intent and item_intent != target_intent: + break + anchor_seen = True + + # Ignore everything before the first assistant anchor. This keeps a + # malformed/incomplete history from pulling unrelated old user turns. + if anchor_seen: + selected.append(item) + + selected.reverse() + rendered = [] + for item in selected: + role = str(item.get("role") or "unknown").strip().lower() + content = str(item.get("content") or "").strip() + rendered.append(f"{role}: {content}") + return "\n".join(rendered) + + @staticmethod + def _collect_transaction_parameter_context( + *, state: dict[str, Any], current_text: str, max_messages: int = 6 + ) -> str: + """Render a bounded recent history only to resolve parameter references. + + This context is deliberately non-authoritative. It may help the extractor + resolve references such as "a de 14,99" to an entity named in the recent + assistant/tool-grounded conversation, but business pre-validation remains + responsible for proving the candidate before confirmation/execution. + """ + history = [item for item in (state.get("history") or []) if isinstance(item, dict)] + if history: + last = history[-1] + if ( + str(last.get("role") or "").strip().lower() == "user" + and str(last.get("content") or "").strip() == str(current_text or "").strip() + ): + history = history[:-1] + selected = history[-max(1, int(max_messages or 1)):] + rendered: list[str] = [] + for item in selected: + role = str(item.get("role") or "unknown").strip().lower() + content = str(item.get("content") or "").strip() + if content: + rendered.append(f"{role}: {content}") + return "\n".join(rendered) + + async def _classify_expected_input_semantically( + self, + *, + text: str, + expected_input: dict[str, Any], + pause_prompt: str, + relevant_conversation_context: str = "", + profile_name: str = "router", + component_name: str = "workflow.expected_input", + generation_name: str = "workflow.expected_input.semantic_classifier", + ) -> tuple[str | None, str | None]: + """Run an agent-defined classifier and constrain its output to allowed_values. + + The framework does not know what any option means. It only renders the + workflow prompt, invokes the configured LLM and rejects every value not + declared in ``allowed_values``. + """ + if not has_semantic_classifier(expected_input) or self.llm is None: + return None, None + classifier = expected_input.get("semantic_classifier") or {} + allowed = [str(x) for x in (expected_input.get("allowed_values") or [])] + prompt = str(classifier.get("prompt") or "") + rendered = ( + prompt.replace("{{ allowed_values }}", json.dumps(allowed, ensure_ascii=False)) + .replace("{{ pending_prompt }}", str(pause_prompt or "")) + .replace("{{ relevant_conversation_context }}", str(relevant_conversation_context or "")) + .replace("{{ user_input }}", str(text or "")) + ) + protocol = ( + "\n\nPROTOCOLO OBRIGATÓRIO DO FRAMEWORK: responda somente com UMA das " + f"opções permitidas, sem explicação adicional: {json.dumps(allowed, ensure_ascii=False)}." + ) + try: + answer = await self.llm.ainvoke( + [ + {"role": "system", "content": rendered + protocol}, + {"role": "user", "content": str(text or "")}, + ], + profile_name=profile_name, + component_name=component_name, + generation_name=generation_name, + ) + except Exception as exc: + logger.warning("Falha no semantic_classifier do expected_input: %s", exc) + return None, None + raw = str(answer or "").strip() + matched = match_semantic_classifier_output(raw, expected_input) + if matched is not None: + return matched, raw + # Tolerate a tiny structured wrapper while still validating its value. + try: + data = parse_json_object(raw) + except Exception: + data = {} + for key in ("value", "option", "choice", "classification", "result"): + if key in data: + matched = match_semantic_classifier_output(str(data.get(key) or ""), expected_input) + if matched is not None: + return matched, raw + return None, raw + + @staticmethod + def _last_assistant_prompt(state: dict[str, Any], current_text: str) -> str: + history = [item for item in (state.get("history") or []) if isinstance(item, dict)] + if history and str(history[-1].get("role") or "").lower() == "user" and str(history[-1].get("content") or "").strip() == str(current_text or "").strip(): + history = history[:-1] + for item in reversed(history): + if str(item.get("role") or "").strip().lower() == "assistant": + content = str(item.get("content") or "").strip() + if content: + return content + return "" + + async def _classify_transaction_confirmation_semantically( + self, *, state: dict[str, Any], text: str + ) -> tuple[str | None, str | None, str]: + """Classify a non-literal confirmation using the existing workflow semantic engine. + + The deterministic parser remains authoritative for explicit yes/no. This + fallback is only reached when that parser returns ``None``. Configuration + is declarative under ``router.transaction_confirmation`` in routing.yaml. + """ + cfg = self.transaction_confirmation if isinstance(self.transaction_confirmation, dict) else {} + semantic = cfg.get("semantic_fallback") if isinstance(cfg.get("semantic_fallback"), dict) else {} + if not bool(semantic.get("enabled", False)) or self.llm is None: + return None, None, "" + + allowed = [str(x) for x in (semantic.get("allowed_values") or ["SIM", "NAO", "CONTINUAR"])] + prompt = str(semantic.get("prompt") or "").strip() + if not prompt: + return None, None, "" + expected_input = { + "allowed_values": allowed, + "semantic_classifier": { + "enabled": True, + "include_relevant_context": bool(semantic.get("include_relevant_context", True)), + "prompt": prompt, + }, + } + relevant_context = "" + if bool(semantic.get("include_relevant_context", True)): + previous = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {} + synthetic_pending = { + "owner_intent": str(previous.get("intent") or state.get("intent") or "").strip(), + "context_anchor_message_id": str((state.get("active_transaction") or {}).get("context_anchor_message_id") or "").strip() if isinstance(state.get("active_transaction"), dict) else "", + } + relevant_context = self._collect_relevant_conversation_context( + state=state, pending_workflow=synthetic_pending, current_text=str(text) + ) + pending_prompt = self._last_assistant_prompt(state, str(text)) + classified, raw = await self._classify_expected_input_semantically( + text=str(text), + expected_input=expected_input, + pause_prompt=pending_prompt, + relevant_conversation_context=relevant_context, + profile_name=str(semantic.get("profile_name") or "router"), + component_name="transaction.confirmation", + generation_name="transaction.confirmation.semantic_classifier", + ) + return classified, raw, relevant_context + + async def _route_contextual_reentry( + self, + *, + state: dict[str, Any], + original_input: str, + relevant_context: str, + classifier_output: str, + raw_classifier: str | None, + allowed_values: list[Any], + ) -> RouteDecision: + """Re-enter normal routing using bounded conversational context. + + This is deliberately a routing aid, not business evidence. The original + utterance remains available separately for audit, while the effective + text is used only to understand the unresolved request and extract + candidate transaction parameters that must still pass normal validation + and confirmation policies. + """ + contextual_input = ( + "CONTEXTO DA SOLICITAÇÃO IMEDIATAMENTE ANTERIOR:\n" + f"{str(relevant_context or '').strip()}\n\n" + "CONTINUAÇÃO ATUAL DO CLIENTE:\n" + f"{str(original_input or '').strip()}" + ).strip() + + reentry_state = dict(state) + reentry_state["pending_domain_workflow"] = None + reentry_state["transaction_status"] = None + + # Contextual reentry is semantically richer than substring matching. + # Prefer the configured LLM router when available; deterministic routing + # remains the fallback for deployments that disable semantic routing. + if self.enable_llm_router and self.llm is not None: + try: + decision = await self._route_by_llm(contextual_input, reentry_state) + except Exception as exc: + logger.exception("Falha no roteamento LLM durante reentrada contextual; usando fallback: %s", exc) + decision = self._route_by_keyword(contextual_input) or RouteDecision( + route=self.fallback_agent, + agent=self.fallback_agent, + intent="fallback", + confidence=0.1, + reason="Falha no classificador semântico durante reentrada contextual; usando fallback configurado.", + method="fallback", + metadata={"contextual_reentry_llm_failed": True}, + ) + else: + decision = self._route_by_keyword(contextual_input) or RouteDecision( + route=self.fallback_agent, + agent=self.fallback_agent, + intent="fallback", + confidence=0.3, + reason="Fallback após reentrada contextual.", + method="fallback", + ) + + decision.metadata = { + **dict(decision.metadata or {}), + "contextual_reentry": True, + "contextual_reentry_input": contextual_input, + "original_input": str(original_input or ""), + "classifier_output": classifier_output, + "classifier_raw_output": raw_classifier, + "allowed_values": list(allowed_values or []), + "relevant_conversation_context": str(relevant_context or ""), + "user_claims_are_evidence": False, + "previous_workflow_cancel_reason": "contextual_reentry", + } + return decision + async def route(self, state: dict[str, Any]) -> RouteDecision: session = (state.get("context") or {}).get("session", {}) or {} explicit_next_state = state.get("next_state") tx_status_at_route = str(state.get("transaction_status") or "").strip().upper() terminal_tx = tx_status_at_route in {"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"} + operational_context_reset = bool(state.get("operational_context_reset")) + if terminal_tx: + # Same conversation/session, new interaction: a terminal workflow may + # remain in durable history, but it must not own the next turn. This is + # also a compatibility guard for checkpoints created before terminal + # workflow tombstones were persisted. + state["pending_domain_workflow"] = None + state["pending_tool_clarification"] = None + state["workflow_input_reprompt"] = None # Um status transacional terminal é a fonte de verdade sobre o latch. Se # um checkpoint legado/parcial ainda trouxer ``next_state`` da transação @@ -67,7 +391,9 @@ class EnterpriseRouter: # de estado. O workflow_state da sessão continua disponível porque pode # representar um workflow conversacional independente da transação já # encerrada. - if terminal_tx and explicit_next_state: + if operational_context_reset: + current_state = None + elif terminal_tx and explicit_next_state: current_state = session.get("metadata", {}).get("workflow_state") else: current_state = explicit_next_state or session.get("metadata", {}).get("workflow_state") @@ -117,6 +443,167 @@ class EnterpriseRouter: await self._emit(decision, state) return decision + # An explicit human-handoff request is a global conversation control, + # not an intent shift and not a value of the paused workflow contract. + # It must therefore preempt the workflow semantic classifier *after* + # deterministic expected_input matching (so "sim"/"não" keep their + # absolute contract precedence) but *before* unmatched semantic resume. + # CONTINUE/ROUTE/END_SESSION decisions from this probe are ignored here; + # the workflow remains authoritative for every non-handoff message. + global_control = await self.continuity.evaluate_global_control( + state, intents=self.intents, allowed_controls={"HUMAN_HANDOFF"} + ) + if global_control is not None: + global_control.metadata = { + **dict(global_control.metadata or {}), + "interrupted_workflow_name": pending_workflow.get("workflow_name"), + "interrupted_workflow_execution_id": pending_workflow.get("execution_id"), + "workflow_interruption": "human_handoff", + } + await self._emit(global_control, state) + return global_control + + # Enumerated contracts retain workflow ownership for unmatched + # replies. A workflow may explicitly opt in to semantic handling: + # coherent free text can be resumed as a workflow-declared value, + # while incoherent input still receives the declarative reprompt. + if isinstance(expected_input, dict) and expected_input.get("allowed_values"): + previous = state.get("route_decision") or {} + owner_agent = str( + pending_workflow.get("owner_agent") + or state.get("active_agent") + or previous.get("agent") + or state.get("route") + or self.fallback_agent + ).strip() + owner_intent = str( + pending_workflow.get("owner_intent") + or previous.get("intent") + or state.get("intent") + or f"workflow_resume:{pending_workflow.get('workflow_name') or 'paused'}" + ).strip() + raw_classifier = None + relevant_context = "" + + # Preferred path: the agent provides a prompt whose output must + # be one of the dynamic allowed_values. The framework adds no + # SIM/NAO or other domain semantics. + if has_semantic_classifier(expected_input): + classifier_cfg = expected_input.get("semantic_classifier") or {} + relevant_context = "" + if bool(classifier_cfg.get("include_relevant_context")): + relevant_context = self._collect_relevant_conversation_context( + state=state, + pending_workflow=pending_workflow, + current_text=str(text), + ) + classified, raw_classifier = await self._classify_expected_input_semantically( + text=str(text), + expected_input=expected_input, + pause_prompt=str(pause.get("prompt") or ""), + relevant_conversation_context=relevant_context, + ) + if classified is not None: + option_actions = classifier_cfg.get("option_actions") if isinstance(classifier_cfg, dict) else {} + option_actions = option_actions if isinstance(option_actions, dict) else {} + action_cfg = option_actions.get(str(classified)) or option_actions.get(str(classified).upper()) + action_cfg = action_cfg if isinstance(action_cfg, dict) else {} + if str(action_cfg.get("action") or "").strip().lower() == "contextual_reentry": + decision = await self._route_contextual_reentry( + state=state, + original_input=str(text), + relevant_context=relevant_context, + classifier_output=str(classified), + raw_classifier=raw_classifier, + allowed_values=list(expected_input.get("allowed_values") or []), + ) + await self._emit(decision, state) + return decision + + decision = RouteDecision( + route=owner_agent, + agent=owner_agent, + intent=owner_intent, + confidence=1.0, + reason="Entrada classificada pelo semantic_classifier do expected_input.", + method="state", + domain=previous.get("domain") or state.get("domain"), + mcp_tools=[str(pending_workflow.get("resume_tool") or "retomar_workflow")], + metadata={ + "route_bypassed": True, + "workflow_resume": True, + "workflow_semantic_classifier": True, + "workflow_name": pending_workflow.get("workflow_name"), + "workflow_execution_id": pending_workflow.get("execution_id"), + "normalized_input": classified, + "classifier_output": classified, + "classifier_raw_output": raw_classifier, + "allowed_values": list(expected_input.get("allowed_values") or []), + "original_input": str(text), + "relevant_conversation_context": relevant_context, + }, + ) + await self._emit(decision, state) + return decision + + # Legacy compatibility for workflows that still use the older + # coherent-unmatched -> resume_as contract. + semantic_coherent = semantic_coherence_from_guardrails(state) + resume_as = meaningful_unmatched_resume_value( + expected_input, + semantic_coherent=semantic_coherent, + ) + if resume_as is not None: + decision = RouteDecision( + route=owner_agent, + agent=owner_agent, + intent=owner_intent, + confidence=1.0, + reason="Entrada coerente fora das opções; aplicando política unmatched legada do workflow pausado.", + method="state", + domain=previous.get("domain") or state.get("domain"), + mcp_tools=[str(pending_workflow.get("resume_tool") or "retomar_workflow")], + metadata={ + "route_bypassed": True, + "workflow_resume": True, + "workflow_unmatched": True, + "workflow_unmatched_action": "resume_as", + "workflow_name": pending_workflow.get("workflow_name"), + "workflow_execution_id": pending_workflow.get("execution_id"), + "normalized_input": resume_as, + "original_input": str(text), + }, + ) + await self._emit(decision, state) + return decision + + decision = RouteDecision( + route=owner_agent, + agent=owner_agent, + intent=owner_intent, + confidence=1.0, + reason="Entrada inválida para o contrato expected_input do workflow pausado; mantendo posse do workflow.", + method="state", + domain=previous.get("domain") or state.get("domain"), + mcp_tools=[], + metadata={ + "route_bypassed": True, + "workflow_input_invalid": True, + "workflow_name": pending_workflow.get("workflow_name"), + "workflow_execution_id": pending_workflow.get("execution_id"), + "workflow_reprompt": expected_input_reprompt( + expected_input, pause_prompt=str(pause.get("prompt") or "") + ), + "workflow_semantic_classifier": bool(has_semantic_classifier(expected_input)), + "classifier_raw_output": raw_classifier if has_semantic_classifier(expected_input) else None, + "allowed_values": list(expected_input.get("allowed_values") or []), + "original_input": str(text), + "relevant_conversation_context": relevant_context if has_semantic_classifier(expected_input) else "", + }, + ) + await self._emit(decision, state) + return decision + # Estados transacionais preservam continuidade para respostas curtas # (parâmetros, "sim", "não"), mas NÃO podem aprisionar a sessão. Antes # de aplicar a política de estado, procuramos uma mudança explícita de @@ -137,15 +624,13 @@ class EnterpriseRouter: await self._emit(consumed, state) return consumed - # While collecting parameters, a turn may both contain a usable field - # value and express a new, incompatible goal. When semantic routing is - # available, classify CONTINUE vs SHIFT before extraction so a field - # value cannot shield a real intent change. Without semantic routing we - # preserve the previous conservative behavior (parameter first), because - # a broad configured keyword alone cannot reliably distinguish a new - # goal from a legitimate parameter utterance. - semantic_shift_available = bool(self.enable_llm_router and self.llm is not None) - if tx_status == "COLLECTING_PARAMETERS" and not semantic_shift_available: + # Transaction parameter precedence is absolute while collecting: + # first let the active transaction try to consume the current turn. + # Only when NO pending parameter can be extracted do we ask the + # semantic classifier whether the user changed goals. This prevents + # value/name/reference answers (for example "a de 14,99") from being + # stolen by a semantically plausible but incompatible intent. + if tx_status == "COLLECTING_PARAMETERS": consumed = await self._transaction_parameter_precedence( state, text=str(text), state_decision=state_decision ) @@ -160,14 +645,6 @@ class EnterpriseRouter: await self._emit(interruption, state) return interruption - if tx_status == "COLLECTING_PARAMETERS" and semantic_shift_available: - consumed = await self._transaction_parameter_precedence( - state, text=str(text), state_decision=state_decision - ) - if consumed is not None: - await self._emit(consumed, state) - return consumed - await self._emit(state_decision, state) return state_decision @@ -205,8 +682,7 @@ class EnterpriseRouter: await self._emit(consumed, state) return consumed - semantic_shift_available = bool(self.enable_llm_router and self.llm is not None) - if tx_status == "COLLECTING_PARAMETERS" and not semantic_shift_available: + if tx_status == "COLLECTING_PARAMETERS": consumed = await self._transaction_parameter_precedence( state, text=str(text), state_decision=synthetic ) @@ -229,18 +705,6 @@ class EnterpriseRouter: await self._emit(interruption, state) return interruption - if tx_status == "COLLECTING_PARAMETERS" and semantic_shift_available: - consumed = await self._transaction_parameter_precedence( - state, text=str(text), state_decision=synthetic - ) - if consumed is not None: - consumed.metadata = { - **(consumed.metadata or {}), - "transaction_state_recovered": True, - } - await self._emit(consumed, state) - return consumed - # A transação continua ativa e a mensagem NÃO representa mudança de # intenção. Neste caso a decisão sintética de estado precisa vencer # route stickiness/continuity. Antes, o código apenas verificava uma @@ -283,7 +747,7 @@ class EnterpriseRouter: # anterior não pode capturar uma nova mensagem depois de COMPLETED, # FAILED, CANCELLED, BLOCKED ou OUT_OF_SCOPE. Nesses casos a mensagem # volta ao roteamento normal (keyword/LLM/fallback). - if not terminal_tx: + if not terminal_tx and not operational_context_reset: decision = await self.continuity.evaluate(state, intents=self.intents) if decision: await self._emit(decision, state) @@ -321,25 +785,51 @@ class EnterpriseRouter: text: str, state_decision: RouteDecision, ) -> RouteDecision | None: - """Consume a turn as transaction input after shift precedence is resolved. + """Try to consume the turn under the active transaction contract first. AWAITING_CONFIRMATION consumes an explicit confirmation before any shift - classification. COLLECTING_PARAMETERS reaches this method only after the - router has established that the current turn does not represent an - incompatible intent-shift; then extracted values may continue the active - transaction deterministically. + classification. COLLECTING_PARAMETERS also has precedence: if at least one + pending parameter can be extracted, the active transaction keeps ownership + of the turn. Semantic intent-shift is evaluated only when extraction returns + no usable pending parameter. """ tx_status = str(state.get("transaction_status") or "").strip().upper() if tx_status == "AWAITING_CONFIRMATION": confirmation = parse_transaction_confirmation(text) + source = "deterministic" + classifier_output = None + raw_classifier = None + relevant_context = "" if confirmation is None: - return None + classified, raw_classifier, relevant_context = await self._classify_transaction_confirmation_semantically( + state=state, text=str(text) + ) + classifier_output = classified + semantic_cfg = self.transaction_confirmation.get("semantic_fallback") if isinstance(self.transaction_confirmation, dict) else {} + semantic_cfg = semantic_cfg if isinstance(semantic_cfg, dict) else {} + confirm_values = {str(x).strip().upper() for x in (semantic_cfg.get("confirm_values") or ["SIM"])} + reject_values = {str(x).strip().upper() for x in (semantic_cfg.get("reject_values") or ["NAO"])} + normalized = str(classified or "").strip().upper() + if normalized in confirm_values: + confirmation = "confirm" + source = "semantic" + elif normalized in reject_values: + confirmation = "reject" + source = "semantic" + else: + return None state_decision.metadata = { **(state_decision.metadata or {}), "transaction_turn_consumed": True, "transaction_confirmation_decision": confirmation, - "transaction_confirmation_source": "deterministic", + "transaction_confirmation_source": source, } + if source == "semantic": + state_decision.metadata.update({ + "transaction_confirmation_classifier_output": classifier_output, + "transaction_confirmation_classifier_raw_output": raw_classifier, + "relevant_conversation_context": relevant_context, + }) return state_decision if tx_status != "COLLECTING_PARAMETERS": return None @@ -353,6 +843,11 @@ class EnterpriseRouter: known = dict(active.get("arguments") or {}) schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else {} description = str(active.get("tool_description") or "") + conversational_context = str(active.get("parameter_conversational_context") or "").strip() + if not conversational_context: + conversational_context = self._collect_transaction_parameter_context( + state=state, current_text=text + ) values = await extract_transaction_parameters( self.llm, text=text, @@ -361,6 +856,7 @@ class EnterpriseRouter: known_arguments=known, parameter_schema=schema, tool_description=description, + conversational_context=conversational_context, ) if not values: return None @@ -453,8 +949,9 @@ class EnterpriseRouter: system = ( "Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " "Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " - "Se a mensagem responde ao dado/confirmacao pendente, retorne CONTINUE. " - "Se o usuário passou a perseguir outro objetivo, retorne SHIFT e a nova intent permitida. " + "A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " + "Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " + "Se o usuário passou claramente a perseguir outro objetivo, retorne SHIFT e a nova intent permitida. " "Retorne somente JSON válido com decision, intent, agent, confidence, reason." ) user = { @@ -705,7 +1202,7 @@ class EnterpriseRouter: user = { "message": text, "allowed_intents": allowed_payload, - "session_context": (state.get("context") or {}).get("session", {}), + "session_context": ({} if state.get("operational_context_reset") else (state.get("context") or {}).get("session", {})), "transaction_context": transaction_context, } answer = await self.llm.ainvoke( diff --git a/libs/agent_framework/src/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc index 6f41b8ea..d6ccfe1d 100644 Binary files a/libs/agent_framework/src/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc b/libs/agent_framework/src/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc index e1b46ea4..7f36409c 100644 Binary files a/libs/agent_framework/src/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc b/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc index 38063fd0..20a83fd9 100644 Binary files a/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc b/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc index 156855a4..319f01c9 100644 Binary files a/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py b/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py index d4b66414..5f62e2e6 100644 --- a/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py +++ b/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py @@ -801,13 +801,45 @@ class AgentRuntimeMixin: payload = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result eligible = payload.get("eligible") if isinstance(payload, dict) else None if eligible is True: + # Generic domain-decision contract. A validator may canonicalize + # transaction arguments and may also decide that the canonical entity + # belongs to another domain-owned action/tool. The framework does not + # interpret business classes; it only applies the declarative decision. + decision = payload.get("transaction_decision") if isinstance(payload, dict) else None + decision = decision if isinstance(decision, dict) else {} + resolved_arguments = decision.get("resolved_arguments") + resolved_arguments = resolved_arguments if isinstance(resolved_arguments, dict) else {} + requested_arguments = dict(arguments or {}) + for key, value in resolved_arguments.items(): + if value not in (None, "", [], {}): + arguments[str(key)] = value + + effective_tool = str(decision.get("target_tool") or tool_name).strip() or tool_name + action_changed = bool(decision.get("action_changed")) or effective_tool != tool_name + requires_reconfirmation = bool(decision.get("requires_reconfirmation")) + confirmation_message = str(decision.get("confirmation_message") or "").strip() + state["transaction_pre_validation"] = { - "tool_name": tool_name, "validator_tool": validator, "eligible": True, "result": result + "tool_name": tool_name, + "validator_tool": validator, + "eligible": True, + "result": result, + "requested_arguments": requested_arguments, + "resolved_arguments": dict(resolved_arguments), + "effective_tool_name": effective_tool, + "action_changed": action_changed, + "requires_reconfirmation": requires_reconfirmation, + "confirmation_message": confirmation_message or None, } if emit_events: await self._emit_ic( "IC.TRANSACTION_PREVALIDATION_PASSED", state, - {"tool_name": tool_name, "validator_tool": validator}, + { + "tool_name": tool_name, + "validator_tool": validator, + "effective_tool_name": effective_tool, + "action_changed": action_changed, + }, component="agent_runtime.tool_policy", ) return None @@ -815,6 +847,55 @@ class AgentRuntimeMixin: if transport_failed and bool(cfg.get("fail_open")): return None status = str((payload or {}).get("status") or ("PREVALIDATION_ERROR" if transport_failed else "OUT_OF_SCOPE")) + + # Generic recoverable validation contract. A domain validator may determine + # that one previously extracted parameter does not identify a valid entity + # and request that only this parameter be collected again. The framework + # does not know what the parameter means; it merely honors the declarative + # ``NEEDS_PARAMETER`` + ``parameter`` contract and preserves every other + # argument already collected in the transaction. + if status == "NEEDS_PARAMETER" and isinstance(payload, dict): + parameter = str(payload.get("parameter") or "").strip() + if parameter: + recovered_arguments = dict(arguments or {}) + recovered_arguments.pop(parameter, None) + recovered_policy = self._resolve_tool_execution_policy(tool_name, recovered_arguments) + missing = self._missing_required_arguments(recovered_policy, recovered_arguments) + if parameter not in missing: + missing = [parameter, *[name for name in missing if name != parameter]] + self._set_collecting_parameters( + state, + tool_name=tool_name, + arguments=recovered_arguments, + policy=recovered_policy, + missing=missing, + ) + state["transaction_pre_validation"] = { + "tool_name": tool_name, + "validator_tool": validator, + "eligible": False, + "status": status, + "parameter": parameter, + "terminal": False, + "result": result, + } + if emit_events: + await self._emit_ic( + "IC.TRANSACTION_PREVALIDATION_PARAMETER_REJECTED", + state, + {"tool_name": tool_name, "validator_tool": validator, "parameter": parameter}, + component="agent_runtime.tool_policy", + ) + enriched = dict(result or {}) + enriched.update({ + "pre_validation": True, + "target_tool": tool_name, + "collecting_parameters": True, + "missing_parameters": missing, + "transaction_status": "COLLECTING_PARAMETERS", + }) + return enriched + state["transaction_pre_validation"] = { "tool_name": tool_name, "validator_tool": validator, @@ -844,6 +925,33 @@ class AgentRuntimeMixin: enriched["transaction_status"] = "OUT_OF_SCOPE" return enriched + def _apply_prevalidated_transaction_decision( + self, + state: dict[str, Any], + *, + tool_name: str, + arguments: dict[str, Any], + policy: dict[str, Any], + ) -> tuple[str, dict[str, Any], bool]: + """Apply a generic domain decision produced by transaction pre-validation. + + The framework never derives domain semantics here. It only consumes the + validator contract: canonical arguments, effective target tool and whether + the resulting action needs explicit confirmation. + """ + pv = state.get("transaction_pre_validation") + pv = pv if isinstance(pv, dict) and pv.get("eligible") is True else {} + effective_tool = str(pv.get("effective_tool_name") or tool_name).strip() or tool_name + effective_policy = policy + if effective_tool != tool_name: + effective_policy = self._resolve_tool_execution_policy(effective_tool, arguments) + force_confirmation = bool(pv.get("requires_reconfirmation")) + if pv.get("confirmation_message"): + state["transaction_confirmation_message_override"] = str(pv.get("confirmation_message")) + else: + state.pop("transaction_confirmation_message_override", None) + return effective_tool, effective_policy, force_confirmation + def _validate_tool_execution_policy(self, tool_name: str, arguments: dict[str, Any]) -> tuple[bool, str | None]: """Aplica a mesma política central usada pelo MCPToolRouter.""" router = getattr(self, "tool_router", None) @@ -1363,11 +1471,24 @@ class AgentRuntimeMixin: """ route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} cached = route_meta.get("transaction_parameter_values") + allowed = set(str(x) for x in missing_parameters) + reused: dict[str, Any] = {} if isinstance(cached, dict): - allowed = set(str(x) for x in missing_parameters) reused = {str(k): v for k, v in cached.items() if str(k) in allowed and v not in _EMPTY_VALUES} - if reused: - return reused + + # Router-side extraction is an optimization, not an authoritative final + # extraction. If it only filled a subset of the pending contract, keep + # those candidates and continue extracting the remaining fields instead + # of returning early. This is especially important after contextual + # reentry, where a short follow-up may identify the entity while the + # bounded prior context carries an associated value that still requires + # domain pre-validation. + remaining_parameters = [ + str(name) for name in missing_parameters + if str(name) not in reused + ] + if not remaining_parameters: + return reused active = self._active_transaction(state) or {} schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else None @@ -1375,16 +1496,45 @@ class AgentRuntimeMixin: policy = self._resolve_tool_execution_policy(tool_name, known_arguments or {}) schema = self._transaction_parameter_schema(tool_name, policy) description = str(active.get("tool_description") or self._transaction_tool_description(tool_name) or "") - text = state.get("sanitized_input") or state.get("user_text") or "" - return await extract_transaction_parameters( + route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} + contextual_reentry = bool(route_meta.get("contextual_reentry")) + # In contextual reentry keep the current utterance separate from prior + # conversation. The prior context can resolve references, but remains + # non-authoritative and is never promoted to business evidence. + text = ( + route_meta.get("original_input") + if contextual_reentry + else None + ) or state.get("sanitized_input") or state.get("user_text") or "" + conversational_context = ( + route_meta.get("relevant_conversation_context") + if contextual_reentry + else None + ) + # Once a contextual reentry opens a transaction, preserve only its + # bounded conversational context as an interpretation aid for subsequent + # COLLECTING_PARAMETERS turns. It is explicitly non-authoritative: the + # domain pre-validation step must still prove every candidate against + # backend/MCP evidence before confirmation/execution. + if not str(conversational_context or "").strip(): + conversational_context = active.get("parameter_conversational_context") + if contextual_reentry and not str(conversational_context or "").strip(): + effective = str(route_meta.get("contextual_reentry_input") or "") + prefix = "CONTEXTO DA SOLICITAÇÃO IMEDIATAMENTE ANTERIOR:\n" + suffix = "\n\nCONTINUAÇÃO ATUAL DO CLIENTE:\n" + if prefix in effective and suffix in effective: + conversational_context = effective.split(prefix, 1)[1].split(suffix, 1)[0].strip() + extracted = await extract_transaction_parameters( getattr(self, "llm", None), text=str(text), tool_name=tool_name, - missing_parameters=list(missing_parameters or []), - known_arguments=known_arguments or {}, + missing_parameters=remaining_parameters, + known_arguments={**dict(known_arguments or {}), **reused}, parameter_schema=schema, tool_description=description, + conversational_context=str(conversational_context or ""), ) + return {**reused, **extracted} def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None: """Detecta solicitação transacional usando metadados de tools.yaml. @@ -1482,17 +1632,60 @@ class AgentRuntimeMixin: return descriptor @staticmethod - def _workflow_payload_from_tool_result(result: dict[str, Any]) -> dict[str, Any] | None: + def _workflow_declares_final_response(workflow: dict[str, Any]) -> bool: + """Return True when the workflow payload itself declares a final response. + + Some legacy/domain adapters can return ``status=PAUSED`` even after the + resumed workflow has reached a terminal node. The authoritative signal + for conversation lifecycle is the domain contract + ``workflow_response_final=true``. Treating that payload as still paused + would persist the old expected_input and resurrect it on the next turn. + """ + if not isinstance(workflow, dict): + return False + candidates: list[dict[str, Any]] = [] + output = workflow.get("output") + if isinstance(output, dict): + # Adapters may promote the final node payload directly to ``output``. + candidates.append(output) + state = workflow.get("state") if isinstance(workflow.get("state"), dict) else {} + current_node = str(state.get("current_node") or "").strip() + if current_node and isinstance(output.get(current_node), dict): + candidates.append(output[current_node]) + # Other adapters keep node outputs under ``nodes``/``vars``. + for key in ("nodes", "vars"): + node_map = state.get(key) if isinstance(state.get(key), dict) else {} + if current_node and isinstance(node_map.get(current_node), dict): + candidates.append(node_map[current_node]) + candidates.append(workflow) + return any(item.get("workflow_response_final") is True for item in candidates) + + @classmethod + def _workflow_payload_from_tool_result(cls, result: dict[str, Any]) -> dict[str, Any] | None: data = result.get("result") if isinstance(result, dict) else None if not isinstance(data, dict): return None # MCP HTTP envelope may contain another result layer. nested = data.get("result") + candidate = None if isinstance(nested, dict) and nested.get("status") in {"PAUSED", "COMPLETED", "FAILED"}: - return nested - if data.get("status") in {"PAUSED", "COMPLETED", "FAILED"}: - return data - return None + candidate = nested + elif data.get("status") in {"PAUSED", "COMPLETED", "FAILED"}: + candidate = data + if not isinstance(candidate, dict): + return None + + # Normalize a stale PAUSED status when the domain has explicitly declared + # that its final user response was produced. Work on a shallow copy so + # the raw MCP evidence remains untouched for telemetry/audit. + if candidate.get("status") == "PAUSED" and cls._workflow_declares_final_response(candidate): + candidate = dict(candidate) + candidate["status"] = "COMPLETED" + metadata = dict(candidate.get("metadata") or {}) + metadata["status_normalized_from"] = "PAUSED" + metadata["status_normalized_reason"] = "workflow_response_final" + candidate["metadata"] = metadata + return candidate def _capture_pending_domain_workflow(self, state: dict[str, Any], tool_result: dict[str, Any]) -> None: workflow = self._workflow_payload_from_tool_result(tool_result) @@ -1506,18 +1699,43 @@ class AgentRuntimeMixin: executed.append(workflow_name) state["business_workflows_executed"] = executed if workflow.get("status") != "PAUSED": - # Clearing must be materialized in the graph-state patch. ``pop``/absence - # is not enough with LangGraph state merging: an older latch can survive - # into the next turn and incorrectly resume a workflow that already - # completed. Only clear the currently owned execution (or an unlabeled - # legacy latch); never clear a different concurrently tracked workflow. + # A completed/failed workflow is a terminal interaction lifecycle, but + # NOT a terminal user session. Materialize explicit tombstones so an old + # LangGraph checkpoint cannot resurrect ``expected_input``/pause on the + # next message in the same session. Only clear the execution currently + # owned by this latch (or an unlabeled legacy latch). pending = state.get("pending_domain_workflow") pending_execution = (pending or {}).get("execution_id") if isinstance(pending, dict) else None workflow_execution = metadata.get("workflow_execution_id") or workflow.get("execution_id") - if not pending_execution or not workflow_execution or str(pending_execution) == str(workflow_execution): + owns_latch = ( + not pending_execution + or not workflow_execution + or str(pending_execution) == str(workflow_execution) + ) + if owns_latch: + terminal_status = "COMPLETED" if workflow.get("status") == "COMPLETED" else "FAILED" + # Close any operational transaction created to own the paused + # workflow before changing transaction_status to a terminal value. + if self._active_transaction(state): + self._finish_active_transaction(state, terminal_status, result=tool_result) + else: + state["active_transaction"] = None + state["selected_tool_call"] = {} + state["pending_tool_call"] = {} + state["missing_parameters"] = [] + state["confirmation_required"] = False + state["confirmation_received"] = terminal_status == "COMPLETED" + state["next_state"] = None + state["transaction_status"] = terminal_status state["pending_domain_workflow"] = None - if state.get("transaction_status") == "WORKFLOW_PAUSED": - state["transaction_status"] = None + state["pending_tool_clarification"] = None + state["workflow_input_reprompt"] = None + # Conversation session remains the same, but the completed + # workflow defines an operational-context boundary. The next + # user turn consumes this marker and starts with a clean + # short-term interaction context (history is still durable for + # audit/telemetry and long-term memory remains available). + state["operational_context_boundary_pending"] = True return state["pending_domain_workflow"] = { "workflow_name": metadata.get("workflow_name") or workflow.get("workflow_name"), @@ -1525,6 +1743,14 @@ class AgentRuntimeMixin: "resume_tool": metadata.get("resume_tool") or "retomar_workflow", "owner_agent": state.get("active_agent") or state.get("route"), "owner_intent": state.get("intent"), + # Anchor the conversational context to the user turn that produced + # this exact pause. On a later pause/resume cycle this value is + # refreshed, preventing old same-intent topics from leaking into the + # next expected_input decision. + "context_anchor_message_id": ( + (state.get("context") or {}).get("message_id") + or state.get("message_id") + ), "pause": self._workflow_pause_descriptor(workflow), } state["transaction_status"] = "WORKFLOW_PAUSED" @@ -1534,10 +1760,20 @@ class AgentRuntimeMixin: if not isinstance(pending, dict) or not pending.get("execution_id"): return None tool_name = str(pending.get("resume_tool") or "retomar_workflow") + route_metadata = (state.get("route_decision") or {}).get("metadata") or {} + routed_resume_value = ( + route_metadata.get("normalized_input") + if route_metadata.get("workflow_resume") + else None + ) arguments = { "workflow_name": pending.get("workflow_name"), "execution_id": pending.get("execution_id"), - "resposta_usuario": self._workflow_resume_decision(text, pending), + "resposta_usuario": ( + str(routed_resume_value) + if routed_resume_value is not None + else self._workflow_resume_decision(text, pending) + ), } result = await self._call_mcp_tool(tool_name, arguments, state) workflow = self._workflow_payload_from_tool_result(result) @@ -1678,9 +1914,29 @@ class AgentRuntimeMixin: current = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} txid = transaction_id or current.get("transaction_id") or str(uuid.uuid4()) if str(current.get("tool_name") or "") != str(tool_name): - state["transaction_pre_validation"] = None + pre_validation = state.get("transaction_pre_validation") + pre_validation = pre_validation if isinstance(pre_validation, dict) else {} + effective_prevalidated_tool = str(pre_validation.get("effective_tool_name") or "").strip() + # Preserve the validator decision when the active transaction is being + # moved to the exact tool selected by that decision. Any unrelated tool + # shift still invalidates stale pre-validation evidence. + if effective_prevalidated_tool != str(tool_name): + state["transaction_pre_validation"] = None cfg = self._tool_config(tool_name) policy = self._resolve_tool_execution_policy(tool_name, arguments or {}) + route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} + parameter_context = current.get("parameter_conversational_context") + if route_meta.get("contextual_reentry"): + bounded = str(route_meta.get("relevant_conversation_context") or "").strip() + prior_claim = str(route_meta.get("original_input") or "").strip() + if bounded and prior_claim: + parameter_context = ( + bounded + + "\nprevious_user_continuation_non_authoritative: " + + prior_claim + ) + else: + parameter_context = bounded or prior_claim or parameter_context tx = { "transaction_id": txid, "tool_name": tool_name, @@ -1690,6 +1946,10 @@ class AgentRuntimeMixin: "requires": list(policy.get("requires") or getattr(cfg, "requires", []) or []), "parameter_schema": self._transaction_parameter_schema(tool_name, policy), "tool_description": self._transaction_tool_description(tool_name), + # Conversation context is only an interpretation aid. Never expose + # it through transaction_evidence or treat user claims as proof. + "parameter_conversational_context": parameter_context or "", + "user_claims_are_evidence": False if parameter_context else current.get("user_claims_are_evidence", False), } state["active_transaction"] = tx return tx @@ -1813,11 +2073,13 @@ class AgentRuntimeMixin: state["active_transaction"] = None state["selected_tool_call"] = {} state["pending_tool_call"] = {} + state["confirmation_snapshot"] = None state["missing_parameters"] = [] state["confirmation_required"] = False state["confirmation_received"] = status == "COMPLETED" state["next_state"] = None state["transaction_status"] = status + state.pop("transaction_confirmation_message_override", None) def _normalize_transaction_lifecycle(self, state: dict[str, Any]) -> None: """Ensure closed transactions cannot leak into a later user turn.""" @@ -1839,28 +2101,105 @@ class AgentRuntimeMixin: state["active_transaction"] = None state["selected_tool_call"] = {} state["pending_tool_call"] = {} + state["confirmation_snapshot"] = None state["missing_parameters"] = [] state["confirmation_required"] = False state["confirmation_received"] = False state["next_state"] = None + # Defensive cleanup for checkpoints written by older versions: a + # terminal interaction must never retain a resumable workflow contract. + state["pending_domain_workflow"] = None + state["pending_tool_clarification"] = None + state["workflow_input_reprompt"] = None + state.pop("transaction_confirmation_message_override", None) return if self._transaction_is_active(state): self._active_transaction(state) + def _freeze_confirmation_snapshot( + self, + state: dict[str, Any], + *, + tool_name: str, + arguments: dict[str, Any], + ) -> dict[str, Any]: + """Freeze the exact tool call that the user is being asked to confirm. + + Confirmation is a control boundary. Once the runtime exposes a confirmation + prompt, later turns must not re-extract/re-resolve arguments before execution. + The immutable snapshot is therefore the source of truth for an explicit + confirmation. The active transaction may continue carrying presentation/audit + metadata, but execution consumes this snapshot only. + """ + active = self._active_transaction(state) or {} + snapshot = { + "transaction_id": active.get("transaction_id") or str(uuid.uuid4()), + "tool_name": str(tool_name or ""), + "arguments": dict(arguments or {}), + "started_from_intent": active.get("started_from_intent") or state.get("intent"), + } + state["confirmation_snapshot"] = snapshot + return snapshot + + @staticmethod + def _confirmation_snapshot(state: dict[str, Any]) -> dict[str, Any] | None: + snapshot = state.get("confirmation_snapshot") + if not isinstance(snapshot, dict) or not snapshot.get("tool_name"): + return None + return { + **snapshot, + "arguments": dict(snapshot.get("arguments") or {}), + } + + def _transaction_user_prompt( + self, + state: dict[str, Any], + *, + parameter: str, + ) -> str: + """Render a user-facing prompt without exposing implementation names. + + Domain semantics are declared by the agent in ``args_schema``. Supported + optional keys are ``user_prompt`` (preferred), ``label`` and ``description``. + Legacy schemas remain valid; when no semantic metadata exists the framework + uses a neutral prompt rather than leaking the technical parameter key. + """ + active = self._active_transaction(state) or {} + schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else {} + raw = schema.get(parameter) + entry = raw if isinstance(raw, dict) else {} + explicit = str(entry.get("user_prompt") or "").strip() + if explicit: + return explicit + label = str(entry.get("label") or "").strip() + if label: + return f"Para prosseguir, informe {label}." + description = str(entry.get("description") or "").strip() + if description: + # Descriptions can be long/extractor-oriented. Keep user output concise. + sentence = description.split(".", 1)[0].strip() + if sentence: + return f"Para prosseguir, informe {sentence[0].lower() + sentence[1:] if len(sentence) > 1 else sentence.lower()}." + return "Para prosseguir, preciso de mais uma informação para continuar com a solicitação." + def transaction_state_patch(self, state: dict[str, Any]) -> dict[str, Any]: keys = ( "available_mcp_tools", "selected_tool_call", "pending_tool_call", "transaction_status", "confirmation_required", "confirmation_received", "tool_policy_result", "missing_parameters", "next_state", "pending_domain_workflow", "pending_tool_clarification", - "business_workflows_executed", "active_transaction", "last_transaction", + "business_workflows_executed", "active_transaction", "last_transaction", "confirmation_snapshot", "transaction_evidence", "last_transaction_evidence", "relevant_transaction_evidence", - "transaction_pre_validation", + "transaction_pre_validation", "tool_terminal_result", "transaction_confirmation_message_override", + "operational_context_boundary_pending", "operational_context_reset", ) return {key: state.get(key) for key in keys if key in state} def transaction_clarification_message(self, state: dict[str, Any]) -> str | None: """Retorna pergunta determinística para parâmetros ou resultado ambíguo.""" + workflow_reprompt = str(state.get("workflow_input_reprompt") or "").strip() + if workflow_reprompt: + return workflow_reprompt if state.get("transaction_status") == "TOOL_RESULT_CLARIFICATION": pending = state.get("pending_tool_clarification") or {} question = str(pending.get("question") or "Qual opção você quis dizer?").strip() @@ -1873,17 +2212,10 @@ class AgentRuntimeMixin: missing = list(state.get("missing_parameters") or []) if not missing: return None - labels = { - "order_id": "o número do pedido", - "reason": "o motivo da solicitação", - "customer_id": "a identificação do cliente", - } - friendly = [labels.get(name, str(name).replace("_", " ")) for name in missing] - if len(friendly) == 1: - detail = friendly[0] - else: - detail = ", ".join(friendly[:-1]) + " e " + friendly[-1] - return f"Para prosseguir, informe {detail}." + # Ask one semantic question at a time. The LLM extractor can still consume + # multiple values when the user volunteers them in the same turn. This keeps + # the conversation natural and, critically, never exposes internal field names. + return self._transaction_user_prompt(state, parameter=str(missing[0])) @staticmethod def _missing_required_arguments(policy: dict[str, Any], arguments: dict[str, Any]) -> list[str]: @@ -1919,6 +2251,9 @@ class AgentRuntimeMixin: def transaction_confirmation_message(self, state: dict[str, Any]) -> str | None: if state.get("transaction_status") != "AWAITING_CONFIRMATION": return None + override = str(state.get("transaction_confirmation_message_override") or "").strip() + if override: + return override pending = state.get("pending_tool_call") or {} tool_name = pending.get("tool_name") or "a operação solicitada" args = pending.get("arguments") or {} @@ -2143,6 +2478,96 @@ class AgentRuntimeMixin: return None return None + @staticmethod + def _terminal_tool_payload(tool_result: dict[str, Any] | None) -> dict[str, Any] | None: + """Return an explicitly terminal application payload, if present. + + The framework deliberately does not know domain status codes. A tool may + stop the current tool chain only by declaring ``terminal=true`` either + on the normalized result wrapper or on its application ``result`` body. + """ + if not isinstance(tool_result, dict): + return None + nested = tool_result.get("result") + candidates = [nested, tool_result] if isinstance(nested, dict) else [tool_result] + for payload in candidates: + if isinstance(payload, dict) and payload.get("terminal") is True: + return payload + return None + + def _apply_terminal_tool_result(self, state: dict[str, Any], tool_result: dict[str, Any]) -> None: + payload = self._terminal_tool_payload(tool_result) or {} + self._finish_active_transaction(state, "BLOCKED", result=tool_result) + state["tool_terminal_result"] = tool_result + state["tool_policy_result"] = { + "action": "terminal_tool_result", + "tool_name": tool_result.get("tool_name") or tool_result.get("tool"), + "reason": payload.get("reason") or tool_result.get("error"), + "terminal_action": payload.get("terminal_action") or "block", + } + + def _terminal_workflow_payload(self, tool_result: dict[str, Any] | None) -> dict[str, Any] | None: + """Return a terminal COMPLETED workflow payload using only generic signals. + + Workflow terminality must take precedence over RAG/LLM composition. The + framework intentionally does not know workflow names or domain status + codes; it recognizes only structural terminal contracts. + """ + if not isinstance(tool_result, dict): + return None + workflow = self._workflow_payload_from_tool_result(tool_result) + if not workflow or workflow.get("status") != "COMPLETED": + return None + + candidates: list[dict[str, Any]] = [workflow] + state = workflow.get("state") if isinstance(workflow.get("state"), dict) else {} + outputs = workflow.get("output") if isinstance(workflow.get("output"), dict) else {} + terminal_node = str(state.get("current_node") or "").strip() + if terminal_node and isinstance(outputs.get(terminal_node), dict): + candidates.insert(0, outputs[terminal_node]) + + for payload in candidates: + session_control = str(payload.get("session_control") or "").strip().upper() + terminal_status = str(payload.get("terminal_status") or "").strip() + if ( + payload.get("terminal") is True + or payload.get("session_ended") is True + or payload.get("handoff") is True + or bool(terminal_status) + or session_control in {"HUMAN_HANDOFF", "END_SESSION"} + ): + return payload + return None + + def _final_workflow_response_payload(self, tool_result: dict[str, Any] | None) -> dict[str, Any] | None: + """Return the final response payload of a COMPLETED workflow. + + This contract is intentionally different from session terminality. A + workflow may finish its own response while keeping the user session open. + Domain nodes opt in with ``workflow_response_final=true``. This prevents + directives emitted by an earlier pause node (for example + ``requires_llm_composition``) from being replayed after the user has + already completed the workflow. + """ + if not isinstance(tool_result, dict): + return None + workflow = self._workflow_payload_from_tool_result(tool_result) + if not workflow or workflow.get("status") != "COMPLETED": + return None + state = workflow.get("state") if isinstance(workflow.get("state"), dict) else {} + outputs = workflow.get("output") if isinstance(workflow.get("output"), dict) else {} + final_node = str(state.get("current_node") or "").strip() + candidates: list[dict[str, Any]] = [] + if final_node and isinstance(outputs.get(final_node), dict): + candidates.append(outputs[final_node]) + # Some workflow adapters promote the final node output to the workflow + # root. Support that generic shape as well. + candidates.append(workflow) + for payload in candidates: + if payload.get("workflow_response_final") is True: + return payload + return None + def build_direct_mcp_answer(self, state: dict[str, Any], mcp_results: list[dict[str, Any]], *, agent_label: str) -> str | None: """Retorna resposta MCP direta somente quando a aplicação declarar isso explicitamente. @@ -2152,6 +2577,44 @@ class AgentRuntimeMixin: declarar ``response.direct: true`` e fornecer uma política de apresentação válida. Sem essa declaração, o fluxo continua para retrieval/composição. """ + # Explicit terminal results own the turn regardless of normal response + # composition directives. A completed terminal workflow must therefore be + # checked BEFORE requires_rag/requires_llm_composition; otherwise an + # instruction emitted by an earlier workflow node can resurrect LLM + # composition after the workflow has already handed off/ended the session. + for item in mcp_results or []: + payload = self._terminal_tool_payload(item) + if payload: + message = str(payload.get("user_message") or payload.get("message") or payload.get("mensagem") or "").strip() + if message: + return message + + workflow_terminal = self._terminal_workflow_payload(item) + if workflow_terminal: + workflow = self._workflow_payload_from_tool_result(item) or {} + message = str( + workflow_terminal.get("user_message") + or workflow_terminal.get("message") + or workflow_terminal.get("mensagem") + or workflow.get("user_message") + or workflow.get("message") + or workflow.get("mensagem") + or "" + ).strip() + if message: + return message + + workflow_final = self._final_workflow_response_payload(item) + if workflow_final: + message = str( + workflow_final.get("user_message") + or workflow_final.get("message") + or workflow_final.get("mensagem") + or "" + ).strip() + if message: + return message + requires_rag, _ = self._mcp_rag_directive(mcp_results) requires_llm_composition, _ = self._mcp_llm_composition_directive(mcp_results) if requires_rag or requires_llm_composition: @@ -2277,7 +2740,12 @@ class AgentRuntimeMixin: results: list[dict[str, Any]] = [] available_tools = list(tools if tools is not None else (state.get("mcp_tools") or [])) state["available_mcp_tools"] = available_tools - text = state.get("sanitized_input") or state.get("user_text") or "" + route_meta = (state.get("route_decision") or {}).get("metadata") or {} + text = ( + route_meta.get("contextual_reentry_input") + if route_meta.get("contextual_reentry") + else None + ) or state.get("sanitized_input") or state.get("user_text") or "" self._normalize_transaction_lifecycle(state) # Uma transação em coleta/confirmação não pode aprisionar a sessão. O @@ -2285,7 +2753,6 @@ class AgentRuntimeMixin: # Não existe interpretação lexical de desistência no runtime: mudou a # intent, a transação anterior é encerrada e seus latches são limpos. active_before_interruption = self._active_transaction(state) - route_meta = (state.get("route_decision") or {}).get("metadata") or {} interruption = str(route_meta.get("transaction_interruption") or "").strip().lower() if active_before_interruption and interruption == "intent_shift": interrupted_tool = active_before_interruption.get("tool_name") @@ -2306,6 +2773,13 @@ class AgentRuntimeMixin: # Workflows conversacionais pausados têm precedência sobre novo roteamento/tool selection. # O domínio informa apenas workflow/execution_id; a retomada é uma capability genérica. + # Invalid enumerated replies remain owned by the paused workflow and are + # answered with a declarative reprompt; the resume tool is not called. + if route_meta.get("workflow_input_invalid") and state.get("pending_domain_workflow"): + state["workflow_input_reprompt"] = str(route_meta.get("workflow_reprompt") or "").strip() + state["transaction_status"] = "WORKFLOW_PAUSED" + return [] + state["workflow_input_reprompt"] = None if state.get("pending_domain_workflow"): resumed = await self._resume_pending_domain_workflow(state, str(text)) return [resumed] if resumed else [] @@ -2320,13 +2794,19 @@ class AgentRuntimeMixin: missing_before = self._missing_required_arguments(policy, previous_args) # Parâmetros TRANSACIONAIS são interpretados exclusivamente pelo - # extrator LLM genérico. Não existem regexes/nome de entidade - # hardcoded no framework. O extrator recebe apenas os parâmetros - # ainda pendentes da policy e pode consumir um ou vários no turno. + # extrator LLM genérico. Durante COLLECTING_PARAMETERS, a fala atual + # também pode CORRIGIR um required field já coletado em turno anterior + # (ex.: valor=19,99 e o cliente diz "desculpa, é 14,99" enquanto + # subject ainda está pendente). Por isso o contrato editável do turno + # é o conjunto completo de ``requires``; somente as chaves realmente + # extraídas pela LLM sobrescrevem ``previous_args``. Campos não citados + # permanecem intactos. Isso preserva parameter-before-intent-shift sem + # tornar valores antigos imutáveis por acidente. + editable_required = [str(name) for name in (policy.get("requires") or [])] extracted = await self._extract_transaction_parameters( state, tool_name=tool_name, - missing_parameters=missing_before, + missing_parameters=editable_required, known_arguments=previous_args, ) arguments = {**previous_args, **extracted} @@ -2378,7 +2858,13 @@ class AgentRuntimeMixin: if pre_validation_result is not None: return [pre_validation_result] - if policy.get("require_confirmation"): + tool_name, policy, force_confirmation = self._apply_prevalidated_transaction_decision( + state, tool_name=tool_name, arguments=arguments, policy=policy + ) + selected = {"tool_name": tool_name, "arguments": arguments} + state["selected_tool_call"] = selected + + if policy.get("require_confirmation") or force_confirmation: waiting_state = self._waiting_state_name(state) state.update({ "pending_tool_call": selected, @@ -2391,6 +2877,9 @@ class AgentRuntimeMixin: self._set_active_transaction( state, tool_name=tool_name, arguments=arguments, status="AWAITING_CONFIRMATION" ) + self._freeze_confirmation_snapshot( + state, tool_name=tool_name, arguments=arguments + ) return [{ "ok": True, "executed": False, @@ -2404,7 +2893,7 @@ class AgentRuntimeMixin: result = await self._call_mcp_tool(tool_name, arguments, state) self._capture_pending_domain_workflow(state, result) self._capture_pending_tool_clarification(state, result, tool_name=tool_name, arguments=arguments) - final_status = ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED"))) + final_status = ("BLOCKED" if self._terminal_tool_payload(result) else ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED")))) if final_status in _TERMINAL_TRANSACTION_STATUSES: self._finish_active_transaction(state, final_status, result=result) else: @@ -2421,9 +2910,13 @@ class AgentRuntimeMixin: return [result] active_tx = self._active_transaction(state) - pending = (active_tx if isinstance(active_tx, dict) and active_tx.get("status") == "AWAITING_CONFIRMATION" else state.get("pending_tool_call")) or {} + frozen_confirmation = self._confirmation_snapshot(state) + pending = frozen_confirmation or (active_tx if isinstance(active_tx, dict) and active_tx.get("status") == "AWAITING_CONFIRMATION" else state.get("pending_tool_call")) or {} if pending: - decision = self._confirmation_decision(text) + route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} + routed_decision = str(route_meta.get("transaction_confirmation_decision") or "").strip().lower() + routed_consumed = bool(route_meta.get("transaction_turn_consumed")) + decision = routed_decision if routed_consumed and routed_decision in {"confirm", "reject"} else self._confirmation_decision(text) if decision == "reject": state["tool_policy_result"] = {"action": "cancelled", "tool_name": pending.get("tool_name")} self._finish_active_transaction(state, "CANCELLED") @@ -2436,7 +2929,7 @@ class AgentRuntimeMixin: result = await self._call_mcp_tool(tool_name, arguments, state) self._capture_pending_domain_workflow(state, result) self._capture_pending_tool_clarification(state, result, tool_name=tool_name, arguments=arguments) - final_status = ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED"))) + final_status = ("BLOCKED" if self._terminal_tool_payload(result) else ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED")))) state["tool_policy_result"] = {"action": "executed_after_confirmation", "tool_name": tool_name} if final_status in _TERMINAL_TRANSACTION_STATUSES: self._finish_active_transaction(state, final_status, result=result) @@ -2456,6 +2949,12 @@ class AgentRuntimeMixin: self._set_active_transaction( state, tool_name=str(pending.get("tool_name") or ""), arguments=dict(pending.get("arguments") or {}), status="AWAITING_CONFIRMATION" ) + if self._confirmation_snapshot(state) is None: + self._freeze_confirmation_snapshot( + state, + tool_name=str(pending.get("tool_name") or ""), + arguments=dict(pending.get("arguments") or {}), + ) return [{"ok": False, "tool_name": pending.get("tool_name"), "awaiting_confirmation": True, "transaction_status": "AWAITING_CONFIRMATION"}] read_only_tools = [ @@ -2478,6 +2977,16 @@ class AgentRuntimeMixin: self._capture_pending_domain_workflow(state, result) self._capture_pending_tool_clarification(state, result, tool_name=tool, arguments=args) results.append(result) + if self._terminal_tool_payload(result): + self._apply_terminal_tool_result(state, result) + if emit_events: + await self._emit_ic( + "IC.TOOL_CHAIN_TERMINATED", + state, + {"tool_name": tool, "reason": (self._terminal_tool_payload(result) or {}).get("reason")}, + component="agent_runtime.tool_policy", + ) + return results if emit_events: await self._emit_ic( "IC.TOOL_CALLED", @@ -2581,7 +3090,13 @@ class AgentRuntimeMixin: results.append(pre_validation_result) return results - if policy.get("require_confirmation"): + selected_action, policy, force_confirmation = self._apply_prevalidated_transaction_decision( + state, tool_name=selected_action, arguments=action_args, policy=policy + ) + selected = {"tool_name": selected_action, "arguments": action_args} + state["selected_tool_call"] = selected + + if policy.get("require_confirmation") or force_confirmation: state.update({ "pending_tool_call": selected, "transaction_status": "AWAITING_CONFIRMATION", @@ -2591,6 +3106,9 @@ class AgentRuntimeMixin: self._set_active_transaction( state, tool_name=selected_action, arguments=action_args, status="AWAITING_CONFIRMATION" ) + self._freeze_confirmation_snapshot( + state, tool_name=selected_action, arguments=action_args + ) state["next_state"] = self._waiting_state_name(state) if emit_events: await self._emit_ic("IC.TRANSACTION_CONFIRMATION_REQUIRED", state, {"tool_name": selected_action, **policy}, component="agent_runtime.tool_policy") @@ -2600,7 +3118,7 @@ class AgentRuntimeMixin: action_args["confirmed"] = True result = await self._call_mcp_tool(selected_action, action_args, state) self._capture_pending_domain_workflow(state, result) - final_status = ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED"))) + final_status = ("BLOCKED" if self._terminal_tool_payload(result) else ("WORKFLOW_PAUSED" if state.get("pending_domain_workflow") else ("TOOL_RESULT_CLARIFICATION" if state.get("pending_tool_clarification") else ("COMPLETED" if result.get("ok") else "FAILED")))) if final_status in _TERMINAL_TRANSACTION_STATUSES: self._finish_active_transaction(state, final_status, result=result) else: @@ -2657,6 +3175,33 @@ class AgentRuntimeMixin: if not resolved_session_id: return None + # A completed workflow can keep the same session identifier while + # opening a fresh operational interaction. On that first post-boundary + # turn, do not inject ConversationSummaryMemory/recent messages from the + # closed workflow. Durable message history is intentionally untouched. + # Long-term memory is loaded below as usual because identity/preferences + # are not short-term workflow state. + reset_short_term = bool(state.get("operational_context_reset")) + if reset_short_term: + memory_context = MemoryContext( + summary="", + recent_messages=[], + compressed=False, + metadata={"operational_context_reset": True, "session_id": resolved_session_id}, + ) + state["memory_context"] = memory_context + state["memory_context_metadata"] = memory_context.metadata + if bool(getattr(settings, "ENABLE_LONG_TERM_MEMORY", False)): + manager = getattr(self, "long_term_memory_manager", None) + if manager is None: + from agent_framework.memory.long_term_memory import create_long_term_memory_manager + manager = create_long_term_memory_manager(settings, telemetry=getattr(self, "telemetry", None)) + self.long_term_memory_manager = manager + items = await manager.load(state) + state["long_term_memories"] = [item.to_dict() for item in items] + state["long_term_memory_context"] = manager.render(items) + return memory_context + summary_memory = getattr(self, "summary_memory", None) if summary_memory is None: from agent_framework.memory.message_history import create_memory diff --git a/libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py b/libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py index 4682a1f8..1a394f79 100644 --- a/libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py +++ b/libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py @@ -78,6 +78,7 @@ async def extract_transaction_parameters( known_arguments: Mapping[str, Any] | None = None, parameter_schema: Mapping[str, Any] | None = None, tool_description: str | None = None, + conversational_context: str | None = None, ) -> dict[str, Any]: """Extract values for pending transactional parameters using the LLM only. @@ -120,13 +121,16 @@ async def extract_transaction_parameters( "7. Para cada parâmetro, considere o nome técnico, o tipo quando disponível e principalmente a descrição semântica quando disponível. A ausência de tipo ou descrição NÃO impede a extração.\n" "8. Se a mensagem deixar clara a correspondência entre um trecho e um parâmetro, preencha-o mesmo que o usuário não cite o nome técnico do campo.\n" "9. Não use conhecimento externo para completar valores ausentes e não transforme aproximações ou suposições em fatos.\n" - "10. Em caso de dúvida razoável sobre a correspondência ou o valor, prefira null.\n" - "11. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n" + "10. conversational_context, quando presente, serve SOMENTE para resolver referências da mensagem atual (por exemplo: 'a de 14,99' apontando para um item citado imediatamente antes). Não trate texto do contexto como uma nova afirmação do cliente nem como evidência de negócio.\n" + "11. Quando a mensagem atual identifica um valor OU nome e o contexto imediatamente anterior contém uma única entidade compatível, você pode preencher essa entidade e os atributos pendentes inequivocamente associados a ela como CANDIDATOS. Exemplo genérico: se a fala identifica uma entidade e o contexto associa unicamente essa entidade a um valor requerido, o valor pode ser retornado como candidato. A validação autoritativa ocorrerá depois; não invente se houver ambiguidade.\n" + "12. Em caso de dúvida razoável sobre a correspondência ou o valor, prefira null.\n" + "13. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n" f"transaction_tool: {tool_name}\n" f"transaction_description: {tool_description or ''}\n" f"pending_parameters: {json.dumps(pending, ensure_ascii=False)}\n" f"parameter_schema: {json.dumps(field_spec, ensure_ascii=False, default=str)}\n" f"known_arguments: {json.dumps(known, ensure_ascii=False, default=str)}\n" + f"conversational_context: {str(conversational_context or '').strip()}\n" f"user_message: {message}\n" f"Formato obrigatório: {json.dumps(output_shape, ensure_ascii=False)}" ) @@ -138,7 +142,6 @@ async def extract_transaction_parameters( component_name="transaction_parameter_extraction", generation_name="llm.transaction_parameter_extraction", temperature=0.0, - max_tokens=max(120, min(500, 80 + 60 * len(pending))), ) except TypeError: # Compatibilidade com doubles/testes e providers mínimos que aceitam diff --git a/libs/agent_framework/src/agent_framework/security/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/security/__pycache__/__init__.cpython-313.pyc index 111dafdb..9bd73dfb 100644 Binary files a/libs/agent_framework/src/agent_framework/security/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/security/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/security/__pycache__/authentication.cpython-313.pyc b/libs/agent_framework/src/agent_framework/security/__pycache__/authentication.cpython-313.pyc index edb64da3..0a09839a 100644 Binary files a/libs/agent_framework/src/agent_framework/security/__pycache__/authentication.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/security/__pycache__/authentication.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/security/__pycache__/factory.cpython-313.pyc b/libs/agent_framework/src/agent_framework/security/__pycache__/factory.cpython-313.pyc index 5c8d321f..9dc99b2c 100644 Binary files a/libs/agent_framework/src/agent_framework/security/__pycache__/factory.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/security/__pycache__/factory.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/security/__pycache__/installer.cpython-313.pyc b/libs/agent_framework/src/agent_framework/security/__pycache__/installer.cpython-313.pyc index c9dd3eed..61f78488 100644 Binary files a/libs/agent_framework/src/agent_framework/security/__pycache__/installer.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/security/__pycache__/installer.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/security/__pycache__/middleware.cpython-313.pyc b/libs/agent_framework/src/agent_framework/security/__pycache__/middleware.cpython-313.pyc index 3cfe661c..e64257a7 100644 Binary files a/libs/agent_framework/src/agent_framework/security/__pycache__/middleware.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/security/__pycache__/middleware.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/sse/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/sse/__pycache__/__init__.cpython-313.pyc index 34c93c24..b457b7ac 100644 Binary files a/libs/agent_framework/src/agent_framework/sse/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/sse/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/sse/__pycache__/events.cpython-313.pyc b/libs/agent_framework/src/agent_framework/sse/__pycache__/events.cpython-313.pyc index d1fb5e0b..c078ece9 100644 Binary files a/libs/agent_framework/src/agent_framework/sse/__pycache__/events.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/sse/__pycache__/events.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/supervisor/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/supervisor/__pycache__/__init__.cpython-313.pyc index 685b3559..6d05d072 100644 Binary files a/libs/agent_framework/src/agent_framework/supervisor/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/supervisor/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/supervisor/__pycache__/router_supervisor.cpython-313.pyc b/libs/agent_framework/src/agent_framework/supervisor/__pycache__/router_supervisor.cpython-313.pyc index 45add6eb..2c9b2f68 100644 Binary files a/libs/agent_framework/src/agent_framework/supervisor/__pycache__/router_supervisor.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/supervisor/__pycache__/router_supervisor.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/supervisor/__pycache__/supervisor.cpython-313.pyc b/libs/agent_framework/src/agent_framework/supervisor/__pycache__/supervisor.cpython-313.pyc index 04cd414f..d75e3588 100644 Binary files a/libs/agent_framework/src/agent_framework/supervisor/__pycache__/supervisor.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/supervisor/__pycache__/supervisor.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc index 97afa37a..01bfd5ce 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/graph.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/graph.cpython-313.pyc index 5309359f..937cfe5f 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/graph.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/graph.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc index b69667dc..f94e3d92 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/models.cpython-313.pyc index 2d18d615..6d421c59 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/registry.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/registry.cpython-313.pyc index 6022ef82..1af1d810 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/registry.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/registry.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/repository.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/repository.cpython-313.pyc index 10c9a539..83eb516b 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/repository.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/repository.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc index b709e0d2..c64be928 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc index 59cb3440..94097fc6 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/input_contract.py b/libs/agent_framework/src/agent_framework/workflows/input_contract.py index 2492553b..15b1a926 100644 --- a/libs/agent_framework/src/agent_framework/workflows/input_contract.py +++ b/libs/agent_framework/src/agent_framework/workflows/input_contract.py @@ -39,3 +39,113 @@ def match_expected_input(text: str, expected_input: dict[str, Any] | None) -> st if item is not None } return normalized if normalized in allowed_normalized else None + +def expected_input_reprompt(expected_input: dict[str, Any] | None, *, pause_prompt: str | None = None) -> str: + """Return a user-facing retry prompt for an invalid paused-workflow reply. + + Domains may declare ``reprompt`` in the workflow contract. When absent, the + framework builds a neutral message from ``allowed_values`` without guessing + domain semantics. + """ + contract = expected_input if isinstance(expected_input, dict) else {} + declared = str(contract.get("reprompt") or "").strip() + if declared: + return declared + allowed = [str(x).strip() for x in (contract.get("allowed_values") or []) if str(x).strip()] + if allowed: + rendered = ", ".join(allowed) + return f"Não entendi. Responda com uma das opções: {rendered}." + prompt = str(pause_prompt or "").strip() + if prompt: + return f"Não entendi. {prompt}" + return "Não entendi sua resposta. Por favor, tente novamente." + +def has_semantic_classifier(expected_input: dict[str, Any] | None) -> bool: + """Whether an enumerated contract opts in to agent-defined semantic classification.""" + if not isinstance(expected_input, dict) or not expected_input.get("allowed_values"): + return False + classifier = expected_input.get("semantic_classifier") + return ( + isinstance(classifier, dict) + and classifier.get("enabled", True) is not False + and bool(str(classifier.get("prompt") or "").strip()) + ) + + +def match_semantic_classifier_output( + output: str, expected_input: dict[str, Any] | None +) -> str | None: + """Validate classifier output strictly against dynamic ``allowed_values``. + + No option semantics live in the framework. The returned value is the same + normalized representation used by deterministic ``match_expected_input``. + """ + if not isinstance(expected_input, dict): + return None + candidate = str(output or "").strip().strip("` \n\r\t\"'") + if not candidate: + return None + allowed = expected_input.get("allowed_values") or [] + allowed_map = { + normalize_expected_input(str(item), expected_input): normalize_expected_input(str(item), expected_input) + for item in allowed + if item is not None + } + normalized = normalize_expected_input(candidate, expected_input) + return allowed_map.get(normalized) + + +def has_meaningful_unmatched_policy(expected_input: dict[str, Any] | None) -> bool: + """Whether the contract explicitly opts in to semantic handling of unmatched text.""" + if not isinstance(expected_input, dict): + return False + unmatched = expected_input.get("unmatched") + if not isinstance(unmatched, dict): + return False + meaningful = unmatched.get("meaningful_input") + return ( + isinstance(meaningful, dict) + and str(meaningful.get("action") or "").strip().lower() == "resume_as" + and meaningful.get("value") is not None + ) + + +def meaningful_unmatched_resume_value( + expected_input: dict[str, Any] | None, + *, + semantic_coherent: bool | None, +) -> str | None: + """Resolve a configured ``resume_as`` value for coherent unmatched input. + + The framework never invents domain semantics here. It only applies the + value declared by the workflow after the coherence rail classified the + free-text reply as meaningful. + """ + if semantic_coherent is not True or not has_meaningful_unmatched_policy(expected_input): + return None + unmatched = expected_input.get("unmatched") or {} + meaningful = unmatched.get("meaningful_input") or {} + raw = meaningful.get("value") + if raw is None: + return None + return normalize_expected_input(str(raw), expected_input) + + +def semantic_coherence_from_guardrails(state: dict[str, Any]) -> bool | None: + """Read the non-blocking COER signal emitted for a paused workflow contract.""" + decisions = state.get("guardrail_decisions") or state.get("guardrails") or [] + if not isinstance(decisions, list): + return None + for decision in reversed(decisions): + if hasattr(decision, "model_dump"): + decision = decision.model_dump() + if not isinstance(decision, dict) or str(decision.get("code") or "").upper() != "COER": + continue + metadata = decision.get("metadata") or {} + if isinstance(metadata, dict) and isinstance(metadata.get("semantic_coherent"), bool): + return metadata["semantic_coherent"] + data = metadata.get("data") if isinstance(metadata, dict) else None + if isinstance(data, dict) and isinstance(data.get("allowed"), bool): + return data["allowed"] + return None + diff --git a/libs/agent_framework/src/agent_framework/workflows/models.py b/libs/agent_framework/src/agent_framework/workflows/models.py index 0dd996f5..fd8ca93e 100644 --- a/libs/agent_framework/src/agent_framework/workflows/models.py +++ b/libs/agent_framework/src/agent_framework/workflows/models.py @@ -4,10 +4,53 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator +class WorkflowMeaningfulInputAction(BaseModel): + """Legacy action for coherent unmatched input (kept for compatibility).""" + + action: Literal["resume_as"] = "resume_as" + value: Any + + +class WorkflowExpectedInputUnmatched(BaseModel): + meaningful_input: WorkflowMeaningfulInputAction | None = None + + +class WorkflowSemanticOptionAction(BaseModel): + """Optional generic action attached to one classified option. + + ``contextual_reentry`` releases the paused workflow and asks the normal + router/runtime to reinterpret the current utterance together with the + bounded conversational context that produced the pause. It never confirms + user-provided facts by itself. + """ + + action: Literal["contextual_reentry"] + + +class WorkflowSemanticClassifier(BaseModel): + """Agent-defined semantic classifier constrained by ``allowed_values``. + + The framework provides only execution/validation. The prompt defines the + domain meaning of every allowed option and may reference the runtime + placeholders ``{{ allowed_values }}``, ``{{ pending_prompt }}``, + ``{{ relevant_conversation_context }}`` and ``{{ user_input }}``. + Per-option actions are also agent configuration; the framework knows only + their generic mechanics. + """ + + enabled: bool = True + include_relevant_context: bool = False + prompt: str = Field(min_length=1) + option_actions: dict[str, WorkflowSemanticOptionAction] = Field(default_factory=dict) + + class WorkflowExpectedInput(BaseModel): key: str = Field(min_length=1) allowed_values: list[Any] = Field(default_factory=list) normalize: Literal["none", "upper_strip", "lower_strip", "strip"] = "none" + reprompt: str | None = None + semantic_classifier: WorkflowSemanticClassifier | None = None + unmatched: WorkflowExpectedInputUnmatched | None = None class WorkflowPause(BaseModel): diff --git a/libs/agent_framework/src/agent_framework/workflows/runtime.py b/libs/agent_framework/src/agent_framework/workflows/runtime.py index d721fb6c..c171010c 100644 --- a/libs/agent_framework/src/agent_framework/workflows/runtime.py +++ b/libs/agent_framework/src/agent_framework/workflows/runtime.py @@ -459,6 +459,68 @@ class WorkflowRuntime: self._compiled[key] = graph return graph + def _snapshot_interrupts(self, snapshot: Any) -> list[Any]: + """Return real LangGraph interrupt payloads from a durable snapshot. + + ``snapshot.next`` only means that LangGraph still exposes pending graph + work. It is *not* proof that execution is waiting for user input. + Pause semantics belong exclusively to real ``interrupt()`` payloads. + + LangGraph/checkpointer versions expose durable interrupts in more than + one shape. Newer snapshots normally attach them to ``task.interrupts``; + other supported versions persist them in ``snapshot.values`` under the + reserved ``__interrupt__`` key. Accept both representations so a real + pause is never mistaken for generic pending work and failed closed. + """ + interrupts: list[Any] = [] + + def append_interrupt(item: Any) -> None: + if isinstance(item, dict) and "value" in item: + value = item.get("value") + else: + value = getattr(item, "value", item) + # Avoid duplicating the same payload when a LangGraph version + # exposes it through both task metadata and durable state values. + if value not in interrupts: + interrupts.append(value) + + for task in getattr(snapshot, "tasks", ()) or (): + for item in getattr(task, "interrupts", ()) or (): + append_interrupt(item) + + # Compatibility with LangGraph/checkpointer snapshots where interrupts + # are durable state values instead of task metadata. This is the shape + # observed with pause nodes such as ``formatar__pause``. + values = getattr(snapshot, "values", None) + if isinstance(values, dict): + persisted = values.get("__interrupt__") + if isinstance(persisted, (list, tuple)): + for item in persisted: + append_interrupt(item) + elif persisted is not None: + append_interrupt(persisted) + + # Be tolerant of versions/adapters that expose a top-level collection. + for item in getattr(snapshot, "interrupts", ()) or (): + append_interrupt(item) + + return interrupts + + def _is_structurally_terminal(self, definition: WorkflowDefinition, state: dict[str, Any]) -> bool: + """Return True when the current completed node has an active edge to END. + + This intentionally evaluates the workflow definition rather than relying + on ``snapshot.next``. Some LangGraph/checkpointer combinations may leave + a truthy ``next`` after the final action node has already completed. + """ + current_node = state.get("current_node") + if not isinstance(current_node, str) or not current_node: + return False + for edge in self._outgoing(definition).get(current_node, []): + if _matches(edge.when, state): + return edge.target in {"END", "__end__"} + return False + def _result_from_state(self, definition: WorkflowDefinition, eid: str, state: dict[str, Any]) -> WorkflowRunResult: return WorkflowRunResult( execution_id=eid, @@ -505,12 +567,9 @@ class WorkflowRuntime: state = await graph.ainvoke(initial, config=config) phase = "aget_state" snapshot = await graph.aget_state(config) - if getattr(snapshot, "next", None): - interrupts = [] - for task in getattr(snapshot, "tasks", ()) or (): - for item in getattr(task, "interrupts", ()) or (): - interrupts.append(getattr(item, "value", item)) - pause = interrupts[-1] if interrupts else {"node": state.get("current_node")} + interrupts = self._snapshot_interrupts(snapshot) + if interrupts: + pause = interrupts[-1] return WorkflowRunResult( execution_id=eid, workflow_name=name, @@ -521,6 +580,14 @@ class WorkflowRuntime: pause=pause if isinstance(pause, dict) else {"value": pause}, trace=list(state.get("trace") or []), ) + if self._is_structurally_terminal(definition, state): + return self._result_from_state(definition, eid, state) + if getattr(snapshot, "next", None): + raise RuntimeError( + "LangGraph retornou trabalho pendente sem interrupt real em estado não terminal; " + f"workflow={definition.name!r} current_node={state.get('current_node')!r} " + f"next={getattr(snapshot, 'next', None)!r}" + ) return self._result_from_state(definition, eid, state) except Exception as exc: # Preserve the last durable LangGraph snapshot instead of discarding @@ -582,12 +649,9 @@ class WorkflowRuntime: state = await graph.ainvoke(Command(resume=resume_value), config=config) phase = "aget_state_resume" snapshot = await graph.aget_state(config) - if getattr(snapshot, "next", None): - interrupts = [] - for task in getattr(snapshot, "tasks", ()) or (): - for item in getattr(task, "interrupts", ()) or (): - interrupts.append(getattr(item, "value", item)) - pause = interrupts[-1] if interrupts else {"node": state.get("current_node")} + interrupts = self._snapshot_interrupts(snapshot) + if interrupts: + pause = interrupts[-1] return WorkflowRunResult( execution_id=execution_id, workflow_name=name, @@ -598,6 +662,14 @@ class WorkflowRuntime: pause=pause if isinstance(pause, dict) else {"value": pause}, trace=list(state.get("trace") or []), ) + if self._is_structurally_terminal(definition, state): + return self._result_from_state(definition, execution_id, state) + if getattr(snapshot, "next", None): + raise RuntimeError( + "LangGraph retornou trabalho pendente sem interrupt real em estado não terminal; " + f"workflow={definition.name!r} current_node={state.get('current_node')!r} " + f"next={getattr(snapshot, 'next', None)!r}" + ) return self._result_from_state(definition, execution_id, state) except Exception as exc: partial: dict[str, Any] = {} diff --git a/mcp/servers/telecom_mcp_server/__pycache__/main.cpython-313.pyc b/mcp/servers/telecom_mcp_server/__pycache__/main.cpython-313.pyc new file mode 100644 index 00000000..e1fdcb0e Binary files /dev/null and b/mcp/servers/telecom_mcp_server/__pycache__/main.cpython-313.pyc differ diff --git a/templates/agent_template_backend/.env b/templates/agent_template_backend/.env deleted file mode 100644 index 45567341..00000000 --- a/templates/agent_template_backend/.env +++ /dev/null @@ -1,207 +0,0 @@ -############################################################################### -# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA -# Este arquivo é lido por Pydantic Settings no framework e no backend template. -############################################################################### - -APP_NAME=ai-agent-template -APP_ENV=local -LOG_LEVEL=INFO -API_HOST=0.0.0.0 -API_PORT=8000 -CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 - -############################################################################### -# LLM - OCI Generative AI como provider principal -############################################################################### -# Opções: mock, oci_openai, oci_sdk, openai_compatible -LLM_PROVIDER=oci_sdk -LLM_TEMPERATURE=0.2 -LLM_MAX_TOKENS=2048 -LLM_TIMEOUT_SECONDS=120 - -# OCI OpenAI-compatible endpoint -OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com -OCI_GENAI_MODEL=openai.gpt-4.1 -OCI_GENAI_API_KEY=sk-ph3FgX6iP3fxAQCXb9IpPIDTadkeeYAWntUWhzcWysIM6zsS -OCI_GENAI_PROJECT_OCID= - -#OCI_GENAI_BASE_URL=https://pegruagntaiatenddev.pe.inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com -#OCI_GENAI_MODEL=openai.gpt-4.1 -#OCI_GENAI_API_KEY= -#OCI_GENAI_PROJECT_OCID= - - -# OCI_AUTH_MODE=config_file|instance_principal|resource_principal -OCI_AUTH_MODE=config_file -# OCI SDK / signer / profiles -OCI_CONFIG_FILE=~/.oci/config -OCI_PROFILE=LATINOAMERICA-Chicago -OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q -OCI_REGION=us-chicago-1 - -############################################################################### -# Persistência -############################################################################### -# Opções: memory, autonomous, mongodb -SESSION_REPOSITORY_PROVIDER=autonomous -MEMORY_REPOSITORY_PROVIDER=autonomous -CHECKPOINT_REPOSITORY_PROVIDER=autonomous - -# Autonomous Database -ADB_USER=admin -ADB_PASSWORD=Moniquinha19721972 -ADB_DSN=oradb23ai_high -ADB_WALLET_LOCATION=/mnt/d/Dropbox/ORACLE/LatinoAmerica/Wallet_ORADB23ai -ADB_WALLET_PASSWORD=Moniquinha1972 -ADB_TABLE_PREFIX=AGENTFW - -# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente -MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017 -MONGODB_DATABASE=agent_platform - -# Redis -REDIS_URL=redis://localhost:6379/0 -ENABLE_REDIS_CACHE=false - -############################################################################### -# RAG / Vector / Graph -############################################################################### -VECTOR_STORE_PROVIDER=autonomous -GRAPH_STORE_PROVIDER=autonomous -RAG_TOP_K=5 -EMBEDDING_PROVIDER=oci -OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0 -RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json - -############################################################################### -# Observabilidade -############################################################################### -ENABLE_LANGFUSE=true - # Opcional: verbose, compact -LANGFUSE_TRACE_MODE=compact -# Nome customizado do trace pai, ex.: backoffice.checklist.workflow ou backoffice.emulador.workflow -LANGFUSE_COMPACT_VISIBLE_EVENT_PREFIXES=AGA.,NOC., IC. -LANGFUSE_COMPACT_SUPPRESSED_PREFIXES=llm.chat_completion -LANGFUSE_IGNORE_HEALTHCHECKS=true -LANGFUSE_IGNORED_PATHS=/health,/ready,/metrics -LANGFUSE_PUBLIC_KEY=pk-lf-4a1e3921-5158-4fd3-a16d-7a77549fb312 -LANGFUSE_SECRET_KEY=sk-lf-efc6fd59-c5ec-4858-b6ec-4aa129734915 -LANGFUSE_HOST=http://localhost:3005 -ENABLE_OTEL=false -OTEL_EXPORTER_OTLP_ENDPOINT= -OTEL_SERVICE_NAME=ai-agent-template -ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true -ENABLE_LANGFUSE_ANALYTICS_PUBLISHER=false - -############################################################################### -# Analytics / Observer corporativo -############################################################################### -# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo. -ENABLE_ANALYTICS=false -# Providers aceitos: oci_streaming,pubsub,noop -ANALYTICS_PROVIDERS=oci_streaming -# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente. -AGENT_PUBSUB_TOPIC= -GCP_PUBSUB_TOPIC_PATH= -GCP_PROJECT_ID= -GCP_PUBSUB_TOPIC= -GCP_PUBSUB_TIMEOUT_SECONDS=30 -# Credencial GCP segue padrão Google: -# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json - -############################################################################### -# OCI Streaming -############################################################################### -ENABLE_OCI_STREAMING=false -OCI_STREAM_ENDPOINT= -OCI_STREAM_OCID= -OCI_STREAM_PARTITION_KEY=agent-events - -############################################################################### -# Guardrails, Judges, Supervisor -############################################################################### -ENABLE_INPUT_GUARDRAILS=true -ENABLE_OUTPUT_GUARDRAILS=true -ENABLE_JUDGES=true -ENABLE_SUPERVISOR=true -ENABLE_OUTPUT_SUPERVISOR=true -ENABLE_PARALLEL_GUARDRAILS=true -GUARDRAILS_FAIL_FAST=true -OUTPUT_SUPERVISOR_MAX_RETRIES=3 -GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml -JUDGES_CONFIG_PATH=./config/judges.yaml -PROMPT_POLICY_PATH=./config/prompt_policy.yaml - -############################################################################### -# Gateway de canais -############################################################################### -DEFAULT_CHANNEL=web -# embedded = backend may parse simple/native channel payloads. -# external = backend only accepts GatewayRequest normalized by an external Channel Gateway. -FRAMEWORK_CHANNEL_INPUT_MODE=embedded -ENABLE_VOICE_ADAPTER=true -ENABLE_WHATSAPP_ADAPTER=true -ENABLE_TEXT_ADAPTER=true - -################################################# -# ENTERPRISE ROUTING -################################################# -# Arquivo YAML com intents, keywords, políticas de estado e fallback. -ROUTING_CONFIG_PATH=./config/routing.yaml -# true = usa LLM para classificar quando keywords/estado não resolverem. -# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência. -ENABLE_LLM_ROUTER=true - -# Semantic route stickiness (optional). -# Uses a lightweight LLM profile to decide only CONTINUE vs ROUTE. -# There are no regexes or deterministic language rules. -ENABLE_ROUTE_STICKINESS=true -ROUTE_STICKINESS_LLM_PROFILE=route_continuity -ROUTE_STICKINESS_CONFIDENCE_THRESHOLD=0.90 -ROUTE_STICKINESS_HISTORY_TURNS=2 -ROUTE_STICKINESS_MAX_TOKENS=80 -HUMAN_HANDOFF_MESSAGE=Vou encaminhar seu atendimento para uma pessoa. -END_SESSION_MESSAGE=Atendimento encerrado. Obrigado pelo contato. - -############################################################################### -# MCP / Tools -############################################################################### -ENABLE_MCP_TOOLS=true -MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml -TOOLS_CONFIG_PATH=./config/tools.yaml -MCP_TOOL_TIMEOUT_SECONDS=30 - -# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes -ROUTING_MODE=router - -# Usage/cost accounting -USAGE_REPOSITORY_PROVIDER=autonomous -IDENTITY_CONFIG_PATH=./config/identity.yaml -MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml - -# ----------------------------------------------------------------------------- -# ConversationSummaryMemory / compressão de contexto conversacional -# ----------------------------------------------------------------------------- -ENABLE_CONVERSATION_SUMMARY_MEMORY=true -MEMORY_CONTEXT_STRATEGY=summary -MEMORY_HISTORY_LIMIT=80 -MEMORY_RECENT_MESSAGES_LIMIT=8 -MEMORY_SUMMARY_TRIGGER_MESSAGES=20 -MEMORY_MAX_SUMMARY_CHARS=6000 -MEMORY_SUMMARY_USE_LLM=true -MEMORY_INJECT_RECENT_MESSAGES=true -MEMORY_INJECT_SUMMARY=true - -############################################################################### -# LONG-TERM MEMORY -############################################################################### -ENABLE_LONG_TERM_MEMORY=true -LONG_TERM_MEMORY_PROVIDER=sqlite -LONG_TERM_MEMORY_SQLITE_PATH=./data/agent_framework.db -LONG_TERM_MEMORY_TABLE=agentfw_long_term_memory -# For Autonomous/Oracle, defaults to ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY -# LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY -LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20 -LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70 -LONG_TERM_MEMORY_AUTO_EXTRACT=true -LONG_TERM_MEMORY_INJECT_CONTEXT=true diff --git a/templates/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc b/templates/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc index ee504c1e..f6a2b79f 100644 Binary files a/templates/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc and b/templates/agent_template_backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/__pycache__/main.cpython-313.pyc b/templates/agent_template_backend/app/__pycache__/main.cpython-313.pyc index 521accba..08f8cb6c 100644 Binary files a/templates/agent_template_backend/app/__pycache__/main.cpython-313.pyc and b/templates/agent_template_backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc b/templates/agent_template_backend/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc index 0e8f5864..8d7093df 100644 Binary files a/templates/agent_template_backend/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc and b/templates/agent_template_backend/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/__pycache__/state.cpython-313.pyc b/templates/agent_template_backend/app/__pycache__/state.cpython-313.pyc index 50e1f65a..31cdc3a5 100644 Binary files a/templates/agent_template_backend/app/__pycache__/state.cpython-313.pyc and b/templates/agent_template_backend/app/__pycache__/state.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/agents/__pycache__/billing_agent.cpython-313.pyc b/templates/agent_template_backend/app/agents/__pycache__/billing_agent.cpython-313.pyc index 7a1b3b5b..4e27b205 100644 Binary files a/templates/agent_template_backend/app/agents/__pycache__/billing_agent.cpython-313.pyc and b/templates/agent_template_backend/app/agents/__pycache__/billing_agent.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/agents/__pycache__/orders_agent.cpython-313.pyc b/templates/agent_template_backend/app/agents/__pycache__/orders_agent.cpython-313.pyc index 92838379..803273eb 100644 Binary files a/templates/agent_template_backend/app/agents/__pycache__/orders_agent.cpython-313.pyc and b/templates/agent_template_backend/app/agents/__pycache__/orders_agent.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/agents/__pycache__/product_agent.cpython-313.pyc b/templates/agent_template_backend/app/agents/__pycache__/product_agent.cpython-313.pyc index cec44484..104fb209 100644 Binary files a/templates/agent_template_backend/app/agents/__pycache__/product_agent.cpython-313.pyc and b/templates/agent_template_backend/app/agents/__pycache__/product_agent.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/agents/__pycache__/prompting.cpython-313.pyc b/templates/agent_template_backend/app/agents/__pycache__/prompting.cpython-313.pyc index f4cf1727..eda48f56 100644 Binary files a/templates/agent_template_backend/app/agents/__pycache__/prompting.cpython-313.pyc and b/templates/agent_template_backend/app/agents/__pycache__/prompting.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/agents/__pycache__/runtime.cpython-313.pyc b/templates/agent_template_backend/app/agents/__pycache__/runtime.cpython-313.pyc index a2df5a5f..df7bf154 100644 Binary files a/templates/agent_template_backend/app/agents/__pycache__/runtime.cpython-313.pyc and b/templates/agent_template_backend/app/agents/__pycache__/runtime.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/agents/__pycache__/support_agent.cpython-313.pyc b/templates/agent_template_backend/app/agents/__pycache__/support_agent.cpython-313.pyc index de82b0f7..7fbc59f0 100644 Binary files a/templates/agent_template_backend/app/agents/__pycache__/support_agent.cpython-313.pyc and b/templates/agent_template_backend/app/agents/__pycache__/support_agent.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/agents/billing_agent.py b/templates/agent_template_backend/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/templates/agent_template_backend/app/agents/billing_agent.py +++ b/templates/agent_template_backend/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/templates/agent_template_backend/app/agents/orders_agent.py b/templates/agent_template_backend/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/templates/agent_template_backend/app/agents/orders_agent.py +++ b/templates/agent_template_backend/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/templates/agent_template_backend/app/agents/product_agent.py b/templates/agent_template_backend/app/agents/product_agent.py index 6c691d24..113bd8e9 100644 --- a/templates/agent_template_backend/app/agents/product_agent.py +++ b/templates/agent_template_backend/app/agents/product_agent.py @@ -110,7 +110,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/templates/agent_template_backend/app/agents/support_agent.py b/templates/agent_template_backend/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/templates/agent_template_backend/app/agents/support_agent.py +++ b/templates/agent_template_backend/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/templates/agent_template_backend/app/examples/__pycache__/__init__.cpython-313.pyc b/templates/agent_template_backend/app/examples/__pycache__/__init__.cpython-313.pyc index 53216fc2..a6540ec7 100644 Binary files a/templates/agent_template_backend/app/examples/__pycache__/__init__.cpython-313.pyc and b/templates/agent_template_backend/app/examples/__pycache__/__init__.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/examples/__pycache__/grl_examples.cpython-313.pyc b/templates/agent_template_backend/app/examples/__pycache__/grl_examples.cpython-313.pyc index 5a027786..8fc575e9 100644 Binary files a/templates/agent_template_backend/app/examples/__pycache__/grl_examples.cpython-313.pyc and b/templates/agent_template_backend/app/examples/__pycache__/grl_examples.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/examples/__pycache__/ic_examples.cpython-313.pyc b/templates/agent_template_backend/app/examples/__pycache__/ic_examples.cpython-313.pyc index fa305eab..8253000a 100644 Binary files a/templates/agent_template_backend/app/examples/__pycache__/ic_examples.cpython-313.pyc and b/templates/agent_template_backend/app/examples/__pycache__/ic_examples.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/examples/__pycache__/mcp_examples.cpython-313.pyc b/templates/agent_template_backend/app/examples/__pycache__/mcp_examples.cpython-313.pyc index 761f5f79..41a72082 100644 Binary files a/templates/agent_template_backend/app/examples/__pycache__/mcp_examples.cpython-313.pyc and b/templates/agent_template_backend/app/examples/__pycache__/mcp_examples.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/examples/__pycache__/noc_examples.cpython-313.pyc b/templates/agent_template_backend/app/examples/__pycache__/noc_examples.cpython-313.pyc index 4f9483cf..a8f3f887 100644 Binary files a/templates/agent_template_backend/app/examples/__pycache__/noc_examples.cpython-313.pyc and b/templates/agent_template_backend/app/examples/__pycache__/noc_examples.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/examples/__pycache__/observer_examples.cpython-313.pyc b/templates/agent_template_backend/app/examples/__pycache__/observer_examples.cpython-313.pyc index ccfb7552..3b7d6a83 100644 Binary files a/templates/agent_template_backend/app/examples/__pycache__/observer_examples.cpython-313.pyc and b/templates/agent_template_backend/app/examples/__pycache__/observer_examples.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/observability/__pycache__/__init__.cpython-313.pyc b/templates/agent_template_backend/app/observability/__pycache__/__init__.cpython-313.pyc index 288f277b..9144c5b2 100644 Binary files a/templates/agent_template_backend/app/observability/__pycache__/__init__.cpython-313.pyc and b/templates/agent_template_backend/app/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/observability/__pycache__/telemetry_observer.cpython-313.pyc b/templates/agent_template_backend/app/observability/__pycache__/telemetry_observer.cpython-313.pyc index 51251828..9495cf23 100644 Binary files a/templates/agent_template_backend/app/observability/__pycache__/telemetry_observer.cpython-313.pyc and b/templates/agent_template_backend/app/observability/__pycache__/telemetry_observer.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc b/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc index 69de758a..32e74b95 100644 Binary files a/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc and b/templates/agent_template_backend/app/presentation/__pycache__/tool_renderers.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/presentation/tool_renderers.py b/templates/agent_template_backend/app/presentation/tool_renderers.py index cb442023..f77c47a4 100644 --- a/templates/agent_template_backend/app/presentation/tool_renderers.py +++ b/templates/agent_template_backend/app/presentation/tool_renderers.py @@ -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: - """Renderiza somente campos de negócio seguros da fatura. - - 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 + return f"[{agent_label}] Fatura consultada: {result}." def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None: diff --git a/templates/agent_template_backend/app/workflow_actions/__pycache__/__init__.cpython-313.pyc b/templates/agent_template_backend/app/workflow_actions/__pycache__/__init__.cpython-313.pyc index 32363647..d2173760 100644 Binary files a/templates/agent_template_backend/app/workflow_actions/__pycache__/__init__.cpython-313.pyc and b/templates/agent_template_backend/app/workflow_actions/__pycache__/__init__.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/workflow_actions/__pycache__/devolucao.cpython-313.pyc b/templates/agent_template_backend/app/workflow_actions/__pycache__/devolucao.cpython-313.pyc index 001fdff4..9ec9aa1b 100644 Binary files a/templates/agent_template_backend/app/workflow_actions/__pycache__/devolucao.cpython-313.pyc and b/templates/agent_template_backend/app/workflow_actions/__pycache__/devolucao.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 720b535e..3515b5c1 100644 Binary files a/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/templates/agent_template_backend/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/templates/agent_template_backend/app/workflows/agent_graph.py b/templates/agent_template_backend/app/workflows/agent_graph.py index fef0791c..99bf4c5f 100644 --- a/templates/agent_template_backend/app/workflows/agent_graph.py +++ b/templates/agent_template_backend/app/workflows/agent_graph.py @@ -160,7 +160,7 @@ class AgentWorkflow: builder.add_conditional_edges( "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_conditional_edges( @@ -186,7 +186,11 @@ class AgentWorkflow: builder.add_edge("end_session", "output_supervisor") builder.add_edge("supervisor_agent", "output_supervisor") builder.add_edge("output_supervisor", "output_guardrails") - builder.add_edge("output_guardrails", "judge") + builder.add_conditional_edges( + "output_guardrails", + lambda s: "blocked" if s.get("blocked") else "continue", + {"blocked": "persist", "continue": "judge"}, + ) builder.add_edge("judge", "supervisor_review") builder.add_edge("supervisor_review", "persist_long_term_memory") builder.add_edge("persist_long_term_memory", "persist") @@ -197,6 +201,28 @@ class AgentWorkflow: def _after_input_guardrails(self, state): return "blocked" if state.get("blocked") else "continue" + @staticmethod + def _input_guardrail_user_message(decisions, state, sanitized_text): + 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): if state.get("session_ended") is True: answer = str(getattr( @@ -281,12 +307,32 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) if any(not d.allowed for d in decisions): + # Input blocks stop routing/tools. Keep the internal reason in telemetry, + # create a safe user-facing message, then send it through output guardrails. + user_message = self._input_guardrail_user_message(decisions, state, sanitized) return { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -494,119 +540,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -622,15 +555,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -716,7 +649,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/templates/agent_template_backend/config/routing.yaml b/templates/agent_template_backend/config/routing.yaml index 03aeaa95..bb4ef6f9 100644 --- a/templates/agent_template_backend/config/routing.yaml +++ b/templates/agent_template_backend/config/routing.yaml @@ -7,6 +7,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/templates/agent_template_backend/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/templates/agent_template_backend_day_zero/.env b/templates/agent_template_backend_day_zero/.env deleted file mode 100644 index 45567341..00000000 --- a/templates/agent_template_backend_day_zero/.env +++ /dev/null @@ -1,207 +0,0 @@ -############################################################################### -# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA -# Este arquivo é lido por Pydantic Settings no framework e no backend template. -############################################################################### - -APP_NAME=ai-agent-template -APP_ENV=local -LOG_LEVEL=INFO -API_HOST=0.0.0.0 -API_PORT=8000 -CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 - -############################################################################### -# LLM - OCI Generative AI como provider principal -############################################################################### -# Opções: mock, oci_openai, oci_sdk, openai_compatible -LLM_PROVIDER=oci_sdk -LLM_TEMPERATURE=0.2 -LLM_MAX_TOKENS=2048 -LLM_TIMEOUT_SECONDS=120 - -# OCI OpenAI-compatible endpoint -OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com -OCI_GENAI_MODEL=openai.gpt-4.1 -OCI_GENAI_API_KEY=sk-ph3FgX6iP3fxAQCXb9IpPIDTadkeeYAWntUWhzcWysIM6zsS -OCI_GENAI_PROJECT_OCID= - -#OCI_GENAI_BASE_URL=https://pegruagntaiatenddev.pe.inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com -#OCI_GENAI_MODEL=openai.gpt-4.1 -#OCI_GENAI_API_KEY= -#OCI_GENAI_PROJECT_OCID= - - -# OCI_AUTH_MODE=config_file|instance_principal|resource_principal -OCI_AUTH_MODE=config_file -# OCI SDK / signer / profiles -OCI_CONFIG_FILE=~/.oci/config -OCI_PROFILE=LATINOAMERICA-Chicago -OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q -OCI_REGION=us-chicago-1 - -############################################################################### -# Persistência -############################################################################### -# Opções: memory, autonomous, mongodb -SESSION_REPOSITORY_PROVIDER=autonomous -MEMORY_REPOSITORY_PROVIDER=autonomous -CHECKPOINT_REPOSITORY_PROVIDER=autonomous - -# Autonomous Database -ADB_USER=admin -ADB_PASSWORD=Moniquinha19721972 -ADB_DSN=oradb23ai_high -ADB_WALLET_LOCATION=/mnt/d/Dropbox/ORACLE/LatinoAmerica/Wallet_ORADB23ai -ADB_WALLET_PASSWORD=Moniquinha1972 -ADB_TABLE_PREFIX=AGENTFW - -# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente -MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017 -MONGODB_DATABASE=agent_platform - -# Redis -REDIS_URL=redis://localhost:6379/0 -ENABLE_REDIS_CACHE=false - -############################################################################### -# RAG / Vector / Graph -############################################################################### -VECTOR_STORE_PROVIDER=autonomous -GRAPH_STORE_PROVIDER=autonomous -RAG_TOP_K=5 -EMBEDDING_PROVIDER=oci -OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0 -RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json - -############################################################################### -# Observabilidade -############################################################################### -ENABLE_LANGFUSE=true - # Opcional: verbose, compact -LANGFUSE_TRACE_MODE=compact -# Nome customizado do trace pai, ex.: backoffice.checklist.workflow ou backoffice.emulador.workflow -LANGFUSE_COMPACT_VISIBLE_EVENT_PREFIXES=AGA.,NOC., IC. -LANGFUSE_COMPACT_SUPPRESSED_PREFIXES=llm.chat_completion -LANGFUSE_IGNORE_HEALTHCHECKS=true -LANGFUSE_IGNORED_PATHS=/health,/ready,/metrics -LANGFUSE_PUBLIC_KEY=pk-lf-4a1e3921-5158-4fd3-a16d-7a77549fb312 -LANGFUSE_SECRET_KEY=sk-lf-efc6fd59-c5ec-4858-b6ec-4aa129734915 -LANGFUSE_HOST=http://localhost:3005 -ENABLE_OTEL=false -OTEL_EXPORTER_OTLP_ENDPOINT= -OTEL_SERVICE_NAME=ai-agent-template -ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true -ENABLE_LANGFUSE_ANALYTICS_PUBLISHER=false - -############################################################################### -# Analytics / Observer corporativo -############################################################################### -# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo. -ENABLE_ANALYTICS=false -# Providers aceitos: oci_streaming,pubsub,noop -ANALYTICS_PROVIDERS=oci_streaming -# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente. -AGENT_PUBSUB_TOPIC= -GCP_PUBSUB_TOPIC_PATH= -GCP_PROJECT_ID= -GCP_PUBSUB_TOPIC= -GCP_PUBSUB_TIMEOUT_SECONDS=30 -# Credencial GCP segue padrão Google: -# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json - -############################################################################### -# OCI Streaming -############################################################################### -ENABLE_OCI_STREAMING=false -OCI_STREAM_ENDPOINT= -OCI_STREAM_OCID= -OCI_STREAM_PARTITION_KEY=agent-events - -############################################################################### -# Guardrails, Judges, Supervisor -############################################################################### -ENABLE_INPUT_GUARDRAILS=true -ENABLE_OUTPUT_GUARDRAILS=true -ENABLE_JUDGES=true -ENABLE_SUPERVISOR=true -ENABLE_OUTPUT_SUPERVISOR=true -ENABLE_PARALLEL_GUARDRAILS=true -GUARDRAILS_FAIL_FAST=true -OUTPUT_SUPERVISOR_MAX_RETRIES=3 -GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml -JUDGES_CONFIG_PATH=./config/judges.yaml -PROMPT_POLICY_PATH=./config/prompt_policy.yaml - -############################################################################### -# Gateway de canais -############################################################################### -DEFAULT_CHANNEL=web -# embedded = backend may parse simple/native channel payloads. -# external = backend only accepts GatewayRequest normalized by an external Channel Gateway. -FRAMEWORK_CHANNEL_INPUT_MODE=embedded -ENABLE_VOICE_ADAPTER=true -ENABLE_WHATSAPP_ADAPTER=true -ENABLE_TEXT_ADAPTER=true - -################################################# -# ENTERPRISE ROUTING -################################################# -# Arquivo YAML com intents, keywords, políticas de estado e fallback. -ROUTING_CONFIG_PATH=./config/routing.yaml -# true = usa LLM para classificar quando keywords/estado não resolverem. -# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência. -ENABLE_LLM_ROUTER=true - -# Semantic route stickiness (optional). -# Uses a lightweight LLM profile to decide only CONTINUE vs ROUTE. -# There are no regexes or deterministic language rules. -ENABLE_ROUTE_STICKINESS=true -ROUTE_STICKINESS_LLM_PROFILE=route_continuity -ROUTE_STICKINESS_CONFIDENCE_THRESHOLD=0.90 -ROUTE_STICKINESS_HISTORY_TURNS=2 -ROUTE_STICKINESS_MAX_TOKENS=80 -HUMAN_HANDOFF_MESSAGE=Vou encaminhar seu atendimento para uma pessoa. -END_SESSION_MESSAGE=Atendimento encerrado. Obrigado pelo contato. - -############################################################################### -# MCP / Tools -############################################################################### -ENABLE_MCP_TOOLS=true -MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml -TOOLS_CONFIG_PATH=./config/tools.yaml -MCP_TOOL_TIMEOUT_SECONDS=30 - -# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes -ROUTING_MODE=router - -# Usage/cost accounting -USAGE_REPOSITORY_PROVIDER=autonomous -IDENTITY_CONFIG_PATH=./config/identity.yaml -MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml - -# ----------------------------------------------------------------------------- -# ConversationSummaryMemory / compressão de contexto conversacional -# ----------------------------------------------------------------------------- -ENABLE_CONVERSATION_SUMMARY_MEMORY=true -MEMORY_CONTEXT_STRATEGY=summary -MEMORY_HISTORY_LIMIT=80 -MEMORY_RECENT_MESSAGES_LIMIT=8 -MEMORY_SUMMARY_TRIGGER_MESSAGES=20 -MEMORY_MAX_SUMMARY_CHARS=6000 -MEMORY_SUMMARY_USE_LLM=true -MEMORY_INJECT_RECENT_MESSAGES=true -MEMORY_INJECT_SUMMARY=true - -############################################################################### -# LONG-TERM MEMORY -############################################################################### -ENABLE_LONG_TERM_MEMORY=true -LONG_TERM_MEMORY_PROVIDER=sqlite -LONG_TERM_MEMORY_SQLITE_PATH=./data/agent_framework.db -LONG_TERM_MEMORY_TABLE=agentfw_long_term_memory -# For Autonomous/Oracle, defaults to ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY -# LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY -LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20 -LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70 -LONG_TERM_MEMORY_AUTO_EXTRACT=true -LONG_TERM_MEMORY_INJECT_CONTEXT=true diff --git a/templates/agent_template_backend_day_zero/app/__pycache__/__init__.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/__pycache__/__init__.cpython-313.pyc index 6a9d2090..eafedeeb 100644 Binary files a/templates/agent_template_backend_day_zero/app/__pycache__/__init__.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/__pycache__/main.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/__pycache__/main.cpython-313.pyc index 7e6bd82b..c13b42c4 100644 Binary files a/templates/agent_template_backend_day_zero/app/__pycache__/main.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/__pycache__/main.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc index a18a16d7..a8f0280d 100644 Binary files a/templates/agent_template_backend_day_zero/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/__pycache__/state.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/__pycache__/state.cpython-313.pyc index 4dc5fa79..f856d0c4 100644 Binary files a/templates/agent_template_backend_day_zero/app/__pycache__/state.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/__pycache__/state.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/agents/__pycache__/billing_agent.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/agents/__pycache__/billing_agent.cpython-313.pyc index fa947b3c..406206df 100644 Binary files a/templates/agent_template_backend_day_zero/app/agents/__pycache__/billing_agent.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/agents/__pycache__/billing_agent.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/agents/__pycache__/orders_agent.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/agents/__pycache__/orders_agent.cpython-313.pyc index 50916c38..cbe2d5df 100644 Binary files a/templates/agent_template_backend_day_zero/app/agents/__pycache__/orders_agent.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/agents/__pycache__/orders_agent.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/agents/__pycache__/product_agent.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/agents/__pycache__/product_agent.cpython-313.pyc index 4e1d80ea..d394a5bf 100644 Binary files a/templates/agent_template_backend_day_zero/app/agents/__pycache__/product_agent.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/agents/__pycache__/product_agent.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/agents/__pycache__/prompting.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/agents/__pycache__/prompting.cpython-313.pyc index ac2905a7..d96c6e4c 100644 Binary files a/templates/agent_template_backend_day_zero/app/agents/__pycache__/prompting.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/agents/__pycache__/prompting.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/agents/__pycache__/runtime.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/agents/__pycache__/runtime.cpython-313.pyc index f1619c00..0d395862 100644 Binary files a/templates/agent_template_backend_day_zero/app/agents/__pycache__/runtime.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/agents/__pycache__/runtime.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/agents/__pycache__/support_agent.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/agents/__pycache__/support_agent.cpython-313.pyc index fa42c73e..9dee7059 100644 Binary files a/templates/agent_template_backend_day_zero/app/agents/__pycache__/support_agent.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/agents/__pycache__/support_agent.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/agents/billing_agent.py b/templates/agent_template_backend_day_zero/app/agents/billing_agent.py index 05254d17..aa600999 100644 --- a/templates/agent_template_backend_day_zero/app/agents/billing_agent.py +++ b/templates/agent_template_backend_day_zero/app/agents/billing_agent.py @@ -95,7 +95,7 @@ class BillingAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/templates/agent_template_backend_day_zero/app/agents/orders_agent.py b/templates/agent_template_backend_day_zero/app/agents/orders_agent.py index b793c260..f557bed4 100644 --- a/templates/agent_template_backend_day_zero/app/agents/orders_agent.py +++ b/templates/agent_template_backend_day_zero/app/agents/orders_agent.py @@ -95,7 +95,7 @@ class OrdersAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/templates/agent_template_backend_day_zero/app/agents/product_agent.py b/templates/agent_template_backend_day_zero/app/agents/product_agent.py index 998dad66..34433f57 100644 --- a/templates/agent_template_backend_day_zero/app/agents/product_agent.py +++ b/templates/agent_template_backend_day_zero/app/agents/product_agent.py @@ -95,7 +95,7 @@ class ProductAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/templates/agent_template_backend_day_zero/app/agents/support_agent.py b/templates/agent_template_backend_day_zero/app/agents/support_agent.py index b15a0e42..b4f0244f 100644 --- a/templates/agent_template_backend_day_zero/app/agents/support_agent.py +++ b/templates/agent_template_backend_day_zero/app/agents/support_agent.py @@ -95,7 +95,7 @@ class SupportAgent(AgentRuntimeMixin): state, system_prompt=apply_agent_profile_prompt( 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, rag_context=rag_context, diff --git a/templates/agent_template_backend_day_zero/app/observability/__pycache__/__init__.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/observability/__pycache__/__init__.cpython-313.pyc index 029d41d1..359dee6e 100644 Binary files a/templates/agent_template_backend_day_zero/app/observability/__pycache__/__init__.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/observability/__pycache__/telemetry_observer.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/observability/__pycache__/telemetry_observer.cpython-313.pyc index 2d327ac3..76a03bbd 100644 Binary files a/templates/agent_template_backend_day_zero/app/observability/__pycache__/telemetry_observer.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/observability/__pycache__/telemetry_observer.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc index 09d9e25f..31ea4a26 100644 Binary files a/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/templates/agent_template_backend_day_zero/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py b/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py index aa1eb63b..2770df01 100644 --- a/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py +++ b/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py @@ -184,7 +184,11 @@ class AgentWorkflow: builder.add_edge("end_session", "output_supervisor") builder.add_edge("supervisor_agent", "output_supervisor") builder.add_edge("output_supervisor", "output_guardrails") - builder.add_edge("output_guardrails", "judge") + builder.add_conditional_edges( + "output_guardrails", + lambda s: "blocked" if s.get("blocked") else "continue", + {"blocked": "persist", "continue": "judge"}, + ) builder.add_edge("judge", "supervisor_review") builder.add_edge("supervisor_review", "persist_long_term_memory") builder.add_edge("persist_long_term_memory", "persist") @@ -195,6 +199,28 @@ class AgentWorkflow: def _after_input_guardrails(self, state): return "blocked" if state.get("blocked") else "continue" + @staticmethod + def _input_guardrail_user_message(decisions, state, sanitized_text): + 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): if state.get("session_ended") is True: answer = str(getattr( @@ -279,12 +305,32 @@ class AgentWorkflow: component="workflow.input_guardrails.final", ) if any(not d.allowed for d in decisions): + # Input blocks stop routing/tools. Keep the internal reason in telemetry, + # create a safe user-facing message, then send it through output guardrails. + user_message = self._input_guardrail_user_message(decisions, state, sanitized) return { "sanitized_input": sanitized, - "answer": "Não consegui seguir com essa mensagem por regra de segurança.", - "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "answer": user_message, + "final_answer": None, "guardrail_decisions": [d.model_dump() for d in decisions], "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, } return { @@ -492,119 +538,6 @@ class AgentWorkflow: "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): """Valida a resposta candidata com o OutputSupervisor corporativo. @@ -620,15 +553,15 @@ class AgentWorkflow: } candidate = state.get("answer") or "" - context = self._output_guardrail_context(state) - context.update({ + context = { + **(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": state.get("conversation_key") or state.get("session_id"), "route": state.get("route"), "intent": state.get("intent"), "supervisor_attempt": int(state.get("supervisor_attempt", 0)), - }) + } async with self.telemetry.span( "workflow.output_supervisor", session_id=state.get("conversation_key") or state.get("session_id"), @@ -714,7 +647,7 @@ class AgentWorkflow: component="workflow.output_guardrails.start", ) final, decisions = await self.guardrails.run_output( - state["answer"], self._output_guardrail_context(state) + state["answer"], state.get("context", {}) ) for _decision in decisions: await self.guardrail_telemetry.evaluated("output", _decision) diff --git a/templates/agent_template_backend_day_zero/config/routing.yaml b/templates/agent_template_backend_day_zero/config/routing.yaml index 187a070e..1e6e0794 100644 --- a/templates/agent_template_backend_day_zero/config/routing.yaml +++ b/templates/agent_template_backend_day_zero/config/routing.yaml @@ -12,6 +12,35 @@ router: confidence_threshold: 0.65 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: WAITING_BILLING_CONFIRMATION agent: billing_agent diff --git a/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md b/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md new file mode 100644 index 00000000..a5508710 --- /dev/null +++ b/templates/agent_template_backend_day_zero/docs/TRANSACTION_SEMANTIC_CONFIRMATION.md @@ -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. diff --git a/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc index 2e7e76e5..8290c40a 100644 Binary files a/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc index 130bc82e..92db6fdb 100644 Binary files a/tests/__pycache__/test_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_compliance_protocol_expected_values.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_compliance_protocol_expected_values.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..807d2efa Binary files /dev/null and b/tests/__pycache__/test_compliance_protocol_expected_values.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_contextual_reentry_transaction_parameters.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_contextual_reentry_transaction_parameters.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..ca9f9c5a Binary files /dev/null and b/tests/__pycache__/test_contextual_reentry_transaction_parameters.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_dlex_out_expected_protocol_authorization.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_dlex_out_expected_protocol_authorization.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..f6b2a493 Binary files /dev/null and b/tests/__pycache__/test_dlex_out_expected_protocol_authorization.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_expected_input_coherence_delegation.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_expected_input_coherence_delegation.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..2bd695b0 Binary files /dev/null and b/tests/__pycache__/test_expected_input_coherence_delegation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_expected_input_semantic_classifier.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_expected_input_semantic_classifier.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..e4ec634d Binary files /dev/null and b/tests/__pycache__/test_expected_input_semantic_classifier.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_fraseologia_business_parameter_prompt.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_fraseologia_business_parameter_prompt.cpython-313-pytest-9.0.2.pyc index 35408331..984d80b7 100644 Binary files a/tests/__pycache__/test_fraseologia_business_parameter_prompt.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_fraseologia_business_parameter_prompt.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_generic_tool_response_presentation.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_generic_tool_response_presentation.cpython-313-pytest-9.0.2.pyc index 0e408dc1..f303beb1 100644 Binary files a/tests/__pycache__/test_generic_tool_response_presentation.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_generic_tool_response_presentation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_judge_transaction_sampling.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_judge_transaction_sampling.cpython-313-pytest-9.0.2.pyc index 2721be2a..7fdc40ac 100644 Binary files a/tests/__pycache__/test_judge_transaction_sampling.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_judge_transaction_sampling.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_mcp_parameter_extraction_runtime.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_mcp_parameter_extraction_runtime.cpython-313-pytest-9.0.2.pyc index c7491df0..1fc2fc55 100644 Binary files a/tests/__pycache__/test_mcp_parameter_extraction_runtime.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_mcp_parameter_extraction_runtime.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_observer_cross_loop_deadlock_fix.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_observer_cross_loop_deadlock_fix.cpython-313-pytest-9.0.2.pyc index 15dcc40b..bbffaa90 100644 Binary files a/tests/__pycache__/test_observer_cross_loop_deadlock_fix.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_observer_cross_loop_deadlock_fix.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_paused_workflow_resume_precedence.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_paused_workflow_resume_precedence.cpython-313-pytest-9.0.2.pyc index 4cbb1d17..262cbd11 100644 Binary files a/tests/__pycache__/test_paused_workflow_resume_precedence.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_paused_workflow_resume_precedence.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_performance_optimizations.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_performance_optimizations.cpython-313-pytest-9.0.2.pyc index dafcf302..d7511ad2 100644 Binary files a/tests/__pycache__/test_performance_optimizations.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_performance_optimizations.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_phraseology_rewrite_revalidation.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_phraseology_rewrite_revalidation.cpython-313-pytest-9.0.2.pyc index ab54f2cd..fed35916 100644 Binary files a/tests/__pycache__/test_phraseology_rewrite_revalidation.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_phraseology_rewrite_revalidation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_route_stickiness_transaction_shift.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_route_stickiness_transaction_shift.cpython-313-pytest-9.0.2.pyc index 479cc369..ec506487 100644 Binary files a/tests/__pycache__/test_route_stickiness_transaction_shift.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_route_stickiness_transaction_shift.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_structured_output_parser.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_structured_output_parser.cpython-313-pytest-9.0.2.pyc index c0b3bb4b..7689c57f 100644 Binary files a/tests/__pycache__/test_structured_output_parser.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_structured_output_parser.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_confirmation_customer_facing.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_confirmation_customer_facing.cpython-313-pytest-9.0.2.pyc index 64c4baab..adba635f 100644 Binary files a/tests/__pycache__/test_transaction_confirmation_customer_facing.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_confirmation_customer_facing.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_parameter_descriptions.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_parameter_descriptions.cpython-313-pytest-9.0.2.pyc index d701b122..d66663ed 100644 Binary files a/tests/__pycache__/test_transaction_parameter_descriptions.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_parameter_descriptions.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_parameter_llm_precedence.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_parameter_llm_precedence.cpython-313-pytest-9.0.2.pyc index 8d7f43ca..8fe5b156 100644 Binary files a/tests/__pycache__/test_transaction_parameter_llm_precedence.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_parameter_llm_precedence.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_state_regression_matrix.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_state_regression_matrix.cpython-313-pytest-9.0.2.pyc index 7b19f265..db714c57 100644 Binary files a/tests/__pycache__/test_transaction_state_regression_matrix.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_state_regression_matrix.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_state_router_interruption.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_state_router_interruption.cpython-313-pytest-9.0.2.pyc index 89a99916..9ab2f1bc 100644 Binary files a/tests/__pycache__/test_transaction_state_router_interruption.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_state_router_interruption.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transactional_tool_flow.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transactional_tool_flow.cpython-313-pytest-9.0.2.pyc index 25a6167d..49727452 100644 Binary files a/tests/__pycache__/test_transactional_tool_flow.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transactional_tool_flow.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/test_contextual_reentry_transaction_parameters.py b/tests/test_contextual_reentry_transaction_parameters.py new file mode 100644 index 00000000..46a7b3bd --- /dev/null +++ b/tests/test_contextual_reentry_transaction_parameters.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +import pytest + +from agent_framework.runtime.transaction_parameters import extract_transaction_parameters + + +class _ContextAwareLLM: + def __init__(self): + self.prompt = "" + + async def ainvoke(self, messages, **kwargs): + self.prompt = messages[-1]["content"] + # This simulates a semantic extractor resolving the current reference + # against the bounded conversation context. The values remain candidates; + # authoritative validation belongs to the domain pre-validation step. + return json.dumps({"subject": "Tamboro Mensal", "valor": 14.99}, ensure_ascii=False) + + +@pytest.mark.asyncio +async def test_contextual_reentry_separates_current_claim_from_prior_context_for_candidate_extraction(): + llm = _ContextAwareLLM() + out = await extract_transaction_parameters( + llm, + text="é a de quatorze e noventa e nove", + conversational_context=( + "user: tem uma cobrança aqui que eu não reconheço\n" + "assistant: Cobrança Tamboro Mensal no valor de R$ 14,99; " + "TIM Fashion Mensal no valor de R$ 10,00." + ), + tool_name="contestar_cobranca", + missing_parameters=["subject", "valor"], + parameter_schema={ + "subject": {"type": "string", "description": "item concreto da fatura"}, + "valor": {"type": "number", "description": "valor explicitamente associado pelo cliente"}, + }, + tool_description="Contesta uma cobrança após validação autoritativa e confirmação.", + ) + assert out == {"subject": "Tamboro Mensal", "valor": 14.99} + assert "conversational_context:" in llm.prompt + assert "Cobrança Tamboro Mensal" in llm.prompt + assert "user_message: é a de quatorze e noventa e nove" in llm.prompt + assert "Não trate texto do contexto como uma nova afirmação do cliente" in llm.prompt diff --git a/tests/test_dlex_out_expected_protocol_authorization.py b/tests/test_dlex_out_expected_protocol_authorization.py new file mode 100644 index 00000000..3367f288 --- /dev/null +++ b/tests/test_dlex_out_expected_protocol_authorization.py @@ -0,0 +1,153 @@ +import pytest + +from agent_framework.guardrails.rails import DataLeakageOutputRail + + +@pytest.mark.asyncio +async def test_dlex_out_masks_protocol_explicitly_authorized_by_expected_protocols(monkeypatch): + captured = {} + + async def fake_classifier(_llm, task, payload, **_kwargs): + assert task == "DLEX_OUT" + captured.update(payload) + assert "1234567890" not in payload["text"] + assert "" in payload["text"] + # The raw value must also not leak back into classifier context. + assert "1234567890" not in repr(payload["context"]) + return {"allowed": True, "label": "OK", "reason": "authorized protocol masked"} + + monkeypatch.setattr( + "agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier + ) + + rail = DataLeakageOutputRail() + decision = await rail.evaluate( + "Seu número de protocolo é 1234567890.", + { + "__guardrails_yaml_controlled": True, + "expected_protocols": ["1234567890"], + }, + ) + + assert decision.allowed is True + assert decision.sanitized_text == "Seu número de protocolo é 1234567890." + assert decision.metadata["protocol_authorization"] == "expected_values" + assert decision.metadata["authorized_protocols_masked"] == 1 + + +@pytest.mark.asyncio +async def test_dlex_out_does_not_mask_unexpected_protocol(monkeypatch): + async def fake_classifier(_llm, task, payload, **_kwargs): + assert task == "DLEX_OUT" + assert "9999999999" in payload["text"] + assert "" not in payload["text"] + return {"allowed": False, "label": "DLEX_OUT", "reason": "unexpected identifier"} + + monkeypatch.setattr( + "agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier + ) + + rail = DataLeakageOutputRail() + decision = await rail.evaluate( + "Seu número de protocolo é 9999999999.", + { + "__guardrails_yaml_controlled": True, + "expected_protocols": ["1234567890"], + }, + ) + + assert decision.allowed is False + assert "protocol_authorization" not in decision.metadata + + +@pytest.mark.asyncio +async def test_dlex_out_masks_expected_protocol_but_keeps_other_sensitive_content_visible(monkeypatch): + async def fake_classifier(_llm, task, payload, **_kwargs): + assert task == "DLEX_OUT" + assert "1234567890" not in payload["text"] + assert "" in payload["text"] + assert "sk-abcdefghijklmnop" in payload["text"] + return {"allowed": False, "label": "DLEX_OUT", "reason": "secret remains visible"} + + monkeypatch.setattr( + "agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier + ) + + rail = DataLeakageOutputRail() + decision = await rail.evaluate( + "Protocolo 1234567890; token sk-abcdefghijklmnop", + { + "__guardrails_yaml_controlled": True, + "expected_protocols": ["1234567890"], + }, + ) + + assert decision.allowed is False + assert decision.metadata["protocol_authorization"] == "expected_values" + +@pytest.mark.asyncio +async def test_dlex_out_rechecks_and_allows_false_positive_caused_only_by_authorized_protocol(monkeypatch): + calls = [] + + async def fake_classifier(_llm, task, payload, **_kwargs): + assert task == "DLEX_OUT" + calls.append(payload) + if len(calls) == 1: + assert "" in payload["text"] + return { + "allowed": False, + "label": "DLEX_OUT", + "reason": "Resposta expõe protocolo interno (identificador) que não é permitido divulgar", + } + assert "1234567890" not in payload["text"] + assert "referência pública autorizada para este cliente" in payload["text"] + assert payload["context"]["authorized_customer_protocol"] is True + return {"allowed": True, "label": "OK", "reason": "nenhum outro vazamento"} + + monkeypatch.setattr( + "agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier + ) + + rail = DataLeakageOutputRail() + decision = await rail.evaluate( + "A contestação foi criada com sucesso. O protocolo gerado é 1234567890.", + { + "__guardrails_yaml_controlled": True, + "expected_protocols": ["1234567890"], + }, + ) + + assert decision.allowed is True + assert len(calls) == 2 + assert decision.metadata["protocol_authorization"] == "expected_values" + assert decision.metadata["protocol_authorization_verified"] is True + + +@pytest.mark.asyncio +async def test_dlex_out_recheck_does_not_hide_other_leakage(monkeypatch): + calls = [] + + async def fake_classifier(_llm, task, payload, **_kwargs): + assert task == "DLEX_OUT" + calls.append(payload) + # First pass blocks; second pass must still see the unrelated secret. + assert "sk-abcdefghijklmnop" in payload["text"] + return {"allowed": False, "label": "DLEX_OUT", "reason": "token secreto exposto"} + + monkeypatch.setattr( + "agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier + ) + + rail = DataLeakageOutputRail() + decision = await rail.evaluate( + "Protocolo 1234567890; token sk-abcdefghijklmnop", + { + "__guardrails_yaml_controlled": True, + "expected_protocols": ["1234567890"], + }, + ) + + assert decision.allowed is False + assert len(calls) == 1 + assert decision.metadata["protocol_authorization"] == "expected_values" + assert decision.metadata["protocol_authorization_verified"] is False diff --git a/tests/test_expected_input_coherence_delegation.py b/tests/test_expected_input_coherence_delegation.py new file mode 100644 index 00000000..555ce4da --- /dev/null +++ b/tests/test_expected_input_coherence_delegation.py @@ -0,0 +1,133 @@ +import pytest + +from agent_framework.guardrails.rails import CoherenceRail + + +@pytest.mark.asyncio +async def test_coer_delegates_to_enumerated_expected_input_contract_without_calling_llm(): + rail = CoherenceRail() + decision = await rail.evaluate( + "ano", + { + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "reprompt": "Não entendi. Responda sim ou não.", + } + }, + ) + assert decision.allowed is True + assert decision.code == "COER" + assert decision.metadata["mechanism"] == "expected_input_contract" + assert decision.metadata["delegated"] is True + + +@pytest.mark.asyncio +async def test_coer_without_expected_input_keeps_normal_classification(monkeypatch): + async def fake_classifier(*args, **kwargs): + return {"allowed": False, "label": "COER", "reason": "fala incompreensível"} + + monkeypatch.setattr("agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier) + rail = CoherenceRail() + decision = await rail.evaluate("ano", {}) + assert decision.allowed is False + assert decision.metadata["mechanism"] == "llm_rail" + + +@pytest.mark.asyncio +async def test_coer_emits_non_blocking_semantic_signal_for_opt_in_unmatched(monkeypatch): + async def fake_classifier(*args, **kwargs): + return {"allowed": True, "label": "OK", "reason": "fala coerente e substantiva"} + + monkeypatch.setattr("agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier) + rail = CoherenceRail() + decision = await rail.evaluate( + "então tirando esses serviços o valor será 275, certo?", + { + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "reprompt": "Não entendi. Responda sim ou não.", + "unmatched": { + "meaningful_input": {"action": "resume_as", "value": "NAO"} + }, + } + }, + ) + assert decision.allowed is True + assert decision.metadata["mechanism"] == "expected_input_contract" + assert decision.metadata["semantic_coherent"] is True + assert decision.metadata["data"]["allowed"] is True + + +@pytest.mark.asyncio +async def test_coer_emits_incoherent_signal_without_blocking_when_unmatched_policy_exists(monkeypatch): + async def fake_classifier(*args, **kwargs): + return {"allowed": False, "label": "COER", "reason": "fala incompreensível"} + + monkeypatch.setattr("agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier) + rail = CoherenceRail() + decision = await rail.evaluate( + "ano", + { + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "reprompt": "Não entendi. Responda sim ou não.", + "unmatched": { + "meaningful_input": {"action": "resume_as", "value": "NAO"} + }, + } + }, + ) + assert decision.allowed is True + assert decision.metadata["semantic_coherent"] is False + + +@pytest.mark.asyncio +async def test_coer_delegates_without_own_llm_when_semantic_classifier_is_configured(monkeypatch): + async def should_not_run(*args, **kwargs): + raise AssertionError("COER LLM should not run when expected_input semantic_classifier owns semantics") + + monkeypatch.setattr("agent_framework.guardrails.rails.classify_with_framework_llm", should_not_run) + rail = CoherenceRail() + decision = await rail.evaluate( + "legal!", + { + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "semantic_classifier": { + "enabled": True, + "prompt": "Classifique em {{ allowed_values }}", + }, + } + }, + ) + assert decision.allowed is True + assert decision.metadata["mechanism"] == "expected_input_semantic_classifier" + assert decision.metadata["delegated"] is True + +@pytest.mark.asyncio +async def test_coer_delegates_short_reply_to_active_transaction_parameter_contract(monkeypatch): + async def should_not_run(*args, **kwargs): + raise AssertionError("COER LLM must not own coherence while transaction parameters are being collected") + + monkeypatch.setattr("agent_framework.guardrails.rails.classify_with_framework_llm", should_not_run) + rail = CoherenceRail() + decision = await rail.evaluate( + "Tamboro", + { + "transaction_status": "COLLECTING_PARAMETERS", + "missing_parameters": ["subject"], + "active_transaction": {"tool_name": "contestar_cobranca"}, + }, + ) + assert decision.allowed is True + assert decision.metadata["mechanism"] == "transaction_parameter_contract" + assert decision.metadata["delegated"] is True + assert decision.metadata["missing_parameters"] == ["subject"] diff --git a/tests/test_expected_input_semantic_classifier.py b/tests/test_expected_input_semantic_classifier.py new file mode 100644 index 00000000..215cfeac --- /dev/null +++ b/tests/test_expected_input_semantic_classifier.py @@ -0,0 +1,284 @@ +from types import SimpleNamespace + +import pytest + +from agent_framework.routing.enterprise_router import EnterpriseRouter +from agent_framework.workflows.input_contract import match_semantic_classifier_output + + +ROUTING_YAML = """ +router: + fallback_agent: fallback_agent + confidence_threshold: 0.70 +intents: [] +""" + + +class _ClassifierLLM: + def __init__(self, answers): + self.answers = list(answers) + self.calls = [] + + async def ainvoke(self, messages, **kwargs): + self.calls.append((messages, kwargs)) + return self.answers.pop(0) + + +def _router(tmp_path, answers): + routing = tmp_path / "routing.yaml" + routing.write_text(ROUTING_YAML, encoding="utf-8") + settings = SimpleNamespace( + ROUTING_CONFIG_PATH=str(routing), + ENABLE_LLM_ROUTER=False, + ENABLE_ROUTE_STICKINESS=False, + ) + return EnterpriseRouter(settings, llm=_ClassifierLLM(answers)) + + +def _state(text, allowed, prompt, *, history=None, include_relevant_context=False): + return { + "user_text": text, + "sanitized_input": text, + "route": "owner_agent", + "active_agent": "owner_agent", + "intent": "owner_intent", + "route_decision": {"route": "owner_agent", "agent": "owner_agent", "intent": "owner_intent"}, + "pending_domain_workflow": { + "workflow_name": "example", + "execution_id": "exec-1", + "resume_tool": "retomar_workflow", + "owner_agent": "owner_agent", + "owner_intent": "owner_intent", + "pause": { + "prompt": "Pergunta pendente", + "expected_input": { + "key": "resposta_usuario", + "allowed_values": allowed, + "normalize": "upper_strip", + "reprompt": "Escolha novamente.", + "semantic_classifier": { + "enabled": True, + "include_relevant_context": include_relevant_context, + "prompt": prompt, + }, + }, + }, + }, + "history": list(history or []), + } + + +@pytest.mark.asyncio +async def test_semantic_classifier_maps_acknowledgement_to_configured_option(tmp_path): + router = _router(tmp_path, ["SIM"]) + decision = await router.route(_state("legal!", ["SIM", "NAO"], "Classifique {{ user_input }} em {{ allowed_values }}")) + assert decision.metadata["workflow_semantic_classifier"] is True + assert decision.metadata["normalized_input"] == "SIM" + assert decision.metadata["original_input"] == "legal!" + + +@pytest.mark.asyncio +async def test_semantic_classifier_can_map_forward_fact_question_to_nao(tmp_path): + router = _router(tmp_path, ["NAO"]) + decision = await router.route(_state("então minha fatura ficaria R$ 275,00, certo?", ["SIM", "NAO"], "Hipóteses => NAO. Opções {{ allowed_values }}")) + assert decision.metadata["normalized_input"] == "NAO" + assert decision.metadata["original_input"].startswith("então minha fatura") + + +@pytest.mark.asyncio +async def test_semantic_classifier_is_generic_for_three_dynamic_options(tmp_path): + router = _router(tmp_path, ["ALTERAR"]) + decision = await router.route(_state("quero mudar", ["CONFIRMAR", "ALTERAR", "CANCELAR"], "Escolha uma de {{ allowed_values }}")) + assert decision.metadata["normalized_input"] == "ALTERAR" + assert decision.metadata["allowed_values"] == ["CONFIRMAR", "ALTERAR", "CANCELAR"] + + +@pytest.mark.asyncio +async def test_semantic_classifier_reprompts_when_llm_returns_value_outside_allowlist(tmp_path): + router = _router(tmp_path, ["TALVEZ"]) + decision = await router.route(_state("hmm", ["SIM", "NAO"], "Retorne uma de {{ allowed_values }}")) + assert decision.mcp_tools == [] + assert decision.metadata["workflow_input_invalid"] is True + assert decision.metadata["workflow_reprompt"] == "Escolha novamente." + + +def test_classifier_output_validator_uses_dynamic_allowlist(): + contract = {"allowed_values": ["A", "B", "C"], "normalize": "upper_strip"} + assert match_semantic_classifier_output(" b ", contract) == "B" + assert match_semantic_classifier_output("D", contract) is None + + +@pytest.mark.asyncio +async def test_semantic_classifier_receives_contiguous_relevant_context(tmp_path): + router = _router(tmp_path, ["NAO"]) + history = [ + {"role": "user", "content": "qual é meu plano?", "metadata": {}}, + {"role": "assistant", "content": "Seu plano é X.", "metadata": {"intent": "contas_plan_query"}}, + {"role": "user", "content": "tem uma cobrança aqui que eu não reconheço", "metadata": {}}, + {"role": "assistant", "content": "Expliquei a fatura. Com essa explicação, sanei sua dúvida?", "metadata": {"intent": "owner_intent"}}, + {"role": "user", "content": "é a de quatorze e noventa e nove", "metadata": {}}, + ] + prompt = ( + "Contexto:\ +{{ relevant_conversation_context }}\ +" + "Atual={{ user_input }} Opções={{ allowed_values }}" + ) + decision = await router.route( + _state( + "é a de quatorze e noventa e nove", + ["SIM", "NAO"], + prompt, + history=history, + include_relevant_context=True, + ) + ) + assert decision.metadata["normalized_input"] == "NAO" + context = decision.metadata["relevant_conversation_context"] + assert "tem uma cobrança aqui que eu não reconheço" in context + assert "Expliquei a fatura" in context + assert "qual é meu plano?" not in context + assert "Seu plano é X." not in context + + messages, kwargs = router.llm.calls[0] + rendered = messages[0]["content"] + assert "tem uma cobrança aqui que eu não reconheço" in rendered + assert "é a de quatorze e noventa e nove" in rendered + assert "max_tokens" not in kwargs + + +@pytest.mark.asyncio +async def test_semantic_classifier_context_does_not_inject_transaction_state(tmp_path): + router = _router(tmp_path, ["NAO"]) + state = _state( + "é a de quatorze e noventa e nove", + ["SIM", "NAO"], + "Contexto={{ relevant_conversation_context }}", + history=[ + {"role": "user", "content": "tem uma cobrança que não reconheço", "metadata": {}}, + {"role": "assistant", "content": "Expliquei. Sanei sua dúvida?", "metadata": {"intent": "owner_intent"}}, + {"role": "user", "content": "é a de quatorze e noventa e nove", "metadata": {}}, + ], + include_relevant_context=True, + ) + state["active_transaction"] = {"tool": "contestar_cobranca", "subject": "x"} + state["transaction_evidence"] = [{"secret": "should-not-be-in-context"}] + decision = await router.route(state) + context = decision.metadata["relevant_conversation_context"] + assert "contestar_cobranca" not in context + assert "should-not-be-in-context" not in context + + +@pytest.mark.asyncio +async def test_semantic_classifier_failure_exposes_raw_output_for_audit(tmp_path): + router = _router(tmp_path, ["TALVEZ porque..."]) + decision = await router.route( + _state("hmm", ["SIM", "NAO"], "Retorne {{ allowed_values }}") + ) + assert decision.metadata["workflow_input_invalid"] is True + assert decision.metadata["workflow_semantic_classifier"] is True + assert decision.metadata["classifier_raw_output"] == "TALVEZ porque..." + assert decision.metadata["allowed_values"] == ["SIM", "NAO"] + +@pytest.mark.asyncio +async def test_context_anchor_excludes_older_same_intent_topic(tmp_path): + router = _router(tmp_path, ["NAO"]) + state = _state( + "é a de quatorze e noventa e nove", + ["SIM", "NAO"], + "Contexto={{ relevant_conversation_context }}", + history=[ + {"role": "user", "content": "explique a fatura de janeiro", "metadata": {"message_id": "old-user"}}, + {"role": "assistant", "content": "Expliquei janeiro.", "metadata": {"intent": "owner_intent", "message_id": "old-assistant"}}, + {"role": "user", "content": "tem uma cobrança aqui que eu não reconheço", "metadata": {"message_id": "anchor-1"}}, + {"role": "assistant", "content": "Expliquei. Sanei sua dúvida?", "metadata": {"intent": "owner_intent", "message_id": "assistant-anchor"}}, + {"role": "user", "content": "é a de quatorze e noventa e nove", "metadata": {"message_id": "current"}}, + ], + include_relevant_context=True, + ) + state["pending_domain_workflow"]["context_anchor_message_id"] = "anchor-1" + decision = await router.route(state) + context = decision.metadata["relevant_conversation_context"] + assert "tem uma cobrança aqui que eu não reconheço" in context + assert "Expliquei. Sanei sua dúvida?" in context + assert "explique a fatura de janeiro" not in context + assert "Expliquei janeiro" not in context + +@pytest.mark.asyncio +async def test_contextual_reentry_option_releases_pause_and_reroutes_with_bounded_context(tmp_path): + routing = tmp_path / "routing.yaml" + routing.write_text( + """ +router: + fallback_agent: fallback_agent + confidence_threshold: 0.70 +intents: + - name: invoice_explanation + agent: billing_agent + description: explanation + domain: demo + mcp_tools: [invoice_explanation] + - name: contestation + agent: contestation_agent + description: contestation + domain: demo + mcp_tools: [consultar_faturas, contestar_cobranca] +""", + encoding="utf-8", + ) + llm = _ClassifierLLM([ + "CONTINUAR", + '{"intent":"contestation","agent":"contestation_agent","confidence":0.99,"reason":"pedido anterior de não reconhecimento agora tem alvo identificado"}', + ]) + settings = SimpleNamespace( + ROUTING_CONFIG_PATH=str(routing), + ENABLE_LLM_ROUTER=True, + ENABLE_ROUTE_STICKINESS=False, + ) + router = EnterpriseRouter(settings, llm=llm) + state = _state( + "é a de quatorze e noventa e nove", + ["SIM", "NAO", "CONTINUAR"], + "Classifique {{ user_input }} considerando {{ relevant_conversation_context }} em {{ allowed_values }}", + history=[ + {"role": "user", "content": "tem uma cobrança aqui que eu não reconheço", "metadata": {"message_id": "anchor"}}, + {"role": "assistant", "content": "Tamboro Mensal R$ 14,99. Sanei sua dúvida?", "metadata": {"intent": "owner_intent"}}, + {"role": "user", "content": "é a de quatorze e noventa e nove", "metadata": {}}, + ], + include_relevant_context=True, + ) + state["pending_domain_workflow"]["context_anchor_message_id"] = "anchor" + state["pending_domain_workflow"]["pause"]["expected_input"]["semantic_classifier"]["option_actions"] = { + "CONTINUAR": {"action": "contextual_reentry"} + } + + decision = await router.route(state) + + assert decision.intent == "contestation" + assert decision.agent == "contestation_agent" + assert decision.metadata["contextual_reentry"] is True + assert decision.metadata["classifier_output"] == "CONTINUAR" + assert decision.metadata["original_input"] == "é a de quatorze e noventa e nove" + assert decision.metadata["user_claims_are_evidence"] is False + effective = decision.metadata["contextual_reentry_input"] + assert "tem uma cobrança aqui que eu não reconheço" in effective + assert "Tamboro Mensal R$ 14,99" in effective + assert "é a de quatorze e noventa e nove" in effective + assert decision.mcp_tools == ["consultar_faturas", "contestar_cobranca"] + + +def test_invoice_explanation_uses_continue_as_contextual_reentry_option(): + import yaml + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + workflow = yaml.safe_load((root / "workflows" / "invoice_explanation.v2.yaml").read_text(encoding="utf-8")) + formatar = next(node for node in workflow["nodes"] if node["id"] == "formatar") + contract = formatar["pause"]["expected_input"] + assert contract["allowed_values"] == ["SIM", "NAO", "CONTINUAR"] + classifier = contract["semantic_classifier"] + assert classifier["option_actions"]["CONTINUAR"]["action"] == "contextual_reentry" + prompt = classifier["prompt"] + assert "R$ 275,00" in prompt and "CONTINUAR" in prompt + assert "quatorze e noventa e nove" in prompt and "CONTINUAR" in prompt + assert "Nunca trate" in prompt diff --git a/tests/test_paused_workflow_resume_precedence.py b/tests/test_paused_workflow_resume_precedence.py index 2772a8d6..09106e50 100644 --- a/tests/test_paused_workflow_resume_precedence.py +++ b/tests/test_paused_workflow_resume_precedence.py @@ -168,6 +168,7 @@ async def test_runtime_resume_uses_contract_normalized_value(): assert runtime.called[0] == "retomar_workflow" assert runtime.called[1]["resposta_usuario"] == "SIM" assert state["pending_domain_workflow"] is None + assert state["transaction_status"] == "COMPLETED" def test_terminal_workflow_capture_materializes_latch_clear_for_graph_merge(): @@ -197,7 +198,8 @@ def test_terminal_workflow_capture_materializes_latch_clear_for_graph_merge(): }, ) assert state["pending_domain_workflow"] is None - assert state["transaction_status"] is None + assert state["transaction_status"] == "COMPLETED" + assert state.get("active_transaction") is None patch = runtime.transaction_state_patch(state) assert "pending_domain_workflow" in patch assert patch["pending_domain_workflow"] is None @@ -231,6 +233,37 @@ def test_terminal_workflow_does_not_clear_different_pending_execution(): assert state["transaction_status"] == "WORKFLOW_PAUSED" +@pytest.mark.asyncio +async def test_terminal_status_treats_next_turn_as_new_interaction_same_session(tmp_path): + router = _router(tmp_path) + session_id = "same-session-22" + state = { + "user_text": "ah espera", + "sanitized_input": "ah espera", + "session_id": session_id, + "transaction_status": "COMPLETED", + # Simulate a stale pre-fix checkpoint. Terminal status must win. + "pending_domain_workflow": { + "workflow_name": "invoice_explanation", + "execution_id": "exec-old", + "resume_tool": "retomar_workflow", + "owner_agent": "faturas_agent", + "owner_intent": "billing_invoice_explanation", + "pause": { + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO", "CONTINUAR"], + "normalize": "upper_strip", + } + }, + }, + } + decision = await router.route(state) + assert state["session_id"] == session_id + assert state["pending_domain_workflow"] is None + assert not (decision.metadata or {}).get("workflow_resume") + + def test_route_shift_clears_paused_workflow_and_live_latches_without_touching_history(): runtime = _Runtime() state = { @@ -334,3 +367,514 @@ def test_same_workflow_owner_without_resume_does_not_get_cleared_as_intent_shift } assert runtime._clear_active_interaction_context_on_route_shift(state) is False assert state["pending_domain_workflow"] == pending + +@pytest.mark.asyncio +async def test_invalid_enumerated_workflow_input_keeps_workflow_ownership_and_reprompts(tmp_path): + router = _router(tmp_path) + state = { + "user_text": "ano", + "sanitized_input": "ano", + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "route_decision": { + "route": "faturas_agent", + "agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "domain": "telecom", + }, + "pending_domain_workflow": { + "workflow_name": "invoice_explanation", + "execution_id": "exec-1", + "resume_tool": "retomar_workflow", + "owner_agent": "faturas_agent", + "owner_intent": "billing_invoice_explanation", + "pause": { + "prompt": "Sanei sua dúvida?", + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "reprompt": "Não entendi. Essa explicação resolveu sua dúvida? Responda sim ou não.", + }, + }, + }, + } + decision = await router.route(state) + assert decision.route == "faturas_agent" + assert decision.method == "state" + assert decision.mcp_tools == [] + assert decision.metadata["workflow_input_invalid"] is True + assert decision.metadata["workflow_reprompt"] == ( + "Não entendi. Essa explicação resolveu sua dúvida? Responda sim ou não." + ) + + +@pytest.mark.asyncio +async def test_invalid_workflow_input_does_not_call_resume_tool_and_returns_reprompt(): + runtime = _Runtime() + pending = { + "workflow_name": "invoice_explanation", + "execution_id": "exec-1", + "resume_tool": "retomar_workflow", + "owner_agent": "faturas_agent", + "owner_intent": "billing_invoice_explanation", + "pause": { + "prompt": "Sanei sua dúvida?", + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "reprompt": "Não entendi. Essa explicação resolveu sua dúvida? Responda sim ou não.", + }, + }, + } + state = { + "sanitized_input": "ano", + "user_text": "ano", + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "pending_domain_workflow": dict(pending), + "transaction_status": "WORKFLOW_PAUSED", + "route_decision": { + "route": "faturas_agent", + "agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "metadata": { + "workflow_input_invalid": True, + "workflow_reprompt": "Não entendi. Essa explicação resolveu sua dúvida? Responda sim ou não.", + }, + }, + "mcp_tools": [], + } + results = await runtime.execute_tools_for_intent(state) + assert results == [] + assert not hasattr(runtime, "called") + assert state["pending_domain_workflow"] == pending + assert state["transaction_status"] == "WORKFLOW_PAUSED" + assert runtime.transaction_clarification_message(state) == ( + "Não entendi. Essa explicação resolveu sua dúvida? Responda sim ou não." + ) + + +@pytest.mark.asyncio +async def test_meaningful_unmatched_workflow_input_resumes_as_declared_value(tmp_path): + router = _router(tmp_path) + state = { + "user_text": "então tirando esses serviços o valor será 275, certo?", + "sanitized_input": "então tirando esses serviços o valor será 275, certo?", + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "route_decision": { + "route": "faturas_agent", + "agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "domain": "telecom", + }, + "guardrail_decisions": [ + { + "code": "COER", + "allowed": True, + "metadata": { + "mechanism": "expected_input_contract", + "semantic_coherent": True, + }, + } + ], + "pending_domain_workflow": { + "workflow_name": "invoice_explanation", + "execution_id": "exec-1", + "resume_tool": "retomar_workflow", + "owner_agent": "faturas_agent", + "owner_intent": "billing_invoice_explanation", + "pause": { + "prompt": "Sanei sua dúvida?", + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "reprompt": "Não entendi. Essa explicação resolveu sua dúvida? Responda sim ou não.", + "unmatched": { + "meaningful_input": {"action": "resume_as", "value": "NAO"} + }, + }, + }, + }, + } + decision = await router.route(state) + assert decision.mcp_tools == ["retomar_workflow"] + assert decision.metadata["workflow_resume"] is True + assert decision.metadata["workflow_unmatched"] is True + assert decision.metadata["workflow_unmatched_action"] == "resume_as" + assert decision.metadata["normalized_input"] == "NAO" + + +@pytest.mark.asyncio +async def test_incoherent_unmatched_workflow_input_still_reprompts(tmp_path): + router = _router(tmp_path) + state = { + "user_text": "ano", + "sanitized_input": "ano", + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "route_decision": { + "route": "faturas_agent", + "agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "domain": "telecom", + }, + "guardrail_decisions": [ + { + "code": "COER", + "allowed": True, + "metadata": { + "mechanism": "expected_input_contract", + "semantic_coherent": False, + }, + } + ], + "pending_domain_workflow": { + "workflow_name": "invoice_explanation", + "execution_id": "exec-1", + "resume_tool": "retomar_workflow", + "owner_agent": "faturas_agent", + "owner_intent": "billing_invoice_explanation", + "pause": { + "prompt": "Sanei sua dúvida?", + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "reprompt": "Não entendi. Essa explicação resolveu sua dúvida? Responda sim ou não.", + "unmatched": { + "meaningful_input": {"action": "resume_as", "value": "NAO"} + }, + }, + }, + }, + } + decision = await router.route(state) + assert decision.mcp_tools == [] + assert decision.metadata["workflow_input_invalid"] is True + assert decision.metadata["workflow_reprompt"].startswith("Não entendi.") + + +@pytest.mark.asyncio +async def test_runtime_uses_router_declared_resume_as_value_for_unmatched_input(): + runtime = _Runtime() + state = { + "sanitized_input": "pergunta substantiva", + "user_text": "pergunta substantiva", + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "pending_domain_workflow": { + "workflow_name": "invoice_explanation", + "execution_id": "exec-1", + "resume_tool": "retomar_workflow", + "owner_agent": "faturas_agent", + "owner_intent": "billing_invoice_explanation", + "pause": { + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO"], + "normalize": "upper_strip", + "unmatched": { + "meaningful_input": {"action": "resume_as", "value": "NAO"} + }, + }, + }, + }, + "transaction_status": "WORKFLOW_PAUSED", + "route_decision": { + "route": "faturas_agent", + "agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "metadata": { + "workflow_resume": True, + "workflow_unmatched": True, + "workflow_unmatched_action": "resume_as", + "normalized_input": "NAO", + }, + }, + "mcp_tools": ["retomar_workflow"], + } + results = await runtime.execute_tools_for_intent(state) + assert len(results) == 1 + assert runtime.called[0] == "retomar_workflow" + assert runtime.called[1]["resposta_usuario"] == "NAO" + + +def test_completed_workflow_final_response_preempts_prior_llm_composition(): + runtime = _Runtime() + result = { + "ok": True, + "result": { + "status": "COMPLETED", + "workflow_name": "example", + "output": { + "formatar": { + "mensagem": "Pergunta antiga?", + "requires_llm_composition": True, + "await_user_input": True, + }, + "finalizar": { + "success": True, + "workflow_response_final": True, + "mensagem": "Seu número de protocolo é 1234567890.", + }, + }, + "state": {"current_node": "finalizar"}, + }, + } + answer = runtime.build_direct_mcp_answer({}, [result], agent_label="Agent") + assert answer == "Seu número de protocolo é 1234567890." + + +def test_completed_workflow_without_final_response_keeps_old_composition_behavior(): + runtime = _Runtime() + result = { + "ok": True, + "result": { + "status": "COMPLETED", + "workflow_name": "example", + "output": { + "formatar": { + "mensagem": "Pergunta antiga?", + "requires_llm_composition": True, + }, + "finalizar": {"success": True, "protocol_number": "123"}, + }, + "state": {"current_node": "finalizar"}, + }, + } + assert runtime.build_direct_mcp_answer({}, [result], agent_label="Agent") is None + +class _HandoffContinuityLLM: + async def ainvoke(self, messages, **kwargs): + if kwargs.get("profile_name") == "route_continuity": + current = str(messages[-1].get("content") or "") + if "atendente" in current.lower(): + return '{"decision":"HUMAN_HANDOFF","confidence":0.99,"reason":"pedido explícito de humano"}' + return '{"decision":"CONTINUE","confidence":0.99,"reason":"continuidade"}' + if kwargs.get("generation_name") == "workflow.expected_input.semantic_classifier": + return "CONTINUAR" + return '{}' + + +def _router_with_handoff_llm(tmp_path): + routing = tmp_path / "routing-handoff.yaml" + routing.write_text(ROUTING_YAML, encoding="utf-8") + settings = SimpleNamespace( + ROUTING_CONFIG_PATH=str(routing), + ENABLE_LLM_ROUTER=False, + ENABLE_ROUTE_STICKINESS=True, + ROUTE_STICKINESS_LLM_PROFILE="route_continuity", + ROUTE_STICKINESS_CONFIDENCE_THRESHOLD=0.7, + ROUTE_STICKINESS_HISTORY_TURNS=2, + ) + return EnterpriseRouter(settings, llm=_HandoffContinuityLLM()) + + +@pytest.mark.asyncio +async def test_explicit_human_handoff_preempts_paused_expected_input_semantic_classifier(tmp_path): + router = _router_with_handoff_llm(tmp_path) + state = { + "user_text": "quero falar com um atendente", + "sanitized_input": "quero falar com um atendente", + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "route_decision": { + "route": "faturas_agent", + "agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "domain": "telecom", + }, + "history": [ + {"role": "user", "content": "minha conta veio mais cara, quero entender"}, + {"role": "assistant", "content": "Com essa explicação, sanei sua dúvida?"}, + ], + "transaction_status": "WORKFLOW_PAUSED", + "pending_domain_workflow": { + "workflow_name": "invoice_explanation", + "execution_id": "exec-10", + "resume_tool": "retomar_workflow", + "owner_agent": "faturas_agent", + "owner_intent": "billing_invoice_explanation", + "pause": { + "prompt": "Com essa explicação, sanei sua dúvida?", + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO", "CONTINUAR"], + "normalize": "upper_strip", + "semantic_classifier": { + "enabled": True, + "include_relevant_context": True, + "prompt": "Classifique em {{ allowed_values }}: {{ user_input }}", + "option_actions": {"CONTINUAR": {"action": "contextual_reentry"}}, + }, + }, + }, + }, + } + + decision = await router.route(state) + + assert decision.route == "human_handoff" + assert decision.intent == "human_handoff" + assert decision.handoff is True + assert decision.metadata["session_control"] == "HUMAN_HANDOFF" + assert decision.metadata["workflow_interruption"] == "human_handoff" + assert decision.metadata["interrupted_workflow_name"] == "invoice_explanation" + + +@pytest.mark.asyncio +async def test_paused_expected_input_still_keeps_precedence_for_direct_match_with_global_probe_available(tmp_path): + router = _router_with_handoff_llm(tmp_path) + state = { + "user_text": "sim", + "sanitized_input": "sim", + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "route_decision": { + "route": "faturas_agent", + "agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "domain": "telecom", + }, + "pending_domain_workflow": { + "workflow_name": "invoice_explanation", + "execution_id": "exec-10", + "resume_tool": "retomar_workflow", + "owner_agent": "faturas_agent", + "owner_intent": "billing_invoice_explanation", + "pause": { + "expected_input": { + "key": "resposta_usuario", + "allowed_values": ["SIM", "NAO", "CONTINUAR"], + "normalize": "upper_strip", + } + }, + }, + } + + decision = await router.route(state) + + assert decision.route == "faturas_agent" + assert decision.metadata["workflow_resume"] is True + assert decision.metadata["normalized_input"] == "SIM" + + +def test_completed_workflow_marks_next_turn_operational_boundary(): + runtime = _Runtime() + state = { + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "pending_domain_workflow": { + "execution_id": "exec-1", + "workflow_name": "invoice_explanation", + }, + "transaction_status": "WORKFLOW_PAUSED", + } + completed = { + "result": { + "result": { + "status": "COMPLETED", + "execution_id": "exec-1", + "workflow_name": "invoice_explanation", + "metadata": { + "workflow_name": "invoice_explanation", + "workflow_execution_id": "exec-1", + }, + } + } + } + runtime._capture_pending_domain_workflow(state, completed) + assert state["transaction_status"] == "COMPLETED" + assert state["pending_domain_workflow"] is None + assert state["operational_context_boundary_pending"] is True + patch = runtime.transaction_state_patch(state) + assert patch["operational_context_boundary_pending"] is True + + +@pytest.mark.asyncio +async def test_operational_context_reset_skips_route_continuity(tmp_path): + router = _router(tmp_path) + + async def _must_not_run(*args, **kwargs): + raise AssertionError("route continuity must not run after a closed workflow boundary") + + router.continuity.evaluate = _must_not_run + state = { + "user_text": "ah espera", + "sanitized_input": "ah espera", + "operational_context_reset": True, + "route": "faturas_agent", + "active_agent": "faturas_agent", + "intent": "billing_invoice_explanation", + "route_decision": {"route": "faturas_agent", "intent": "billing_invoice_explanation"}, + "context": {"session": {"metadata": {"workflow_state": "WAITING_BILLING_CONFIRMATION"}}}, + "history": [ + {"role": "user", "content": "quero saber por que minha conta subiu"}, + {"role": "assistant", "content": "Com essa explicação, sanei sua dúvida?"}, + {"role": "user", "content": "entendi, obrigado, era só isso"}, + {"role": "assistant", "content": "Seu número de protocolo é 1234567890."}, + {"role": "user", "content": "ah espera"}, + ], + } + decision = await router.route(state) + assert decision.method in {"fallback", "keyword"} + assert not (decision.metadata or {}).get("workflow_resume") + assert decision.intent != "billing_invoice_explanation" + + +def test_workflow_response_final_overrides_stale_paused_status_and_sets_boundary(): + runtime = _Runtime() + state = { + "pending_domain_workflow": { + "execution_id": "exec-final-stale", + "workflow_name": "invoice_explanation", + }, + "transaction_status": "WORKFLOW_PAUSED", + } + stale_adapter_result = { + "ok": True, + "result": { + "result": { + "status": "PAUSED", + "execution_id": "exec-final-stale", + "metadata": { + "workflow_name": "invoice_explanation", + "workflow_execution_id": "exec-final-stale", + "resume_tool": "retomar_workflow", + }, + "output": { + "success": True, + "workflow_response_final": True, + "mensagem": "Seu número de protocolo é 1234567890.", + }, + "state": {"current_node": "registrar_protocolo_aceite"}, + "pause": { + "expected_input": { + "allowed_values": ["SIM", "NAO", "CONTINUAR"] + } + }, + } + }, + } + + normalized = runtime._workflow_payload_from_tool_result(stale_adapter_result) + assert normalized is not None + assert normalized["status"] == "COMPLETED" + assert normalized["metadata"]["status_normalized_from"] == "PAUSED" + + runtime._capture_pending_domain_workflow(state, stale_adapter_result) + assert state["pending_domain_workflow"] is None + assert state["transaction_status"] == "COMPLETED" + assert state["operational_context_boundary_pending"] is True diff --git a/tests/test_transaction_parameter_llm_precedence.py b/tests/test_transaction_parameter_llm_precedence.py index 5e723d5a..fe2ed05b 100644 --- a/tests/test_transaction_parameter_llm_precedence.py +++ b/tests/test_transaction_parameter_llm_precedence.py @@ -15,6 +15,13 @@ class _SemanticLLM: async def ainvoke(self, messages, **kwargs): prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) profile = kwargs.get("profile_name") + if kwargs.get("generation_name") == "transaction.confirmation.semantic_classifier": + low = prompt.lower() + if "isso mesmo" in low or "pode confirmar" in low: + return "SIM" + if "melhor não" in low or "melhor nao" in low: + return "NAO" + return "CONTINUAR" if profile == "transaction_parameter_extraction" or "pending_parameters:" in prompt: marker = "user_message: " user = prompt.split(marker, 1)[1].split("\nFormato obrigatório:", 1)[0].strip() if marker in prompt else "" @@ -293,14 +300,8 @@ intents: assert "transaction_interruption" not in decision.metadata @pytest.mark.asyncio -async def test_incompatible_intent_shift_wins_even_when_turn_could_fill_pending_parameter(tmp_path): - """A value-like turn cannot shield an incompatible new goal from intent-shift. - - Regression for the edge case where the active transaction is collecting a - field, but the same utterance clearly starts another transactional intent. - The framework must classify the goal first; parameter extraction is allowed - only after the classifier says CONTINUE. - """ +async def test_incompatible_intent_shift_runs_only_when_parameter_extractor_does_not_consume(tmp_path): + """A real new goal still shifts, but only after parameter extraction declines it.""" routing = tmp_path / "routing.yaml" routing.write_text( """ @@ -332,9 +333,9 @@ intents: prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: self.extraction_calls += 1 - # This demonstrates the dangerous overlap: if extraction ran - # first, it could consume a field from the same utterance. - return json.dumps({"reason": "cancelar pedido PED-2002"}) + # The extractor must not convert a clearly new request into the + # pending field of the old transaction. + return json.dumps({"reason": None}) self.shift_calls += 1 return json.dumps({ "decision": "SHIFT", @@ -374,12 +375,12 @@ intents: assert decision.agent == "orders_agent" assert decision.metadata["transaction_interruption"] == "intent_shift" assert llm.shift_calls == 1 - assert llm.extraction_calls == 0 + assert llm.extraction_calls == 1 @pytest.mark.asyncio -async def test_semantic_shift_wins_before_parameter_extraction_when_no_keyword_matches(tmp_path): - """Semantic SHIFT must win even if extraction could return a pending field.""" +async def test_semantic_shift_without_keyword_runs_after_parameter_extractor_declines(tmp_path): + """Semantic SHIFT remains available when no pending parameter is consumed.""" routing = tmp_path / "routing.yaml" routing.write_text( """ @@ -411,7 +412,7 @@ intents: prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: self.extraction_calls += 1 - return json.dumps({"reason": "encerrar a compra"}) + return json.dumps({"reason": None}) self.shift_calls += 1 return json.dumps({ "decision": "SHIFT", @@ -452,4 +453,193 @@ intents: assert decision.metadata["transaction_interruption"] == "intent_shift" assert decision.metadata["interruption_source"] == "semantic_classifier" assert llm.shift_calls == 1 - assert llm.extraction_calls == 0 + assert llm.extraction_calls == 1 + + +@pytest.mark.asyncio +async def test_parameter_reference_from_recent_context_wins_before_semantic_shift(tmp_path): + routing = tmp_path / "routing.yaml" + routing.write_text( + """ +router: + fallback_agent: contestacao_agent + confidence_threshold: 0.70 +state_policies: + - state: COLLECTING_CONTESTACAO_PARAMETERS + agent: contestacao_agent +intents: + - name: contas_vas_cancel + agent: contestacao_agent + priority: 145 + keywords: [cancelar serviço] + - name: contas_contestation + agent: contestacao_agent + priority: 120 + keywords: [contestar cobrança] +""", + encoding="utf-8", + ) + + class _ContextAwareLLM: + def __init__(self): + self.extraction_calls = 0 + self.shift_calls = 0 + + async def ainvoke(self, messages, **kwargs): + prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) + if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: + self.extraction_calls += 1 + assert "Tamboro Mensal" in prompt + assert "R$ 14,99" in prompt + return json.dumps({"subject": "Tamboro Mensal"}, ensure_ascii=False) + self.shift_calls += 1 + return json.dumps({ + "decision": "SHIFT", + "intent": "contas_contestation", + "agent": "contestacao_agent", + "confidence": 0.96, + "reason": "valor específico parece uma cobrança contestada", + }, ensure_ascii=False) + + llm = _ContextAwareLLM() + settings = SimpleNamespace( + ROUTING_CONFIG_PATH=str(routing), + ENABLE_LLM_ROUTER=True, + ENABLE_ROUTE_STICKINESS=False, + ) + router = EnterpriseRouter(settings, llm=llm) + state = { + "user_text": "desculpa, é a de quatorze e noventa e nove", + "sanitized_input": "desculpa, é a de quatorze e noventa e nove", + "next_state": "COLLECTING_CONTESTACAO_PARAMETERS", + "transaction_status": "COLLECTING_PARAMETERS", + "missing_parameters": ["subject"], + "active_agent": "contestacao_agent", + "intent": "state:COLLECTING_CONTESTACAO_PARAMETERS", + "history": [ + {"role": "assistant", "content": "Cobrança Tamboro Mensal no valor de R$ 14,99; TIM Fashion Mensal no valor de R$ 10,00."}, + {"role": "assistant", "content": "Qual serviço você deseja cancelar?"}, + {"role": "user", "content": "desculpa, é a de quatorze e noventa e nove"}, + ], + "active_transaction": { + "tool_name": "cancelar_vas_avulso", + "arguments": {}, + "status": "COLLECTING_PARAMETERS", + "started_from_intent": "contas_vas_cancel", + "parameter_schema": { + "subject": { + "type": "string", + "description": "Referência a um serviço concreto identificável no contexto recente.", + } + }, + "tool_description": "Cancela um VAS avulso.", + }, + } + + decision = await router.route(state) + + assert decision.agent == "contestacao_agent" + assert decision.intent == "state:COLLECTING_CONTESTACAO_PARAMETERS" + assert decision.metadata["transaction_turn_consumed"] is True + assert decision.metadata["transaction_parameter_values"] == {"subject": "Tamboro Mensal"} + assert "transaction_interruption" not in decision.metadata + assert llm.extraction_calls == 1 + assert llm.shift_calls == 0 + + +@pytest.mark.asyncio +async def test_semantic_confirmation_fallback_consumes_equivalent_positive_reply(tmp_path): + routing = tmp_path / "routing.yaml" + routing.write_text( + """ +router: + fallback_agent: support_agent + confidence_threshold: 0.70 + transaction_confirmation: + semantic_fallback: + enabled: true + allowed_values: [SIM, NAO, CONTINUAR] + confirm_values: [SIM] + reject_values: [NAO] + include_relevant_context: true + prompt: | + Classifique a resposta atual em {{ allowed_values }}. + Pergunta pendente: {{ pending_prompt }} + Contexto relevante: {{ relevant_conversation_context }} + Resposta: {{ user_input }} +state_policies: + - state: WAITING_SUPPORT_CONFIRMATION + agent: support_agent +intents: [] +""", + encoding="utf-8", + ) + settings = SimpleNamespace( + ROUTING_CONFIG_PATH=str(routing), + ENABLE_LLM_ROUTER=True, + ENABLE_ROUTE_STICKINESS=False, + ) + router = EnterpriseRouter(settings, llm=_SemanticLLM()) + state = { + "user_text": "isso mesmo, pode confirmar", + "sanitized_input": "isso mesmo, pode confirmar", + "next_state": "WAITING_SUPPORT_CONFIRMATION", + "transaction_status": "AWAITING_CONFIRMATION", + "active_agent": "support_agent", + "intent": "retail_support_exchange_return", + "active_transaction": { + "tool_name": "solicitar_devolucao", + "arguments": {"order_id": "PED-1001"}, + "status": "AWAITING_CONFIRMATION", + "started_from_intent": "retail_support_exchange_return", + }, + "history": [ + {"role": "user", "content": "quero devolver o pedido PED-1001", "metadata": {"intent": "retail_support_exchange_return"}}, + {"role": "assistant", "content": "Você confirma a devolução do pedido PED-1001?", "metadata": {"intent": "retail_support_exchange_return"}}, + {"role": "user", "content": "isso mesmo, pode confirmar"}, + ], + } + decision = await router.route(state) + assert decision.agent == "support_agent" + assert decision.metadata["transaction_turn_consumed"] is True + assert decision.metadata["transaction_confirmation_decision"] == "confirm" + assert decision.metadata["transaction_confirmation_source"] == "semantic" + assert decision.metadata["transaction_confirmation_classifier_output"] == "SIM" + assert "Você confirma a devolução" in decision.metadata["relevant_conversation_context"] + + +@pytest.mark.asyncio +async def test_semantic_confirmation_fallback_does_not_replace_deterministic_yes(tmp_path): + routing = tmp_path / "routing.yaml" + routing.write_text( + """ +router: + fallback_agent: support_agent + transaction_confirmation: + semantic_fallback: + enabled: true + allowed_values: [SIM, NAO, CONTINUAR] + confirm_values: [SIM] + reject_values: [NAO] + include_relevant_context: true + prompt: "Classifique {{ user_input }} em {{ allowed_values }}" +state_policies: + - state: WAITING_SUPPORT_CONFIRMATION + agent: support_agent +intents: [] +""", encoding="utf-8") + class _MustNotCallLLM: + async def ainvoke(self, *args, **kwargs): + raise AssertionError("LLM não deve ser chamada para confirmação determinística") + settings = SimpleNamespace(ROUTING_CONFIG_PATH=str(routing), ENABLE_LLM_ROUTER=True, ENABLE_ROUTE_STICKINESS=False) + router = EnterpriseRouter(settings, llm=_MustNotCallLLM()) + state = { + "user_text": "sim", "sanitized_input": "sim", + "next_state": "WAITING_SUPPORT_CONFIRMATION", + "transaction_status": "AWAITING_CONFIRMATION", + "active_agent": "support_agent", + "active_transaction": {"tool_name": "solicitar_devolucao", "arguments": {}, "status": "AWAITING_CONFIRMATION"}, + } + decision = await router.route(state) + assert decision.metadata["transaction_confirmation_decision"] == "confirm" + assert decision.metadata["transaction_confirmation_source"] == "deterministic" diff --git a/tests/test_transactional_tool_flow.py b/tests/test_transactional_tool_flow.py index ec6d57f9..b40d1294 100644 --- a/tests/test_transactional_tool_flow.py +++ b/tests/test_transactional_tool_flow.py @@ -666,3 +666,565 @@ async def test_route_intent_shift_clears_collecting_transaction_before_new_tools assert state["next_state"] is None assert state["missing_parameters"] == [] assert state["tool_policy_result"]["action"] == "cancelled_by_intent_shift" + + +def test_transaction_clarification_uses_agent_declared_user_prompt(): + from types import SimpleNamespace + + class _PromptRouter: + registry = SimpleNamespace( + get_tool=lambda _name: SimpleNamespace( + args_schema={ + "subject": { + "type": "string", + "description": "Item técnico da operação.", + "user_prompt": "Qual cobrança você deseja tratar?", + } + }, + requires=["subject"], + description="Operação de teste", + ) + ) + + def resolve_execution_policy(self, tool_name, arguments=None): + return { + "operation_type": "transactional", + "require_confirmation": True, + "requires": ["subject"], + } + + runtime = object.__new__(AgentRuntimeMixin) + runtime.tool_router = _PromptRouter() + state = { + "transaction_status": "COLLECTING_PARAMETERS", + "intent": "test_intent", + "missing_parameters": ["subject"], + "active_transaction": { + "transaction_id": "tx1", + "tool_name": "tool_teste", + "arguments": {}, + "status": "COLLECTING_PARAMETERS", + "started_from_intent": "test_intent", + "parameter_schema": { + "subject": { + "type": "string", + "description": "Item técnico da operação.", + "user_prompt": "Qual cobrança você deseja tratar?", + } + }, + }, + } + + assert runtime.transaction_clarification_message(state) == "Qual cobrança você deseja tratar?" + + +def test_transaction_clarification_never_leaks_technical_parameter_name_without_metadata(): + runtime = object.__new__(AgentRuntimeMixin) + state = { + "transaction_status": "COLLECTING_PARAMETERS", + "missing_parameters": ["internal_subject_code"], + "active_transaction": { + "transaction_id": "tx1", + "tool_name": "tool_teste", + "arguments": {}, + "status": "COLLECTING_PARAMETERS", + "parameter_schema": {"internal_subject_code": "string"}, + }, + } + + text = runtime.transaction_clarification_message(state) + assert text == "Para prosseguir, preciso de mais uma informação para continuar com a solicitação." + assert "internal_subject_code" not in text + assert "internal subject code" not in text + + +@pytest.mark.asyncio +async def test_confirmation_executes_frozen_snapshot_even_if_operational_state_is_mutated(): + runtime = _Runtime() + state = { + "user_text": "Quero devolver o pedido 123 porque me arrependi", + "sanitized_input": "Quero devolver o pedido 123 porque me arrependi", + "mcp_tools": ["consultar_pedido", "solicitar_devolucao"], + "route": "support_agent", + "intent": "retail_support_exchange_return", + } + await runtime.execute_tools_for_intent(state) + assert state["transaction_status"] == "AWAITING_CONFIRMATION" + assert state["confirmation_snapshot"]["arguments"]["order_id"] == "123" + + # Simula enriquecimento/mutação acidental do state entre a pergunta de + # confirmação e o turno "sim". A execução deve permanecer no snapshot. + state["active_transaction"]["arguments"]["order_id"] = "999" + state["pending_tool_call"]["arguments"]["order_id"] = "999" + state["user_text"] = "sim" + state["sanitized_input"] = "sim" + + await runtime.execute_tools_for_intent(state) + assert runtime.calls[-1][0] == "solicitar_devolucao" + assert runtime.calls[-1][1]["order_id"] == "123" + assert runtime.calls[-1][1]["confirmed"] is True + assert state["transaction_status"] == "COMPLETED" + assert state.get("confirmation_snapshot") is None + +class _RecoverablePreValidationRuntime(_PreValidationRuntime): + async def _call_mcp_tool(self, tool_name, arguments, state): + self.calls.append((tool_name, dict(arguments))) + if tool_name == "validar_contestacao": + return { + "ok": True, + "tool_name": tool_name, + "result": { + "eligible": False, + "status": "NEEDS_PARAMETER", + "parameter": "subject", + "reason": "subject_not_resolved", + }, + } + return {"ok": True, "tool_name": tool_name, "result": {"status": "OPENED"}} + + +@pytest.mark.asyncio +async def test_prevalidation_can_reopen_only_invalid_parameter_and_preserve_other_values(): + runtime = _RecoverablePreValidationRuntime(eligible=False) + state = { + "user_text": "R$ 10,00", + "sanitized_input": "R$ 10,00", + "route": "contestacao_agent", + "intent": "state:COLLECTING_CONTESTACAO_PARAMETERS", + "transaction_status": "COLLECTING_PARAMETERS", + "selected_tool_call": { + "tool_name": "contestar_cobranca", + "arguments": {"subject": "fatura", "valor": 10.0}, + }, + "context": {}, + } + result = await runtime.execute_tools_for_intent(state, tools=[]) + assert result[-1]["pre_validation"] is True + assert result[-1]["collecting_parameters"] is True + assert result[-1]["transaction_status"] == "COLLECTING_PARAMETERS" + assert state["transaction_status"] == "COLLECTING_PARAMETERS" + assert state["missing_parameters"] == ["subject"] + args = state["selected_tool_call"]["arguments"] + assert "subject" not in args + assert args["valor"] == 10.0 + assert state["transaction_pre_validation"]["terminal"] is False + assert state["transaction_pre_validation"]["parameter"] == "subject" + +class _TerminalShortCircuitRuntime(AgentRuntimeMixin): + def __init__(self): + self.tool_router = None + self.llm = _TransactionTestLLM() + self.calls = [] + + def _resolve_tool_execution_policy(self, tool_name, arguments=None): + if tool_name == "cancelar_vas_avulso": + return {"operation_type": "transactional", "require_confirmation": True, "requires": ["subject"]} + return {"operation_type": "read_only", "require_confirmation": False, "requires": []} + + def _validate_tool_execution_policy(self, tool_name, arguments=None): + return True, None + + def _select_read_only_tools(self, tools, text): + return ["consultar_vas"] if "consultar_vas" in tools else [] + + def _select_transactional_tool(self, tools, text): + # This must never be reached after an explicitly terminal read result. + raise AssertionError("transactional selection must be short-circuited") + + async def _call_mcp_tool(self, tool_name, arguments, state): + self.calls.append(tool_name) + return { + "ok": False, + "tool_name": tool_name, + "result": { + "success": False, + "status": "ANY_DOMAIN_STATUS", + "terminal": True, + "terminal_action": "block", + "reason": "resource_not_authorized", + "user_message": "Não é possível operar nesse recurso.", + }, + "error": "Falha de domínio", + } + + +@pytest.mark.asyncio +async def test_explicit_terminal_tool_result_short_circuits_remaining_tool_chain(): + runtime = _TerminalShortCircuitRuntime() + state = { + "user_text": "quero cancelar o serviço", + "sanitized_input": "quero cancelar o serviço", + "mcp_tools": ["consultar_vas", "cancelar_vas_avulso"], + "route": "agent", + "intent": "cancel", + } + results = await runtime.execute_tools_for_intent(state) + assert runtime.calls == ["consultar_vas"] + assert len(results) == 1 + assert state["transaction_status"] == "BLOCKED" + assert state["selected_tool_call"] == {} + assert state["pending_tool_call"] == {} + assert state["tool_policy_result"]["action"] == "terminal_tool_result" + + +def test_explicit_terminal_tool_result_user_message_is_direct_answer_without_domain_status_hardcode(): + runtime = _TerminalShortCircuitRuntime() + result = { + "ok": False, + "tool_name": "qualquer_tool", + "result": { + "terminal": True, + "status": "ARBITRARY_APPLICATION_CODE", + "user_message": "Mensagem amigável da aplicação.", + }, + } + answer = runtime.build_direct_mcp_answer({}, [result], agent_label="Agent") + assert answer == "Mensagem amigável da aplicação." + +class _ContextualReentryContestLLM: + def __init__(self): + self.prompts = [] + + async def ainvoke(self, messages, **kwargs): + import json + prompt = messages[-1]["content"] + self.prompts.append(prompt) + if kwargs.get("profile_name") == "transaction_parameter_extraction": + assert "tem uma cobrança aqui que eu não reconheço" in prompt + assert "Tamboro Mensal" in prompt + assert "quatorze e noventa e nove" in prompt + pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0]) + out = {name: None for name in pending} + if "subject" in out: + out["subject"] = "Tamboro Mensal" + if "valor" in out: + out["valor"] = 14.99 + return {"content": json.dumps(out, ensure_ascii=False)} + return {"content": "{}"} + + +class _ContextualReentryPolicyRouter(_ContestPolicyRouter): + def __init__(self): + from types import SimpleNamespace + self.registry = SimpleNamespace( + tools={"contestar_cobranca": object()}, + get_tool=lambda name: SimpleNamespace( + selection_keywords=["contestar", "não reconheço"], + args_schema={"subject": "string", "valor": "number"}, + requires=["subject", "valor"], + description="Contesta cobrança validada", + ), + ) + + +class _ContextualReentryRuntime(AgentRuntimeMixin): + def __init__(self): + self.tool_router = _ContextualReentryPolicyRouter() + self.llm = _ContextualReentryContestLLM() + self.calls = [] + + async def _call_mcp_tool(self, tool_name, arguments, state): + self.calls.append((tool_name, dict(arguments))) + return {"ok": True, "tool_name": tool_name, "result": {"status": "OPENED"}} + + +@pytest.mark.asyncio +async def test_contextual_reentry_uses_bounded_context_for_transaction_parameter_candidates(): + runtime = _ContextualReentryRuntime() + effective = ( + "CONTEXTO DA SOLICITAÇÃO IMEDIATAMENTE ANTERIOR:\n" + "user: tem uma cobrança aqui que eu não reconheço\n" + "assistant: Cobrança Tamboro Mensal no valor de R$ 14,99.\n\n" + "CONTINUAÇÃO ATUAL DO CLIENTE:\n" + "é a de quatorze e noventa e nove" + ) + state = { + "user_text": "é a de quatorze e noventa e nove", + "sanitized_input": "é a de quatorze e noventa e nove", + "mcp_tools": ["contestar_cobranca"], + "route": "contestacao_agent", + "active_agent": "contestacao_agent", + "intent": "contas_contestation", + "route_decision": { + "route": "contestacao_agent", + "agent": "contestacao_agent", + "intent": "contas_contestation", + "metadata": { + "contextual_reentry": True, + "contextual_reentry_input": effective, + "original_input": "é a de quatorze e noventa e nove", + "user_claims_are_evidence": False, + }, + }, + "pending_domain_workflow": { + "workflow_name": "invoice_explanation", + "execution_id": "old-exec", + "owner_agent": "faturas_agent", + "owner_intent": "contas_invoice_explanation", + "pause": {}, + }, + "transaction_status": "WORKFLOW_PAUSED", + } + + results = await runtime.execute_tools_for_intent(state) + + assert state["pending_domain_workflow"] is None + assert state["transaction_status"] == "AWAITING_CONFIRMATION" + args = state["pending_tool_call"]["arguments"] + assert args["subject"] == "Tamboro Mensal" + assert args["valor"] == 14.99 + # The original utterance is still preserved separately; context is an + # interpretation aid, not proof that the customer's amount is correct. + assert state["route_decision"]["metadata"]["original_input"] == "é a de quatorze e noventa e nove" + assert state["route_decision"]["metadata"]["user_claims_are_evidence"] is False + assert runtime.calls == [] # confirmation is still mandatory + assert results[-1]["awaiting_confirmation"] is True + +class _PersistedContextFollowupLLM: + async def ainvoke(self, messages, **kwargs): + import json + prompt = messages[-1]["content"] + if kwargs.get("profile_name") == "transaction_parameter_extraction": + assert "Cobrança Tamboro Mensal no valor de R$ 14,99" in prompt + assert "previous_user_continuation_non_authoritative: é a de quatorze e noventa e nove" in prompt + assert "user_message: Tamboro" in prompt + pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0]) + return {"content": json.dumps({name: (14.99 if name == "valor" else None) for name in pending})} + return {"content": "{}"} + + +@pytest.mark.asyncio +async def test_collecting_parameters_merges_partial_router_cache_with_persisted_reentry_context(): + runtime = _ContextualReentryRuntime() + runtime.llm = _PersistedContextFollowupLLM() + state = { + "user_text": "Tamboro", + "sanitized_input": "Tamboro", + "route": "contestacao_agent", + "active_agent": "contestacao_agent", + "intent": "state:COLLECTING_CONTESTACAO_PARAMETERS", + "route_decision": { + "route": "contestacao_agent", + "agent": "contestacao_agent", + "intent": "state:COLLECTING_CONTESTACAO_PARAMETERS", + "metadata": { + # Simulates router precedence extracting only the short entity + # mention on the follow-up turn. + "transaction_parameter_values": {"subject": "Tamboro Mensal"}, + "transaction_parameter_source": "llm", + }, + }, + "transaction_status": "COLLECTING_PARAMETERS", + "missing_parameters": ["subject", "valor"], + "active_transaction": { + "transaction_id": "tx-context", + "tool_name": "contestar_cobranca", + "arguments": {}, + "status": "COLLECTING_PARAMETERS", + "started_from_intent": "contas_contestation", + "requires": ["subject", "valor"], + "parameter_schema": { + "subject": {"type": "string", "description": "item concreto da fatura"}, + "valor": {"type": "number", "description": "valor da cobrança"}, + }, + "tool_description": "Contesta cobrança validada", + "parameter_conversational_context": ( + "user: tem uma cobrança aqui que eu não reconheço\n" + "assistant: Cobrança Tamboro Mensal no valor de R$ 14,99; " + "TIM Fashion Mensal no valor de R$ 10,00.\n" + "previous_user_continuation_non_authoritative: é a de quatorze e noventa e nove" + ), + "user_claims_are_evidence": False, + }, + "selected_tool_call": {"tool_name": "contestar_cobranca", "arguments": {}}, + } + + results = await runtime.execute_tools_for_intent(state, tools=[]) + + assert results[-1]["transaction_status"] == "AWAITING_CONFIRMATION" + args = state["pending_tool_call"]["arguments"] + assert args["subject"] == "Tamboro Mensal" + assert args["valor"] == 14.99 + assert state["active_transaction"]["parameter_conversational_context"].startswith("user: tem uma cobrança") + assert state["active_transaction"]["user_claims_are_evidence"] is False + assert runtime.calls == [] + +class _CorrectionDuringCollectingLLM: + def __init__(self): + self.prompts = [] + + async def ainvoke(self, messages, **kwargs): + import json + prompt = messages[-1]["content"] + self.prompts.append(prompt) + if kwargs.get("profile_name") == "transaction_parameter_extraction": + pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0]) + out = {name: None for name in pending} + # O router já resolveu subject a partir do contexto. O runtime ainda + # precisa permitir que a mensagem atual corrija um valor previamente + # coletado, mesmo que valor não esteja em missing_parameters. + if "valor" in out: + out["valor"] = 14.99 + return {"content": json.dumps(out, ensure_ascii=False)} + return {"content": "{}"} + + +@pytest.mark.asyncio +async def test_collecting_parameters_current_turn_can_correct_already_collected_required_value(): + runtime = _ContextualReentryRuntime() + runtime.llm = _CorrectionDuringCollectingLLM() + state = { + "user_text": "desculpa, é a de quatorze e noventa e nove", + "sanitized_input": "desculpa, é a de quatorze e noventa e nove", + "route": "contestacao_agent", + "active_agent": "contestacao_agent", + "intent": "state:COLLECTING_CONTESTACAO_PARAMETERS", + "route_decision": { + "route": "contestacao_agent", + "agent": "contestacao_agent", + "intent": "state:COLLECTING_CONTESTACAO_PARAMETERS", + "metadata": { + "transaction_parameter_values": {"subject": "Tamboro Mensal"}, + "transaction_parameter_source": "llm", + }, + }, + "transaction_status": "COLLECTING_PARAMETERS", + # Só subject está oficialmente pendente; valor=19.99 veio do turno anterior. + "missing_parameters": ["subject"], + "active_transaction": { + "transaction_id": "tx-correction", + "tool_name": "contestar_cobranca", + "arguments": {"valor": 19.99}, + "status": "COLLECTING_PARAMETERS", + "started_from_intent": "contas_contestation", + "requires": ["subject", "valor"], + "parameter_schema": { + "subject": {"type": "string", "description": "item concreto da fatura"}, + "valor": {"type": "number", "description": "valor da cobrança"}, + }, + "tool_description": "Contesta cobrança validada", + }, + "selected_tool_call": { + "tool_name": "contestar_cobranca", + "arguments": {"valor": 19.99}, + }, + } + + results = await runtime.execute_tools_for_intent(state, tools=[]) + + assert results[-1]["transaction_status"] == "AWAITING_CONFIRMATION" + args = state["pending_tool_call"]["arguments"] + assert args["subject"] == "Tamboro Mensal" + assert args["valor"] == 14.99 + assert state["active_transaction"]["arguments"]["valor"] == 14.99 + # O prompt de continuação deve deixar valor editável mesmo não estando faltante. + assert any('"valor"' in prompt for prompt in runtime.llm.prompts) + assert runtime.calls == [] + + +class _DomainRedirectRouter(_PreValidationRouter): + def resolve_execution_policy(self, tool_name, arguments=None): + if tool_name == "cancelar_vas_avulso": + return { + "operation_type": "transactional", + "require_confirmation": True, + "requires": ["subject"], + "policy_source": "test", + "pre_validation": {"enabled": True, "tool": "validar_vas_subject", "fail_open": False}, + } + if tool_name == "tratar_vas_estrategico": + return { + "operation_type": "conversational", + "require_confirmation": False, + "requires": ["subject"], + "policy_source": "test", + "pre_validation": {"enabled": True, "tool": "validar_vas_subject", "fail_open": False}, + } + return {"operation_type": "internal", "require_confirmation": False, "requires": [], "policy_source": "test", "pre_validation": {"enabled": False}} + + +class _DomainRedirectRuntime(AgentRuntimeMixin): + def __init__(self): + self.tool_router = _DomainRedirectRouter() + self.calls = [] + + async def _call_mcp_tool(self, tool_name, arguments, state): + self.calls.append((tool_name, dict(arguments))) + if tool_name == "validar_vas_subject": + return { + "ok": True, + "tool_name": tool_name, + "result": { + "eligible": True, + "status": "ELIGIBLE", + "resolved_subject": "Youtube Premium", + "transaction_decision": { + "resolved_arguments": {"subject": "Youtube Premium"}, + "target_tool": "tratar_vas_estrategico", + "action_changed": True, + "requires_reconfirmation": True, + "confirmation_message": "Identifiquei o serviço Youtube Premium. Esse serviço possui tratamento específico. Você deseja prosseguir?", + }, + }, + } + return {"ok": True, "tool_name": tool_name, "result": {"status": "DONE"}} + + +@pytest.mark.asyncio +async def test_prevalidation_can_canonicalize_arguments_and_redirect_domain_action_before_confirmation(): + runtime = _DomainRedirectRuntime() + state = { + "user_text": "quero cancelar youtube", + "sanitized_input": "quero cancelar youtube", + "mcp_tools": ["cancelar_vas_avulso"], + "route": "contestacao_agent", + "intent": "contas_vas_cancel", + "context": {"tool_arguments": {"subject": "youtube"}}, + } + result = await runtime.execute_tools_for_intent(state) + assert [name for name, _ in runtime.calls] == ["validar_vas_subject"] + assert result[-1]["awaiting_confirmation"] is True + assert state["pending_tool_call"]["tool_name"] == "tratar_vas_estrategico" + assert state["pending_tool_call"]["arguments"]["subject"] == "Youtube Premium" + assert state["active_transaction"]["tool_name"] == "tratar_vas_estrategico" + assert state["transaction_pre_validation"]["requested_arguments"]["subject"] == "youtube" + assert state["transaction_pre_validation"]["resolved_arguments"]["subject"] == "Youtube Premium" + assert runtime.transaction_confirmation_message(state).startswith("Identifiquei o serviço Youtube Premium") + + state["user_text"] = "sim" + state["sanitized_input"] = "sim" + confirmed = await runtime.execute_tools_for_intent(state, tools=[]) + assert runtime.calls[-1][0] == "tratar_vas_estrategico" + assert runtime.calls[-1][1]["subject"] == "Youtube Premium" + assert confirmed[-1]["ok"] is True + +@pytest.mark.asyncio +async def test_runtime_reuses_semantic_confirmation_decision_from_router_metadata(): + runtime = _Runtime() + state = { + "user_text": "Quero devolver o pedido 123 porque me arrependi", + "sanitized_input": "Quero devolver o pedido 123 porque me arrependi", + "mcp_tools": ["solicitar_devolucao"], + "route": "support_agent", + "intent": "retail_support_exchange_return", + } + await runtime.execute_tools_for_intent(state) + assert state["transaction_status"] == "AWAITING_CONFIRMATION" + + state["user_text"] = "isso mesmo, pode confirmar" + state["sanitized_input"] = state["user_text"] + state["route_decision"] = { + "route": "support_agent", + "agent": "support_agent", + "intent": "state:WAITING_SUPPORT_CONFIRMATION", + "metadata": { + "transaction_turn_consumed": True, + "transaction_confirmation_decision": "confirm", + "transaction_confirmation_source": "semantic", + }, + } + result = await runtime.execute_tools_for_intent(state, tools=[]) + assert state["transaction_status"] == "COMPLETED" + assert runtime.calls[-1][0] == "solicitar_devolucao" + assert runtime.calls[-1][1]["confirmed"] is True + assert result[-1]["ok"] is True diff --git a/tests/unit/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.2.pyc index 0cad7786..c359e7e8 100644 Binary files a/tests/unit/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_authentication.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_authentication.cpython-313-pytest-9.0.2.pyc index d7ff70ec..ba792a5f 100644 Binary files a/tests/unit/__pycache__/test_authentication.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_authentication.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_authentication_policies.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_authentication_policies.cpython-313-pytest-9.0.2.pyc index 4831c43a..901223a9 100644 Binary files a/tests/unit/__pycache__/test_authentication_policies.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_authentication_policies.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_cache.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_cache.cpython-313-pytest-9.0.2.pyc index 092f7633..14e35e28 100644 Binary files a/tests/unit/__pycache__/test_cache.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_cache.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_cache_distributed.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_cache_distributed.cpython-313-pytest-9.0.2.pyc index f0a24c17..e85febcc 100644 Binary files a/tests/unit/__pycache__/test_cache_distributed.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_cache_distributed.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_imports_compile.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_imports_compile.cpython-313-pytest-9.0.2.pyc index a31b823c..36c0350b 100644 Binary files a/tests/unit/__pycache__/test_imports_compile.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_imports_compile.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_langgraph_checkpoint_interrupt_controlled.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_langgraph_checkpoint_interrupt_controlled.cpython-313-pytest-9.0.2.pyc index f8fe590f..0a4c1e9c 100644 Binary files a/tests/unit/__pycache__/test_langgraph_checkpoint_interrupt_controlled.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_langgraph_checkpoint_interrupt_controlled.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_langgraph_checkpoint_runtime_config.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_langgraph_checkpoint_runtime_config.cpython-313-pytest-9.0.2.pyc index 3a780712..8c3a549b 100644 Binary files a/tests/unit/__pycache__/test_langgraph_checkpoint_runtime_config.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_langgraph_checkpoint_runtime_config.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_langgraph_checkpoint_saver.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_langgraph_checkpoint_saver.cpython-313-pytest-9.0.2.pyc index 192c9849..728d734e 100644 Binary files a/tests/unit/__pycache__/test_langgraph_checkpoint_saver.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_langgraph_checkpoint_saver.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_langgraph_telemetry.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_langgraph_telemetry.cpython-313-pytest-9.0.2.pyc index e4adf4bc..b14942c6 100644 Binary files a/tests/unit/__pycache__/test_langgraph_telemetry.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_langgraph_telemetry.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_llm_rich_response.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_llm_rich_response.cpython-313-pytest-9.0.2.pyc index 44e704dd..9faf0504 100644 Binary files a/tests/unit/__pycache__/test_llm_rich_response.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_llm_rich_response.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_long_term_memory_autonomous.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_long_term_memory_autonomous.cpython-313-pytest-9.0.2.pyc index 64941e75..d501c8ea 100644 Binary files a/tests/unit/__pycache__/test_long_term_memory_autonomous.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_long_term_memory_autonomous.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_pubsub_analytics_publisher.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_pubsub_analytics_publisher.cpython-313-pytest-9.0.2.pyc index 9e323f1e..5d84875f 100644 Binary files a/tests/unit/__pycache__/test_pubsub_analytics_publisher.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_pubsub_analytics_publisher.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_rag.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_rag.cpython-313-pytest-9.0.2.pyc index dbafeb2e..968b7bfa 100644 Binary files a/tests/unit/__pycache__/test_rag.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_rag.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_rag_kbdb_provider.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_rag_kbdb_provider.cpython-313-pytest-9.0.2.pyc index 8c401057..6e01a84b 100644 Binary files a/tests/unit/__pycache__/test_rag_kbdb_provider.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_rag_kbdb_provider.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_rag_oracle_sql_generation.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_rag_oracle_sql_generation.cpython-313-pytest-9.0.2.pyc index 0ae33556..3f984564 100644 Binary files a/tests/unit/__pycache__/test_rag_oracle_sql_generation.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_rag_oracle_sql_generation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_rag_runtime_grounding.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_rag_runtime_grounding.cpython-313-pytest-9.0.2.pyc index 3cf6c23f..601f0434 100644 Binary files a/tests/unit/__pycache__/test_rag_runtime_grounding.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_rag_runtime_grounding.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_resilient_checkpointer.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_resilient_checkpointer.cpython-313-pytest-9.0.2.pyc index c6608e0b..e2976a7e 100644 Binary files a/tests/unit/__pycache__/test_resilient_checkpointer.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_resilient_checkpointer.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_semantic_route_stickiness.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_semantic_route_stickiness.cpython-313-pytest-9.0.2.pyc index db1cf341..5b826c08 100644 Binary files a/tests/unit/__pycache__/test_semantic_route_stickiness.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_semantic_route_stickiness.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_sse.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_sse.cpython-313-pytest-9.0.2.pyc index b0b10404..6b3b883a 100644 Binary files a/tests/unit/__pycache__/test_sse.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_sse.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_sse_replay_dedup.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_sse_replay_dedup.cpython-313-pytest-9.0.2.pyc index d79f76ee..94e49d20 100644 Binary files a/tests/unit/__pycache__/test_sse_replay_dedup.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_sse_replay_dedup.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_telemetry_langfuse_compact.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_telemetry_langfuse_compact.cpython-313-pytest-9.0.2.pyc index 3def25f4..a8820c24 100644 Binary files a/tests/unit/__pycache__/test_telemetry_langfuse_compact.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_telemetry_langfuse_compact.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_token_cost_enterprise.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_token_cost_enterprise.cpython-313-pytest-9.0.2.pyc index 0aa346b1..d56aec53 100644 Binary files a/tests/unit/__pycache__/test_token_cost_enterprise.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_token_cost_enterprise.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_tool_policies.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_tool_policies.cpython-313-pytest-9.0.2.pyc index 6d9294e1..60a51521 100644 Binary files a/tests/unit/__pycache__/test_tool_policies.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_tool_policies.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_transactional_workflows.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_transactional_workflows.cpython-313-pytest-9.0.2.pyc index 33ee3e1c..077c71fe 100644 Binary files a/tests/unit/__pycache__/test_transactional_workflows.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_transactional_workflows.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_workflow_runtime_diagnostics.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_workflow_runtime_diagnostics.cpython-313-pytest-9.0.2.pyc index be260971..741d542e 100644 Binary files a/tests/unit/__pycache__/test_workflow_runtime_diagnostics.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_workflow_runtime_diagnostics.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_workflow_static.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_workflow_static.cpython-313-pytest-9.0.2.pyc index 0c4a1f9e..e33a0d49 100644 Binary files a/tests/unit/__pycache__/test_workflow_static.cpython-313-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_workflow_static.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/__pycache__/test_workflow_terminal_snapshot_semantics.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_workflow_terminal_snapshot_semantics.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..203dd725 Binary files /dev/null and b/tests/unit/__pycache__/test_workflow_terminal_snapshot_semantics.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/unit/test_workflow_terminal_snapshot_semantics.py b/tests/unit/test_workflow_terminal_snapshot_semantics.py new file mode 100644 index 00000000..7436db5e --- /dev/null +++ b/tests/unit/test_workflow_terminal_snapshot_semantics.py @@ -0,0 +1,190 @@ +from pathlib import Path +from types import ModuleType, SimpleNamespace +import sys + +import pytest + +from agent_framework.workflows import FileWorkflowRepository, WorkflowActionRegistry, WorkflowRuntime + + +def _write_workflow(tmp_path: Path) -> None: + (tmp_path / "terminal.active.yaml").write_text("version: 1\n", encoding="utf-8") + (tmp_path / "terminal.v1.yaml").write_text( + """name: terminal +version: 1 +start: finish +nodes: + - id: finish + action: finish +edges: + - from: finish + to: END +""", + encoding="utf-8", + ) + + +def _terminal_state(execution_id: str) -> dict: + return { + "execution_id": execution_id, + "workflow_name": "terminal", + "workflow_version": 1, + "input": {}, + "nodes": {"finish": {"success": True}}, + "vars": {"finish": {"success": True}}, + "output": {"success": True}, + "trace": [{"node": "finish", "action": "finish", "attempt": 1, "status": "COMPLETED"}], + "current_node": "finish", + } + + +class _FakeGraph: + def __init__(self, state: dict, snapshot): + self.state = state + self.snapshot = snapshot + + async def ainvoke(self, *args, **kwargs): + return self.state + + async def aget_state(self, config): + return self.snapshot + + +@pytest.mark.asyncio +async def test_arun_truthy_next_without_interrupt_is_completed_when_definition_is_terminal(tmp_path: Path, monkeypatch): + _write_workflow(tmp_path) + runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry()) + state = _terminal_state("exec-1") + # Regression shape observed in production: LangGraph still exposes a truthy + # next, but there is no real interrupt and the current node routes to END. + snapshot = SimpleNamespace(next=("finish__continue",), tasks=(), values=state) + monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot)) + + result = await runtime.arun("terminal", {}, execution_id="exec-1") + + assert result.status == "COMPLETED" + assert result.pause is None + assert result.state["current_node"] == "finish" + + +@pytest.mark.asyncio +async def test_aresume_truthy_next_without_interrupt_is_completed_when_definition_is_terminal(tmp_path: Path, monkeypatch): + _write_workflow(tmp_path) + runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry()) + state = _terminal_state("exec-2") + snapshot = SimpleNamespace(next=("finish__continue",), tasks=(), values=state) + monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot)) + # aresume imports langgraph.types.Command before invoking the compiled graph. + langgraph_module = ModuleType("langgraph") + types_module = ModuleType("langgraph.types") + class _Command: + def __init__(self, **kwargs): + self.kwargs = kwargs + types_module.Command = _Command + monkeypatch.setitem(sys.modules, "langgraph", langgraph_module) + monkeypatch.setitem(sys.modules, "langgraph.types", types_module) + + result = await runtime.aresume("terminal", "exec-2", "sim") + + assert result.status == "COMPLETED" + assert result.pause is None + + +@pytest.mark.asyncio +async def test_real_interrupt_still_has_precedence_over_structural_terminal(tmp_path: Path, monkeypatch): + _write_workflow(tmp_path) + runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry()) + state = _terminal_state("exec-3") + interrupt = SimpleNamespace(value={"node": "finish", "expected_input": {"key": "confirm"}}) + task = SimpleNamespace(interrupts=(interrupt,)) + snapshot = SimpleNamespace(next=("finish__pause",), tasks=(task,), values=state) + monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot)) + + result = await runtime.arun("terminal", {}, execution_id="exec-3") + + assert result.status == "PAUSED" + assert result.pause == {"node": "finish", "expected_input": {"key": "confirm"}} + + +@pytest.mark.asyncio +async def test_pending_nonterminal_without_interrupt_fails_closed_instead_of_faking_pause(tmp_path: Path, monkeypatch): + (tmp_path / "nonterminal.active.yaml").write_text("version: 1\n", encoding="utf-8") + (tmp_path / "nonterminal.v1.yaml").write_text( + """name: nonterminal +version: 1 +start: one +nodes: + - id: one + action: one + - id: two + action: two +edges: + - from: one + to: two + - from: two + to: END +""", + encoding="utf-8", + ) + runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry()) + state = { + "execution_id": "exec-4", + "workflow_name": "nonterminal", + "workflow_version": 1, + "input": {}, + "nodes": {"one": {"success": True}}, + "vars": {}, + "output": {}, + "trace": [{"node": "one", "action": "one", "attempt": 1, "status": "COMPLETED"}], + "current_node": "one", + } + snapshot = SimpleNamespace(next=("two",), tasks=(), values=state) + monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot)) + + result = await runtime.arun("nonterminal", {}, execution_id="exec-4") + + assert result.status == "FAILED" + assert "trabalho pendente sem interrupt real" in (result.error or "") + assert result.pause is None + +@pytest.mark.asyncio +async def test_persisted_interrupt_in_snapshot_values_is_real_pause(tmp_path: Path, monkeypatch): + """LangGraph may persist interrupts in values['__interrupt__'] only. + + Regression: this shape used to be mistaken for non-terminal pending work + when snapshot.next pointed at a framework-generated ``__pause`` node. + """ + _write_workflow(tmp_path) + runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry()) + state = _terminal_state("exec-values-interrupt") + pause_payload = { + "node": "finish", + "prompt": "Confirma?", + "expected_input": {"key": "resposta_usuario", "allowed_values": ["SIM", "NAO"]}, + } + state["__interrupt__"] = [{"value": pause_payload, "id": "pause-1"}] + # current_node is deliberately non-terminal so the PAUSED decision must + # come from the persisted interrupt, not structural-terminal detection. + state["current_node"] = None + snapshot = SimpleNamespace(next=("finish__pause",), tasks=(), values=state) + monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot)) + + result = await runtime.arun("terminal", {}, execution_id="exec-values-interrupt") + + assert result.status == "PAUSED" + assert result.pause == pause_payload + assert result.error is None + + +def test_snapshot_interrupts_deduplicates_task_and_persisted_shapes(tmp_path: Path): + _write_workflow(tmp_path) + runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry()) + payload = {"node": "finish", "expected_input": {"key": "confirm"}} + task = SimpleNamespace(interrupts=(SimpleNamespace(value=payload),)) + snapshot = SimpleNamespace( + tasks=(task,), + values={"__interrupt__": [{"value": payload, "id": "same-pause"}]}, + interrupts=(), + ) + + assert runtime._snapshot_interrupts(snapshot) == [payload]