diff --git a/README.md b/README.md index b91aab5..1b43152 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 834af65..46603af 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 dbe6f09..70ba3b2 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 b8ed7bc..ed17e04 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 79a8274..009b7c1 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 cb44202..f77c47a 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 192132d..49ad6af 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 303bf51..ed17e04 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 cb44202..f77c47a 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 7e37046..d345c8b 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 2cb86e1..fc29245 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 823d284..884409e 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 baf5776..7f097b7 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 71b1339..d662183 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 7d103e6..e5055fe 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 abd23f2..0e9f1e1 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 3a5ff2f..f05ef2c 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 cfd8819..2b36337 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 0ab1a52..27c5dd6 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 1089291..321fc08 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 66abc54..bcd0559 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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 82f6849..e011e2c 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 5ce2480..417fdea 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 636a41a..43c2841 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 0f860a4..8684f23 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 4e5f533..19cee2f 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 063a9b9..5919f9d 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 e06cfd2..60f4328 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 70a3957..a01c7f1 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 2c6e20e..32cbdf8 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 cb44202..f77c47a 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 8adf49f..0807ab7 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 303bf51..ed17e04 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 970b809..23b52e1 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 cb44202..f77c47a 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 114625a..7293dd3 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 2cb86e1..fc29245 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 3c19c45..ce2047c 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 2cb86e1..fc29245 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 9b317c8..53682d6 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 13d92b4..1efe5af 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 0000000..6952974 --- /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 0000000..e69de29 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 0000000..41f7f0a 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 0000000..4bb04c5 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 0000000..7e96bd4 --- /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 0000000..a550871 --- /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 0000000..9673945 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 0000000..52a5922 --- /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 0000000..b825518 --- /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 0000000..2e723ce --- /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 0000000..a8d3718 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 0000000..aca91b7 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 0000000..a550871 --- /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 0000000..2d339c6 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 d764998..52a5922 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 923d17f..2e723ce 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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 8c88b04..567eb4d 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 cb44202..f77c47a 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 91f49fc..03dc5e9 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 2cb86e1..fc29245 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 d268a63..e22859d 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 2cb86e1..fc29245 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 cb44202..f77c47a 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 515e583..a4df996 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 fef0791..e22cf44 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 cb44202..f77c47a 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 8604cd4..f8da241 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 fef0791..e22cf44 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 0000000..9fb0b13 --- /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 0000000..cc669d0 --- /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 0000000..bcc646b --- /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 0000000..c9fba0a --- /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 0000000..80dd6de --- /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 86a3cd9..4adc0bb 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 7a83179..e98fa51 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 0000000..88bb312 --- /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 e88a907..20714e0 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 601f0c1..aa753ba 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 b56c07d..18801e6 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 0000000..ed9d8a6 --- /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 91b9e9a..079cf67 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 0000000..393fb8b 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 0000000..6bcfde5 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 0000000..8f935c6 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 0000000..d7cfedb 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 0000000..5482781 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 0000000..3823813 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 0000000..91e613e 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 0000000..98c3131 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 0000000..4446a3f 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 0000000..8a3e728 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 0000000..39a0728 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 0000000..3710122 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 0000000..025f771 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 0000000..9a84349 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 0000000..b2e4a9a 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 0000000..eb3c2ea 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 0000000..850581c 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 0000000..91e6596 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 0000000..e9e66d6 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 0000000..a67530c 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 0000000..04b8687 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 0000000..e47f0c9 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 0000000..37b3d89 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 0000000..432b7d6 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 0000000..53e0fb6 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 0000000..161d232 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 0000000..5c310da 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 0000000..07cf4ed 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 0000000..f7d3dbc 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 0000000..9b48baa 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 0000000..f3c6317 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 0000000..a6db1bb 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 0000000..c1db452 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 0000000..8830564 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 0000000..dfe6ced 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 0000000..6a51709 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 0000000..1497c7b 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 0000000..938c58d 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 0000000..13d24b8 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 0000000..3ee859c 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 0000000..370f6f6 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 0000000..2a6c75c 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 0000000..a99fd41 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 0000000..063978d 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 0000000..9503cb7 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 0000000..ad11545 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 0000000..4a6373b 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 0000000..5f16e14 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 0000000..76a0ce3 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 0000000..005475b 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 0000000..53ac82d 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 0000000..36676c0 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 0000000..6fe0384 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 0000000..6e5a9dc 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 0000000..2faecbb 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 0000000..de06bc8 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 0000000..a933433 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 0000000..3d3b39b 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 0000000..03f36cf 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 0000000..5a8663c 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 0000000..a1da716 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 0000000..009d915 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 0000000..69fe1a5 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 0000000..9abed83 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 0000000..f18e5d1 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 0000000..ab6cbe5 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 0000000..8f10fdc 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 0000000..d6e600e 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 0000000..1b2dff7 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 0000000..c2af6f6 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 0000000..9e4ba3e 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 0000000..e0c2e93 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 0000000..053dc69 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 0000000..08b46a4 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 0000000..023ef96 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 0000000..77bac8f 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 0000000..d5cd633 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 0000000..4bf4d61 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 0000000..e21f595 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 0000000..97277c8 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 0000000..33e3018 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 0000000..334e62b 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 0000000..ea56658 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 0000000..afce58b 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 0000000..aacec91 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 b88caef..09fb7a6 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 0000000..903f4e9 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 0000000..e7111a6 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 0000000..12e38f6 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 0000000..e4547ab 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 0000000..9cd565f 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 0000000..7ebd55b 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 0000000..ca1905e 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 0000000..bf674c1 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 0000000..516d9ba 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 0000000..aa71f5f 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 0000000..a26fce7 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 0000000..c8f9c0f 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 0000000..dae0fca 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 0000000..2520398 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 0000000..3f7c681 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 0000000..9401242 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 0000000..ce058ca 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 0000000..ab875b4 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 0000000..26d97a2 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 0000000..63716a3 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 0000000..82c85db 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 0000000..64cb256 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 0000000..47569e8 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 0000000..ca1fa16 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 f8c70c4..187e557 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 0000000..3693bdb 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 0000000..28329f0 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 0000000..0506d81 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 0000000..e760e2a 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 0000000..3f398b4 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 0000000..49d40ba 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 0000000..6d6627d 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 0000000..da779fe 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 0000000..d867344 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 0000000..f014ca0 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 0000000..6713dee 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 0000000..9b1920e 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 0000000..8d25a7e 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 0000000..ac88701 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 0000000..23cbc9f 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 0000000..ce95340 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 0000000..f53408a 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 0000000..c272390 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 0000000..240d8a2 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 0000000..0e46e89 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 0000000..fac8466 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 0000000..5671eaa 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 0000000..e105142 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 0000000..ab34357 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 0000000..4a2f72c 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 0000000..ff81f2f 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 0000000..a83ed8f 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 0000000..dfc32d2 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 0000000..75617bc 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 0000000..e0542ae 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 0000000..ab1f438 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 0000000..bceeeb7 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 0000000..8a4baf1 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 0000000..f2e6d50 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 0000000..ea2e302 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 0000000..6237061 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 0000000..1a6b4d0 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 0000000..ecdbb99 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 0000000..e592285 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 0000000..6bb7356 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 0000000..24187ed 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 0000000..8a51c87 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 0000000..0eb8dc7 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 0000000..0d54e8b 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 0000000..edbd6b5 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 0000000..31552cd 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 0000000..50c66ae 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 0000000..1af4354 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 0000000..fb64d4f 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 0000000..f1dbfa3 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 0000000..e3f93f8 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 0000000..acd379c 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 0000000..30ac0c2 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 0000000..d37ab17 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 0000000..47c1def 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 0000000..4871e78 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 0000000..0c0e164 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 0000000..346186b 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 0000000..e46d02e 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 0000000..77915f0 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 0000000..bd3eedb 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 0000000..eb020ac 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 0000000..de1a012 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 0000000..8a331fb 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 0000000..a8cca2a 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 0000000..0393bae 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 0000000..546cc34 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 0000000..836b1e2 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 0000000..28d590e 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 0000000..2ea8209 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 0000000..966f6d4 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 0000000..b06d83a 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 0000000..f002723 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 0000000..e9db1f8 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 0000000..35ac6bf 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 0000000..871c7d8 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 0000000..7a3ea01 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 0000000..6f1fb7a 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 0000000..b8815bb 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 0000000..222d34e 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 0000000..17ab49d 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 0000000..a3edd9e 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 0000000..b805d8d 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 0000000..52d1888 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 501e2bf..ea5188d 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 0000000..14725d4 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 0000000..5030833 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 0000000..f7d5ff8 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 0000000..0df895b 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 f5aa1ef..7363992 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 b79bc08..1a394f7 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 0000000..8d437d4 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 0000000..4e93223 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 0000000..e79d4ac 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 0000000..4b60c06 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 0000000..7ac290c 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 0000000..6cccf5f 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 0000000..4f40a31 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 0000000..ab585f8 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 0000000..02b5751 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 0000000..790fbed 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 0000000..662e3cc 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 0000000..ecf06f7 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 0000000..9280ab9 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 0000000..e364844 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 0000000..ac7534a 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 0000000..4ba8494 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 0000000..f739c63 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 0000000..7714ab8 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 0000000..15b1a92 --- /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 0dd996f..fd8ca93 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 d721fb6..c171010 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 de09ac2..e1a2af2 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 a726204..c8ae879 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 83fa4b8..133a5dd 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 d548db5..3b82cb6 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 69bd22b..c5eab8c 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 562f4db..5f69ea0 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 48f7d5a..11ba648 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 4bcd451..c8c9645 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 c4901cd..b63884a 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 8c60d8f..4e2f8a9 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 5fd44af..5438b73 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 384343e..14baae3 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 ce3475c..556155f 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 20cb3fa..e241436 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 8921350..bc82faa 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 1458ff0..c9b5e88 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 5c31516..8590014 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 5285deb..7f7a991 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 13881e7..24078c2 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 66072c9..42b0651 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 b4b3fde..969a666 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 a307663..a6d7710 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 fd5ec41..e3a60be 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 a84f951..484e608 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 5ad662b..5de22cb 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 a898bfe..bb06c97 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 333f20d..dc7c612 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 47f2463..b0e2a20 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 f230256..74169e8 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 69e908a..89bc20f 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 5210e7c..06f182d 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 4dc9805..7297e96 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 3cae7c9..b4ebf5d 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 2b66521..eb093bc 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 25db31f..4be3b30 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 6f0b4bf..28c590a 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 656d136..480279a 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 7909a8a..7bcb3ba 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 6bfab5a..6a97e1d 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 7c4d856..b90fd8f 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 16c4b37..885a41e 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 527a4a1..000c9f6 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 ef4b2a9..a93b618 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 43f4453..5df19df 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 4734a86..0deff96 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 06f9eae..5301e0b 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 db931db..bc08ea9 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 112d959..faf3122 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 d2a59de..9f64e65 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 f18abd0..2955e5d 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 359c587..0b3b871 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 edb612d..3e07f68 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 67c2445..2771712 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 7541193..928a7d4 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 d9e53cb..19579a2 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 59f52bb..739c6b0 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 f758829..af2b357 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 66f0d1e..7236997 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 2c248c3..00a3c38 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 7ef5a80..3461303 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 c7c88fa..50840c9 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 1d50f43..46bd8d0 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 908bd59..73ed1bd 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 b12fd64..705b3d7 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 293a24c..c5a389c 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 f7ce815..19d6195 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 d7e938b..7485f64 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 8d8604f..3189892 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 9fcf3a3..8dd7caf 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 529052c..5b2fc1a 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 07e87ce..6504754 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 251de0c..6e6fc85 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 b9fd59f..33bebb8 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 ada751d..d25d8aa 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 b4a792a..803649c 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 8905a4e..c16f49a 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 31ddc96..80432b4 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 68f9cbd..46dd726 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 b787756..0a85a80 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 62ca20f..9227931 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 300c22a..fb8381f 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 5e31e72..0e1ea71 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 0c08d83..d1787ac 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 1fff83b..6b59942 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 74f244a..949da14 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 b88caef..09fb7a6 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 eb1d9a3..d5a6bac 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 4d96758..545c415 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 bbf441d..8708f30 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 92b74c0..0862eef 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 96af157..8cb1eaf 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 a6393b4..27b0d37 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 ec87619..9af5793 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 b764e2a..f8495a8 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 ca91413..0e97149 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 e3e74e4..0b9d083 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 02973ae..8b49f3a 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 ad81f20..a7c7249 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 6d92edd..6cc5508 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 693e8a8..aeec185 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 ab54fd4..2a76c07 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 4372f5e..fefa284 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 8a5d4ee..6c3b8f9 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 8bdf382..9525536 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 d2b7d24..110ac50 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 c70f960..63aabca 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 d5664d6..a7ea241 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 ef9f3f3..4f7fe94 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 0243e60..6860d9f 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 0fd52f7..3e11b80 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 f8c70c4..187e557 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 8523e2f..fdf2a69 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 4965994..2a8d295 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 ce757d7..6549426 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 2a481a8..11567c7 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 65d8871..106c8e5 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 22b3d70..b282e70 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 7e6b5e8..4a8a15f 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 24ca7be..8039405 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 03efe84..95f8d64 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 ca31105..198f6bf 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 2c6e05d..0bee723 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 7eeb142..e3638cf 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 7dd5a20..fd40f93 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 95a4f91..f28f301 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 0ca27c9..23858e3 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 c35712e..50ef1f1 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 c0dafc3..cc9fa8c 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 ae721c4..aaf2b9d 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 6fe3ec3..49f5460 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 72a3a3f..fe57b10 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 9664a07..f72d54c 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 50a7816..26337df 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 b683e47..0e1e45f 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 cc89f5e..6ef32f0 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 0f9e4af..5f455f0 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 62cf581..653ae2f 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 41f361c..d2f7510 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 a2184ab..bd2b61d 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 1455ef5..93dff6c 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 bf72c59..04a0911 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 79c43f7..2bcc27c 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 4cade82..e047e20 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 bcdd694..737f434 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 3472676..e53b148 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 837ee36..ee594a6 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 37d834a..bc025fb 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 58daae4..c47826b 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 806a543..2867784 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 4a12ed1..83ef853 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 1f1d73f..742f9a8 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 8e92516..3b6093d 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 7a83068..3ac77c0 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 3e8308d..5bb1e55 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 3e59d70..0566f5f 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 45b240c..f82b25b 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 a7c7b45..278d210 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 5ca3097..a29bf67 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 f469a9d..ca9dccd 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 bcdbacf..b22ea7b 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 36dc576..036e5e9 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 1a3b13e..447bf41 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 fe0bd9c..5604b3a 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 fce4799..bb8ff14 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 fd18fc0..7e937fe 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 8de88ae..6f66900 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 ccb1323..6ac24ad 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 a340117..60edf47 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 185c1fd..5809ce8 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 5a623de..9be9619 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 5fcb1d7..b40b78b 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 95e8949..d024ff2 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 34add0f..42851b9 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 42a053e..ff48d4e 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 6bc687d..d65da67 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 cf48e74..46ad29e 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 d30f51a..4695d5d 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 7b26f45..e674756 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 1f4357f..6d9acc9 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 b5e162e..d6bb4fb 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 63c06a1..aace1fa 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 99dd036..b382948 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 b6895c7..35d97c8 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 4a1a73d..78ad4c3 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 962fa82..2899312 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 5c31d32..c756944 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 ee7273b..ca93113 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 bb2200e..9db7b40 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 2fbeaed..fce5015 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 c7a1f00..811c598 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 f3a89d5..27bf2a5 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 b11c48d..eeb85f8 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 be1d14f..38db495 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 417d540..e0ac9a0 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 0f8e9ff..413590b 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 34028e5..fab4d4f 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 dcd5ec7..0d134a4 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 84125b2..34d1cd6 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 06a92f8..8f3758e 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 6f41b8e..d6ccfe1 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 e1b46ea..7f36409 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 38063fd..20a83fd 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 156855a..319f01c 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 d4b6641..5f62e2e 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 4682a1f..1a394f7 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 111dafd..9bd73df 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 edb64da..0a09839 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 5c8d321..9dc99b2 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 c9dd3ee..61f7848 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 3cfe661..e64257a 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 34c93c2..b457b7a 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 d1fb5e0..c078ece 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 685b355..6d05d07 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 45add6e..2c9b2f6 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 04cd414..d75e358 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 97afa37..01bfd5c 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 5309359..937cfe5 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 b69667d..f94e3d9 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 2d18d61..6d421c5 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 6022ef8..1af1d81 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 10c9a53..83eb516 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 b709e0d..c64be92 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 59cb344..94097fc 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 2492553..15b1a92 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 0dd996f..fd8ca93 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 d721fb6..c171010 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 0000000..e1fdcb0 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 4556734..0000000 --- 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 ee504c1..f6a2b79 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 521accb..08f8cb6 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 0e8f586..8d7093d 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 50e1f65..31cdc3a 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 7a1b3b5..4e27b20 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 9283837..803273e 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 cec4448..104fb20 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 f4cf172..eda48f5 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 a2df5a5..df7bf15 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 de82b0f..7fbc59f 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 05254d1..aa60099 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 b793c26..f557bed 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 6c691d2..113bd8e 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 b15a0e4..b4f0244 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 53216fc..a6540ec 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 5a02778..8fc575e 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 fa305ea..8253000 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 761f5f7..41a7208 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 4f9483c..a8f3f88 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 ccfb755..3b7d6a8 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 288f277..9144c5b 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 5125182..9495cf2 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 69de758..32e74b9 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 cb44202..f77c47a 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 3236364..d217376 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 001fdff..9ec9aa1 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 720b535..3515b5c 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 fef0791..99bf4c5 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 03aeaa9..bb4ef6f 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 0000000..a550871 --- /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 4556734..0000000 --- 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 6a9d209..eafedee 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 7e6bd82..c13b42c 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 a18a16d..a8f0280 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 4dc5fa7..f856d0c 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 fa947b3..406206d 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 50916c3..cbe2d5d 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 4e1d80e..d394a5b 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 ac2905a..d96c6e4 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 f1619c0..0d39586 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 fa42c73..9dee705 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 05254d1..aa60099 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 b793c26..f557bed 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 998dad6..34433f5 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 b15a0e4..b4f0244 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 029d41d..359dee6 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 2d327ac..76a03bb 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 09d9e25..31ea4a2 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 aa1eb63..2770df0 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 187a070..1e6e079 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 0000000..a550871 --- /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 2e7e76e..8290c40 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 130bc82..92db6fd 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 0000000..807d2ef 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 0000000..ca9f9c5 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 0000000..f6b2a49 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 0000000..2bd695b 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 0000000..e4ec634 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 3540833..984d80b 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 0e408dc..f303beb 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 2721be2..7fdc40a 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 c7491df..1fc2fc5 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 15dcc40..bbffaa9 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 4cbb1d1..262cbd1 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 dafcf30..d7511ad 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 ab54f2c..fed3591 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 479cc36..ec50648 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 c0b3bb4..7689c57 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 64c4baa..adba635 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 d701b12..d66663e 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 8d7f43c..8fe5b15 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 7b19f26..db714c5 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 89a9991..9ab2f1b 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 25a6167..4972745 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 0000000..46a7b3b --- /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 0000000..3367f28 --- /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 0000000..555ce4d --- /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 0000000..215cfea --- /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 2772a8d..09106e5 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 5e723d5..fe2ed05 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 ec6d57f..b40d129 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 0cad778..c359e7e 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 d7ff70e..ba792a5 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 4831c43..901223a 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 092f763..14e35e2 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 f0a24c1..e85febc 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 a31b823..36c0350 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 f8fe590..0a4c1e9 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 3a78071..8c3a549 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 192c984..728d734 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 e4adf4b..b14942c 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 44e704d..9faf050 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 64941e7..d501c8e 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 9e323f1..5d84875 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 dbafeb2..968b7bf 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 8c40105..6e01a84 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 0ae3355..3f98456 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 3cf6c23..601f043 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 c6608e0..e2976a7 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 db1cf34..5b826c0 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 b0b1040..6b3b883 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 d79f76e..94e49d2 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 3def25f..a8820c2 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 0aa346b..d56aec5 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 6d9294e..60a5152 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 33ee3e1..077c71f 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 be26097..741d542 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 0c4a1f9..e33a0d4 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 0000000..203dd72 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 0000000..7436db5 --- /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]