new feature: Integration with kbdb Autonomous

This commit is contained in:
T3782834
2026-08-26 10:15:51 -03:00
parent ac18d68eaf
commit faf5ca55ba
405 changed files with 2368 additions and 138 deletions

View File

@@ -43,7 +43,7 @@ def test_renderer_mode_uses_application_registered_renderer():
return f"[{agent_label}] {result['name']} / {state['intent']}"
register_tool_response_renderer("test.entity", renderer)
rt = _Runtime({"consultar_algo": {"mode": "renderer", "renderer": "test.entity"}})
rt = _Runtime({"consultar_algo": {"mode": "renderer", "renderer": "test.entity", "direct": True}})
answer = rt.build_direct_mcp_answer(
{"user_text": "consulta", "intent": "test_intent"},
_result("consultar_algo", {"name": "OK"}),
@@ -52,21 +52,74 @@ def test_renderer_mode_uses_application_registered_renderer():
assert answer == "[TestAgent] OK / test_intent"
def test_missing_renderer_falls_back_without_breaking_runtime():
rt = _Runtime({"consultar_plano": {"mode": "renderer", "renderer": "missing.renderer"}})
def test_missing_renderer_does_not_break_runtime_or_use_domain_fallback():
rt = _Runtime({"consultar_plano": {"mode": "renderer", "renderer": "missing.renderer", "direct": True}})
answer = rt.build_direct_mcp_answer(
{"user_text": "qual meu plano"},
_result("consultar_plano", {"plano": "Controle", "internet_gb": 50, "status": "ATIVO"}),
agent_label="ProductAgent",
)
assert answer == "[ProductAgent] Seu plano é Controle, com 50 GB e status ATIVO."
assert answer is None
def test_no_declared_response_keeps_legacy_fallback():
def test_renderer_without_explicit_direct_continues_to_llm_or_rag():
def renderer(*, tool_name, result, state, agent_label):
return f"[{agent_label}] {result['plano']}"
register_tool_response_renderer("test.plan", renderer)
rt = _Runtime({"consultar_plano": {"mode": "renderer", "renderer": "test.plan"}})
answer = rt.build_direct_mcp_answer(
{"user_text": "como funciona a tarifação do plano?"},
_result("consultar_plano", {"plano": "Controle", "internet_gb": 50, "status": "ATIVO"}),
agent_label="ProductAgent",
)
assert answer is None
def test_no_declared_response_has_no_domain_hardcoded_fallback():
rt = _Runtime({})
answer = rt.build_direct_mcp_answer(
{"user_text": "qual meu plano"},
_result("consultar_plano", {"plano": "Controle", "internet_gb": 50, "status": "ATIVO"}),
agent_label="ProductAgent",
)
assert answer == "[ProductAgent] Seu plano é Controle, com 50 GB e status ATIVO."
assert answer is None
def test_completed_workflow_does_not_replay_message_from_non_terminal_node():
rt = AgentRuntimeMixin()
state = {"user_text": "nao", "sanitized_input": "nao"}
result = {
"ok": True,
"tool_name": "resume_workflow",
"result": {
"status": "COMPLETED",
"workflow_name": "example",
"output": {
"format": {"mensagem": "Pergunta antiga?"},
"decide": {"ok": True},
"check": {"has_items": True},
},
"state": {"current_node": "check"},
},
}
assert rt.build_direct_mcp_answer(state, [result], agent_label="Agent") is None
def test_completed_workflow_uses_only_terminal_node_explicit_message():
rt = AgentRuntimeMixin()
state = {"user_text": "ok", "sanitized_input": "ok"}
result = {
"ok": True,
"tool_name": "resume_workflow",
"result": {
"status": "COMPLETED",
"workflow_name": "example",
"output": {
"format": {"mensagem": "Pergunta antiga?"},
"finish": {"mensagem": "Resposta final."},
},
"state": {"current_node": "finish"},
},
}
assert rt.build_direct_mcp_answer(state, [result], agent_label="Agent") == "Resposta final."

View File

@@ -0,0 +1,336 @@
from types import SimpleNamespace
import pytest
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
from agent_framework.workflows.input_contract import match_expected_input
ROUTING_YAML = """
router:
fallback_agent: billing_agent
confidence_threshold: 0.70
intents:
- name: billing_invoice_explanation
domain: telecom
agent: faturas_agent
priority: 40
keywords: [fatura]
"""
class _ContinuityLLM:
async def ainvoke(self, messages, **kwargs):
if kwargs.get("profile_name") == "route_continuity":
return '{"decision":"END_SESSION","confidence":0.99,"reason":"sim"}'
return '{}'
def _router(tmp_path):
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=True,
ROUTE_STICKINESS_LLM_PROFILE="route_continuity",
ROUTE_STICKINESS_CONFIDENCE_THRESHOLD=0.7,
)
return EnterpriseRouter(settings, llm=_ContinuityLLM())
def _workflow_result():
return {
"result": {
"result": {
"status": "PAUSED",
"execution_id": "exec-1",
"workflow_name": "invoice_explanation",
"metadata": {
"workflow_name": "invoice_explanation",
"workflow_execution_id": "exec-1",
"resume_tool": "retomar_workflow",
},
"pause": {"node": "formatar"},
"state": {
"__interrupt__": [
{
"value": {
"node": "formatar",
"prompt": "Sanei sua dúvida?",
"expected_input": {
"key": "resposta_usuario",
"allowed_values": ["SIM", "NAO"],
"normalize": "upper_strip",
},
"resume_from": "decisao",
},
"id": "interrupt-1",
}
]
},
}
}
}
class _Runtime(AgentRuntimeMixin):
async def _call_mcp_tool(self, tool_name, arguments, state):
self.called = (tool_name, arguments)
return {
"result": {
"result": {
"status": "COMPLETED",
"execution_id": arguments["execution_id"],
"workflow_name": arguments["workflow_name"],
"metadata": {"workflow_name": arguments["workflow_name"]},
}
}
}
def test_capture_recovers_expected_input_from_interrupt_descriptor():
runtime = _Runtime()
state = {"route": "faturas_agent", "active_agent": "faturas_agent", "intent": "billing_invoice_explanation"}
runtime._capture_pending_domain_workflow(state, _workflow_result())
pending = state["pending_domain_workflow"]
assert pending["owner_agent"] == "faturas_agent"
assert pending["pause"]["expected_input"]["allowed_values"] == ["SIM", "NAO"]
assert pending["pause"]["resume_from"] == "decisao"
def test_expected_input_contract_is_deterministic():
contract = {"allowed_values": ["SIM", "NAO"], "normalize": "upper_strip"}
assert match_expected_input(" sim ", contract) == "SIM"
assert match_expected_input("não", contract) is None
assert match_expected_input("quero minha fatura", contract) is None
@pytest.mark.asyncio
async def test_paused_workflow_expected_input_preempts_route_continuity(tmp_path):
router = _router(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-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",
}
},
},
}
decision = await router.route(state)
assert decision.method == "state"
assert decision.metadata["workflow_resume"] is True
assert decision.route == "faturas_agent"
assert decision.mcp_tools == ["retomar_workflow"]
assert decision.metadata["normalized_input"] == "SIM"
@pytest.mark.asyncio
async def test_runtime_resume_uses_contract_normalized_value():
runtime = _Runtime()
state = {
"pending_domain_workflow": {
"workflow_name": "invoice_explanation",
"execution_id": "exec-1",
"resume_tool": "retomar_workflow",
"pause": {
"expected_input": {
"key": "resposta_usuario",
"allowed_values": ["SIM", "NAO"],
"normalize": "upper_strip",
}
},
},
"transaction_status": "WORKFLOW_PAUSED",
}
result = await runtime._resume_pending_domain_workflow(state, " sim ")
assert result is not None
assert runtime.called[0] == "retomar_workflow"
assert runtime.called[1]["resposta_usuario"] == "SIM"
assert state["pending_domain_workflow"] is None
def test_terminal_workflow_capture_materializes_latch_clear_for_graph_merge():
runtime = _Runtime()
state = {
"pending_domain_workflow": {
"workflow_name": "invoice_explanation",
"execution_id": "exec-1",
"resume_tool": "retomar_workflow",
},
"transaction_status": "WORKFLOW_PAUSED",
}
runtime._capture_pending_domain_workflow(
state,
{
"result": {
"result": {
"status": "COMPLETED",
"execution_id": "exec-1",
"workflow_name": "invoice_explanation",
"metadata": {
"workflow_name": "invoice_explanation",
"workflow_execution_id": "exec-1",
},
}
}
},
)
assert state["pending_domain_workflow"] is None
assert state["transaction_status"] is None
patch = runtime.transaction_state_patch(state)
assert "pending_domain_workflow" in patch
assert patch["pending_domain_workflow"] is None
def test_terminal_workflow_does_not_clear_different_pending_execution():
runtime = _Runtime()
pending = {
"workflow_name": "other_workflow",
"execution_id": "exec-other",
"resume_tool": "retomar_workflow",
}
state = {"pending_domain_workflow": dict(pending), "transaction_status": "WORKFLOW_PAUSED"}
runtime._capture_pending_domain_workflow(
state,
{
"result": {
"result": {
"status": "COMPLETED",
"execution_id": "exec-1",
"workflow_name": "invoice_explanation",
"metadata": {
"workflow_name": "invoice_explanation",
"workflow_execution_id": "exec-1",
},
}
}
},
)
assert state["pending_domain_workflow"] == pending
assert state["transaction_status"] == "WORKFLOW_PAUSED"
def test_route_shift_clears_paused_workflow_and_live_latches_without_touching_history():
runtime = _Runtime()
state = {
"route": "new_agent",
"intent": "new_intent",
"route_decision": {
"route": "new_agent",
"agent": "new_agent",
"intent": "new_intent",
"metadata": {},
},
"pending_domain_workflow": {
"workflow_name": "old_workflow",
"execution_id": "exec-old",
"resume_tool": "resume_old",
"owner_agent": "old_agent",
"owner_intent": "old_intent",
"pause": {"expected_input": {"allowed_values": ["YES", "NO"]}},
},
"transaction_status": "WORKFLOW_PAUSED",
"selected_tool_call": {"tool_name": "old_tool", "arguments": {"x": 1}},
"pending_tool_call": {"tool_name": "old_tool", "arguments": {"x": 1}},
"missing_parameters": ["x"],
"confirmation_required": True,
"confirmation_received": True,
"next_state": "OLD_STATE",
"transaction_pre_validation": {"eligible": True},
"pending_tool_clarification": {"tool_name": "old_tool"},
"mcp_results": [{"tool_name": "resume_old", "ok": False}],
"business_workflows_executed": ["historical_workflow"],
}
changed = runtime._clear_active_interaction_context_on_route_shift(state)
assert changed is True
assert state["pending_domain_workflow"] is None
assert state["transaction_status"] is None
assert state["selected_tool_call"] == {}
assert state["pending_tool_call"] == {}
assert state["missing_parameters"] == []
assert state["confirmation_required"] is False
assert state["confirmation_received"] is False
assert state["next_state"] is None
assert state["transaction_pre_validation"] is None
assert state["pending_tool_clarification"] is None
assert state["mcp_results"] == []
assert state["business_workflows_executed"] == ["historical_workflow"]
assert state["last_interrupted_domain_workflow"]["execution_id"] == "exec-old"
assert state["last_interrupted_domain_workflow"]["reason"] == "intent_shift"
def test_workflow_resume_does_not_clear_paused_workflow():
runtime = _Runtime()
pending = {
"workflow_name": "wf",
"execution_id": "exec-1",
"owner_agent": "agent-a",
"owner_intent": "intent-a",
}
state = {
"route": "agent-a",
"intent": "intent-a",
"route_decision": {
"route": "agent-a",
"agent": "agent-a",
"intent": "intent-a",
"metadata": {"workflow_resume": True},
},
"pending_domain_workflow": dict(pending),
"transaction_status": "WORKFLOW_PAUSED",
"mcp_results": [{"tool_name": "something"}],
}
changed = runtime._clear_active_interaction_context_on_route_shift(state)
assert changed is False
assert state["pending_domain_workflow"] == pending
assert state["transaction_status"] == "WORKFLOW_PAUSED"
assert state["mcp_results"] == [{"tool_name": "something"}]
def test_same_workflow_owner_without_resume_does_not_get_cleared_as_intent_shift():
runtime = _Runtime()
pending = {
"workflow_name": "wf",
"execution_id": "exec-1",
"owner_agent": "agent-a",
"owner_intent": "intent-a",
}
state = {
"route": "agent-a",
"intent": "intent-a",
"route_decision": {
"route": "agent-a",
"agent": "agent-a",
"intent": "intent-a",
"metadata": {},
},
"pending_domain_workflow": dict(pending),
"transaction_status": "WORKFLOW_PAUSED",
}
assert runtime._clear_active_interaction_context_on_route_shift(state) is False
assert state["pending_domain_workflow"] == pending

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters
class _Router:
def __init__(self, tool, rules=None):
self.registry = SimpleNamespace(get_tool=lambda name: tool if name == "tx_tool" else None)
self._rules = rules or {}
def parameter_extract_rules(self, tool_name):
return dict(self._rules)
class _Runtime(AgentRuntimeMixin):
def __init__(self, tool, rules=None):
self.tool_router = _Router(tool, rules)
class _CaptureLLM:
def __init__(self):
self.prompt = ""
async def ainvoke(self, messages, **kwargs):
self.prompt = messages[-1]["content"]
return json.dumps({"subject": "TIM CTRL Redes Sociais 8.0", "valor": None}, ensure_ascii=False)
def test_legacy_args_schema_remains_unchanged_without_description():
tool = SimpleNamespace(args_schema={"subject": "string", "valor": "number"}, requires=["subject", "valor"])
runtime = _Runtime(tool)
schema = runtime._transaction_parameter_schema("tx_tool", {"requires": ["subject", "valor"]})
assert schema == {"subject": "string", "valor": "number"}
def test_legacy_args_schema_is_enriched_from_mcp_mapping_description():
tool = SimpleNamespace(args_schema={"subject": "string", "valor": "number"}, requires=["subject", "valor"])
runtime = _Runtime(
tool,
{
"subject": {
"from": "message",
"strategy": "llm",
"description": "Nome do serviço, produto, item ou cobrança objeto da contestação.",
},
"valor": {
"from": "message",
"strategy": "llm",
"description": "Valor monetário explicitamente informado pelo cliente.",
},
},
)
schema = runtime._transaction_parameter_schema("tx_tool", {"requires": ["subject", "valor"]})
assert schema["subject"] == {
"type": "string",
"description": "Nome do serviço, produto, item ou cobrança objeto da contestação.",
}
assert schema["valor"] == {
"type": "number",
"description": "Valor monetário explicitamente informado pelo cliente.",
}
def test_enriched_args_schema_has_precedence_over_mcp_mapping_description():
tool = SimpleNamespace(
args_schema={
"subject": {"type": "string", "description": "Descrição definida no args_schema."},
"valor": {"type": "number"},
},
requires=["subject", "valor"],
)
runtime = _Runtime(
tool,
{
"subject": {"description": "Fallback que não deve sobrescrever."},
"valor": {"description": "Descrição de fallback para valor."},
},
)
schema = runtime._transaction_parameter_schema("tx_tool", {"requires": ["subject", "valor"]})
assert schema["subject"]["description"] == "Descrição definida no args_schema."
assert schema["valor"] == {"type": "number", "description": "Descrição de fallback para valor."}
@pytest.mark.asyncio
async def test_extractor_prompt_uses_optional_semantic_descriptions_and_keeps_null_rule():
llm = _CaptureLLM()
extracted = await extract_transaction_parameters(
llm,
text="nao contratei TIM CTRL Redes Sociais 8.0",
tool_name="contestar_cobranca",
missing_parameters=["subject", "valor"],
known_arguments={},
parameter_schema={
"subject": {
"type": "string",
"description": "Nome do serviço, produto, item ou cobrança objeto da contestação.",
},
"valor": {
"type": "number",
"description": "Valor monetário explicitamente informado pelo cliente.",
},
},
tool_description="Executa contestação de cobrança.",
)
assert extracted == {"subject": "TIM CTRL Redes Sociais 8.0"}
assert "principalmente a descrição semântica quando disponível" in llm.prompt
assert "A ausência de tipo ou descrição NÃO impede a extração" in llm.prompt
assert "Em caso de dúvida razoável sobre a correspondência ou o valor, prefira null" in llm.prompt
assert "Nome do serviço, produto, item ou cobrança objeto da contestação." in llm.prompt

