Ajustes conforme relatorio de testes 2026-08-27

This commit is contained in:
2026-08-29 09:53:32 -03:00
parent 0ecff719b7
commit 88e1f070d7
791 changed files with 27040 additions and 29038 deletions

View File

@@ -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

View File

@@ -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 "<AUTHORIZED_PROTOCOL>" 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 "<AUTHORIZED_PROTOCOL>" 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 "<AUTHORIZED_PROTOCOL>" 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 "<AUTHORIZED_PROTOCOL>" 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

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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]