new feature: Multi-Intent

This commit is contained in:
2026-09-10 16:12:08 -03:00
parent 2a563840c1
commit 82d96f2dbf
302 changed files with 2070 additions and 28 deletions

View File

@@ -0,0 +1,70 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _template_dirs():
return sorted(
p
for p in ROOT.rglob("*")
if p.is_dir() and p.name in {"agent_template_backend", "agent_template_backend_day_zero"}
)
def test_all_agent_template_backend_copies_have_safe_agent_prompts():
targets = _template_dirs()
assert targets, "Nenhum template backend encontrado"
for base in targets:
checks = {
"billing_agent.py": (
"Nunca exponha identificadores técnicos",
"Não acrescente canais",
"Se uma tool retornar BLOCKED",
),
"product_agent.py": (
"Nunca exponha identificadores técnicos",
"can.cancel",
"Se uma tool retornar BLOCKED",
),
"orders_agent.py": (
"Nunca exponha identificadores técnicos",
"Não declare sucesso",
"Se uma tool retornar BLOCKED",
),
"support_agent.py": (
"Nunca exponha identificadores técnicos",
"Não declare sucesso",
"Se uma tool retornar BLOCKED",
),
}
for filename, required in checks.items():
text = (base / "app" / "agents" / filename).read_text(encoding="utf-8")
for marker in required:
assert marker in text, f"{base.relative_to(ROOT)}/{filename} sem {marker!r}"
def test_all_agent_template_backend_copies_isolate_interrupted_transaction_context():
for base in _template_dirs():
graph = (base / "app" / "workflows" / "agent_graph.py").read_text(encoding="utf-8")
assert 'route_metadata.get("transaction_interruption")' in graph
assert '== "intent_shift"' in graph
assert 'ctx["historical_transaction_ignored"] = True' in graph
assert 'ctx["expected_protocols"] = protocols' in graph
assert 'self._output_guardrail_context(state)' in graph
def test_full_templates_do_not_dump_raw_invoice_payload():
checked = 0
for base in _template_dirs():
renderer = base / "app" / "presentation" / "tool_renderers.py"
if not renderer.exists():
continue
checked += 1
text = renderer.read_text(encoding="utf-8")
assert 'Fatura consultada: {result}' not in text
assert 'result.get("valor_total")' in text
assert 'result.get("vencimento")' in text
assert 'result.get("status")' in text
assert 'result.get("itens")' in text
assert checked > 0

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import pytest
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, reconcile_transaction_parameters
class _ContextAwareLLM:
@@ -355,3 +355,57 @@ async def test_temporal_reconciler_single_scan_uses_all_known_fields_as_anchors_
assert result["values"]["data"] == "01/11/2025"
assert result["provenance"]["subject"] == "history:1"
class _AssistantEvidenceSegmentationLLM:
def __init__(self):
self.calls = 0
async def ainvoke(self, messages, **kwargs):
import json
self.calls += 1
prompt = messages[-1]["content"]
evidence = json.loads(prompt.split("evidence_blocks: ", 1)[1].split("\n", 1)[0])
anchored = [
block for block in evidence
if block.get("role") == "assistant"
and any(match.get("field") == "valor" and float(match.get("value")) == 14.99 for match in block.get("anchor_matches", []))
]
assert len(anchored) == 1
assert "Tamboro Mensal" in anchored[0]["text"]
assert "TIM Fashion" not in anchored[0]["text"]
assert "Neymar" not in anchored[0]["text"]
return {
"content": json.dumps({
"fields": {
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "history:1"},
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
}
}, ensure_ascii=False)
}
@pytest.mark.asyncio
async def test_reconciler_segments_assistant_list_so_known_anchor_resolves_missing_field():
llm = _AssistantEvidenceSegmentationLLM()
result = await reconcile_transaction_parameters(
llm,
text="é a de quatorze e noventa e nove",
tool_name="contestar_cobranca",
parameter_names=["subject", "valor"],
known_arguments={"valor": 14.99},
parameter_schema={
"subject": {"type": "string", "description": "Nome do item ou cobrança a contestar."},
"valor": {"type": "number", "description": "Valor monetário do item a contestar."},
},
conversational_context=(
"priority_3_previous_assistant_tool_or_evidence_context:\n"
"history:1: assistant: Houve as cobranças: * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25. "
"* Cobrança Neymar Jr no valor de R$ 12,00 no dia 01/11/25.\n"
"priority_4_previous_user_utterances:\n"
"history:2: user: tem uma cobrança aqui que eu não reconheço"
),
)
assert llm.calls == 1
assert result["values"]["subject"] == "Tamboro Mensal"
assert float(result["values"]["valor"]) == 14.99

