bugfix: fraseologia
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.
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
|
||||
from agent_framework.guardrails.rails import ProactiveOfferRail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"])
|
||||
async def test_aoferta_bypasses_transaction_continuation_without_llm(monkeypatch, status):
|
||||
async def should_not_run(*args, **kwargs):
|
||||
raise AssertionError("AOFERTA LLM must not run for transaction continuation")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.guardrails.rails.classify_with_framework_llm",
|
||||
should_not_run,
|
||||
)
|
||||
|
||||
decision = await ProactiveOfferRail().evaluate(
|
||||
"Para prosseguir, informe valor.",
|
||||
{"transaction_status": status},
|
||||
)
|
||||
|
||||
assert decision.allowed is True
|
||||
assert decision.code == "AOFERTA"
|
||||
assert decision.metadata["mechanism"] == "deterministic_transaction_bypass"
|
||||
assert decision.metadata["transaction_status"] == status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aoferta_detects_transaction_continuation_from_mcp_results(monkeypatch):
|
||||
async def should_not_run(*args, **kwargs):
|
||||
raise AssertionError("AOFERTA LLM must not run for transaction continuation")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.guardrails.rails.classify_with_framework_llm",
|
||||
should_not_run,
|
||||
)
|
||||
|
||||
decision = await ProactiveOfferRail().evaluate(
|
||||
"Você confirma o cancelamento do serviço TIM Fashion?",
|
||||
{
|
||||
"mcp_results": [
|
||||
{
|
||||
"tool_name": "cancelar_vas_avulso",
|
||||
"awaiting_confirmation": True,
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert decision.allowed is True
|
||||
assert decision.metadata["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aoferta_still_calls_llm_outside_transaction_continuation(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_classify(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return {"allowed": False, "reason": "oferta proativa"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.guardrails.rails.classify_with_framework_llm",
|
||||
fake_classify,
|
||||
)
|
||||
|
||||
decision = await ProactiveOfferRail().evaluate(
|
||||
"Quer aproveitar e cancelar outro serviço?",
|
||||
{"transaction_status": "COMPLETED"},
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert decision.allowed is False
|
||||
assert decision.metadata["mechanism"] == "llm_supervisor"
|
||||
@@ -0,0 +1,23 @@
|
||||
from agent_framework.guardrails.calibrated.prompts.fraseologia import build_fraseologia_prompt
|
||||
|
||||
|
||||
def test_fraseologia_prompt_allows_business_parameter_collection():
|
||||
prompt = build_fraseologia_prompt("[ContestacaoAgent] Para prosseguir, informe valor.")
|
||||
assert '"Para prosseguir, informe valor."' in prompt
|
||||
assert "DADOS DE NEGOCIO" in prompt
|
||||
assert "NAO expoe raciocinio" in prompt
|
||||
|
||||
|
||||
def test_fraseologia_prompt_distinguishes_business_data_from_internal_keys():
|
||||
prompt = build_fraseologia_prompt("Informe subject.")
|
||||
assert '"subject"' in prompt
|
||||
assert '"asset_id"' in prompt
|
||||
assert '"invoice_id"' in prompt
|
||||
assert '"COLLECTING_PARAMETERS"' in prompt
|
||||
assert '"AWAITING_CONFIRMATION"' in prompt
|
||||
|
||||
|
||||
def test_fraseologia_prompt_keeps_natural_transaction_confirmation_allowed():
|
||||
prompt = build_fraseologia_prompt("Voce confirma o cancelamento do servico TIM Fashion?")
|
||||
assert "confirmacoes de uma acao ja em andamento" in prompt
|
||||
assert "TIM Fashion" in prompt
|
||||
@@ -0,0 +1,114 @@
|
||||
import pytest
|
||||
|
||||
from agent_framework.guardrails.base import RailDecision
|
||||
from agent_framework.guardrails.output_supervisor import OutputSupervisor
|
||||
from agent_framework.guardrails.rail_action import RailAction
|
||||
|
||||
|
||||
class PhraseologyRail:
|
||||
code = "FRASEOLOGIA"
|
||||
stage = "output"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def evaluate(self, text, context):
|
||||
self.calls.append(text)
|
||||
blocked = "categoria tratável por esta operação" in text
|
||||
return RailDecision(
|
||||
code=self.code,
|
||||
allowed=not blocked,
|
||||
reason=(
|
||||
"remova linguagem interna" if blocked else ""
|
||||
),
|
||||
sanitized_text=text,
|
||||
metadata={"calibrated": True},
|
||||
)
|
||||
|
||||
|
||||
class AllowRail:
|
||||
code = "AOFERTA"
|
||||
stage = "output"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def evaluate(self, text, context):
|
||||
self.calls.append(text)
|
||||
return RailDecision(
|
||||
code=self.code,
|
||||
allowed=True,
|
||||
reason="",
|
||||
sanitized_text=text,
|
||||
metadata={"calibrated": True},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_phraseology_block_is_rewritten_once_and_all_rails_are_revalidated(monkeypatch):
|
||||
phrase = PhraseologyRail()
|
||||
allow = AllowRail()
|
||||
|
||||
async def fake_classify(llm, task, payload, **kwargs):
|
||||
assert task == "FALLBACK"
|
||||
assert payload["context"]["guardrail_code"] == "FRASEOLOGIA"
|
||||
return {
|
||||
"allowed": True,
|
||||
"label": "FALLBACK",
|
||||
"reason": (
|
||||
'[ContestacaoAgent] O item "TIM CTRL Redes Sociais 8.0" no valor de '
|
||||
'R$ 71,99 é um plano da sua fatura e, por isso, não pode ser '
|
||||
'contestado como serviço adicional.'
|
||||
),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.guardrails.output_supervisor.classify_with_framework_llm",
|
||||
fake_classify,
|
||||
)
|
||||
|
||||
supervisor = OutputSupervisor(
|
||||
rails=[allow, phrase],
|
||||
enable_parallel=False,
|
||||
llm=object(),
|
||||
)
|
||||
original = (
|
||||
'[ContestacaoAgent] O item "TIM CTRL Redes Sociais 8.0" no valor de '
|
||||
'R$ 71,99 é classificado como plano na sua fatura. Não é possível '
|
||||
'contestar esse tipo de cobrança por este canal, pois planos não pertencem '
|
||||
'a uma categoria tratável por esta operação.'
|
||||
)
|
||||
|
||||
decision = await supervisor.evaluate(original, {})
|
||||
|
||||
assert decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}
|
||||
assert "categoria tratável por esta operação" not in decision.candidate
|
||||
assert "TIM CTRL Redes Sociais 8.0" in decision.candidate
|
||||
assert "R$ 71,99" in decision.candidate
|
||||
assert len(phrase.calls) == 2
|
||||
assert len(allow.calls) == 2
|
||||
assert decision.metadata["phraseology_rewritten"] is True
|
||||
assert any(r.code == "FRASEOLOGIA_REWRITE" for r in decision.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_phraseology_rewrite_does_not_loop_when_rewritten_text_is_still_blocked(monkeypatch):
|
||||
phrase = PhraseologyRail()
|
||||
|
||||
async def fake_classify(llm, task, payload, **kwargs):
|
||||
return {
|
||||
"allowed": True,
|
||||
"label": "FALLBACK",
|
||||
"reason": payload["text"] + " ",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.guardrails.output_supervisor.classify_with_framework_llm",
|
||||
fake_classify,
|
||||
)
|
||||
supervisor = OutputSupervisor(rails=[phrase], enable_parallel=False, llm=object())
|
||||
original = "planos não pertencem a uma categoria tratável por esta operação"
|
||||
|
||||
decision = await supervisor.evaluate(original, {})
|
||||
assert decision.action == RailAction.BLOCK
|
||||
assert len(phrase.calls) == 1
|
||||
@@ -10,9 +10,9 @@ def test_explicit_keyword_shift_preempts_stickiness():
|
||||
assert EnterpriseRouter._is_explicit_intent_shift(d) is True
|
||||
|
||||
|
||||
def test_short_generic_keyword_does_not_preempt():
|
||||
def test_configured_keyword_is_explicit_regardless_of_literal_length():
|
||||
d = RouteDecision(route="x", agent="x", intent="x", method="keyword", metadata={"matched_keyword": "id"})
|
||||
assert EnterpriseRouter._is_explicit_intent_shift(d) is False
|
||||
assert EnterpriseRouter._is_explicit_intent_shift(d) is True
|
||||
|
||||
|
||||
def test_same_agent_explicit_intent_shift_must_preempt_stickiness():
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
ROUTING_YAML = """
|
||||
router:
|
||||
fallback_agent: billing_agent
|
||||
confidence_threshold: 0.70
|
||||
state_policies:
|
||||
- state: COLLECTING_ORDER_PARAMETERS
|
||||
agent: orders_agent
|
||||
- state: WAITING_ORDER_CONFIRMATION
|
||||
agent: orders_agent
|
||||
intents:
|
||||
- name: retail_order_cancel
|
||||
domain: retail
|
||||
agent: orders_agent
|
||||
priority: 30
|
||||
keywords: [cancelar pedido]
|
||||
- name: retail_order_tracking
|
||||
domain: retail
|
||||
agent: orders_agent
|
||||
priority: 20
|
||||
keywords: [rastrear pedido, pedido]
|
||||
- name: billing_invoice_explanation
|
||||
domain: telecom
|
||||
agent: billing_agent
|
||||
priority: 40
|
||||
keywords: [fatura, vencimento]
|
||||
"""
|
||||
|
||||
|
||||
def _router(tmp_path, *, stickiness=True):
|
||||
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=stickiness,
|
||||
)
|
||||
return EnterpriseRouter(settings)
|
||||
|
||||
|
||||
def _active_tx(status="COLLECTING_PARAMETERS", arguments=None):
|
||||
return {
|
||||
"tool_name": "cancelar_pedido",
|
||||
"status": status,
|
||||
"started_from_intent": "retail_order_cancel",
|
||||
"arguments": dict(arguments or {"order_id": "PED-1001"}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("message", ["PED-1001", "12345", "R$ 71,99", "10/09/2026"])
|
||||
async def test_matrix_collecting_parameter_answers_keep_transaction(message, tmp_path):
|
||||
router = _router(tmp_path)
|
||||
state = {
|
||||
"user_text": message,
|
||||
"sanitized_input": message,
|
||||
"next_state": "COLLECTING_ORDER_PARAMETERS",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["order_id"],
|
||||
"active_transaction": _active_tx(arguments={}),
|
||||
"active_agent": "orders_agent",
|
||||
"intent": "state:COLLECTING_ORDER_PARAMETERS",
|
||||
"route_decision": {"agent": "orders_agent", "intent": "retail_order_cancel"},
|
||||
}
|
||||
decision = await router.route(state)
|
||||
assert decision.method == "state"
|
||||
assert decision.agent == "orders_agent"
|
||||
assert (decision.metadata or {}).get("transaction_interruption") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_missing_next_state_recovers_active_transaction_before_stickiness(tmp_path):
|
||||
router = _router(tmp_path)
|
||||
state = {
|
||||
"user_text": "PED-1001",
|
||||
"sanitized_input": "PED-1001",
|
||||
"next_state": None,
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["order_id"],
|
||||
"selected_tool_call": {"tool_name": "cancelar_pedido", "arguments": {}},
|
||||
"active_transaction": _active_tx(arguments={}),
|
||||
"active_agent": "orders_agent",
|
||||
"route": "orders_agent",
|
||||
"route_decision": {"agent": "orders_agent", "intent": "retail_order_cancel"},
|
||||
}
|
||||
decision = await router.route(state)
|
||||
assert decision.method == "state"
|
||||
assert decision.metadata["transaction_state_recovered"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"],
|
||||
)
|
||||
async def test_matrix_terminal_transaction_never_recovers_from_latch(status, tmp_path):
|
||||
router = _router(tmp_path, stickiness=False)
|
||||
state = {
|
||||
"user_text": "quero ver minha fatura",
|
||||
"sanitized_input": "quero ver minha fatura",
|
||||
"next_state": None,
|
||||
"transaction_status": status,
|
||||
# Deliberately stale latch: terminal status must win.
|
||||
"active_transaction": _active_tx(status=status),
|
||||
"selected_tool_call": {"tool_name": "cancelar_pedido", "arguments": {"order_id": "PED-1001"}},
|
||||
"active_agent": "orders_agent",
|
||||
"intent": "retail_order_cancel",
|
||||
"route_decision": {"agent": "orders_agent", "intent": "retail_order_cancel"},
|
||||
}
|
||||
decision = await router.route(state)
|
||||
assert decision.intent == "billing_invoice_explanation"
|
||||
assert (decision.metadata or {}).get("transaction_state_recovered") is not True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"],
|
||||
)
|
||||
async def test_matrix_terminal_transaction_ignores_stale_next_state(status, tmp_path):
|
||||
router = _router(tmp_path, stickiness=False)
|
||||
state = {
|
||||
"user_text": "quero ver minha fatura",
|
||||
"sanitized_input": "quero ver minha fatura",
|
||||
# Simulate a partially persisted/legacy state where terminal status was
|
||||
# written but the transactional next_state was not cleared.
|
||||
"next_state": "COLLECTING_ORDER_PARAMETERS",
|
||||
"transaction_status": status,
|
||||
"active_transaction": _active_tx(status=status),
|
||||
"active_agent": "orders_agent",
|
||||
"intent": "retail_order_cancel",
|
||||
"route_decision": {"agent": "orders_agent", "intent": "retail_order_cancel"},
|
||||
}
|
||||
decision = await router.route(state)
|
||||
assert decision.intent == "billing_invoice_explanation"
|
||||
assert decision.agent == "billing_agent"
|
||||
assert decision.method != "state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"status,next_state",
|
||||
[
|
||||
("COLLECTING_PARAMETERS", "COLLECTING_ORDER_PARAMETERS"),
|
||||
("AWAITING_CONFIRMATION", "WAITING_ORDER_CONFIRMATION"),
|
||||
],
|
||||
)
|
||||
async def test_matrix_clear_intent_shift_preempts_active_transaction(status, next_state, tmp_path):
|
||||
router = _router(tmp_path)
|
||||
state = {
|
||||
"user_text": "esquece, quero ver minha fatura",
|
||||
"sanitized_input": "esquece, quero ver minha fatura",
|
||||
"next_state": next_state,
|
||||
"transaction_status": status,
|
||||
"active_transaction": _active_tx(status=status),
|
||||
"active_agent": "orders_agent",
|
||||
"intent": f"state:{next_state}",
|
||||
"route_decision": {"agent": "orders_agent", "intent": "retail_order_cancel"},
|
||||
}
|
||||
decision = await router.route(state)
|
||||
assert decision.intent == "billing_invoice_explanation"
|
||||
assert decision.agent == "billing_agent"
|
||||
assert decision.metadata["transaction_interruption"] == "intent_shift"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_route_stickiness_without_transaction_preserves_previous_behavior(tmp_path):
|
||||
router = _router(tmp_path)
|
||||
# Generic short follow-up: no transaction latch, no explicit new intent.
|
||||
state = {
|
||||
"user_text": "e o status?",
|
||||
"sanitized_input": "e o status?",
|
||||
"next_state": None,
|
||||
"transaction_status": None,
|
||||
"active_transaction": None,
|
||||
"active_agent": "orders_agent",
|
||||
"intent": "retail_order_tracking",
|
||||
"route_decision": {
|
||||
"route": "orders_agent",
|
||||
"agent": "orders_agent",
|
||||
"intent": "retail_order_tracking",
|
||||
"domain": "retail",
|
||||
"mcp_tools": [],
|
||||
},
|
||||
"context": {"session": {}},
|
||||
}
|
||||
decision = await router.route(state)
|
||||
# Semantic continuity may be disabled by settings/profile defaults in the
|
||||
# isolated test; regardless, transaction recovery must not be involved.
|
||||
assert (decision.metadata or {}).get("transaction_state_recovered") is not True
|
||||
|
||||
|
||||
class _PolicyRouter:
|
||||
def __init__(self):
|
||||
self.registry = SimpleNamespace(
|
||||
tools={"cancelar_pedido": object()},
|
||||
get_tool=lambda name: SimpleNamespace(selection_keywords=["cancelar pedido", "cancelar"]),
|
||||
)
|
||||
|
||||
def resolve_execution_policy(self, tool_name, arguments=None):
|
||||
return {
|
||||
"operation_type": "transactional",
|
||||
"require_confirmation": True,
|
||||
"requires": ["order_id"],
|
||||
"policy_source": "matrix-test",
|
||||
}
|
||||
|
||||
def validate_execution_policy(self, tool_name, arguments=None):
|
||||
return True, None, self.resolve_execution_policy(tool_name, arguments)
|
||||
|
||||
|
||||
class _Runtime(AgentRuntimeMixin):
|
||||
def __init__(self, *, call_ok=True):
|
||||
self.tool_router = _PolicyRouter()
|
||||
self.calls = []
|
||||
self.call_ok = call_ok
|
||||
|
||||
async def _call_mcp_tool(self, tool_name, arguments, state):
|
||||
self.calls.append((tool_name, dict(arguments)))
|
||||
return {"ok": self.call_ok, "tool_name": tool_name, "result": {"status": "DONE" if self.call_ok else "ERROR"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_confirmation_yes_completes_and_clears_latches():
|
||||
runtime = _Runtime(call_ok=True)
|
||||
state = {
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"active_transaction": _active_tx(status="AWAITING_CONFIRMATION"),
|
||||
"pending_tool_call": {"tool_name": "cancelar_pedido", "arguments": {"order_id": "PED-1001"}},
|
||||
}
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
assert result[-1]["ok"] is True
|
||||
assert state["transaction_status"] == "COMPLETED"
|
||||
assert state["active_transaction"] is None
|
||||
assert state["next_state"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_confirmation_no_cancels_and_clears_latches():
|
||||
runtime = _Runtime(call_ok=True)
|
||||
state = {
|
||||
"user_text": "não",
|
||||
"sanitized_input": "não",
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"active_transaction": _active_tx(status="AWAITING_CONFIRMATION"),
|
||||
"pending_tool_call": {"tool_name": "cancelar_pedido", "arguments": {"order_id": "PED-1001"}},
|
||||
}
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
assert result[-1]["transaction_status"] == "CANCELLED"
|
||||
assert state["transaction_status"] == "CANCELLED"
|
||||
assert state["active_transaction"] is None
|
||||
assert state["next_state"] is None
|
||||
assert runtime.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_runtime_does_not_cancel_from_literal_words_without_intent_shift():
|
||||
runtime = _Runtime(call_ok=True)
|
||||
state = {
|
||||
"user_text": "texto livre sem classificação de nova intent",
|
||||
"sanitized_input": "texto livre sem classificação de nova intent",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"active_transaction": _active_tx(status="COLLECTING_PARAMETERS", arguments={}),
|
||||
"selected_tool_call": {"tool_name": "cancelar_pedido", "arguments": {}},
|
||||
"missing_parameters": ["order_id"],
|
||||
}
|
||||
await runtime.execute_tools_for_intent(state, tools=[])
|
||||
assert state["transaction_status"] != "CANCELLED"
|
||||
assert state["active_transaction"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_intent_shift_cancels_old_transaction_before_new_tool_path():
|
||||
runtime = _Runtime(call_ok=True)
|
||||
state = {
|
||||
"user_text": "quero ver minha fatura",
|
||||
"sanitized_input": "quero ver minha fatura",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"active_transaction": _active_tx(status="COLLECTING_PARAMETERS", arguments={}),
|
||||
"selected_tool_call": {"tool_name": "cancelar_pedido", "arguments": {}},
|
||||
"missing_parameters": ["order_id"],
|
||||
"route_decision": {"metadata": {"transaction_interruption": "intent_shift"}},
|
||||
}
|
||||
await runtime.execute_tools_for_intent(state, tools=[])
|
||||
assert state["transaction_status"] == "CANCELLED"
|
||||
assert state["active_transaction"] is None
|
||||
assert state["tool_policy_result"]["action"] == "cancelled_by_intent_shift"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_intent_shift_clears_arguments_and_restart_starts_from_zero():
|
||||
runtime = _Runtime(call_ok=True)
|
||||
state = {
|
||||
"user_text": "nova intenção",
|
||||
"sanitized_input": "nova intenção",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"active_transaction": _active_tx(
|
||||
status="COLLECTING_PARAMETERS",
|
||||
arguments={"order_id": "PED-OLD"},
|
||||
),
|
||||
"selected_tool_call": {
|
||||
"tool_name": "cancelar_pedido",
|
||||
"arguments": {"order_id": "PED-OLD"},
|
||||
},
|
||||
"pending_tool_call": {
|
||||
"tool_name": "cancelar_pedido",
|
||||
"arguments": {"order_id": "PED-OLD"},
|
||||
},
|
||||
"missing_parameters": [],
|
||||
"route_decision": {"metadata": {"transaction_interruption": "intent_shift"}},
|
||||
}
|
||||
await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert state["transaction_status"] == "CANCELLED"
|
||||
assert state["active_transaction"] is None
|
||||
assert state["selected_tool_call"] == {}
|
||||
assert state["pending_tool_call"] == {}
|
||||
assert state["missing_parameters"] == []
|
||||
assert state["next_state"] is None
|
||||
assert state["last_transaction"]["arguments"] == {"order_id": "PED-OLD"}
|
||||
|
||||
# Se a intent antiga voltar depois, o histórico fica apenas em last_transaction;
|
||||
# nenhum argumento operacional é restaurado para a nova transação.
|
||||
state["transaction_status"] = None
|
||||
state["route_decision"] = {}
|
||||
state["user_text"] = "iniciar novamente"
|
||||
state["sanitized_input"] = "iniciar novamente"
|
||||
state["mcp_tools"] = ["cancelar_pedido"]
|
||||
await runtime.execute_tools_for_intent(state, tools=["cancelar_pedido"])
|
||||
assert state.get("selected_tool_call", {}).get("arguments", {}).get("order_id") != "PED-OLD"
|
||||
assert (state.get("active_transaction") or {}).get("arguments", {}).get("order_id") != "PED-OLD"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_tool_failure_is_terminal_and_not_reactivated():
|
||||
runtime = _Runtime(call_ok=False)
|
||||
state = {
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"active_transaction": _active_tx(status="AWAITING_CONFIRMATION"),
|
||||
"pending_tool_call": {"tool_name": "cancelar_pedido", "arguments": {"order_id": "PED-1001"}},
|
||||
}
|
||||
await runtime.execute_tools_for_intent(state, tools=[])
|
||||
assert state["transaction_status"] == "FAILED"
|
||||
assert state["active_transaction"] is None
|
||||
assert state["pending_tool_call"] == {}
|
||||
assert state["next_state"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"])
|
||||
def test_matrix_normalization_clears_terminal_operational_latches(status):
|
||||
runtime = _Runtime()
|
||||
state = {
|
||||
"transaction_status": status,
|
||||
"next_state": "COLLECTING_ORDER_PARAMETERS",
|
||||
"active_transaction": _active_tx(status=status),
|
||||
"selected_tool_call": {"tool_name": "cancelar_pedido", "arguments": {"order_id": "PED-1001"}},
|
||||
"pending_tool_call": {"tool_name": "cancelar_pedido", "arguments": {"order_id": "PED-1001"}},
|
||||
"missing_parameters": ["order_id"],
|
||||
"confirmation_required": True,
|
||||
}
|
||||
runtime._normalize_transaction_lifecycle(state)
|
||||
assert state["active_transaction"] is None
|
||||
assert state["selected_tool_call"] == {}
|
||||
assert state["pending_tool_call"] == {}
|
||||
assert state["missing_parameters"] == []
|
||||
assert state["next_state"] is None
|
||||
assert state["last_transaction"]["status"] == status
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"])
|
||||
async def test_matrix_terminal_stale_next_state_does_not_lock_generic_followup(status, tmp_path):
|
||||
router = _router(tmp_path, stickiness=False)
|
||||
state = {
|
||||
"user_text": "ok",
|
||||
"sanitized_input": "ok",
|
||||
"next_state": "COLLECTING_ORDER_PARAMETERS",
|
||||
"transaction_status": status,
|
||||
"active_transaction": _active_tx(status=status),
|
||||
"active_agent": "orders_agent",
|
||||
"intent": "retail_order_cancel",
|
||||
"route_decision": {"agent": "orders_agent", "intent": "retail_order_cancel"},
|
||||
"context": {"session": {}},
|
||||
}
|
||||
decision = await router.route(state)
|
||||
assert decision.method != "state"
|
||||
assert (decision.metadata or {}).get("transaction_state_recovered") is not True
|
||||
@@ -7,7 +7,7 @@ from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
|
||||
class _LLM:
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
return '{"intent":"contas_vas_information","agent":"vas_agent","confidence":0.96,"reason":"nova consulta de serviços"}'
|
||||
return '{"decision":"SHIFT","intent":"contas_vas_information","agent":"vas_agent","confidence":0.96,"reason":"nova consulta de serviços"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -86,7 +86,7 @@ intents:
|
||||
|
||||
class _LowConfidenceLLM:
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
return '{"intent":"contas_vas_information","agent":"vas_agent","confidence":0.30,"reason":"incerto"}'
|
||||
return '{"decision":"SHIFT","intent":"contas_vas_information","agent":"vas_agent","confidence":0.30,"reason":"incerto"}'
|
||||
|
||||
settings = SimpleNamespace(
|
||||
ROUTING_CONFIG_PATH=str(routing),
|
||||
@@ -320,3 +320,76 @@ intents:
|
||||
|
||||
decision = await router.route(state)
|
||||
assert (decision.metadata or {}).get("transaction_interruption") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_next_state_parameter_answer_recovers_transaction_state_before_continuity(tmp_path):
|
||||
"""Regressão: parâmetro de uma transação ativa não pode cair no route-continuity.
|
||||
|
||||
Reproduz o caso Contas de forma genérica: o latch transacional sobreviveu ao
|
||||
checkpoint, ``next_state`` não, e o usuário fornece exatamente o parâmetro
|
||||
faltante. A decisão deve continuar determinística (method=state), preservando
|
||||
a tool ativa para o AgentRuntime completar os argumentos já coletados.
|
||||
"""
|
||||
routing = tmp_path / "routing.yaml"
|
||||
routing.write_text(
|
||||
"""
|
||||
router:
|
||||
fallback_agent: contestacao_agent
|
||||
confidence_threshold: 0.70
|
||||
state_policies: []
|
||||
intents:
|
||||
- name: contas_contestation
|
||||
agent: contestacao_agent
|
||||
priority: 20
|
||||
keywords: [nao contratei]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
ROUTING_CONFIG_PATH=str(routing),
|
||||
ENABLE_LLM_ROUTER=False,
|
||||
ENABLE_ROUTE_STICKINESS=True,
|
||||
)
|
||||
router = EnterpriseRouter(settings)
|
||||
state = {
|
||||
"user_text": "R$ 71,99",
|
||||
"sanitized_input": "R$ 71,99",
|
||||
"next_state": None,
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["valor"],
|
||||
"selected_tool_call": {
|
||||
"tool_name": "contestar_cobranca",
|
||||
"arguments": {"subject": "Plano Exemplo"},
|
||||
},
|
||||
"active_agent": "contestacao_agent",
|
||||
"route": "contestacao_agent",
|
||||
"route_decision": {
|
||||
"route": "contestacao_agent",
|
||||
"agent": "contestacao_agent",
|
||||
"intent": "contas_contestation",
|
||||
},
|
||||
}
|
||||
|
||||
decision = await router.route(state)
|
||||
|
||||
assert decision.method == "state"
|
||||
assert decision.agent == "contestacao_agent"
|
||||
assert decision.intent == "state:COLLECTING_PARAMETERS"
|
||||
assert decision.next_state == "COLLECTING_PARAMETERS"
|
||||
assert decision.metadata["transaction_state_recovered"] is True
|
||||
|
||||
|
||||
def test_agent_state_declares_durable_transaction_latch():
|
||||
"""O schema do host deve manter os campos que o AgentRuntime persiste."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(__file__).parents[2] / "app" / "state.py"
|
||||
spec = importlib.util.spec_from_file_location("contas_agent_state_v10", state_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
annotations = module.AgentState.__annotations__
|
||||
assert "active_transaction" in annotations
|
||||
assert "last_transaction" in annotations
|
||||
|
||||
@@ -572,8 +572,8 @@ async def test_pre_validation_rejection_clears_collecting_latches_and_is_exposed
|
||||
async def test_collecting_parameters_can_be_cancelled_explicitly():
|
||||
runtime = _ContestRuntime()
|
||||
state = {
|
||||
"user_text": "cancele essa operação anterior",
|
||||
"sanitized_input": "cancele essa operação anterior",
|
||||
"user_text": "nova intenção classificada pelo router",
|
||||
"sanitized_input": "nova intenção classificada pelo router",
|
||||
"route": "contestacao_agent",
|
||||
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
@@ -587,12 +587,12 @@ async def test_collecting_parameters_can_be_cancelled_explicitly():
|
||||
"selected_tool_call": {"tool_name": "contestar_cobranca", "arguments": {}},
|
||||
"missing_parameters": ["subject"],
|
||||
"next_state": "COLLECTING_CONTESTACAO_PARAMETERS",
|
||||
"route_decision": {"metadata": {"transaction_interruption": "intent_shift"}},
|
||||
}
|
||||
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert result[-1]["transaction_status"] == "CANCELLED"
|
||||
assert result[-1]["cancelled"] is True
|
||||
assert result == []
|
||||
assert state["transaction_status"] == "CANCELLED"
|
||||
assert state["active_transaction"] is None
|
||||
assert state["next_state"] is None
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -52,7 +52,7 @@ def test_saver_strips_private_runtime_before_put_and_restore() -> None:
|
||||
checkpoint = {"id": "cp1", "v": 1}
|
||||
|
||||
returned = asyncio.run(saver.aput(config, checkpoint, {}, {}))
|
||||
assert returned["configurable"] == {"thread_id": "t1", "checkpoint_id": "cp1"}
|
||||
assert returned["configurable"] == {"thread_id": "t1", "checkpoint_ns": "", "checkpoint_id": "cp1"}
|
||||
assert repo.saved is not None
|
||||
persisted = repo.saved[1]
|
||||
assert "__pregel_runtime" not in persisted["config"]["configurable"]
|
||||
@@ -62,3 +62,134 @@ def test_saver_strips_private_runtime_before_put_and_restore() -> None:
|
||||
restored = saver._make_tuple(persisted)
|
||||
restored_config = restored.config if hasattr(restored, "config") else restored["config"]
|
||||
assert "__pregel_runtime" not in restored_config["configurable"]
|
||||
|
||||
|
||||
def test_nested_pregel_runtime_is_removed_from_checkpoint_and_pending_writes() -> None:
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
config = {"configurable": {"thread_id": "t-nested"}}
|
||||
checkpoint = {
|
||||
"id": "cp-nested",
|
||||
"v": 1,
|
||||
"channel_values": {
|
||||
"__pregel_tasks": {"durable": True},
|
||||
"task": {
|
||||
"config": {
|
||||
"configurable": {
|
||||
"thread_id": "t-nested",
|
||||
"__pregel_runtime": "Runtime(...) nested",
|
||||
"business_key": "kept",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
asyncio.run(saver.aput(config, checkpoint, {}, {}))
|
||||
asyncio.run(
|
||||
saver.aput_writes(
|
||||
config,
|
||||
[
|
||||
(
|
||||
"tasks",
|
||||
{
|
||||
"nested": {
|
||||
"configurable": {
|
||||
"__pregel_runtime": "Runtime(...) write",
|
||||
"subject": "TIM Fashion",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
"task-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert repo.saved is not None
|
||||
persisted = repo.saved[1]
|
||||
assert persisted["checkpoint"]["channel_values"]["__pregel_tasks"] == {"durable": True}
|
||||
task_config = persisted["checkpoint"]["channel_values"]["task"]["config"]["configurable"]
|
||||
assert "__pregel_runtime" not in task_config
|
||||
assert task_config["business_key"] == "kept"
|
||||
pending_cfg = persisted["pending_writes"][0]["value"]["nested"]["configurable"]
|
||||
assert "__pregel_runtime" not in pending_cfg
|
||||
assert pending_cfg["subject"] == "TIM Fashion"
|
||||
|
||||
|
||||
def test_restore_rebuilds_fresh_canonical_config_instead_of_rebinding_stored_config() -> None:
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
legacy_payload = {
|
||||
"thread_id": "thread-legacy",
|
||||
"checkpoint_id": "cp-legacy",
|
||||
"config": {
|
||||
"tags": ["old-run"],
|
||||
"configurable": {
|
||||
"thread_id": "thread-legacy",
|
||||
"checkpoint_ns": "wf",
|
||||
"checkpoint_id": "cp-legacy",
|
||||
"tenant": "default",
|
||||
"__pregel_runtime": "stringified-runtime",
|
||||
"__pregel_store": "stringified-store",
|
||||
},
|
||||
},
|
||||
"checkpoint": {"id": "cp-legacy", "v": 1},
|
||||
"metadata": {},
|
||||
}
|
||||
asyncio.run(repo.put("thread-legacy", legacy_payload))
|
||||
|
||||
# Simulate a new invocation. Runtime/private keys supplied by a previous run
|
||||
# must never be rebound from the persisted checkpoint tuple.
|
||||
request = {
|
||||
"tags": ["new-run"],
|
||||
"configurable": {
|
||||
"thread_id": "thread-legacy",
|
||||
"checkpoint_ns": "wf",
|
||||
"__pregel_runtime": "request-runtime-placeholder",
|
||||
},
|
||||
}
|
||||
restored = asyncio.run(saver.aget_tuple(request))
|
||||
restored_config = restored.config if hasattr(restored, "config") else restored["config"]
|
||||
|
||||
assert restored_config == {
|
||||
"configurable": {
|
||||
"thread_id": "thread-legacy",
|
||||
"checkpoint_ns": "wf",
|
||||
"checkpoint_id": "cp-legacy",
|
||||
}
|
||||
}
|
||||
assert "tags" not in restored_config
|
||||
assert "tenant" not in restored_config["configurable"]
|
||||
assert "__pregel_runtime" not in restored_config["configurable"]
|
||||
|
||||
|
||||
def test_legacy_checkpoint_structural_json_strings_are_recovered() -> None:
|
||||
from agent_framework.checkpoints.langgraph_saver import _normalize_checkpoint, _normalize_config
|
||||
|
||||
legacy = {
|
||||
"v": 1,
|
||||
"id": "cp-json",
|
||||
"channel_values": '{"state":{"ok":true}}',
|
||||
"channel_versions": '{"state":"0001"}',
|
||||
"versions_seen": '{"node":"{\\"state\\":\\"0001\\"}"}',
|
||||
}
|
||||
normalized = _normalize_checkpoint(legacy)
|
||||
assert normalized["channel_values"] == {"state": {"ok": True}}
|
||||
assert normalized["channel_versions"] == {"state": "0001"}
|
||||
assert normalized["versions_seen"] == {"node": {"state": "0001"}}
|
||||
|
||||
cfg = _normalize_config({"configurable": '{"thread_id":"t1","checkpoint_ns":""}'})
|
||||
assert cfg["configurable"] == {"thread_id": "t1", "checkpoint_ns": ""}
|
||||
|
||||
|
||||
def test_checkpoint_serialization_never_silently_stringifies_unknown_objects() -> None:
|
||||
import pytest
|
||||
from agent_framework.checkpoints.langgraph_saver import _strict_json_value
|
||||
|
||||
class RuntimeLike:
|
||||
def override(self):
|
||||
return self
|
||||
|
||||
with pytest.raises(TypeError, match="Checkpoint contém valor não serializável"):
|
||||
_strict_json_value({"channel_values": {"bad": RuntimeLike()}}, path="$.checkpoint")
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from agent_framework.workflows.runtime import _exception_details, _type_shape
|
||||
|
||||
|
||||
def _boom():
|
||||
raise AttributeError("'str' object has no attribute 'override'")
|
||||
|
||||
|
||||
def test_exception_details_contains_traceback():
|
||||
try:
|
||||
_boom()
|
||||
except Exception as exc:
|
||||
details = _exception_details(exc, runtime_context={"phase": "ainvoke"})
|
||||
assert details["type"] == "AttributeError"
|
||||
assert "_boom" in details["traceback"]
|
||||
assert "override" in details["traceback"]
|
||||
assert details["runtime_diagnostics"]["phase"] == "ainvoke"
|
||||
|
||||
|
||||
def test_type_shape_exposes_types_not_values():
|
||||
shape = _type_shape({"configurable": {"thread_id": "secret-thread", "__pregel_runtime": "bad-runtime"}})
|
||||
text = repr(shape)
|
||||
assert "thread_id" in text
|
||||
assert "__pregel_runtime" in text
|
||||
assert "secret-thread" not in text
|
||||
assert "bad-runtime" not in text
|
||||
assert "str" in text
|
||||
Reference in New Issue
Block a user