View File

@@ -133,7 +133,7 @@ async def test_transaction_extractor_handles_multiple_parameters_without_hardcod
@pytest.mark.asyncio
async def test_collecting_one_parameter_consumes_turn_before_intent_shift(tmp_path):
async def test_collecting_one_parameter_consumes_turn_after_classifier_continues(tmp_path):
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
@@ -291,3 +291,165 @@ intents:
assert decision.metadata["transaction_turn_consumed"] is True
assert decision.metadata["transaction_confirmation_decision"] == "confirm"
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.
"""
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: support_agent
confidence_threshold: 0.70
state_policies:
- state: COLLECTING_SUPPORT_PARAMETERS
agent: support_agent
intents:
- name: retail_support_exchange_return
agent: support_agent
priority: 20
keywords: [devolver pedido]
- name: retail_order_cancel
agent: orders_agent
priority: 30
keywords: [cancelar pedido]
""",
encoding="utf-8",
)
class _ShiftAndExtractLLM:
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
# 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"})
self.shift_calls += 1
return json.dumps({
"decision": "SHIFT",
"intent": "retail_order_cancel",
"agent": "orders_agent",
"confidence": 0.99,
"reason": "usuário passou a cancelar outro pedido",
})
llm = _ShiftAndExtractLLM()
settings = SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
)
router = EnterpriseRouter(settings, llm=llm)
state = {
"user_text": "agora quero cancelar pedido PED-2002",
"sanitized_input": "agora quero cancelar pedido PED-2002",
"next_state": "COLLECTING_SUPPORT_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["reason"],
"active_agent": "support_agent",
"intent": "state:COLLECTING_SUPPORT_PARAMETERS",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {"order_id": "PED-1001"},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "retail_support_exchange_return",
"parameter_schema": {"reason": "string"},
},
}
decision = await router.route(state)
assert decision.intent == "retail_order_cancel"
assert decision.agent == "orders_agent"
assert decision.metadata["transaction_interruption"] == "intent_shift"
assert llm.shift_calls == 1
assert llm.extraction_calls == 0
@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."""
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: support_agent
confidence_threshold: 0.70
state_policies:
- state: COLLECTING_SUPPORT_PARAMETERS
agent: support_agent
intents:
- name: retail_support_exchange_return
agent: support_agent
priority: 20
keywords: [devolver pedido]
- name: retail_order_cancel
agent: orders_agent
priority: 30
keywords: []
""",
encoding="utf-8",
)
class _SemanticShiftAndExtractLLM:
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
return json.dumps({"reason": "encerrar a compra"})
self.shift_calls += 1
return json.dumps({
"decision": "SHIFT",
"intent": "retail_order_cancel",
"agent": "orders_agent",
"confidence": 0.98,
"reason": "novo objetivo transacional incompatível",
})
llm = _SemanticShiftAndExtractLLM()
settings = SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
)
router = EnterpriseRouter(settings, llm=llm)
state = {
"user_text": "mudei de ideia, quero encerrar a compra PED-2002",
"sanitized_input": "mudei de ideia, quero encerrar a compra PED-2002",
"next_state": "COLLECTING_SUPPORT_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["reason"],
"active_agent": "support_agent",
"intent": "state:COLLECTING_SUPPORT_PARAMETERS",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {"order_id": "PED-1001"},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "retail_support_exchange_return",
"parameter_schema": {"reason": "string"},
},
}
decision = await router.route(state)
assert decision.intent == "retail_order_cancel"
assert decision.agent == "orders_agent"
assert decision.metadata["transaction_interruption"] == "intent_shift"
assert decision.metadata["interruption_source"] == "semantic_classifier"
assert llm.shift_calls == 1
assert llm.extraction_calls == 0

