adding ABANDON in multi-intent/intent-shift
This commit is contained in:
@@ -13,6 +13,14 @@ class _RouterLLM:
|
||||
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:
|
||||
return json.dumps({"subject": None, "valor": None})
|
||||
if "nao quero mais cancelar" in prompt.lower() or "não quero mais cancelar" in prompt.lower():
|
||||
return json.dumps({
|
||||
"decision": "ABANDON",
|
||||
"intent": None,
|
||||
"agent": None,
|
||||
"confidence": 0.99,
|
||||
"reason": "abandono explícito sem novo objetivo",
|
||||
})
|
||||
if "esquece" in prompt.lower():
|
||||
return json.dumps({
|
||||
"decision": "ABANDON",
|
||||
@@ -101,9 +109,20 @@ async def test_generic_shift_does_not_interrupt_active_transaction(tmp_path):
|
||||
async def test_explicit_abandonment_can_leave_active_transaction(tmp_path):
|
||||
decision = await _router(tmp_path).route(_active_state("esquece isso, quero ver minha fatura"))
|
||||
assert decision.intent == "billing_invoice_explanation"
|
||||
assert decision.agent == "billing_agent"
|
||||
assert decision.metadata["transaction_interruption"] == "explicit_abandonment"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_abandonment_without_new_goal_does_not_rearm_tool(tmp_path):
|
||||
decision = await _router(tmp_path).route(_active_state("nao quero mais cancelar"))
|
||||
assert decision.intent == "state:TRANSACTION_ABANDONED"
|
||||
assert decision.agent == "contestacao_agent"
|
||||
assert decision.mcp_tools == []
|
||||
assert decision.metadata["transaction_interruption"] == "explicit_abandonment"
|
||||
assert decision.metadata["interrupted_intent"] == "contas_contestation"
|
||||
|
||||
|
||||
class _PolicyRouter:
|
||||
def __init__(self):
|
||||
self.registry = SimpleNamespace(
|
||||
@@ -296,3 +315,42 @@ async def test_initially_missing_subject_clear_still_allows_unique_anchored_cand
|
||||
assert rec["candidate_provenance"] == {
|
||||
"subject": "unique_anchored_evidence_block"
|
||||
}
|
||||
|
||||
|
||||
class _AbandonRuntime(AgentRuntimeMixin):
|
||||
def __init__(self):
|
||||
self.tool_router = None
|
||||
self.llm = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_abandon_cancels_only_active_transaction_without_rearming_tool():
|
||||
runtime = _AbandonRuntime()
|
||||
state = {
|
||||
"sanitized_input": "nao quero mais cancelar",
|
||||
"route_decision": {
|
||||
"intent": "state:TRANSACTION_ABANDONED",
|
||||
"agent": "orders_agent",
|
||||
"metadata": {"transaction_interruption": "explicit_abandonment"},
|
||||
},
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["order_id"],
|
||||
"active_transaction": {
|
||||
"tool_name": "cancelar_pedido",
|
||||
"arguments": {},
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"started_from_intent": "retail_order_cancel",
|
||||
},
|
||||
"selected_tool_call": {"tool_name": "cancelar_pedido", "arguments": {}},
|
||||
"pending_topics": [{"operation_id": "op-x", "intent": "other_pending_intent"}],
|
||||
}
|
||||
|
||||
results = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert results == []
|
||||
assert state["transaction_status"] == "CANCELLED"
|
||||
assert state["active_transaction"] is None
|
||||
assert state["selected_tool_call"] == {}
|
||||
assert state["tool_policy_result"]["action"] == "cancelled_by_explicit_abandonment"
|
||||
# ABANDON encerra a ação ativa; não apaga outras ações pendentes por efeito colateral.
|
||||
assert state["pending_topics"] == [{"operation_id": "op-x", "intent": "other_pending_intent"}]
|
||||
|
||||
@@ -115,3 +115,208 @@ async def test_deferred_transaction_is_promoted_after_primary_completes():
|
||||
assert result.state_patch["pending_tool_call"]["tool_name"] == "cancelar_pedido"
|
||||
assert result.agent_responses[-1]["primary"] is True
|
||||
assert result.agent_responses[-1]["agent"] == "orders_agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_agent_responses_do_not_leak_into_new_turn_projection():
|
||||
state = {
|
||||
"answer": "Lista atual de serviços.",
|
||||
"route": "vas_agent",
|
||||
"active_agent": "vas_agent",
|
||||
"intent": "contas_vas_information",
|
||||
"agent_responses": [
|
||||
{"agent": "contestacao_agent", "answer": "Você confirma Tamboro e Paramount+?", "primary": True, "status": "completed"},
|
||||
{"operation_id": "op-2", "agent": "faturas_agent", "answer": "Resposta antiga de fatura.", "primary": False, "status": "completed"},
|
||||
],
|
||||
"pending_topics": [],
|
||||
}
|
||||
result = await drain_pending_topics(state, state["answer"], {})
|
||||
assert [r["answer"] for r in result.agent_responses] == ["Lista atual de serviços."]
|
||||
assert result.agent_responses[0]["agent"] == "vas_agent"
|
||||
assert result.agent_responses[0]["primary"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_secondary_result_is_kept_as_durable_operation_result():
|
||||
async def billing_agent(state):
|
||||
return {
|
||||
"answer": "Fatura consultada.",
|
||||
"mcp_results": [{"tool_name": "consultar_faturas", "ok": True, "invoice_id": "INV-1"}],
|
||||
}
|
||||
state = {
|
||||
"answer": "Cancelamento concluÃdo.",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "contas_vas_cancel",
|
||||
"operation_results": {
|
||||
"op-1": {"operation_id": "op-1", "status": "completed", "answer": "Cancelamento concluÃdo."}
|
||||
},
|
||||
"pending_topics": [{
|
||||
"operation_id": "op-2", "intent": "contas_invoice_query", "agent": "faturas_agent",
|
||||
"domain": "telecom_contas", "tools": ["consultar_faturas"],
|
||||
"source_text": "consulte minha fatura", "disposition": "execute", "status": "pending",
|
||||
}],
|
||||
}
|
||||
result = await drain_pending_topics(state, state["answer"], {"faturas_agent": billing_agent})
|
||||
assert result.operation_results["op-1"]["answer"] == "Cancelamento concluÃdo."
|
||||
assert result.operation_results["op-2"]["status"] == "completed"
|
||||
assert result.operation_results["op-2"]["mcp_results"][0]["invoice_id"] == "INV-1"
|
||||
assert [r["answer"] for r in result.agent_responses] == ["Cancelamento concluÃdo.", "Fatura consultada."]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_primary_operation_is_not_promoted_while_waiting_confirmation():
|
||||
state = {
|
||||
"answer": "Você confirma o cancelamento?",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "contas_vas_cancel",
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"confirmation_required": True,
|
||||
"pending_tool_call": {"tool_name": "cancelar_vas_avulso", "arguments": {"subject": "Tamboro"}},
|
||||
"multi_intent_plan": {
|
||||
"plan_id": "mip-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "contas_vas_cancel", "agent": "contestacao_agent", "domain": "telecom_contas", "tools": ["cancelar_vas_avulso"], "source_text": "cancele tamboro"},
|
||||
],
|
||||
},
|
||||
"operation_results": {},
|
||||
"pending_topics": [],
|
||||
}
|
||||
result = await drain_pending_topics(state, state["answer"], {})
|
||||
assert "op-1" not in result.operation_results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_primary_operation_is_promoted_and_keeps_previous_results():
|
||||
state = {
|
||||
"answer": "Tamboro cancelado; Paramount+ não encontrado.",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "state:WAITING_CONTESTACAO_CONFIRMATION",
|
||||
"transaction_status": "COMPLETED",
|
||||
"multi_intent_plan": {
|
||||
"plan_id": "mip-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "contas_vas_cancel", "agent": "contestacao_agent", "domain": "telecom_contas", "tools": ["cancelar_vas_avulso"], "source_text": "cancele tamboro e paramount"},
|
||||
{"operation_id": "op-2", "intent": "contas_invoice_query", "agent": "faturas_agent", "domain": "telecom_contas", "tools": ["consultar_faturas"], "source_text": "consulte minha fatura"},
|
||||
],
|
||||
},
|
||||
"operation_results": {
|
||||
"op-2": {"operation_id": "op-2", "status": "completed", "answer": "Fatura consultada."}
|
||||
},
|
||||
"mcp_results": [
|
||||
{
|
||||
"tool_name": "cancelar_vas_avulso",
|
||||
"ok": True,
|
||||
"result": {
|
||||
"status": "COMPLETED",
|
||||
"output": {
|
||||
"cancelar_vas_avulso": {
|
||||
"results": [
|
||||
{"subject": "Tamboro Mensal", "success": True, "protocol": "123"},
|
||||
{"subject": "Paramount+", "success": False, "reason": "service_not_found"},
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"transaction_evidence": [{"transaction_id": "tx-1", "status": "COMPLETED"}],
|
||||
"pending_topics": [],
|
||||
}
|
||||
result = await drain_pending_topics(state, state["answer"], {})
|
||||
assert result.operation_results["op-2"]["answer"] == "Fatura consultada."
|
||||
primary = result.operation_results["op-1"]
|
||||
assert primary["plan_id"] == "mip-1"
|
||||
assert primary["status"] == "completed"
|
||||
assert primary["mcp_results"][0]["result"]["output"]["cancelar_vas_avulso"]["results"][1]["reason"] == "service_not_found"
|
||||
assert primary["transaction_evidence"][0]["transaction_id"] == "tx-1"
|
||||
assert [item["answer"] for item in result.agent_responses] == ["Tamboro cancelado; Paramount+ não encontrado."]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persisted_plan_does_not_rehydrate_completed_secondary_topics_on_next_turn():
|
||||
calls = {"faturas": 0, "suporte": 0}
|
||||
|
||||
async def billing_agent(state):
|
||||
calls["faturas"] += 1
|
||||
return {"answer": "Fatura executada novamente - ERRO."}
|
||||
|
||||
async def support_agent(state):
|
||||
calls["suporte"] += 1
|
||||
return {"answer": "RAG executado novamente - ERRO."}
|
||||
|
||||
plan = {
|
||||
"plan_id": "mip-continue-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "contas_vas_cancel", "agent": "contestacao_agent", "domain": "telecom_contas", "tools": ["cancelar_vas_avulso"], "source_text": "cancele tamboro"},
|
||||
{"operation_id": "op-2", "intent": "contas_invoice_query", "agent": "faturas_agent", "domain": "telecom_contas", "tools": ["consultar_faturas"], "source_text": "consulte minha fatura", "disposition": "execute", "status": "pending"},
|
||||
{"operation_id": "op-3", "intent": "contas_knowledge", "agent": "suporte_contas_agent", "domain": "telecom_contas", "tools": [], "source_text": "explique infinity pos", "disposition": "execute", "status": "pending"},
|
||||
],
|
||||
}
|
||||
state = {
|
||||
"answer": "Cancelamento concluído.",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "state:WAITING_CONTESTACAO_CONFIRMATION",
|
||||
"transaction_status": "COMPLETED",
|
||||
"route_decision": {"route": "contestacao_agent", "intent": "state:WAITING_CONTESTACAO_CONFIRMATION", "metadata": {}},
|
||||
"multi_intent_plan": plan,
|
||||
# Empty is authoritative: op-2/op-3 were already drained on turn 1.
|
||||
"pending_topics": [],
|
||||
"operation_results": {
|
||||
"op-2": {"operation_id": "op-2", "plan_id": "mip-continue-1", "agent": "faturas_agent", "intent": "contas_invoice_query", "domain": "telecom_contas", "source_text": "consulte minha fatura", "status": "completed", "answer": "Fatura consultada no turno anterior."},
|
||||
"op-3": {"operation_id": "op-3", "plan_id": "mip-continue-1", "agent": "suporte_contas_agent", "intent": "contas_knowledge", "domain": "telecom_contas", "source_text": "explique infinity pos", "status": "completed", "answer": "Infinity explicado no turno anterior."},
|
||||
},
|
||||
"mcp_results": [{"tool_name": "cancelar_vas_avulso", "ok": True, "result": {"status": "COMPLETED"}}],
|
||||
}
|
||||
|
||||
result = await drain_pending_topics(
|
||||
state,
|
||||
state["answer"],
|
||||
{"faturas_agent": billing_agent, "suporte_contas_agent": support_agent},
|
||||
)
|
||||
|
||||
assert calls == {"faturas": 0, "suporte": 0}
|
||||
assert [item["answer"] for item in result.agent_responses] == ["Cancelamento concluído."]
|
||||
assert result.operation_results["op-2"]["answer"] == "Fatura consultada no turno anterior."
|
||||
assert result.operation_results["op-3"]["answer"] == "Infinity explicado no turno anterior."
|
||||
assert result.operation_results["op-1"]["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_queued_topic_is_idempotently_skipped_but_result_remains_available_to_processing():
|
||||
calls = 0
|
||||
|
||||
async def billing_agent(state):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"answer": "não deveria executar"}
|
||||
|
||||
plan = {
|
||||
"plan_id": "mip-idempotent-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "primary", "agent": "primary_agent", "domain": "test", "tools": [], "source_text": "principal"},
|
||||
{"operation_id": "op-2", "intent": "contas_invoice_query", "agent": "faturas_agent", "domain": "telecom_contas", "tools": ["consultar_faturas"], "source_text": "consulte minha fatura", "disposition": "execute", "status": "pending"},
|
||||
],
|
||||
}
|
||||
existing = {
|
||||
"operation_id": "op-2", "plan_id": "mip-idempotent-1", "agent": "faturas_agent",
|
||||
"intent": "contas_invoice_query", "domain": "telecom_contas", "source_text": "consulte minha fatura",
|
||||
"status": "completed", "answer": "Fatura já consultada.",
|
||||
"mcp_results": [{"result": {"invoice_id": "INV-1"}}],
|
||||
}
|
||||
state = {
|
||||
"answer": "Resposta atual.",
|
||||
"route": "primary_agent", "active_agent": "primary_agent", "intent": "primary",
|
||||
"multi_intent_plan": plan,
|
||||
"pending_topics": [dict(plan["operations"][1])],
|
||||
"operation_results": {"op-2": existing},
|
||||
}
|
||||
|
||||
result = await drain_pending_topics(state, state["answer"], {"faturas_agent": billing_agent})
|
||||
|
||||
assert calls == 0
|
||||
assert result.operation_results["op-2"]["mcp_results"][0]["result"]["invoice_id"] == "INV-1"
|
||||
assert [item["answer"] for item in result.agent_responses] == ["Resposta atual."]
|
||||
assert result.pending_topics == []
|
||||
|
||||
@@ -98,3 +98,191 @@ def test_kbdb_build_messages_injects_grounding_policy():
|
||||
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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("workflow_status", ["COMPLETED", "FAILED"])
|
||||
async def test_rag_skips_when_all_turn_workflows_are_terminal(workflow_status):
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"tool_name": "cancelar_vas_avulso",
|
||||
"result": {
|
||||
"execution_id": "wf-1",
|
||||
"workflow_name": "cancelamento_vas_avulso",
|
||||
"status": workflow_status,
|
||||
"output": {"answer": "resultado operacional"},
|
||||
},
|
||||
"metadata": {
|
||||
"workflow_name": "cancelamento_vas_avulso",
|
||||
"workflow_status": workflow_status,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert context == ""
|
||||
assert not rt.rag_service.calls
|
||||
assert metadata["status"] == "skipped"
|
||||
assert metadata["reason"] == "all_turn_workflows_terminal"
|
||||
assert metadata["workflow_statuses"] == [workflow_status]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_still_runs_when_any_turn_workflow_is_not_terminal():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-1", "workflow_name": "w1", "status": "COMPLETED"},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
},
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-2", "workflow_name": "w2", "status": "RUNNING"},
|
||||
"metadata": {"workflow_status": "RUNNING"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["status"] == "executed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_requires_rag_overrides_terminal_workflow_skip():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {
|
||||
"execution_id": "wf-1",
|
||||
"workflow_name": "w1",
|
||||
"status": "COMPLETED",
|
||||
"requires_rag": True,
|
||||
"rag_query": "explicar regra de negócio",
|
||||
},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert rt.rag_service.calls[0][0] == "explicar regra de negócio"
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["required_by_tool"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_workflow_does_not_skip_rag_when_operation_is_pending():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"pending_topics": [
|
||||
{"operation_id": "op-2", "intent": "invoice_query", "status": "pending"}
|
||||
],
|
||||
"operation_results": {},
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-1", "workflow_name": "w1", "status": "COMPLETED"},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["status"] == "executed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_workflow_does_not_skip_rag_when_knowledge_operation_is_unresolved():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"multi_intent_plan": {
|
||||
"plan_id": "mip-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "cancel", "status": "completed"},
|
||||
{"operation_id": "op-2", "intent": "account_knowledge", "status": "pending"},
|
||||
],
|
||||
},
|
||||
# op-1 está resolvida, op-2 ainda não possui resultado terminal.
|
||||
"operation_results": {"op-1": {"status": "completed"}},
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-1", "workflow_name": "w1", "status": "COMPLETED"},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["status"] == "executed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_operation_results_prevent_stale_plan_from_counting_as_pending():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"multi_intent_plan": {
|
||||
"plan_id": "mip-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "cancel", "status": "pending"},
|
||||
{"operation_id": "op-2", "intent": "invoice_query", "status": "pending"},
|
||||
{"operation_id": "op-3", "intent": "account_knowledge", "status": "pending"},
|
||||
],
|
||||
},
|
||||
"operation_results": {
|
||||
"op-1": {"status": "completed"},
|
||||
"op-2": {"status": "completed"},
|
||||
"op-3": {"status": "completed"},
|
||||
},
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-1", "workflow_name": "w1", "status": "COMPLETED"},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert context == ""
|
||||
assert not rt.rag_service.calls
|
||||
assert metadata["status"] == "skipped"
|
||||
assert metadata["reason"] == "all_turn_workflows_terminal"
|
||||
|
||||
Reference in New Issue
Block a user