mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-09-07 10:13:46 +00:00
505 lines
20 KiB
Python
505 lines
20 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
|
from agent_framework.routing.models import RouteDecision
|
|
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]
|
|
"""
|
|
|
|
|
|
class _ParameterLLM:
|
|
async def ainvoke(self, messages, **kwargs):
|
|
import json
|
|
prompt = messages[-1]["content"]
|
|
if kwargs.get("profile_name") == "transaction_parameter_extraction":
|
|
pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
|
|
user = prompt.split("user_message: ", 1)[1].split("\nFormato obrigatório:", 1)[0].strip()
|
|
out = {name: None for name in pending}
|
|
if len(pending) == 1 and user not in {"quero rastrear pedido", "quero ver minha fatura"}:
|
|
out[pending[0]] = user
|
|
return json.dumps(out, ensure_ascii=False)
|
|
return '{}'
|
|
|
|
|
|
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, llm=_ParameterLLM())
|
|
|
|
|
|
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", "o pedido é o 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_specific_same_agent_intent_shift_still_preempts_collection(tmp_path):
|
|
router = _router(tmp_path)
|
|
state = {
|
|
"user_text": "quero rastrear pedido",
|
|
"sanitized_input": "quero rastrear pedido",
|
|
"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.intent == "retail_order_tracking"
|
|
assert decision.metadata["transaction_interruption"] == "intent_shift"
|
|
|
|
|
|
@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
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status", ["COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"])
|
|
async def test_matrix_terminal_transaction_skips_route_continuity(status, tmp_path):
|
|
"""Terminal transaction must release conversational stickiness on next turn."""
|
|
router = _router(tmp_path, stickiness=False)
|
|
|
|
class _ContinuityMustNotRun:
|
|
async def evaluate(self, state, *, intents):
|
|
raise AssertionError("route continuity must not run after terminal transaction")
|
|
|
|
router.continuity = _ContinuityMustNotRun()
|
|
router.enable_llm_router = False
|
|
router.llm = None
|
|
state = {
|
|
"user_text": "nova solicitação sem keyword configurada",
|
|
"sanitized_input": "nova solicitação sem keyword configurada",
|
|
"transaction_status": status,
|
|
"next_state": None,
|
|
"active_transaction": None,
|
|
"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 == "fallback"
|
|
assert decision.intent == "fallback"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_out_of_scope_allows_new_llm_intent_instead_of_continuity(tmp_path):
|
|
"""Regression for OUT_OF_SCOPE -> unrelated/new semantic request on next turn."""
|
|
router = _router(tmp_path, stickiness=False)
|
|
|
|
class _ContinuityMustNotRun:
|
|
async def evaluate(self, state, *, intents):
|
|
raise AssertionError("stale continuity must not capture a post-terminal turn")
|
|
|
|
router.continuity = _ContinuityMustNotRun()
|
|
router.enable_llm_router = True
|
|
router.llm = object()
|
|
|
|
async def _fake_llm(text, state):
|
|
return RouteDecision(
|
|
route="vas_agent",
|
|
agent="vas_agent",
|
|
intent="retail_vas_cancel",
|
|
confidence=0.99,
|
|
reason="new semantic intent",
|
|
method="llm",
|
|
mcp_tools=["consultar_vas", "cancelar_vas_avulso"],
|
|
)
|
|
|
|
router._route_by_llm = _fake_llm
|
|
state = {
|
|
"user_text": "eu quero cancelar um serviço diferente",
|
|
"sanitized_input": "eu quero cancelar um serviço diferente",
|
|
"transaction_status": "OUT_OF_SCOPE",
|
|
"next_state": None,
|
|
"active_transaction": None,
|
|
"active_agent": "contestacao_agent",
|
|
"intent": "contas_contestation",
|
|
"route_decision": {"agent": "contestacao_agent", "intent": "contas_contestation"},
|
|
"context": {"session": {}},
|
|
}
|
|
|
|
decision = await router.route(state)
|
|
assert decision.method == "llm"
|
|
assert decision.agent == "vas_agent"
|
|
assert decision.intent == "retail_vas_cancel"
|
|
assert decision.mcp_tools == ["consultar_vas", "cancelar_vas_avulso"]
|