bugfixes: router stickness vs transaction workflow parameters

This commit is contained in:
2026-08-21 14:34:30 -03:00
parent bdc44b15d6
commit b5d2a12953
170 changed files with 1479 additions and 176 deletions

View File

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

View File

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

View File

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

View File

@@ -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():

View File

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

View File

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

View File

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

View File

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

View File

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