View File

@@ -3,6 +3,7 @@ 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
@@ -428,3 +429,76 @@ async def test_matrix_terminal_stale_next_state_does_not_lock_generic_followup(s
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"]

View File

@@ -0,0 +1,160 @@
import base64
import json
from types import SimpleNamespace
import pytest
from agent_framework.checkpoints.langgraph_saver import (
RepositoryCheckpointSaver,
_TYPED_SERDE_MARKER,
_decode_checkpoint_value,
_encode_checkpoint_value,
_encode_pending_write_value,
)
Interrupt = type("Interrupt", (), {})
Interrupt.__module__ = "langgraph.types"
class FakeSerde:
def dumps_typed(self, value):
assert type(value).__module__ == "langgraph.types"
assert type(value).__qualname__ == "Interrupt"
return "msgpack", b"interrupt-payload"
def loads_typed(self, typed):
type_name, payload = typed
assert type_name == "msgpack"
assert payload == b"interrupt-payload"
return Interrupt()
class MemoryRepo:
def __init__(self):
self.value = None
async def put(self, thread_id, value):
self.value = value
async def get_latest(self, thread_id):
return self.value
def test_checkpoint_interrupt_only_in_special_channel_round_trips():
serde = FakeSerde()
raw_interrupt = Interrupt()
checkpoint = {
"id": "cp1",
"channel_values": {
"__root__": {
"__interrupt__": [raw_interrupt],
"business": {"ok": True},
},
"ordinary": {"value": 1},
},
}
encoded = _encode_checkpoint_value(serde, checkpoint)
leaf = encoded["channel_values"]["__root__"]["__interrupt__"][0]
assert leaf[_TYPED_SERDE_MARKER] is True
assert leaf["type"] == "msgpack"
assert base64.b64decode(leaf["data"]) == b"interrupt-payload"
assert encoded["channel_values"]["ordinary"] == {"value": 1}
decoded = _decode_checkpoint_value(serde, encoded)
assert type(decoded["channel_values"]["__root__"]["__interrupt__"][0]).__module__ == "langgraph.types"
assert decoded["channel_values"]["ordinary"] == {"value": 1}
def test_checkpoint_interrupt_outside_special_channel_is_rejected():
serde = FakeSerde()
checkpoint = {
"id": "cp1",
"channel_values": {"ordinary": [Interrupt()]},
}
with pytest.raises(TypeError, match="não serializável"):
_encode_checkpoint_value(serde, checkpoint)
def test_checkpoint_unknown_object_inside_interrupt_channel_is_rejected():
serde = FakeSerde()
class Unknown:
pass
checkpoint = {
"id": "cp1",
"channel_values": {"__root__": {"__interrupt__": [Unknown()]}},
}
with pytest.raises(TypeError, match="não serializável"):
_encode_checkpoint_value(serde, checkpoint)
def test_plain_checkpoint_keeps_plain_json_shape():
serde = FakeSerde()
checkpoint = {
"id": "cp1",
"channel_values": {"x": {"nested": [1, "a", True, None]}},
"channel_versions": {"x": "1"},
}
encoded = _encode_checkpoint_value(serde, checkpoint)
assert encoded == checkpoint
assert _TYPED_SERDE_MARKER not in json.dumps(encoded)
def test_pending_write_interrupt_requires_interrupt_branch():
serde = FakeSerde()
encoded = _encode_pending_write_value(
serde,
{"__interrupt__": [Interrupt()]},
path="$.pending_writes[t].__root__",
)
assert encoded["__interrupt__"][0][_TYPED_SERDE_MARKER] is True
with pytest.raises(TypeError, match="não serializável"):
_encode_pending_write_value(
serde,
{"ordinary": [Interrupt()]},
path="$.pending_writes[t].__root__",
)
def test_aput_encodes_only_checkpoint_interrupt_and_keeps_other_sections_strict():
repo = MemoryRepo()
settings = SimpleNamespace(CHECKPOINT_REPOSITORY_PROVIDER="memory")
saver = RepositoryCheckpointSaver(settings, repository=repo)
saver.serde = FakeSerde()
checkpoint = {
"id": "cp1",
"channel_values": {"__root__": {"__interrupt__": [Interrupt()]}},
}
import asyncio
asyncio.run(saver.aput(
{"configurable": {"thread_id": "t1"}},
checkpoint,
{"source": "test"},
{"x": 1},
))
assert repo.value["metadata"] == {"source": "test"}
assert repo.value["new_versions"] == {"x": 1}
assert repo.value["checkpoint"]["channel_values"]["__root__"]["__interrupt__"][0][_TYPED_SERDE_MARKER] is True
def test_aput_does_not_enable_typed_serde_for_metadata():
repo = MemoryRepo()
settings = SimpleNamespace(CHECKPOINT_REPOSITORY_PROVIDER="memory")
saver = RepositoryCheckpointSaver(settings, repository=repo)
saver.serde = FakeSerde()
import asyncio
with pytest.raises(TypeError, match="metadata"):
asyncio.run(saver.aput(
{"configurable": {"thread_id": "t1"}},
{"id": "cp1", "channel_values": {"x": 1}},
{"bad": Interrupt()},
{},
))

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import pytest
from types import SimpleNamespace
from agent_framework.checkpoints.langgraph_saver import (
@@ -193,3 +194,104 @@ def test_checkpoint_serialization_never_silently_stringifies_unknown_objects() -
with pytest.raises(TypeError, match="Checkpoint contém valor não serializável"):
_strict_json_value({"channel_values": {"bad": RuntimeLike()}}, path="$.checkpoint")
def test_typed_serializer_bridge_is_limited_to_langgraph_interrupt_pending_write() -> None:
import base64
import json
Interrupt = type("Interrupt", (), {"__module__": "langgraph.types"})
class FakeSerde:
def dumps_typed(self, value):
assert type(value).__module__ == "langgraph.types"
assert type(value).__qualname__ == "Interrupt"
return ("fake_interrupt", json.dumps({"value": value.value}).encode("utf-8"))
def loads_typed(self, payload):
type_name, raw = payload
assert type_name == "fake_interrupt"
obj = Interrupt()
obj.value = json.loads(raw.decode("utf-8"))["value"]
return obj
interrupt = Interrupt()
interrupt.value = "pause"
repo = _Repo()
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
saver.serde = FakeSerde()
config = {"configurable": {"thread_id": "typed-thread"}}
asyncio.run(saver.aput(config, {"id": "cp-typed", "channel_values": {}}, {}, {}))
# Mirrors the real LangGraph shape observed in the runtime log:
# pending_writes -> __root__ -> __interrupt__ -> [Interrupt(...)]
asyncio.run(saver.aput_writes(
config,
[("__root__", {"__interrupt__": [interrupt]})],
"task-typed",
))
persisted = repo.saved[1]
stored = persisted["pending_writes"][0]["value"]["__interrupt__"][0]
assert stored["__agent_framework_langgraph_typed__"] is True
assert stored["type"] == "fake_interrupt"
assert base64.b64decode(stored["data"]).decode("utf-8") == '{"value": "pause"}'
# Critical compatibility assertion: checkpoint/metadata/new_versions remain plain JSON.
assert persisted["checkpoint"] == {"id": "cp-typed", "channel_values": {}, "channel_versions": {}, "versions_seen": {}}
assert persisted["metadata"] == {}
restored = saver._make_tuple(persisted)
pending = restored.pending_writes if hasattr(restored, "pending_writes") else restored["pending_writes"]
assert pending[0][0] == "task-typed"
assert pending[0][1] == "__root__"
restored_interrupt = pending[0][2]["__interrupt__"][0]
assert type(restored_interrupt).__module__ == "langgraph.types"
assert type(restored_interrupt).__qualname__ == "Interrupt"
assert restored_interrupt.value == "pause"
def test_non_interrupt_non_json_pending_write_still_fails_loudly() -> None:
class RuntimeLike:
pass
repo = _Repo()
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
config = {"configurable": {"thread_id": "bad-pending-thread"}}
asyncio.run(saver.aput(config, {"id": "cp-bad", "channel_values": {}}, {}, {}))
with pytest.raises(TypeError, match="Checkpoint contém valor não serializável"):
asyncio.run(saver.aput_writes(config, [("__root__", {"bad": RuntimeLike()})], "task-bad"))
def test_non_json_checkpoint_does_not_fall_back_to_typed_serde() -> None:
class RuntimeLike:
pass
class ExplodingSerde:
def dumps_typed(self, value):
raise AssertionError("global typed serde fallback must not be used")
repo = _Repo()
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
saver.serde = ExplodingSerde()
config = {"configurable": {"thread_id": "bad-checkpoint-thread"}}
with pytest.raises(TypeError, match="Checkpoint contém valor não serializável"):
asyncio.run(saver.aput(
config,
{"id": "cp-bad", "channel_values": {"bad": RuntimeLike()}},
{},
{},
))
def test_json_pending_writes_remain_plain_json() -> None:
repo = _Repo()
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
config = {"configurable": {"thread_id": "plain-thread"}}
asyncio.run(saver.aput(config, {"id": "cp-plain", "channel_values": {}}, {}, {}))
asyncio.run(saver.aput_writes(config, [("result", {"ok": True})], "task-plain"))
persisted = repo.saved[1]
assert persisted["pending_writes"][0]["value"] == {"ok": True}

View File

@@ -0,0 +1,117 @@
from types import SimpleNamespace
import pytest
from agent_framework.rag.rag_service import RagService
def _settings(**overrides):
data = dict(
RAG_PROVIDER="kbdb", RAG_TOP_K=5,
KBDB_DB_USER="kb_user", KBDB_DB_PASSWORD="kb_pwd", KBDB_DB_DSN="kb_tp",
KBDB_DB_WALLET_LOCATION=None, KBDB_DB_WALLET_PASSWORD=None,
ADB_USER=None, ADB_PASSWORD=None, ADB_DSN=None,
ADB_WALLET_LOCATION=None, ADB_WALLET_PASSWORD=None,
KBDB_SEARCH_TYPE="hybrid", KBDB_NODE_EXPANSION=True,
KBDB_NODE_MAX_RELATED=8, KBDB_GRAPH_CROSS_REF=False,
KBDB_MAX_CROSS_REF_HOPS=1, KBDB_DOCUMENT_TYPE="customer_safe",
KBDB_METADATA_JSON=None, KBDB_MIN_SCORE=None,
)
data.update(overrides)
return SimpleNamespace(**data)
@pytest.mark.asyncio
async def test_kbdb_provider_adapts_serving_envelope(monkeypatch):
service = RagService(_settings())
def fake_search(query, k):
return {
"search_type": "hybrid", "confidence": "high", "low_confidence": False,
"top_score": 78.4, "warnings": [],
"seeds": [{"unit_id": 11, "rank": 1, "score": 78.4}],
"units": [
{"unit_id": 10, "content": "passo anterior", "provenance": "parent"},
{"unit_id": 11, "content": "resposta principal", "provenance": "seed"},
],
"documents": [{"document_id": 7, "title": "Politica"}],
}
monkeypatch.setattr(service._kbdb, "_search_sync", fake_search)
result = await service.retrieve("qual a regra?", namespace="billing_agent")
assert [d.id for d in result.documents] == ["10", "11"]
assert result.documents[1].score == 78.4
assert result.metadata["provider"] == "kbdb"
assert result.metadata["confidence"] == "high"
assert "resposta principal" in result.as_prompt_context()
@pytest.mark.asyncio
async def test_kbdb_provider_is_serving_only():
service = RagService(_settings())
with pytest.raises(RuntimeError, match="serving-only"):
await service.add_documents(["texto"])
def test_kbdb_connection_uses_same_wallet_semantics_without_adb_fallback(monkeypatch):
import sys
from agent_framework.rag.kbdb_service import KbdbRagService
captured = {}
class Defaults:
fetch_lobs = True
class Connection:
def close(self):
captured["closed"] = True
class FakeOracleDb:
defaults = Defaults()
@staticmethod
def connect(**kwargs):
captured.update(kwargs)
return Connection()
monkeypatch.setitem(sys.modules, "oracledb", FakeOracleDb)
settings = _settings(
KBDB_DB_USER="kb_user",
KBDB_DB_PASSWORD="kb_pwd",
KBDB_DB_DSN="kb_tp",
KBDB_DB_WALLET_LOCATION="/wallet/kb",
KBDB_DB_WALLET_PASSWORD="wallet_pwd",
ADB_USER="framework_user",
ADB_PASSWORD="framework_pwd",
ADB_DSN="framework_high",
ADB_WALLET_LOCATION="/wallet/framework",
ADB_WALLET_PASSWORD="framework_wallet_pwd",
)
service = KbdbRagService(settings)
with service._connect():
pass
assert captured["user"] == "kb_user"
assert captured["password"] == "kb_pwd"
assert captured["dsn"] == "kb_tp"
assert captured["config_dir"] == "/wallet/kb"
assert captured["wallet_location"] == "/wallet/kb"
assert captured["wallet_password"] == "wallet_pwd"
assert captured["closed"] is True
def test_kbdb_does_not_fallback_to_framework_adb_credentials():
from agent_framework.rag.kbdb_service import KbdbRagService
settings = _settings(
KBDB_DB_USER=None,
KBDB_DB_PASSWORD=None,
KBDB_DB_DSN=None,
ADB_USER="framework_user",
ADB_PASSWORD="framework_pwd",
ADB_DSN="framework_high",
)
with pytest.raises(RuntimeError, match="KBDB_DB_USER"):
KbdbRagService(settings)

View File

@@ -0,0 +1,100 @@
from types import SimpleNamespace
import pytest
from agent_framework.rag.rag_service import RagResult
from agent_framework.rag.vector_store import VectorDocument
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
class DummyRag:
def __init__(self):
self.calls = []
async def retrieve(self, query, *, namespace="default", graph_node=None, rewrite=False, k=None):
self.calls.append((query, namespace, graph_node, rewrite))
return RagResult(
query=query,
documents=[VectorDocument(id="kb-1", content="Tarifação documentada", metadata={}, score=0.9)],
graph_neighbors=[],
latency_ms=3,
metadata={"provider": "kbdb", "confidence": "high", "low_confidence": False},
)
class Runtime(AgentRuntimeMixin):
pass
def _runtime(**settings):
rt = Runtime()
base = dict(
RAG_PROVIDER="kbdb",
SKIP_RAG_WHEN_MCP_SUFFICIENT=True,
ENABLE_RAG_QUERY_REWRITE=False,
ENABLE_RAG_CONTEXT_COMPRESSION=False,
RAG_GROUNDED_ONLY=False,
KBDB_GROUNDED_ONLY=True,
LONG_TERM_MEMORY_INJECT_CONTEXT=False,
)
base.update(settings)
rt.settings = SimpleNamespace(**base)
rt.rag_service = DummyRag()
rt.guardrail_pipeline = None
return rt
@pytest.mark.asyncio
async def test_successful_mcp_does_not_skip_rag_without_explicit_sufficiency():
rt = _runtime()
state = {
"agent_id": "telecom_contas",
"user_text": "Como funciona a tarifação do plano Infinity Pós?",
"sanitized_input": "Como funciona a tarifação do plano Infinity Pós?",
"mcp_results": [{"ok": True, "tool_name": "qualquer_tool", "result": {"plano": "Controle 50GB"}}],
}
context, metadata = await rt._retrieve_rag_context(state)
assert rt.rag_service.calls
assert "Tarifação documentada" in context
assert metadata["provider"] == "kbdb"
assert metadata["status"] == "executed"
assert metadata["document_count"] == 1
@pytest.mark.asyncio
async def test_rag_skips_only_when_mcp_explicitly_declares_sufficiency():
rt = _runtime()
state = {
"agent_id": "telecom_contas",
"user_text": "qual é meu plano?",
"sanitized_input": "qual é meu plano?",
"mcp_results": [{"ok": True, "result": {"plano": "Controle 50GB", "rag_sufficient": True}}],
}
context, metadata = await rt._retrieve_rag_context(state)
assert context == ""
assert not rt.rag_service.calls
assert metadata["reason"] == "mcp_explicitly_sufficient"
def test_kbdb_build_messages_injects_grounding_policy():
rt = _runtime()
state = {
"user_text": "Como funciona?",
"sanitized_input": "Como funciona?",
"context": {},
"business_context": {},
}
messages = rt.build_messages(
state,
system_prompt="system",
mcp_results=[{"ok": True, "result": {"plano": "Controle"}}],
rag_context="",
rag_metadata={"provider": "kbdb", "enabled": True, "status": "empty", "document_count": 0},
)
user = next(m["content"] for m in messages if m["role"] == "user")
assert "Política de grounding obrigatória" in user
assert "Não complete lacunas usando conhecimento paramétrico" in user