new feature: Integration with kbdb Autonomous
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
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,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()},
|
||||
{},
|
||||
))
|
||||
@@ -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}
|
||||
|
||||
117
agent_framework_oci/tests/unit/test_rag_kbdb_provider.py
Normal file
117
agent_framework_oci/tests/unit/test_rag_kbdb_provider.py
Normal 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)
|
||||
100
agent_framework_oci/tests/unit/test_rag_runtime_grounding.py
Normal file
100
agent_framework_oci/tests/unit/test_rag_runtime_grounding.py
Normal 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
|
||||
Reference in New Issue
Block a user