View File

@@ -0,0 +1,191 @@
import json
import pytest
from types import SimpleNamespace
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
class _Router:
def __init__(self):
self.registry = SimpleNamespace(
tools={"contestar_cobranca": object(), "validar_contestacao": object()},
get_tool=lambda name: SimpleNamespace(
description="Contesta uma cobrança após validação",
args_schema={
"subject": {
"type": "string",
"description": "Referência a um item concreto e identificável da fatura",
"user_prompt": "Qual cobrança ou item você deseja contestar?",
},
"valor": {
"type": "number",
"description": "Valor monetário explicitamente associado pelo cliente ao item",
"user_prompt": "Qual é o valor da cobrança que você deseja contestar?",
},
},
requires=["subject", "valor"],
) if name == "contestar_cobranca" else None,
)
def resolve_execution_policy(self, tool_name, arguments=None):
if tool_name == "contestar_cobranca":
return {
"operation_type": "transactional",
"require_confirmation": True,
"requires": ["subject", "valor"],
"policy_source": "test",
"pre_validation": {"enabled": True, "tool": "validar_contestacao", "fail_open": False},
}
return {"operation_type": "read_only", "require_confirmation": False, "policy_source": "test"}
class _LLM:
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
generation = kwargs.get("generation_name")
if generation == "llm.transaction_parameter_current_only":
low = prompt.lower()
fields = json.loads(prompt.split("Formato: ", 1)[1])
out = dict(fields)
if "quatorze e noventa e nove" in low:
if "valor" in out:
out["valor"] = 14.99
elif "vinte e cinco e cinquenta" in low:
if "valor" in out:
out["valor"] = 25.50
elif "dezenove e noventa e nove" in low:
if "valor" in out:
out["valor"] = 19.99
return {"content": json.dumps(out, ensure_ascii=False)}
if generation == "llm.transaction_parameter_extraction":
# Deliberately leave subject unresolved so scenario 8 exercises the
# unique anchored structural candidate + authoritative validator path.
return {"content": json.dumps({"fields": {
"subject": {"decision": "unresolved", "value": None, "source": ""},
"valor": {"decision": "preserve", "value": None, "source": "state"},
}}, ensure_ascii=False)}
return {"content": "{}"}
class _Runtime(AgentRuntimeMixin):
def __init__(self):
self.tool_router = _Router()
self.llm = _LLM()
self.calls = []
async def _call_mcp_tool(self, tool_name, arguments, state):
self.calls.append((tool_name, dict(arguments)))
if tool_name != "validar_contestacao":
return {"ok": True, "tool_name": tool_name, "result": {"status": "OK"}}
subject = str(arguments.get("subject") or "")
valor = arguments.get("valor")
# Canonicalize the raw anchored fragment from scenario 8.
if "Tamboro Mensal" in subject:
canonical = "Tamboro Mensal"
else:
return {"ok": True, "eligible": False, "status": "NEEDS_PARAMETER", "parameter": "subject"}
if float(valor) not in {14.99, 19.99}:
return {
"ok": True,
"eligible": False,
"status": "NEEDS_PARAMETER",
"parameter": "valor",
"clear_fields": ["resolved_value"],
}
return {
"ok": True,
"eligible": True,
"transaction_decision": {
"resolved_arguments": {"subject": canonical, "valor": float(valor)}
},
}
def _collecting_state(arguments, text, *, metadata=None, context=None):
return {
"user_text": text,
"sanitized_input": text,
"route": "contestacao_agent",
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"active_transaction": {
"transaction_id": "tx",
"tool_name": "contestar_cobranca",
"arguments": dict(arguments),
"status": "COLLECTING_PARAMETERS",
"parameter_schema": {
"subject": {"type": "string", "description": "Referência a um item concreto e identificável da fatura"},
"valor": {"type": "number", "description": "Valor monetário explicitamente associado pelo cliente ao item"},
},
"parameter_conversational_context": context or "",
},
"selected_tool_call": {"tool_name": "contestar_cobranca", "arguments": dict(arguments)},
"missing_parameters": [x for x in ["subject", "valor"] if arguments.get(x) in (None, "")],
"route_decision": {"metadata": metadata or {}},
}
@pytest.mark.asyncio
async def test_scenario8_value_anchor_reconciles_subject_then_awaits_confirmation():
runtime = _Runtime()
context = (
"user: tem uma cobrança aqui que eu não reconheço\n"
"assistant: Analisando a fatura. * Cobrança Tamboro Mensal no valor de R$ 14.99 no dia 01/11/25. "
"* Cobrança TIM Fashion Mensal no valor de R$ 10.00 no dia 01/11/25."
)
state = _collecting_state(
{},
"é a de quatorze e noventa e nove",
metadata={
"contextual_reentry": True,
"original_input": "é a de quatorze e noventa e nove",
"relevant_conversation_context": context,
},
context=context,
)
result = await runtime.execute_tools_for_intent(state, tools=[])
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert state["missing_parameters"] == []
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["pending_tool_call"]["arguments"]["valor"] == 14.99
assert state["transaction_pre_validation"]["eligible"] is True
assert result[-1]["awaiting_confirmation"] is True
@pytest.mark.asyncio
async def test_scenario16_invalid_value_clears_only_value_and_preserves_subject():
runtime = _Runtime()
state = _collecting_state({"subject": "Tamboro Mensal"}, "é a de vinte e cinco e cinquenta")
result = await runtime.execute_tools_for_intent(state, tools=[])
assert state["transaction_status"] == "COLLECTING_PARAMETERS"
assert state["active_transaction"]["arguments"]["subject"] == "Tamboro Mensal"
assert "valor" not in state["active_transaction"]["arguments"]
assert state["missing_parameters"] == ["valor"]
assert result[-1]["collecting_parameters"] is True
@pytest.mark.asyncio
async def test_scenario16_current_turn_correction_updates_existing_value_then_awaits_confirmation():
runtime = _Runtime()
state = _collecting_state(
{"subject": "Tamboro Mensal", "valor": 25.50},
"desculpa é a de dezenove e noventa e nove",
)
# The old value exists, so missing_parameters alone must NOT restrict which
# current-turn fields may be corrected.
state["missing_parameters"] = ["subject"]
result = await runtime.execute_tools_for_intent(state, tools=[])
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert state["missing_parameters"] == []
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["pending_tool_call"]["arguments"]["valor"] == 19.99
assert state["transaction_pre_validation"]["eligible"] is True
assert result[-1]["awaiting_confirmation"] is True

