Ajustes conforme relatorio de testes 2026-08-27
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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]
|
||||
Reference in New Issue
Block a user