diff --git a/FIX_TRANSACTION_PARAMETER_PRECEDENCE.md b/FIX_TRANSACTION_PARAMETER_PRECEDENCE.md index ccdaabe..5c235b5 100644 --- a/FIX_TRANSACTION_PARAMETER_PRECEDENCE.md +++ b/FIX_TRANSACTION_PARAMETER_PRECEDENCE.md @@ -1,32 +1,102 @@ -# Transaction parameter precedence fix +# Precedência transacional + extração LLM de parâmetros -Correção para a regressão em que uma resposta curta que preenchia um parâmetro pendente (ex.: `R$ 71,99` para `valor`) era classificada pelo LLM Router como uma nova intent e interrompia a transação. +Esta correção remove a extração textual hardcoded de parâmetros transacionais e faz a coleta de `policy.requires` por um extrator LLM genérico. -## Regra aplicada +## Regra de precedência -Durante `COLLECTING_PARAMETERS` a ordem passa a ser: - -1. resposta compatível com parâmetro pendente -> mantém a política de estado e continua a transação; -2. cancelamento explícito -> cancela a transação; -3. nova intenção clara/pergunta explícita -> interrompe a transação e volta ao roteamento normal; -4. caso ambíguo -> permanece em clarificação. - -Exemplo corrigido: +Enquanto existir uma transação ativa, o framework trata o turno nesta ordem: ```text -não fiz essa contratação TIM CTRL Redes Sociais 8.0 --> informe valor -R$ 71,99 --> valor=71.99; continua contestar_cobranca; executa pré-validação +ACTIVE_TRANSACTION + | + +-- COLLECTING_PARAMETERS + | | + | +-- LLM tenta extrair SOMENTE os parâmetros ainda pendentes + | | + | +-- extraiu >= 1 ? + | | + | +-- SIM -> continua a transação; NÃO avalia intent_shift + | | + | +-- NÃO -> libera EnterpriseRouter para avaliar intent_shift + | + +-- AWAITING_CONFIRMATION + | + +-- reconhece confirmação/rejeição explícita + | + +-- reconheceu ? + | + +-- SIM -> continua/cancela a transação; NÃO avalia intent_shift + | + +-- NÃO -> libera EnterpriseRouter para avaliar intent_shift ``` -A mensagem `R$ 71,99` não pode mais virar `contas_invoice_explanation` enquanto `valor` estiver pendente. +## TransactionParameterExtractor -## Testes +Novo componente: -Foram adicionados testes para: +`libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py` -- valor monetário com LLM sugerindo outra intent; -- entidade curta como resposta de parâmetro; -- pergunta clara durante coleta ainda interrompendo a transação; -- regressões existentes de intent-shift e transactional tool flow. +A extração textual dos parâmetros de negócio é feita exclusivamente por LLM. O componente recebe: + +- nome da tool/transação ativa; +- parâmetros atualmente pendentes; +- argumentos já conhecidos; +- schema/tipos declarados em `tools.yaml` quando disponíveis; +- descrição da tool; +- mensagem atual do usuário. + +Ele não conhece nomes de domínio como `order_id`, `reason`, `subject`, `valor`, TIM ou retail. Não há regex de entidades de negócio. + +A LLM pode interpretar, por exemplo: + +- `PED-1001` quando só há um parâmetro compatível pendente; +- `o pedido é PED-1001`; +- `PED-1001, desisti da compra` preenchendo dois parâmetros no mesmo turno; +- respostas com o nome do parâmetro seguido do valor; +- respostas apenas com o valor, quando semanticamente inequívocas. + +Em caso de dúvida, o prompt manda retornar `null`. Uma nova solicitação não deve ser transformada em valor de parâmetro. + +## Separação de responsabilidades + +`tool_policies.yaml` continua sendo a fonte de verdade para `requires`. + +`tools.yaml` pode fornecer tipos via `args_schema` e descrição da tool para melhorar a interpretação sem introduzir código específico de domínio. + +`mcp_parameter_mapping.yaml` continua responsável pelos parâmetros auxiliares/contrato MCP. As strategies do mapper são explicitamente excluídas dos campos presentes em `policy.requires`, para não misturar extração MCP com coleta transacional. + +O `EnterpriseRouter` usa o mesmo extrator LLM apenas como *probe* de precedência. Se pelo menos um parâmetro pendente for encontrado, o turno permanece no estado transacional. Os valores extraídos são colocados no metadata da decisão e reutilizados pelo runtime, evitando uma segunda chamada LLM no mesmo turno. + +## Profile LLM + +Foi adicionado aos templates: + +```yaml +transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 +``` + +Generation/component: + +- `llm.transaction_parameter_extraction` +- `transaction_parameter_extraction` + +## Limpeza de estado + +Em `intent_shift`, `transaction_pre_validation` da transação abandonada é removido para não contaminar a nova transação. O resultado de pre-validation continua preservado enquanto pertence à própria transação para auditoria. + +## Testes adicionados + +`tests/test_transaction_parameter_llm_precedence.py` + +Cobertura: + +1. dois parâmetros extraídos no mesmo turno; +2. um parâmetro preenchido ganha precedência sobre keyword que indicaria outra intent; +3. nenhum parâmetro encontrado libera `intent_shift`; +4. ausência do antigo `_extract_action_arguments()` hardcoded; +5. confirmação `sim` ganha precedência sobre intent shift. diff --git a/Tuning-Performance/Authentication/agent_template_backend_authentication/llm_profiles.yaml b/Tuning-Performance/Authentication/agent_template_backend_authentication/llm_profiles.yaml index 908b382..29fda41 100644 --- a/Tuning-Performance/Authentication/agent_template_backend_authentication/llm_profiles.yaml +++ b/Tuning-Performance/Authentication/agent_template_backend_authentication/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/llm_profiles.yaml b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/llm_profiles.yaml index 908b382..29fda41 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/llm_profiles.yaml +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/llm_profiles.yaml b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/llm_profiles.yaml index 908b382..29fda41 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/llm_profiles.yaml +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/llm_profiles.yaml b/Tuning-Performance/Normal/templates/agent_template_backend/llm_profiles.yaml index 3992c12..bebfcf1 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/llm_profiles.yaml +++ b/Tuning-Performance/Normal/templates/agent_template_backend/llm_profiles.yaml @@ -72,3 +72,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/llm_profiles.yaml b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/llm_profiles.yaml index 908b382..29fda41 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/llm_profiles.yaml +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/llm_profiles.yaml b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/llm_profiles.yaml index 908b382..29fda41 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/llm_profiles.yaml +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/llm_profiles.yaml b/Tuning-Performance/Transaction_Evidence/agent_template_backend/llm_profiles.yaml index 908b382..29fda41 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/llm_profiles.yaml +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/llm_profiles.yaml b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/llm_profiles.yaml index 908b382..29fda41 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/llm_profiles.yaml +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/libs/agent_framework/src/agent_framework/routing/enterprise_router.py b/libs/agent_framework/src/agent_framework/routing/enterprise_router.py index 469b76a..501e2bf 100644 --- a/libs/agent_framework/src/agent_framework/routing/enterprise_router.py +++ b/libs/agent_framework/src/agent_framework/routing/enterprise_router.py @@ -9,6 +9,7 @@ from typing import Any from .config_loader import load_intents, load_router_defaults, load_state_policies from .continuity import SemanticRouteContinuity from .models import IntentDefinition, RouteDecision, RouterStatePolicy +from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation logger = logging.getLogger("agent_framework.routing") @@ -78,6 +79,12 @@ class EnterpriseRouter: # pendente antes de executar a nova intent. state_decision = self._route_by_state(current_state) if state_decision: + consumed = await self._transaction_parameter_precedence( + state, text=str(text), state_decision=state_decision + ) + if consumed is not None: + await self._emit(consumed, state) + return consumed interruption = await self._transaction_state_interruption_candidate( state, text=str(text), state_decision=state_decision ) @@ -109,6 +116,17 @@ class EnterpriseRouter: method="state", next_state=tx_status, ) + consumed = await self._transaction_parameter_precedence( + state, text=str(text), state_decision=synthetic + ) + if consumed is not None: + consumed.metadata = { + **(consumed.metadata or {}), + "transaction_state_recovered": True, + } + await self._emit(consumed, state) + return consumed + interruption = await self._transaction_state_interruption_candidate( state, text=str(text), state_decision=synthetic ) @@ -186,6 +204,63 @@ class EnterpriseRouter: return decision + async def _transaction_parameter_precedence( + self, + state: dict[str, Any], + *, + text: str, + state_decision: RouteDecision, + ) -> RouteDecision | None: + """Consume a turn as transaction parameters before evaluating intent shift. + + Only COLLECTING_PARAMETERS participates. The LLM extracts values for the + currently missing parameters; if at least one value is found, the state + route wins deterministically and intent-shift classification is skipped. + """ + tx_status = str(state.get("transaction_status") or "").strip().upper() + if tx_status == "AWAITING_CONFIRMATION": + confirmation = parse_transaction_confirmation(text) + if confirmation is None: + return None + state_decision.metadata = { + **(state_decision.metadata or {}), + "transaction_turn_consumed": True, + "transaction_confirmation_decision": confirmation, + "transaction_confirmation_source": "deterministic", + } + return state_decision + if tx_status != "COLLECTING_PARAMETERS": + return None + missing = [str(name) for name in (state.get("missing_parameters") or []) if str(name).strip()] + if not missing: + return None + active = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} + tool_name = str(active.get("tool_name") or ((state.get("selected_tool_call") or {}).get("tool_name") if isinstance(state.get("selected_tool_call"), dict) else "") or "").strip() + if not tool_name: + return None + known = dict(active.get("arguments") or {}) + schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else {} + description = str(active.get("tool_description") or "") + values = await extract_transaction_parameters( + self.llm, + text=text, + tool_name=tool_name, + missing_parameters=missing, + known_arguments=known, + parameter_schema=schema, + tool_description=description, + ) + if not values: + return None + state_decision.metadata = { + **(state_decision.metadata or {}), + "transaction_turn_consumed": True, + "transaction_parameter_values": values, + "transaction_parameter_source": "llm", + "transaction_parameter_missing_before": missing, + } + return state_decision + async def _transaction_state_interruption_candidate( self, state: dict[str, Any], @@ -213,52 +288,15 @@ class EnterpriseRouter: or (previous_intent and not previous_intent.startswith("state:") and candidate.intent != previous_intent) ) if different: - tx_status = str(state.get("transaction_status") or "").strip().upper() - missing = list(state.get("missing_parameters") or []) - same_agent = candidate.agent == state_decision.agent - matched_keyword = str((candidate.metadata or {}).get("matched_keyword") or "").strip() - informative_tokens = [ - token - for token in self._keyword_tokens(matched_keyword) - if len(token) > 1 - ] - - # Durante coleta de parâmetros, uma keyword genérica de uma única - # palavra do MESMO agente não pode preemptar a transação. Ex.: - # ``o pedido é o PED-1001`` enquanto ``order_id`` está pendente. - # Nesse caso ``pedido`` pode casar com ``retail_order_tracking``, - # mas a mensagem é perfeitamente compatível com a resposta ao - # parâmetro solicitado. Keywords mais específicas (duas ou mais - # palavras informativas) continuam aptas a representar mudança - # explícita de intenção. Se o roteador LLM estiver habilitado, - # deixamos a decisão semântica abaixo desempatar o caso fraco. - weak_same_agent_keyword_during_collection = ( - tx_status == "COLLECTING_PARAMETERS" - and bool(missing) - and same_agent - and len(informative_tokens) <= 1 - ) - - if not weak_same_agent_keyword_during_collection: - candidate.metadata = { - **(candidate.metadata or {}), - "transaction_interruption": "intent_shift", - "interrupted_state": state_decision.next_state, - "interrupted_agent": state_decision.agent, - "interrupted_intent": started_intent or previous_intent, - "interruption_source": "configured_routing", - } - return candidate - - logger.debug( - "Keyword transacional fraca não preemptou coleta de parâmetro: " - "keyword=%r intent=%s missing=%s", - matched_keyword, - candidate.intent, - missing, - ) - # Não retorne aqui: se houver LLM router, ele pode confirmar uma - # mudança semântica real; sem LLM, a transação permanece ativa. + candidate.metadata = { + **(candidate.metadata or {}), + "transaction_interruption": "intent_shift", + "interrupted_state": state_decision.next_state, + "interrupted_agent": state_decision.agent, + "interrupted_intent": started_intent or previous_intent, + "interruption_source": "configured_routing", + } + return candidate else: return None diff --git a/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc b/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc new file mode 100644 index 0000000..38063fd Binary files /dev/null and b/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_input.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py b/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py index 9006bd8..f5aa1ef 100644 --- a/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py +++ b/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py @@ -10,6 +10,7 @@ from typing import Any, Iterable, Mapping from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages +from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation logger = logging.getLogger(__name__) @@ -571,6 +572,7 @@ class AgentRuntimeMixin: state: dict[str, Any], *, overwrite_from_message: bool = False, + exclude_fields: Iterable[str] = (), ) -> dict[str, Any]: """Executa regras ``extract`` declaradas para a tool escolhida. @@ -586,11 +588,14 @@ class AgentRuntimeMixin: return dict(arguments or {}) resolved = dict(arguments or {}) + excluded = {str(name) for name in (exclude_fields or ())} runtime = self.get_runtime_context(state) message = runtime.sanitized_input or runtime.original_text or runtime.user_text llm = getattr(self, "llm", None) for field_name, rule in rules.items(): + if str(field_name) in excluded: + continue from_message = str(rule.get("from") or "message").lower() == "message" if not from_message: continue @@ -1233,46 +1238,59 @@ class AgentRuntimeMixin: @staticmethod def _confirmation_decision(text: str) -> str | None: - normalized = " ".join((text or "").strip().lower().split()) - normalized = re.sub(r"[.!?]+$", "", normalized).strip() - if normalized in {"sim", "confirmo", "sim, confirmo", "pode fazer", "pode prosseguir", "sim, desejo", "sim, desejo trocar", "sim, confirmo a devolução", "sim, confirmo a troca"}: - return "confirm" - if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}: - return "reject" - return None + return parse_transaction_confirmation(text) - @staticmethod - def _extract_action_arguments(text: str) -> dict[str, Any]: - """Extrai apenas entidades explicitamente informadas na mensagem. + def _transaction_parameter_schema(self, tool_name: str, policy: dict[str, Any] | None = None) -> dict[str, Any]: + """Return generic schema metadata for transactional required parameters.""" + cfg = self._tool_config(tool_name) + raw_schema = dict(getattr(cfg, "args_schema", {}) or {}) if cfg is not None else {} + required = [str(name) for name in ((policy or {}).get("requires") or getattr(cfg, "requires", []) or [])] + if not required: + return raw_schema + return {name: raw_schema.get(name, "string") for name in required} - Não usa a mensagem inteira como ``reason``: frases como "quero devolver - uma compra" expressam a ação, mas não necessariamente o motivo. Defaults - declarados no mapper continuam sendo aplicados por ``build_tool_arguments``. + def _transaction_tool_description(self, tool_name: str) -> str: + cfg = self._tool_config(tool_name) + return str(getattr(cfg, "description", "") or "") if cfg is not None else "" + + async def _extract_transaction_parameters( + self, + state: dict[str, Any], + *, + tool_name: str, + missing_parameters: list[str], + known_arguments: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Use the dedicated LLM extractor for pending transaction parameters. + + A route decision may already contain the extraction performed by the + router solely to enforce parameter-before-intent-shift precedence. Reuse + it to avoid a second LLM call in the same turn. """ - raw = text or "" - args: dict[str, Any] = {} - match = re.search( - r"(?:pedido|ordem)\s*(?:n[ºo°.]?\s*)?(?:é\s*(?:o\s*)?|[:#=-]\s*)?([A-Za-z0-9_-]+)", - raw, - flags=re.IGNORECASE, - ) - if match: - args["order_id"] = match.group(1) + route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} + cached = route_meta.get("transaction_parameter_values") + if isinstance(cached, dict): + allowed = set(str(x) for x in missing_parameters) + reused = {str(k): v for k, v in cached.items() if str(k) in allowed and v not in _EMPTY_VALUES} + if reused: + return reused - reason_match = re.search( - r"(?:porque|pois|motivo\s*[:=-]?|por\s+(?:arrependimento|defeito|erro|atraso)|me\s+arrependi(?:\s+da\s+compra)?|arrependimento)\s*(.*)", - raw, - flags=re.IGNORECASE, + active = self._active_transaction(state) or {} + schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else None + if not schema: + policy = self._resolve_tool_execution_policy(tool_name, known_arguments or {}) + schema = self._transaction_parameter_schema(tool_name, policy) + description = str(active.get("tool_description") or self._transaction_tool_description(tool_name) or "") + text = state.get("sanitized_input") or state.get("user_text") or "" + return await extract_transaction_parameters( + getattr(self, "llm", None), + text=str(text), + tool_name=tool_name, + missing_parameters=list(missing_parameters or []), + known_arguments=known_arguments or {}, + parameter_schema=schema, + tool_description=description, ) - if reason_match: - reason = reason_match.group(1).strip(" .,:;-") - if not reason: - matched_phrase = reason_match.group(0).strip(" .,:;-") - if re.search(r"me\s+arrependi|arrependimento", matched_phrase, flags=re.IGNORECASE): - reason = "Arrependimento da compra" - if reason: - args["reason"] = reason - return args def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None: """Detecta solicitação transacional usando metadados de tools.yaml. @@ -1515,12 +1533,19 @@ class AgentRuntimeMixin: ) -> dict[str, Any]: current = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} txid = transaction_id or current.get("transaction_id") or str(uuid.uuid4()) + if str(current.get("tool_name") or "") != str(tool_name): + state["transaction_pre_validation"] = None + cfg = self._tool_config(tool_name) + policy = self._resolve_tool_execution_policy(tool_name, arguments or {}) tx = { "transaction_id": txid, "tool_name": tool_name, "arguments": dict(arguments or {}), "status": status, "started_from_intent": current.get("started_from_intent") or state.get("intent"), + "requires": list(policy.get("requires") or getattr(cfg, "requires", []) or []), + "parameter_schema": self._transaction_parameter_schema(tool_name, policy), + "tool_description": self._transaction_tool_description(tool_name), } state["active_transaction"] = tx return tx @@ -2057,6 +2082,7 @@ class AgentRuntimeMixin: if active_before_interruption and interruption == "intent_shift": interrupted_tool = active_before_interruption.get("tool_name") self._finish_active_transaction(state, "CANCELLED") + state["transaction_pre_validation"] = None state["tool_policy_result"] = { "action": "cancelled_by_intent_shift", "tool_name": interrupted_tool, @@ -2080,28 +2106,40 @@ class AgentRuntimeMixin: tool_name = selected.get("tool_name") if tool_name: previous_args = dict(selected.get("arguments") or {}) - new_args = self.build_tool_arguments( + policy = self._resolve_tool_execution_policy(tool_name, previous_args) + missing_before = self._missing_required_arguments(policy, previous_args) + + # Parâmetros TRANSACIONAIS são interpretados exclusivamente pelo + # extrator LLM genérico. Não existem regexes/nome de entidade + # hardcoded no framework. O extrator recebe apenas os parâmetros + # ainda pendentes da policy e pode consumir um ou vários no turno. + extracted = await self._extract_transaction_parameters( state, tool_name=tool_name, - intent=state.get("intent"), - aliases=aliases, - extra_args=self._extract_action_arguments(text), + missing_parameters=missing_before, + known_arguments=previous_args, ) - # Durante coleta incremental, valores de contexto podem ainda conter - # parâmetros de uma operação anterior. O que já foi coletado para a - # transação pendente prevalece; o turno atual só preenche lacunas. - non_empty_new = {k: v for k, v in new_args.items() if v not in (None, "", [], {})} - arguments = {**non_empty_new, **previous_args} + arguments = {**previous_args, **extracted} - # Campos de envelope pertencem ao turno corrente e devem permanecer - # atualizados, mesmo quando os parâmetros de negócio ficam congelados. - for per_turn_key in ("query", "operator_instructions", "interaction_key"): - if non_empty_new.get(per_turn_key) not in (None, "", [], {}): - arguments[per_turn_key] = non_empty_new[per_turn_key] - - # Reutiliza o contrato declarativo para preencher somente os campos - # ainda faltantes; campos previamente coletados não são sobrescritos. - arguments = await self._extract_mcp_parameters(tool_name, arguments, state) + # Argumentos estruturados já presentes no contexto são aceitos de + # forma genérica (não são parsing textual). Para required fields, + # só completam lacunas que a fala atual/LLM não preencheu; valores + # previamente coletados nunca são sobrescritos. + contextual = self.build_tool_arguments( + state, tool_name=tool_name, intent=state.get("intent"), aliases=aliases + ) + required_set = set(str(name) for name in (policy.get("requires") or [])) + for key, value in contextual.items(): + if value in _EMPTY_VALUES: + continue + if key in required_set: + if arguments.get(key) in _EMPTY_VALUES: + arguments[key] = value + else: + arguments[key] = value + arguments = await self._extract_mcp_parameters( + tool_name, arguments, state, exclude_fields=policy.get("requires") or [] + ) policy = self._resolve_tool_execution_policy(tool_name, arguments) missing = self._missing_required_arguments(policy, arguments) if missing: @@ -2250,23 +2288,46 @@ class AgentRuntimeMixin: if not selected_action: return results - explicit_action_args = self._extract_action_arguments(text) action_args = self.build_tool_arguments( state, tool_name=selected_action, intent=state.get("intent"), aliases=aliases, - extra_args=explicit_action_args, ) - # Nova transação: parâmetros declarados ``from: message`` não podem ser - # herdados de context.tool_arguments de uma operação anterior. + # Campos que o contrato MCP declara como vindos da mensagem corrente não + # podem herdar valores textuais de uma transação anterior. Isto é apenas + # uma regra de freshness do envelope MCP; a extração de policy.requires + # continua exclusivamente no TransactionParameterExtractor LLM abaixo. action_args = self._drop_stale_message_extracted_arguments( - selected_action, action_args, explicit_fields=explicit_action_args.keys() + selected_action, action_args, explicit_fields=() ) - # A mensagem atual é a fonte de verdade para esses campos no primeiro - # turno transacional. + policy = self._resolve_tool_execution_policy(selected_action, action_args) + required = [str(name) for name in (policy.get("requires") or [])] + + # Valores já estruturados no contexto podem satisfazer requirements sem + # parsing textual. Para qualquer required field ainda ausente, a fala do + # usuário é interpretada exclusivamente pelo extrator LLM transacional. + missing_initial = self._missing_required_arguments(policy, action_args) + # No primeiro turno, a fala atual pode fornecer/corrigir qualquer required + # field, inclusive um valor que exista no contexto estruturado mas pertença + # a uma transação anterior. O extrator continua restrito ao contrato + # ``requires`` e só sobrescreve quando a LLM realmente extrai um valor. + extracted_initial = await self._extract_transaction_parameters( + state, + tool_name=selected_action, + missing_parameters=required, + known_arguments={k: v for k, v in action_args.items() if k not in set(required)}, + ) + action_args.update(extracted_initial) + + # O mapper MCP continua responsável somente por parâmetros auxiliares que + # não pertencem ao contrato transacional. action_args = await self._extract_mcp_parameters( - selected_action, action_args, state, overwrite_from_message=True + selected_action, + action_args, + state, + overwrite_from_message=True, + exclude_fields=required, ) policy = self._resolve_tool_execution_policy(selected_action, action_args) selected = {"tool_name": selected_action, "arguments": action_args} diff --git a/libs/agent_framework/src/agent_framework/runtime/transaction_input.py b/libs/agent_framework/src/agent_framework/runtime/transaction_input.py new file mode 100644 index 0000000..64a0c86 --- /dev/null +++ b/libs/agent_framework/src/agent_framework/runtime/transaction_input.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import re +from typing import Any + + +def confirmation_decision(text: str) -> str | None: + """Classifica respostas explícitas ao estado AWAITING_CONFIRMATION. + + Esta função é compartilhada pelo router (precedência antes de intent_shift) + e pelo runtime (execução/cancelamento efetivo), garantindo que ambos + reconheçam exatamente o mesmo conjunto de respostas. + """ + normalized = " ".join((text or "").strip().lower().split()) + normalized = re.sub(r"[.!?]+$", "", normalized).strip() + if normalized in { + "sim", + "confirmo", + "sim, confirmo", + "pode fazer", + "pode prosseguir", + "sim, desejo", + "sim, desejo trocar", + "sim, confirmo a devolução", + "sim, confirmo a troca", + }: + return "confirm" + if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}: + return "reject" + return None + + +def extract_action_arguments(text: str) -> dict[str, Any]: + """Extrai entidades explicitamente informadas em ações transacionais. + + É usada tanto pelo runtime quanto pelo probe de precedência do router. Não + transforma a mensagem inteira em motivo: só captura valores explicitamente + identificáveis no turno atual. + """ + raw = text or "" + args: dict[str, Any] = {} + match = re.search( + r"(?:pedido|ordem)\s*(?:n[ºo°.]?\s*)?(?:é\s*(?:o\s*)?|[:#=-]\s*)?([A-Za-z0-9_-]+)", + raw, + flags=re.IGNORECASE, + ) + if match: + args["order_id"] = match.group(1) + + reason_match = re.search( + r"(?:porque|pois|motivo\s*[:=-]?|por\s+(?:arrependimento|defeito|erro|atraso)|me\s+arrependi(?:\s+da\s+compra)?|arrependimento)\s*(.*)", + raw, + flags=re.IGNORECASE, + ) + if reason_match: + reason = reason_match.group(1).strip(" .,:;-") + if not reason: + matched_phrase = reason_match.group(0).strip(" .,:;-") + if re.search(r"me\s+arrependi|arrependimento", matched_phrase, flags=re.IGNORECASE): + reason = "Arrependimento da compra" + if reason: + args["reason"] = reason + return args diff --git a/libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py b/libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py new file mode 100644 index 0000000..b79bc08 --- /dev/null +++ b/libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Mapping + +logger = logging.getLogger(__name__) + +_EMPTY_VALUES = (None, "", {}, []) + + +def _response_text(response: Any) -> str: + if response is None: + return "" + if isinstance(response, str): + return response + if isinstance(response, dict): + return str(response.get("content") or response.get("text") or response.get("answer") or "") + return str(getattr(response, "content", None) or getattr(response, "text", None) or response) + + +def _coerce(value: Any, declared_type: Any) -> Any: + if value in _EMPTY_VALUES: + return None + type_name = str(declared_type or "string").strip().lower() + try: + if type_name in {"integer", "int"}: + return int(value) + if type_name in {"number", "float", "double"}: + return float(value) + if type_name in {"boolean", "bool"}: + if isinstance(value, bool): + return value + normalized = str(value).strip().lower() + if normalized in {"true", "1", "yes", "sim"}: + return True + if normalized in {"false", "0", "no", "não", "nao"}: + return False + return None + if type_name in {"array", "list"}: + return value if isinstance(value, list) else [value] + if type_name in {"object", "dict", "map"}: + return value if isinstance(value, dict) else None + return str(value).strip() + except (TypeError, ValueError): + return None + + +def parse_transaction_confirmation(text: str) -> str | None: + """Recognize an explicit confirmation/rejection before intent-shift routing. + + This is intentionally small and domain-neutral. Parameter interpretation is + LLM-only; confirmation remains a deterministic control token so an explicit + yes/no cannot be reclassified as a new intent. + """ + normalized = " ".join(str(text or "").strip().lower().split()) + normalized = re.sub(r"[.!?]+$", "", normalized).strip() + if normalized in { + "sim", "confirmo", "sim, confirmo", "pode fazer", "pode prosseguir", + "sim, desejo", "sim, desejo trocar", "sim, confirmo a devolução", + "sim, confirmo a troca", + }: + return "confirm" + if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}: + return "reject" + return None + + +async def extract_transaction_parameters( + llm: Any, + *, + text: str, + tool_name: str, + missing_parameters: list[str], + known_arguments: Mapping[str, Any] | None = None, + parameter_schema: Mapping[str, Any] | None = None, + tool_description: str | None = None, +) -> dict[str, Any]: + """Extract values for pending transactional parameters using the LLM only. + + This component intentionally contains no domain/entity regexes and no + knowledge of parameter names such as ``order_id`` or ``reason``. The + transaction runtime supplies the pending parameter names and optional schema; + the LLM only interprets the current user turn. State/control-flow decisions + remain deterministic outside this function. + """ + pending = [str(name) for name in (missing_parameters or []) if str(name).strip()] + message = str(text or "").strip() + if not pending or not message or llm is None: + return {} + + schema = dict(parameter_schema or {}) + known = { + str(key): value + for key, value in dict(known_arguments or {}).items() + if value not in _EMPTY_VALUES and str(key) not in pending + } + field_spec = { + name: { + "type": schema.get(name, "string") if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("type", "string"), + "description": None if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("description"), + } + for name in pending + } + output_shape = {name: None for name in pending} + prompt = ( + "Você extrai parâmetros PENDENTES de uma transação ativa. " + "Sua única tarefa é interpretar a mensagem atual e devolver valores para os parâmetros pendentes. " + "Não decida roteamento, intenção, confirmação ou execução da transação.\n\n" + "REGRAS OBRIGATÓRIAS:\n" + "1. Extraia SOMENTE parâmetros listados em pending_parameters.\n" + "2. Não invente valores e não transforme uma nova solicitação/intenção do usuário em valor de parâmetro.\n" + "3. Se nenhum parâmetro pendente foi realmente informado, devolva null para todos.\n" + "4. Se houver apenas um parâmetro pendente, uma resposta contendo apenas um valor pode ser associada a ele quando isso for semanticamente inequívoco.\n" + "5. Se houver vários parâmetros pendentes, extraia todos os que estiverem presentes no mesmo turno.\n" + "6. O nome do parâmetro não precisa aparecer literalmente na fala; use a semântica, o nome da transação e o schema para associar valores.\n" + "7. Em caso de dúvida, prefira null.\n" + "8. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n" + f"transaction_tool: {tool_name}\n" + f"transaction_description: {tool_description or ''}\n" + f"pending_parameters: {json.dumps(pending, ensure_ascii=False)}\n" + f"parameter_schema: {json.dumps(field_spec, ensure_ascii=False, default=str)}\n" + f"known_arguments: {json.dumps(known, ensure_ascii=False, default=str)}\n" + f"user_message: {message}\n" + f"Formato obrigatório: {json.dumps(output_shape, ensure_ascii=False)}" + ) + + try: + response = await llm.ainvoke( + [{"role": "user", "content": prompt}], + profile_name="transaction_parameter_extraction", + component_name="transaction_parameter_extraction", + generation_name="llm.transaction_parameter_extraction", + temperature=0.0, + max_tokens=max(120, min(500, 80 + 60 * len(pending))), + ) + except TypeError: + # Compatibilidade com doubles/testes e providers mínimos que aceitam + # apenas messages. + response = await llm.ainvoke([{"role": "user", "content": prompt}]) + except Exception as exc: + logger.warning( + "transaction.parameter.llm_extract_failed tool=%s pending=%s error=%s", + tool_name, + pending, + exc, + ) + return {} + + raw = _response_text(response).strip() + if raw.startswith("```"): + raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE | re.DOTALL).strip() + try: + payload = json.loads(raw) + except (TypeError, ValueError, json.JSONDecodeError): + logger.warning( + "transaction.parameter.llm_invalid_json tool=%s pending=%s raw=%r", + tool_name, + pending, + raw[:240], + ) + return {} + if not isinstance(payload, dict): + return {} + + extracted: dict[str, Any] = {} + for name in pending: + value = payload.get(name) + declared = field_spec.get(name, {}).get("type", "string") + coerced = _coerce(value, declared) + if coerced not in _EMPTY_VALUES: + extracted[name] = coerced + + logger.info( + "transaction.parameter.llm_extracted tool=%s pending=%s consumed=%s", + tool_name, + pending, + sorted(extracted), + ) + return extracted diff --git a/templates/agent_template_backend/llm_profiles.yaml b/templates/agent_template_backend/llm_profiles.yaml index 908b382..29fda41 100644 --- a/templates/agent_template_backend/llm_profiles.yaml +++ b/templates/agent_template_backend/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/templates/agent_template_backend_day_zero/llm_profiles.yaml b/templates/agent_template_backend_day_zero/llm_profiles.yaml index 908b382..29fda41 100644 --- a/templates/agent_template_backend_day_zero/llm_profiles.yaml +++ b/templates/agent_template_backend_day_zero/llm_profiles.yaml @@ -78,3 +78,10 @@ profiles: temperature: 0 max_tokens: 80 timeout_seconds: 5 + + transaction_parameter_extraction: + provider: oci_openai + model: openai.gpt-4.1-mini + temperature: 0 + max_tokens: 500 + timeout_seconds: 8 diff --git a/tests/test_transaction_parameter_llm_precedence.py b/tests/test_transaction_parameter_llm_precedence.py new file mode 100644 index 0000000..1ea69fc --- /dev/null +++ b/tests/test_transaction_parameter_llm_precedence.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from agent_framework.routing.enterprise_router import EnterpriseRouter +from agent_framework.runtime.agent_runtime import AgentRuntimeMixin + + +class _SemanticLLM: + """Test double: parameter extraction + intent-shift classification.""" + + async def ainvoke(self, messages, **kwargs): + prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) + profile = kwargs.get("profile_name") + if profile == "transaction_parameter_extraction" or "pending_parameters:" in prompt: + marker = "user_message: " + user = prompt.split(marker, 1)[1].split("\nFormato obrigatório:", 1)[0].strip() if marker in prompt else "" + pending_raw = prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0] + pending = json.loads(pending_raw) + values = {name: None for name in pending} + low = user.lower() + if "ped-1001" in low and "order_id" in values: + values["order_id"] = "PED-1001" + if "desisti" in low and "reason" in values: + values["reason"] = "desisti da compra" + if low.strip() == "71,99" and "valor" in values: + values["valor"] = 71.99 + if low.strip() == "tim music" and "subject" in values: + values["subject"] = "TIM Music" + return json.dumps(values, ensure_ascii=False) + + # Router LLM fallback: treat fatura as a real intent shift. + if "fatura" in prompt.lower(): + return json.dumps({ + "decision": "SHIFT", + "intent": "billing_invoice_explanation", + "agent": "billing_agent", + "confidence": 0.98, + "reason": "nova intenção de fatura", + }) + return json.dumps({ + "decision": "CONTINUE", + "intent": None, + "agent": None, + "confidence": 0.95, + "reason": "continua transação", + }) + + +class _Router: + def __init__(self): + self.registry = SimpleNamespace( + tools={}, + get_tool=self.get_tool, + ) + + def get_tool(self, name): + data = { + "solicitar_devolucao": SimpleNamespace( + name="solicitar_devolucao", + description="Abre uma solicitação de devolução de pedido.", + selection_keywords=["devolver pedido", "devolução", "devolver"], + args_schema={"order_id": "string", "reason": "string"}, + requires=["order_id", "reason"], + confirmation_required=True, + tool_type="action", + ), + "cancelar_pedido": SimpleNamespace( + name="cancelar_pedido", + description="Cancela um pedido.", + selection_keywords=["cancelar pedido", "cancelar compra"], + args_schema={"order_id": "string"}, + requires=["order_id"], + confirmation_required=True, + tool_type="action", + ), + } + return data.get(name) + + def resolve_execution_policy(self, tool_name, arguments=None): + cfg = self.get_tool(tool_name) + if not cfg: + return {"operation_type": "read_only", "require_confirmation": False, "requires": []} + return { + "operation_type": "transactional", + "require_confirmation": True, + "requires": list(cfg.requires), + "policy_source": "test", + } + + def parameter_extract_rules(self, tool_name): + # Deliberately has MCP mappings for the same fields: transactional fields + # must be excluded from this mechanism by the runtime. + return { + "order_id": {"from": "message", "strategy": "regex", "pattern": r"pedido\\s+(\\w+)"}, + "reason": {"from": "message", "strategy": "regex", "pattern": r"motivo\\s+(.+)"}, + } + + def validate_execution_policy(self, tool_name, arguments=None): + return True, None, self.resolve_execution_policy(tool_name, arguments) + + +class _Runtime(AgentRuntimeMixin): + def __init__(self): + self.tool_router = _Router() + self.llm = _SemanticLLM() + self.calls = [] + + async def _call_mcp_tool(self, tool_name, arguments, state): + self.calls.append((tool_name, dict(arguments))) + return {"ok": True, "tool_name": tool_name, "result": {"status": "OK"}} + + +@pytest.mark.asyncio +async def test_transaction_extractor_handles_multiple_parameters_without_hardcoded_regex(): + runtime = _Runtime() + state = { + "user_text": "quero devolver pedido PED-1001 porque desisti da compra", + "sanitized_input": "quero devolver pedido PED-1001 porque desisti da compra", + "mcp_tools": ["solicitar_devolucao"], + "route": "support_agent", + "intent": "retail_support_exchange_return", + } + result = await runtime.execute_tools_for_intent(state) + assert result[-1]["awaiting_confirmation"] is True + assert state["transaction_status"] == "AWAITING_CONFIRMATION" + args = state["pending_tool_call"]["arguments"] + assert args["order_id"] == "PED-1001" + assert args["reason"] == "desisti da compra" + + +@pytest.mark.asyncio +async def test_collecting_one_parameter_consumes_turn_before_intent_shift(tmp_path): + 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_order_tracking + agent: orders_agent + priority: 20 + keywords: [pedido] + - name: retail_support_exchange_return + agent: support_agent + priority: 30 + keywords: [devolver pedido] + - name: billing_invoice_explanation + agent: billing_agent + priority: 40 + keywords: [fatura] +""", + encoding="utf-8", + ) + settings = SimpleNamespace( + ROUTING_CONFIG_PATH=str(routing), + ENABLE_LLM_ROUTER=True, + ENABLE_ROUTE_STICKINESS=False, + ) + router = EnterpriseRouter(settings, llm=_SemanticLLM()) + state = { + "user_text": "o numero do pedido é PED-1001", + "sanitized_input": "o numero do pedido é PED-1001", + "next_state": "COLLECTING_SUPPORT_PARAMETERS", + "transaction_status": "COLLECTING_PARAMETERS", + "missing_parameters": ["order_id", "reason"], + "active_agent": "support_agent", + "intent": "state:COLLECTING_SUPPORT_PARAMETERS", + "active_transaction": { + "tool_name": "solicitar_devolucao", + "arguments": {}, + "status": "COLLECTING_PARAMETERS", + "started_from_intent": "retail_support_exchange_return", + "parameter_schema": {"order_id": "string", "reason": "string"}, + "tool_description": "Abre uma solicitação de devolução de pedido.", + }, + } + decision = await router.route(state) + assert decision.agent == "support_agent" + assert decision.intent == "state:COLLECTING_SUPPORT_PARAMETERS" + assert decision.metadata["transaction_turn_consumed"] is True + assert decision.metadata["transaction_parameter_values"] == {"order_id": "PED-1001"} + assert "transaction_interruption" not in decision.metadata + + +@pytest.mark.asyncio +async def test_no_parameter_found_allows_intent_shift(tmp_path): + 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: billing_invoice_explanation + agent: billing_agent + priority: 40 + keywords: [fatura] +""", + encoding="utf-8", + ) + settings = SimpleNamespace( + ROUTING_CONFIG_PATH=str(routing), + ENABLE_LLM_ROUTER=True, + ENABLE_ROUTE_STICKINESS=False, + ) + router = EnterpriseRouter(settings, llm=_SemanticLLM()) + state = { + "user_text": "esquece isso, quero ver minha fatura", + "sanitized_input": "esquece isso, quero ver minha fatura", + "next_state": "COLLECTING_SUPPORT_PARAMETERS", + "transaction_status": "COLLECTING_PARAMETERS", + "missing_parameters": ["order_id", "reason"], + "active_agent": "support_agent", + "intent": "state:COLLECTING_SUPPORT_PARAMETERS", + "active_transaction": { + "tool_name": "solicitar_devolucao", + "arguments": {}, + "status": "COLLECTING_PARAMETERS", + "started_from_intent": "retail_support_exchange_return", + "parameter_schema": {"order_id": "string", "reason": "string"}, + }, + } + decision = await router.route(state) + assert decision.intent == "billing_invoice_explanation" + assert decision.agent == "billing_agent" + assert decision.metadata["transaction_interruption"] == "intent_shift" + + +def test_hardcoded_action_argument_extractor_removed(): + from pathlib import Path + source = Path("libs/agent_framework/src/agent_framework/runtime/agent_runtime.py").read_text(encoding="utf-8") + assert "def _extract_action_arguments" not in source + assert "pedido|ordem" not in source + assert "reason_match" not in source + +@pytest.mark.asyncio +async def test_confirmation_is_consumed_before_intent_shift(tmp_path): + routing = tmp_path / "routing.yaml" + routing.write_text( + """ +router: + fallback_agent: support_agent + confidence_threshold: 0.70 +state_policies: + - state: WAITING_SUPPORT_CONFIRMATION + agent: support_agent +intents: + - name: generic_yes_intent + agent: other_agent + priority: 50 + keywords: [sim] +""", + encoding="utf-8", + ) + settings = SimpleNamespace( + ROUTING_CONFIG_PATH=str(routing), + ENABLE_LLM_ROUTER=True, + ENABLE_ROUTE_STICKINESS=False, + ) + router = EnterpriseRouter(settings, llm=_SemanticLLM()) + state = { + "user_text": "sim", + "sanitized_input": "sim", + "next_state": "WAITING_SUPPORT_CONFIRMATION", + "transaction_status": "AWAITING_CONFIRMATION", + "active_agent": "support_agent", + "active_transaction": { + "tool_name": "solicitar_devolucao", + "arguments": {"order_id": "PED-1001", "reason": "desisti"}, + "status": "AWAITING_CONFIRMATION", + "started_from_intent": "retail_support_exchange_return", + }, + } + decision = await router.route(state) + assert decision.agent == "support_agent" + assert decision.metadata["transaction_turn_consumed"] is True + assert decision.metadata["transaction_confirmation_decision"] == "confirm" + assert "transaction_interruption" not in decision.metadata diff --git a/tests/test_transaction_state_regression_matrix.py b/tests/test_transaction_state_regression_matrix.py index 6861030..2e89dde 100644 --- a/tests/test_transaction_state_regression_matrix.py +++ b/tests/test_transaction_state_regression_matrix.py @@ -34,6 +34,20 @@ intents: """ +class _ParameterLLM: + async def ainvoke(self, messages, **kwargs): + import json + prompt = messages[-1]["content"] + if kwargs.get("profile_name") == "transaction_parameter_extraction": + pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0]) + user = prompt.split("user_message: ", 1)[1].split("\nFormato obrigatório:", 1)[0].strip() + out = {name: None for name in pending} + if len(pending) == 1 and user not in {"quero rastrear pedido", "quero ver minha fatura"}: + out[pending[0]] = user + return json.dumps(out, ensure_ascii=False) + return '{}' + + def _router(tmp_path, *, stickiness=True): routing = tmp_path / "routing.yaml" routing.write_text(ROUTING_YAML, encoding="utf-8") @@ -42,7 +56,7 @@ def _router(tmp_path, *, stickiness=True): ENABLE_LLM_ROUTER=False, ENABLE_ROUTE_STICKINESS=stickiness, ) - return EnterpriseRouter(settings) + return EnterpriseRouter(settings, llm=_ParameterLLM()) def _active_tx(status="COLLECTING_PARAMETERS", arguments=None): diff --git a/tests/test_transactional_tool_flow.py b/tests/test_transactional_tool_flow.py index 049ff7b..ec6d57f 100644 --- a/tests/test_transactional_tool_flow.py +++ b/tests/test_transactional_tool_flow.py @@ -30,20 +30,45 @@ import pytest from agent_framework.runtime.agent_runtime import AgentRuntimeMixin +class _TransactionTestLLM: + async def ainvoke(self, messages, **kwargs): + import json + prompt = messages[-1]["content"] + if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: + pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0]) + user = prompt.split("user_message: ", 1)[1].split("\nFormato obrigatório:", 1)[0].strip() + out = {name: None for name in pending} + low = user.lower() + if "order_id" in out: + import re + m = re.search(r"\b(?:ped[- ]?)?(\d+)\b", low, re.I) + if m: + out["order_id"] = ("PED-" + m.group(1)) if "ped" in m.group(0).lower() else m.group(1) + if "reason" in out and ("arrepend" in low or "desisti" in low): + out["reason"] = "Arrependimento da compra" if "arrepend" in low else "desisti da compra" + return {"content": json.dumps(out, ensure_ascii=False)} + return {"content": "{}"} + + class _PolicyRouter: def __init__(self): from types import SimpleNamespace self.registry = SimpleNamespace( tools={"consultar_pedido": object(), "solicitar_devolucao": object()}, get_tool=lambda name: { - "consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"]), - "solicitar_devolucao": SimpleNamespace(selection_keywords=["devolver pedido", "devolver", "devolução", "arrependimento"]), + "consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"], args_schema={}, requires=[]), + "solicitar_devolucao": SimpleNamespace( + selection_keywords=["devolver pedido", "devolver", "devolução", "arrependimento"], + args_schema={"order_id": "string", "reason": "string"}, + requires=["order_id", "reason"], + description="Solicita devolução de pedido", + ), }.get(name), ) def resolve_execution_policy(self, tool_name, arguments=None): if tool_name == "solicitar_devolucao": - return {"operation_type": "transactional", "require_confirmation": True, "policy_source": "test"} + return {"operation_type": "transactional", "require_confirmation": True, "requires": ["order_id", "reason"], "policy_source": "test"} return {"operation_type": "read_only", "require_confirmation": False, "policy_source": "test"} def validate_execution_policy(self, tool_name, arguments=None): @@ -56,6 +81,7 @@ class _PolicyRouter: class _Runtime(AgentRuntimeMixin): def __init__(self): self.tool_router = _PolicyRouter() + self.llm = _TransactionTestLLM() self.calls = [] async def _call_mcp_tool(self, tool_name, arguments, state): @@ -163,14 +189,20 @@ async def test_collecting_parameters_does_not_replace_collected_subject_with_sta class _InitialContestLLM: async def ainvoke(self, messages, **kwargs): - prompt = messages[0]["content"] - if "Campo: subject" in prompt: - return {"content": '{"subject": "TIM CTRL Redes Sociais 8.0"}'} - if "Campo: valor" in prompt: - return {"content": '{"valor": null}'} - if "Campo: motivo" in prompt: - return {"content": '{"motivo": "não contratei"}'} - return {"content": '{}'} + import json + prompt = messages[-1]["content"] + if kwargs.get("profile_name") == "transaction_parameter_extraction": + pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0]) + out = {name: None for name in pending} + if "subject" in out: + out["subject"] = "TIM CTRL Redes Sociais 8.0" + return {"content": json.dumps(out, ensure_ascii=False)} + if kwargs.get("profile_name") == "mcp_parameter_extraction": + if "Campo: motivo" in prompt: + return {"content": '{"motivo": "não contratei"}'} + if "Campo: valor" in prompt: + return {"content": '{"valor": null}'} + return {"content": "{}"} class _InitialContestRouter(_ContestPolicyRouter):