View File

@@ -2,6 +2,20 @@ import pytest
from types import SimpleNamespace
from agent_framework.routing.enterprise_router import EnterpriseRouter
def test_single_word_keyword_requires_a_complete_token():
from agent_framework.routing.models import IntentDefinition
router = object.__new__(EnterpriseRouter)
router.intents = [
IntentDefinition(
name="cancel", agent="orders_agent", priority=10,
keywords=["cancela"], examples=[], enabled=True,
)
]
assert router._route_by_keyword("cancela o pedido") is not None
assert router._route_by_keyword("sim, pode cancelar") is None
from agent_framework.routing.models import RouteDecision

View File

@@ -0,0 +1,298 @@
import json
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.runtime.transaction_parameters import reconcile_transaction_parameters
class _RouterLLM:
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:
return json.dumps({"subject": None, "valor": None})
if "esquece" in prompt.lower():
return json.dumps({
"decision": "ABANDON",
"intent": "billing_invoice_explanation",
"agent": "billing_agent",
"confidence": 0.99,
"reason": "abandono explícito da transação atual",
})
return json.dumps({
"decision": "SHIFT",
"intent": "contas_vas_cancel",
"agent": "vas_agent",
"confidence": 0.99,
"reason": "classificação genérica motivada por cancelar",
})
def _router(tmp_path):
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: contestacao_agent
confidence_threshold: 0.70
state_policies:
- state: COLLECTING_PARAMETERS
agent: contestacao_agent
intents:
- name: contas_contestation
agent: contestacao_agent
priority: 10
keywords: [contestar]
- name: contas_vas_cancel
agent: vas_agent
priority: 20
keywords: [cancelar]
- name: billing_invoice_explanation
agent: billing_agent
priority: 30
keywords: [fatura]
""",
encoding="utf-8",
)
return EnterpriseRouter(
SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
),
llm=_RouterLLM(),
)
def _active_state(message):
return {
"user_text": message,
"sanitized_input": message,
"next_state": "COLLECTING_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["valor"],
"intent": "state:COLLECTING_PARAMETERS",
"active_agent": "contestacao_agent",
"active_transaction": {
"tool_name": "contestar_cobranca",
"arguments": {"subject": "Tamboro Mensal"},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "contas_contestation",
"parameter_schema": {"valor": {"type": "number"}},
},
"history": [
{"role": "user", "content": "não reconheço o Tamboro Mensal, quero tirar da fatura"},
{"role": "assistant", "content": "Qual é o valor da cobrança que você deseja contestar?"},
{"role": "user", "content": message},
],
}
@pytest.mark.asyncio
async def test_generic_shift_does_not_interrupt_active_transaction(tmp_path):
decision = await _router(tmp_path).route(_active_state("isso mesmo, pode cancelar"))
assert decision.intent == "state:COLLECTING_PARAMETERS"
assert not (decision.metadata or {}).get("transaction_interruption")
@pytest.mark.asyncio
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.metadata["transaction_interruption"] == "explicit_abandonment"
class _PolicyRouter:
def __init__(self):
self.registry = SimpleNamespace(
tools={"contestar_cobranca": object()},
get_tool=lambda name: SimpleNamespace(
selection_keywords=["contestar"],
args_schema={
"subject": {"type": "string", "description": "item concreto da fatura"},
"valor": {"type": "number", "description": "valor associado"},
},
requires=["subject", "valor"],
description="Contesta cobrança validada",
),
)
def resolve_execution_policy(self, tool_name, arguments=None):
return {
"operation_type": "transactional",
"require_confirmation": True,
"requires": ["subject", "valor"],
"pre_validation": {"enabled": True, "tool": "validar_contestacao", "fail_open": False},
}
class _UnresolvedReconcileLLM:
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
if kwargs.get("profile_name") == "transaction_parameter_extraction":
if "FOCO PRINCIPAL DA RECONCILIAÇÃO" in prompt:
return {"content": json.dumps({
"fields": {
"subject": {"decision": "unresolved", "value": None, "source": ""},
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
}
})}
return {"content": json.dumps({"subject": None, "valor": 14.99})}
return {"content": "{}"}
class _CandidateRuntime(AgentRuntimeMixin):
def __init__(self):
self.tool_router = _PolicyRouter()
self.llm = _UnresolvedReconcileLLM()
self.calls = []
async def _call_mcp_tool(self, tool_name, arguments, state):
self.calls.append((tool_name, dict(arguments)))
if tool_name == "validar_contestacao":
subject = str(arguments.get("subject") or "")
if "Tamboro Mensal" in subject and float(arguments.get("valor")) == 14.99:
return {
"ok": True,
"tool_name": tool_name,
"result": {
"eligible": True,
"status": "ELIGIBLE",
"transaction_decision": {
"resolved_arguments": {"subject": "Tamboro Mensal", "valor": 14.99}
},
},
}
return {"ok": True, "tool_name": tool_name, "result": {"eligible": False, "status": "NEEDS_PARAMETER", "parameter": "subject"}}
@pytest.mark.asyncio
async def test_unique_anchored_block_is_candidate_only_and_prevalidator_canonicalizes():
runtime = _CandidateRuntime()
state = {
"user_text": "é a de quatorze e noventa e nove",
"sanitized_input": "é a de quatorze e noventa e nove",
"mcp_tools": ["contestar_cobranca"],
"route": "contestacao_agent",
"active_agent": "contestacao_agent",
"intent": "contas_contestation",
"route_decision": {"metadata": {}},
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["subject"],
"active_transaction": {
"tool_name": "contestar_cobranca",
"arguments": {"valor": 14.99},
"status": "COLLECTING_PARAMETERS",
"requires": ["subject", "valor"],
"parameter_schema": {
"subject": {"type": "string", "description": "item concreto da fatura"},
"valor": {"type": "number", "description": "valor associado"},
},
"parameter_conversational_context": (
"history:1: assistant: Houve: * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25."
),
},
"selected_tool_call": {"tool_name": "contestar_cobranca", "arguments": {"valor": 14.99}},
}
result = await runtime.execute_tools_for_intent(state, tools=[])
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["pending_tool_call"]["arguments"]["valor"] == 14.99
assert state["transaction_parameter_reconciliation"]["applied_structural_candidates"] == {
"subject": "unique_anchored_evidence_block"
}
validator_args = runtime.calls[0][1]
assert validator_args["subject"].startswith("Cobrança Tamboro Mensal")
assert result[-1]["awaiting_confirmation"] is True
@pytest.mark.asyncio
async def test_current_turn_value_then_unique_anchor_reaches_confirmation_from_empty_transaction():
"""Regression for Contas scenario 08: current amount first, temporal subject second."""
runtime = _CandidateRuntime()
state = {
"user_text": "é a de quatorze e noventa e nove",
"sanitized_input": "é a de quatorze e noventa e nove",
"mcp_tools": ["contestar_cobranca"],
"route": "contestacao_agent",
"active_agent": "contestacao_agent",
"intent": "contas_contestation",
"route_decision": {"metadata": {}},
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["subject", "valor"],
"active_transaction": {
"tool_name": "contestar_cobranca",
"arguments": {},
"status": "COLLECTING_PARAMETERS",
"requires": ["subject", "valor"],
"parameter_schema": {
"subject": {"type": "string", "description": "item concreto da fatura"},
"valor": {"type": "number", "description": "valor monetário associado"},
},
"parameter_conversational_context": (
"history:1: assistant: Houve: * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25."
),
},
"selected_tool_call": {"tool_name": "contestar_cobranca", "arguments": {}},
}
result = await runtime.execute_tools_for_intent(state, tools=[])
assert state["transaction_parameter_collection"]["resolved_fields"] == ["valor"]
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["pending_tool_call"]["arguments"]["valor"] == 14.99
assert result[-1]["awaiting_confirmation"] is True
class _ClearMissingSubjectReconcileLLM(_UnresolvedReconcileLLM):
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
if kwargs.get("profile_name") == "transaction_parameter_extraction":
if "FOCO PRINCIPAL DA RECONCILIAÇÃO" in prompt:
return {"content": json.dumps({
"fields": {
"subject": {"decision": "clear", "value": None, "source": "history:1"},
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
}
})}
return {"content": json.dumps({"subject": None, "valor": 14.99})}
return {"content": "{}"}
@pytest.mark.asyncio
async def test_initially_missing_subject_clear_still_allows_unique_anchored_candidate():
"""Scenario 08: clear on an already-missing field must not suppress candidate fallback."""
rec = await reconcile_transaction_parameters(
_ClearMissingSubjectReconcileLLM(),
text="é a de quatorze e noventa e nove",
tool_name="contestar_cobranca",
parameter_names=["subject", "valor"],
known_arguments={"valor": 14.99},
parameter_schema={
"subject": {"type": "string", "description": "item concreto da fatura"},
"valor": {"type": "number", "description": "valor monetário associado"},
},
tool_description="Contesta uma cobrança identificada por item e valor",
conversational_context=(
"priority_3_previous_assistant_tool_or_evidence_context:\n"
"history:1: assistant: Analisando a sua fatura atual. * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25.\n"
"priority_4_previous_user_utterances:\n"
"history:2: user: tem uma cobrança aqui que eu não reconheço"
),
)
assert rec["decisions"]["subject"] == "clear"
assert rec["clear_fields"] == ["subject"]
assert rec["candidates"] == {
"subject": "Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25."
}
assert rec["candidate_provenance"] == {
"subject": "unique_anchored_evidence_block"
}

View File

@@ -738,3 +738,34 @@ async def test_correction_of_missing_numeric_value_does_not_reopen_resolved_text
)
assert runtime.pending_seen == ["valor"]
assert out == {"valor": 19.99}
@pytest.mark.asyncio
async def test_reconciler_exposes_schema_driven_field_evidence_views_for_missing_fields():
class _LLM:
def __init__(self):
self.prompt = ""
async def ainvoke(self, messages, **kwargs):
self.prompt = messages[0]["content"]
return {"content": '{"fields":{"subject":{"decision":"resolved","value":"Produto A","source":"history:1"},"valor":{"decision":"preserve","value":14.99,"source":"state"}}}'}
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
llm = _LLM()
result = await reconcile_transaction_parameters(
llm,
text="é a de quatorze e noventa e nove",
tool_name="acao_generica",
parameter_names=["subject", "valor"],
known_arguments={"valor": 14.99},
parameter_schema={
"subject": {"type": "string", "description": "referência concreta identificável"},
"valor": {"type": "number", "description": "quantia associada"},
},
conversational_context=(
"priority_3_previous_assistant_tool_or_evidence_context:\n"
"history:1: assistant: * Produto A no atributo 14,99. * Produto B no atributo 10,00."
),
)
assert result["values"]["subject"] == "Produto A"
assert "field_evidence_views:" in llm.prompt
assert '"subject"' in llm.prompt
assert '"anchor_matches"' in llm.prompt

View File

@@ -1058,11 +1058,12 @@ class _CorrectionDuringCollectingLLM:
prompt = messages[-1]["content"]
self.prompts.append(prompt)
if kwargs.get("profile_name") == "transaction_parameter_extraction":
# Current-only extraction now exposes the requested keys through the
# declarative schema/format rather than the legacy pending_parameters label.
if "current_user_message: desculpa, é a de quatorze e noventa e nove" in prompt:
return {"content": json.dumps({"subject": None, "valor": 14.99}, ensure_ascii=False)}
pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
out = {name: None for name in pending}
# O router já resolveu subject a partir do contexto. O runtime ainda
# precisa permitir que a mensagem atual corrija um valor previamente
# coletado, mesmo que valor não esteja em missing_parameters.
if "valor" in out:
out["valor"] = 14.99
return {"content": json.dumps(out, ensure_ascii=False)}
@@ -1374,3 +1375,61 @@ async def test_temporal_reconciler_is_fallback_after_traditional_candidate_fails
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert result[-1]["awaiting_confirmation"] is True
@pytest.mark.asyncio
async def test_collecting_parameters_materializes_router_cache_before_current_only_t6_regression():
"""T6 regression: router-resolved subject must enter active arguments.
The router already consumed the pending subject in the same turn. A subsequent
current-only extraction over the literal message "sim" must not be required to
rediscover it, and temporal reconciliation must not leave it only as metadata.
"""
runtime = _ContestRuntime()
state = {
"user_text": "sim",
"sanitized_input": "sim",
"route": "contestacao_agent",
"active_agent": "contestacao_agent",
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
"route_decision": {
"route": "contestacao_agent",
"agent": "contestacao_agent",
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
"metadata": {
"transaction_turn_consumed": True,
"transaction_parameter_values": {"subject": "Tamboro Mensal"},
"transaction_parameter_source": "llm",
"transaction_parameter_missing_before": ["subject"],
},
},
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["subject"],
"active_transaction": {
"transaction_id": "tx-t6-router-cache",
"tool_name": "contestar_cobranca",
"arguments": {"valor": 19.99},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "contas_contestation",
"requires": ["subject", "valor"],
"parameter_schema": {
"subject": {"type": "string", "description": "item concreto da fatura"},
"valor": {"type": "number", "description": "valor da cobrança"},
},
"tool_description": "Contesta cobrança validada",
},
"selected_tool_call": {
"tool_name": "contestar_cobranca",
"arguments": {"valor": 19.99},
},
}
results = await runtime.execute_tools_for_intent(state, tools=[])
assert results[-1]["transaction_status"] == "AWAITING_CONFIRMATION"
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["pending_tool_call"]["arguments"]["valor"] == 19.99
assert state["active_transaction"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["missing_parameters"] == []
collection = state["transaction_parameter_collection"]
assert collection["router_resolved_fields"] == ["subject"]
assert runtime.calls == []

View File

@@ -0,0 +1,66 @@
from agent_framework.routing.models import IntentDefinition
from agent_framework.routing.multi_intent import MultiIntentPlanner
def _planner():
return MultiIntentPlanner([
IntentDefinition(name="cancel", agent="orders", priority=10, keywords=["cancelar pedido"], mcp_tools=["cancelar_pedido"]),
IntentDefinition(name="invoice", agent="billing", priority=20, keywords=["segunda via"], mcp_tools=["consultar_fatura"]),
], {
"enabled": True,
}, transactional_tools={"cancelar_pedido"})
def test_builds_validated_plan_and_keeps_primary_first():
plan = _planner().plan("quero cancelar pedido e preciso da segunda via")
assert plan is not None
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
assert plan.operations[1].disposition == "execute"
assert plan.operations[1].tools == ["consultar_fatura"]
def test_confirmation_with_secondary_does_not_lose_confirmation():
plan = _planner().plan_confirmation_secondary(
"sim e preciso da segunda via", primary_intent="cancel", primary_agent="orders"
)
assert plan is not None
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
assert MultiIntentPlanner.public_messages(plan) == []
def test_transactional_intent_is_primary_even_when_mentioned_second():
plan = _planner().plan("preciso da segunda via e quero cancelar pedido")
assert plan is not None
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
def test_does_not_plan_single_intent_or_invent_unknown_intent():
assert _planner().plan("quero cancelar pedido") is None
assert _planner().plan("quero cancelar pedido e saber o clima") is None
def test_known_primary_with_actionable_unknown_creates_off_context_topic():
plan = _planner().plan("quero cancelar pedido e alterar meu CNPJ")
assert plan is not None
assert plan.operations[1].intent == "off_context"
assert plan.operations[1].disposition == "unsupported"
def test_confirmation_with_unknown_secondary_keeps_confirmation():
plan = _planner().plan_confirmation_secondary(
"sim e altere meu CNPJ", primary_intent="cancel", primary_agent="orders"
)
assert plan is not None
assert plan.operations[0].intent == "cancel"
assert plan.operations[1].intent == "off_context"
def test_single_word_keyword_does_not_match_a_longer_word_prefix():
planner = MultiIntentPlanner([
IntentDefinition(name="cancel", agent="orders", priority=10, keywords=["cancela"]),
IntentDefinition(name="invoice", agent="billing", priority=20, keywords=["segunda via"]),
])
plan = planner.plan("sim, pode cancelar e preciso da segunda via")
assert plan is not None
assert all(item.intent != "cancel" for item in plan.operations)
assert [item.intent for item in plan.operations] == ["invoice", "off_context"]

View File

@@ -0,0 +1,53 @@
import pytest
from agent_framework.routing.pending_topics import drain_pending_topics
@pytest.mark.asyncio
async def test_secondary_topic_does_not_inherit_primary_transaction_state():
captured = {}
async def billing_agent(state):
captured.update(state)
return {"answer": "Aqui está a sua segunda via."}
state = {
"answer": "Você confirma o cancelamento do Tamboro?",
"sanitized_input": "quero cancelar Tamboro e quero minha segunda via",
"confirmation_required": True,
"active_transaction": {"tool_name": "cancelar_vas_avulso"},
"transaction_status": "AWAITING_CONFIRMATION",
"selected_tool_call": {"tool_name": "cancelar_vas_avulso"},
"pending_tool_call": {"tool_name": "cancelar_vas_avulso"},
"pending_domain_workflow": {"workflow_name": "cancelamento_vas_avulso"},
"missing_parameters": ["subject"],
"next_state": "WAITING_CONTESTACAO_CONFIRMATION",
"mcp_results": [{"tool_name": "consultar_vas"}],
"pending_topics": [{
"operation_id": "op-2",
"intent": "contas_invoice_query",
"agent": "faturas_agent",
"domain": "telecom_contas",
"tools": ["consultar_faturas"],
"source_text": "quero minha segunda via",
"disposition": "execute",
"status": "pending",
}],
}
result = await drain_pending_topics(
state, state["answer"], {"faturas_agent": billing_agent}
)
assert captured["active_transaction"] is None
assert captured["selected_tool_call"] == {}
assert captured["pending_tool_call"] == {}
assert captured["pending_domain_workflow"] is None
assert captured["next_state"] is None
assert captured["sanitized_input"] == "quero minha segunda via"
assert captured["mcp_results"] == []
assert captured["mcp_tools"] == ["consultar_faturas"]
assert captured["available_mcp_tools"] == ["consultar_faturas"]
assert captured["domain"] == "telecom_contas"
assert result.answer.startswith("Aqui está a sua segunda via.")
assert result.pending_topics == []

View File

@@ -0,0 +1,39 @@
from pathlib import Path
import re
from urllib.parse import unquote
ROOT = Path(__file__).resolve().parents[2]
def _local_links(path: Path) -> list[str]:
text = path.read_text(encoding="utf-8")
return [
target for target in re.findall(r"\[[^]]*\]\(([^)]+)\)", text)
if not target.startswith(("http://", "https://", "#", "mailto:"))
]
def test_main_readmes_have_no_broken_local_links():
for name in ("README.md", "README_en.md"):
readme = ROOT / name
for target in _local_links(readme):
relative = unquote(target.split("#", 1)[0])
assert not relative or (readme.parent / relative).exists(), (name, target)
def test_every_feature_guide_is_listed_in_corresponding_main_readme():
pairs = (("pt-BR", "README.md"), ("en", "README_en.md"))
for language, readme_name in pairs:
readme = (ROOT / readme_name).read_text(encoding="utf-8")
guides = sorted((ROOT / "docs" / "features" / language).glob("[0-9][0-9]_*.md"))
assert guides
for guide in guides:
expected = f"docs/features/{language}/{guide.name}"
assert expected in readme, (readme_name, expected)
def test_portuguese_and_english_feature_catalogs_are_symmetric():
pt = {path.name for path in (ROOT / "docs" / "features" / "pt-BR").glob("*.md")}
en = {path.name for path in (ROOT / "docs" / "features" / "en").glob("*.md")}
assert pt == en

View File

@@ -0,0 +1,38 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_all_tuning_backend_graphs_support_pending_topics():
graphs = sorted((ROOT / "Tuning-Performance").glob("**/app/workflows/agent_graph.py"))
assert graphs
for path in graphs:
source = path.read_text(encoding="utf-8")
assert "from agent_framework.routing.pending_topics import drain_pending_topics" in source, path
assert '"pending_topics": drained.pending_topics' in source, path
assert '"handled_topics": drained.handled_topics' in source, path
def test_all_tuning_backend_states_expose_pending_topics():
states = sorted((ROOT / "Tuning-Performance").glob("**/app/state.py"))
assert states
for path in states:
source = path.read_text(encoding="utf-8")
assert "pending_topics: list[dict[str, Any]]" in source, path
assert "handled_topics: list[dict[str, Any]]" in source, path
def test_canonical_templates_delegate_drain_to_framework_helper():
for template in ("agent_template_backend", "agent_template_backend_day_zero"):
graph = ROOT / "templates" / template / "app" / "workflows" / "agent_graph.py"
state = ROOT / "templates" / template / "app" / "state.py"
graph_source = graph.read_text(encoding="utf-8")
state_source = state.read_text(encoding="utf-8")
assert "from agent_framework.routing.pending_topics import drain_pending_topics" in graph_source
assert "drained = await drain_pending_topics(" in graph_source
assert '"pending_topics": drained.pending_topics' in graph_source
assert '"handled_topics": drained.handled_topics' in graph_source
assert "MultiIntentPlanner.public_messages" not in graph_source
assert "pending_topics: list[dict[str, Any]]" in state_source
assert "handled_topics: list[dict[str, Any]]" in state_source