diff --git a/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc index b805d8d4..8dbf9215 100644 Binary files a/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc and b/libs/agent_framework/build/lib/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc differ diff --git a/libs/agent_framework/build/lib/agent_framework/routing/enterprise_router.py b/libs/agent_framework/build/lib/agent_framework/routing/enterprise_router.py index ef8a569e..836282a5 100644 --- a/libs/agent_framework/build/lib/agent_framework/routing/enterprise_router.py +++ b/libs/agent_framework/build/lib/agent_framework/routing/enterprise_router.py @@ -630,32 +630,144 @@ class EnterpriseRouter: if state_decision: tx_status = str(state.get("transaction_status") or "").strip().upper() - # Confirmation is the only transaction input with absolute precedence: - # an explicit yes/no answers the confirmation contract itself. - if tx_status == "AWAITING_CONFIRMATION": - 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 + active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} + legacy_tx = state.get("pending_tool_call") or state.get("selected_tool_call") or {} + state_lock_name = str( + state_decision.next_state + or current_state + or state_decision.intent.removeprefix("state:") + or "" + ).strip().upper() + transactional_state_lock = state_lock_name.startswith( + ("WAITING_", "COLLECTING_", "AWAITING_") + ) - # Transaction parameter precedence is absolute while collecting: - # first let the active transaction try to consume the current turn. - # Only when NO pending parameter can be extracted do we ask the - # semantic classifier whether the user changed goals. This prevents - # value/name/reference answers (for example "a de 14,99") from being - # stolen by a semantically plausible but incompatible intent. - if tx_status == "COLLECTING_PARAMETERS": + # Rich transactional latches remain the primary source of truth. + # The domain state lock is only a recovery fallback for checkpoints + # where those latches were not restored. This prevents a generic + # COLLECTING_* state from stealing a legitimate parameter answer. + rich_transaction_context = bool( + active_tx.get("tool_name") + or (isinstance(legacy_tx, dict) and legacy_tx.get("tool_name")) + or tx_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"} + ) + fallback_state_lock_context = bool( + transactional_state_lock and not rich_transaction_context + ) + has_transaction_context = bool( + rich_transaction_context or fallback_state_lock_context + ) + + # Some hosts restore the domain state but omit transaction_status. + # Infer only the generic phase needed by the precedence logic; do not + # replace richer transaction data when it exists. + effective_tx_status = tx_status + if not effective_tx_status: + if state_lock_name.startswith("COLLECTING_"): + effective_tx_status = "COLLECTING_PARAMETERS" + elif state_lock_name.startswith("AWAITING_") or "CONFIRMATION" in state_lock_name: + effective_tx_status = "AWAITING_CONFIRMATION" + + precedence_state = state + if effective_tx_status and effective_tx_status != tx_status: + precedence_state = dict(state) + precedence_state["transaction_status"] = effective_tx_status + + # Transactional interruption precedence is semantic, not lexical. + # + # COLLECTING_PARAMETERS is special: the pending parameter gets the + # first opportunity to consume the turn. If the input is pertinent + # to the parameter, extraction returns values and the transaction + # continues. Only when the input is not pertinent do we ask the + # semantic interruption classifier whether the user abandoned the + # active transaction. This preserves inputs such as + # ``motivo é que desisti`` as a legitimate ``reason`` value while + # still allowing ``não quero mais devolver; quero ver meus serviços`` + # to abandon the current operation and start a new objective. + if effective_tx_status == "COLLECTING_PARAMETERS": + if rich_transaction_context: + relevance = await self._classify_transaction_parameter_relevance( + precedence_state, text=str(text) + ) + if relevance is True: + consumed = await self._transaction_parameter_precedence( + precedence_state, text=str(text), state_decision=state_decision + ) + if consumed is not None: + consumed.metadata = { + **(consumed.metadata or {}), + "transaction_parameter_relevant": True, + } + await self._emit(consumed, state) + return consumed + + # Pertinent to the requested parameter, but extraction did + # not produce a structured value. Keep transaction + # ownership and let the agent reprompt/clarify; do not + # reinterpret the same input as ABANDON or SHIFT. + state_decision.metadata = { + **(state_decision.metadata or {}), + "transaction_parameter_relevant": True, + "transaction_parameter_extraction_empty": True, + } + await self._emit(state_decision, state) + return state_decision + + if relevance is None: + # Compatibility/fail-safe when the relevance classifier is + # unavailable: retain the previous extractor-first behavior. + consumed = await self._transaction_parameter_precedence( + precedence_state, text=str(text), state_decision=state_decision + ) + if consumed is not None: + consumed.metadata = { + **(consumed.metadata or {}), + "transaction_parameter_relevance": "unknown", + } + await self._emit(consumed, state) + return consumed + + if has_transaction_context: + abandonment = await self._transaction_state_interruption_candidate( + precedence_state, + text=str(text), + state_decision=state_decision, + abandon_only=True, + ) + if abandonment is not None: + abandonment.metadata = { + **(abandonment.metadata or {}), + "transaction_parameter_relevant": False if rich_transaction_context else None, + } + await self._emit(abandonment, state) + return abandonment + + # In confirmation/waiting phases there is no pending free-form + # parameter whose value can be confused with abandonment. Evaluate + # ABANDON semantically before confirmation/state-lock consumption. + # Plain confirmations (e.g. yes/no) are classified as CONTINUE by + # this probe and then follow the normal confirmation path below. + elif has_transaction_context: + abandonment = await self._transaction_state_interruption_candidate( + precedence_state, + text=str(text), + state_decision=state_decision, + abandon_only=True, + ) + if abandonment is not None: + await self._emit(abandonment, state) + return abandonment + + if effective_tx_status == "AWAITING_CONFIRMATION": consumed = await self._transaction_parameter_precedence( - state, text=str(text), state_decision=state_decision + 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 + precedence_state, text=str(text), state_decision=state_decision ) if interruption is not None: await self._emit(interruption, state) @@ -686,19 +798,61 @@ class EnterpriseRouter: method="state", next_state=tx_status, ) - if tx_status == "AWAITING_CONFIRMATION": - 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 - if tx_status == "COLLECTING_PARAMETERS": + relevance = await self._classify_transaction_parameter_relevance( + state, text=str(text) + ) + if relevance is True: + 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, + "transaction_parameter_relevant": True, + } + await self._emit(consumed, state) + return consumed + synthetic.metadata = { + **(synthetic.metadata or {}), + "transaction_state_recovered": True, + "transaction_parameter_relevant": True, + "transaction_parameter_extraction_empty": True, + } + await self._emit(synthetic, state) + return synthetic + + if relevance is None: + 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, + "transaction_parameter_relevance": "unknown", + } + await self._emit(consumed, state) + return consumed + + # No parameter was consumed (or this is confirmation): now decide + # semantically whether the user abandoned the active transaction. + abandonment = await self._transaction_state_interruption_candidate( + state, + text=str(text), + state_decision=synthetic, + abandon_only=True, + ) + if abandonment is not None: + abandonment.metadata = { + **(abandonment.metadata or {}), + "transaction_state_recovered": True, + } + await self._emit(abandonment, state) + return abandonment + + if tx_status == "AWAITING_CONFIRMATION": consumed = await self._transaction_parameter_precedence( state, text=str(text), state_decision=synthetic ) @@ -837,6 +991,82 @@ class EnterpriseRouter: return decision + async def _classify_transaction_parameter_relevance( + self, state: dict[str, Any], *, text: str + ) -> bool | None: + """Semantically decide whether the turn answers a pending parameter. + + This is intentionally separate from extraction. A parameter extractor + may be able to manufacture a syntactically valid value from unrelated + text; relevance answers the higher-level question first: does the user + appear to be responding to what the active transaction asked for? + + Returns True/False when classification succeeds and None when the + classifier is unavailable or its output cannot be trusted. + """ + if not (self.enable_llm_router and self.llm is not None): + 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 "").strip() + if not tool_name: + return None + payload = { + "message": str(text or ""), + "transaction": { + "intent": active.get("started_from_intent") or state.get("intent"), + "tool_name": tool_name, + "tool_description": active.get("tool_description"), + "missing_parameters": missing, + "parameter_schema": active.get("parameter_schema") or {}, + "known_arguments": active.get("arguments") or {}, + "relevant_conversation_context": ( + active.get("parameter_conversational_context") + or self._collect_transaction_parameter_context(state=state, current_text=str(text)) + ), + }, + } + system = ( + "Você decide somente se a mensagem do usuário é uma resposta pertinente ao(s) " + "parâmetro(s) que a transação ativa está pedindo. Avalie significado e contexto, " + "não palavras isoladas. Considere RELEVANT quando a mensagem fornece, esclarece, " + "corrige ou referencia plausivelmente o dado solicitado, mesmo em linguagem livre. " + "Considere NOT_RELEVANT quando a mensagem abandona a ação, inicia outro objetivo, " + "faz uma pergunta diferente ou não responde ao dado pedido. Não extraia valores e " + "não decida abandono/intent; classifique apenas pertinência. Retorne somente JSON " + "válido: {\"relevance\":\"RELEVANT|NOT_RELEVANT\",\"confidence\":0.0,\"reason\":\"...\"}." + ) + try: + answer = await self.llm.ainvoke( + [ + {"role": "system", "content": system}, + {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}, + ], + temperature=0.0, + max_tokens=256, + profile_name="router", + component_name="router.transaction_parameter_relevance", + generation_name="llm.transaction_parameter_relevance", + ) + data = self._parse_json(answer) + except Exception as exc: + logger.warning("Falha ao classificar pertinência do parâmetro transacional: %s", exc) + return None + relevance = str(data.get("relevance") or "").strip().upper() + try: + confidence = float(data.get("confidence") or 0.0) + except (TypeError, ValueError): + return None + if confidence < self.intent_shift_threshold: + return None + if relevance == "RELEVANT": + return True + if relevance == "NOT_RELEVANT": + return False + return None + async def _transaction_parameter_precedence( self, state: dict[str, Any], @@ -846,11 +1076,12 @@ class EnterpriseRouter: ) -> RouteDecision | None: """Try to consume the turn under the active transaction contract first. - AWAITING_CONFIRMATION consumes an explicit confirmation before any shift - classification. COLLECTING_PARAMETERS also has precedence: if at least one - pending parameter can be extracted, the active transaction keeps ownership - of the turn. Semantic intent-shift is evaluated only when extraction returns - no usable pending parameter. + In AWAITING_CONFIRMATION, an explicit ABANDON candidate is classified + before confirmation consumption; plain yes/no responses still keep + deterministic confirmation precedence because they do not pass the + abandonment gate. In COLLECTING_PARAMETERS, ABANDON is likewise checked + before extraction, while SHIFT remains after parameter extraction so valid + referential/value answers are not stolen from the active transaction. """ tx_status = str(state.get("transaction_status") or "").strip().upper() if tx_status == "AWAITING_CONFIRMATION": @@ -966,13 +1197,14 @@ class EnterpriseRouter: *, text: str, state_decision: RouteDecision, + abandon_only: bool = False, ) -> RouteDecision | None: """Detecta semanticamente mudança de intenção durante uma transação. - Não existe lista de palavras para desistência ou mudança de assunto. Uma - interrupção nasce de uma intent diferente resolvida por uma keyword - configurada no ``routing.yaml`` ou, na ausência dela, por uma decisão - semântica do LLM com o contexto da transação pendente. + Não existe lista de palavras para desistência. ABANDON é sempre uma + decisão semântica do LLM baseada no contexto transacional. Uma possível + nova intenção pode aparecer como hint do ``routing.yaml``, mas esse hint + não decide abandono e não substitui a classificação semântica. """ active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} started_intent = str(active_tx.get("started_from_intent") or "").strip() @@ -1038,18 +1270,32 @@ class EnterpriseRouter: else None ), } - system = ( - "Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " - "Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " - "A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " - "Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " - "Se o usuário passou claramente a perseguir outro objetivo sem desistir explicitamente da ação atual, " - "retorne SHIFT e a nova intent permitida. " - "Se o usuário desistiu explicitamente da ação/transação atual, retorne ABANDON. " - "Quando ABANDON vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. " - "Quando for apenas abandono sem novo objetivo, intent e agent podem ser nulos. " - "Retorne somente JSON válido com decision, intent, agent, confidence, reason." - ) + if abandon_only: + system = ( + "Você decide somente se o usuário desistiu explicitamente da ação/transação ativa. " + "Quando a transação estava coletando parâmetros, a pertinência ao parâmetro já foi avaliada antes desta etapa; " + "portanto não transforme uma resposta pertinente ao parâmetro em abandono. " + "Use o significado completo da mensagem e o contexto transacional; não use palavras isoladas como regra. " + "Se houver desistência explícita da ação atual, retorne ABANDON. " + "Quando o abandono vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. " + "Quando for apenas abandono sem novo objetivo, intent e agent podem ser nulos. " + "Se a mensagem apenas fornece parâmetro, confirma contexto, ou somente muda/adiciona outro objetivo sem " + "desistir explicitamente da ação atual, retorne CONTINUE. " + "Retorne somente JSON válido com decision, intent, agent, confidence, reason." + ) + else: + system = ( + "Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " + "Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " + "A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " + "Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " + "Se o usuário passou claramente a perseguir outro objetivo sem desistir explicitamente da ação atual, " + "retorne SHIFT e a nova intent permitida. " + "Se o usuário desistiu explicitamente da ação/transação atual, retorne ABANDON. " + "Quando ABANDON vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. " + "Quando for apenas abandono sem novo objetivo, intent e agent podem ser nulos. " + "Retorne somente JSON válido com decision, intent, agent, confidence, reason." + ) user = { "message": text, "transaction": transaction_context, @@ -1074,7 +1320,10 @@ class EnterpriseRouter: return None decision_kind = str(data.get("decision") or "").strip().upper() - if decision_kind not in {"SHIFT", "ABANDON"}: + if abandon_only: + if decision_kind != "ABANDON": + return None + elif decision_kind not in {"SHIFT", "ABANDON"}: return None confidence = float(data.get("confidence") or 0.0) if confidence < self.intent_shift_threshold: diff --git a/libs/agent_framework/src/agent_framework/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/__pycache__/__init__.cpython-313.pyc index 9e6ab7b1..4a44168d 100644 Binary files a/libs/agent_framework/src/agent_framework/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/__pycache__/idempotency.cpython-313.pyc b/libs/agent_framework/src/agent_framework/__pycache__/idempotency.cpython-313.pyc index 419d753a..019dc27a 100644 Binary files a/libs/agent_framework/src/agent_framework/__pycache__/idempotency.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/__pycache__/idempotency.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/cache/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/cache/__pycache__/__init__.cpython-313.pyc index 570985a0..0bb6f40c 100644 Binary files a/libs/agent_framework/src/agent_framework/cache/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/cache/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/cache/__pycache__/cache.cpython-313.pyc b/libs/agent_framework/src/agent_framework/cache/__pycache__/cache.cpython-313.pyc index d1242ed3..4b39fce9 100644 Binary files a/libs/agent_framework/src/agent_framework/cache/__pycache__/cache.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/cache/__pycache__/cache.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/config/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/config/__pycache__/__init__.cpython-313.pyc index a251e689..07780aa3 100644 Binary files a/libs/agent_framework/src/agent_framework/config/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/config/__pycache__/settings.cpython-313.pyc b/libs/agent_framework/src/agent_framework/config/__pycache__/settings.cpython-313.pyc index 4175dd4c..774134a7 100644 Binary files a/libs/agent_framework/src/agent_framework/config/__pycache__/settings.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/config/__pycache__/settings.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc index fb011b5f..0087fb9a 100644 Binary files a/libs/agent_framework/src/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/gateways/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc b/libs/agent_framework/src/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc index 6c583696..1a6e4c93 100644 Binary files a/libs/agent_framework/src/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/gateways/__pycache__/mcp_gateway_client.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/identity/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/identity/__pycache__/__init__.cpython-313.pyc index 79439883..cea3ca9c 100644 Binary files a/libs/agent_framework/src/agent_framework/identity/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/identity/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc b/libs/agent_framework/src/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc index 1fffb81f..7ed3b800 100644 Binary files a/libs/agent_framework/src/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/identity/__pycache__/mcp_mapper.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/identity/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/identity/__pycache__/models.cpython-313.pyc index b015c14b..0f857e28 100644 Binary files a/libs/agent_framework/src/agent_framework/identity/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/identity/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/identity/__pycache__/resolver.cpython-313.pyc b/libs/agent_framework/src/agent_framework/identity/__pycache__/resolver.cpython-313.pyc index 4e40edf3..385eeb5c 100644 Binary files a/libs/agent_framework/src/agent_framework/identity/__pycache__/resolver.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/identity/__pycache__/resolver.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/__init__.cpython-313.pyc index 914bae72..d86c644c 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/base.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/base.cpython-313.pyc index 05e9824a..3f8b7f64 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/base.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/base.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/structured_output.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/structured_output.cpython-313.pyc index 83898b8e..d598db12 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/structured_output.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/structured_output.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/llm/__pycache__/types.cpython-313.pyc b/libs/agent_framework/src/agent_framework/llm/__pycache__/types.cpython-313.pyc index 94ead776..e00606f1 100644 Binary files a/libs/agent_framework/src/agent_framework/llm/__pycache__/types.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/llm/__pycache__/types.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc index 21ce5a3a..99e6e261 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/client.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/client.cpython-313.pyc index 07e23de3..d54d85d9 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/client.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/client.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/models.cpython-313.pyc index a8a2df48..a7dd605f 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/registry.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/registry.cpython-313.pyc index 9913bbf6..4932ae5d 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/registry.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/registry.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc index 3a3a8c72..a7955344 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_policy.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc b/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc index ecc2d0cf..445d2bfe 100644 Binary files a/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/mcp/__pycache__/tool_router.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/__init__.cpython-313.pyc index f8e01ee5..23d72b7f 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/message_history.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/message_history.cpython-313.pyc index ade5aed4..a71ccf2d 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/message_history.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/message_history.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc index 3e8188b1..94a7de6f 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_memory.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc index 5f3ce0d4..caee3df3 100644 Binary files a/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/memory/__pycache__/summary_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/models/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/models/__pycache__/__init__.cpython-313.pyc index 3fa4f458..87f406dd 100644 Binary files a/libs/agent_framework/src/agent_framework/models/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/models/__pycache__/session.cpython-313.pyc b/libs/agent_framework/src/agent_framework/models/__pycache__/session.cpython-313.pyc index 142868d5..3e0b7ae4 100644 Binary files a/libs/agent_framework/src/agent_framework/models/__pycache__/session.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/models/__pycache__/session.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc index 733a8d0f..696b94fa 100644 Binary files a/libs/agent_framework/src/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/persistence/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc b/libs/agent_framework/src/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc index cb0ebebe..6a3c582d 100644 Binary files a/libs/agent_framework/src/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/persistence/__pycache__/sqlite_store.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/__init__.cpython-313.pyc index 3c9f6b05..7ba8b55e 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc index fcbbfdfb..7eac0414 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/config_loader.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/continuity.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/continuity.cpython-313.pyc index 3bb32a12..0e187ef9 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/continuity.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/continuity.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc index 53b18084..a0950935 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/enterprise_router.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/models.cpython-313.pyc index 6b7c1641..28bb7697 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/multi_intent.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/multi_intent.cpython-313.pyc index 57aed7e1..fea4a01f 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/multi_intent.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/multi_intent.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/routing/__pycache__/pending_topics.cpython-313.pyc b/libs/agent_framework/src/agent_framework/routing/__pycache__/pending_topics.cpython-313.pyc index 34d35240..4b78cac2 100644 Binary files a/libs/agent_framework/src/agent_framework/routing/__pycache__/pending_topics.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/routing/__pycache__/pending_topics.cpython-313.pyc differ 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 ef8a569e..836282a5 100644 --- a/libs/agent_framework/src/agent_framework/routing/enterprise_router.py +++ b/libs/agent_framework/src/agent_framework/routing/enterprise_router.py @@ -630,32 +630,144 @@ class EnterpriseRouter: if state_decision: tx_status = str(state.get("transaction_status") or "").strip().upper() - # Confirmation is the only transaction input with absolute precedence: - # an explicit yes/no answers the confirmation contract itself. - if tx_status == "AWAITING_CONFIRMATION": - 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 + active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} + legacy_tx = state.get("pending_tool_call") or state.get("selected_tool_call") or {} + state_lock_name = str( + state_decision.next_state + or current_state + or state_decision.intent.removeprefix("state:") + or "" + ).strip().upper() + transactional_state_lock = state_lock_name.startswith( + ("WAITING_", "COLLECTING_", "AWAITING_") + ) - # Transaction parameter precedence is absolute while collecting: - # first let the active transaction try to consume the current turn. - # Only when NO pending parameter can be extracted do we ask the - # semantic classifier whether the user changed goals. This prevents - # value/name/reference answers (for example "a de 14,99") from being - # stolen by a semantically plausible but incompatible intent. - if tx_status == "COLLECTING_PARAMETERS": + # Rich transactional latches remain the primary source of truth. + # The domain state lock is only a recovery fallback for checkpoints + # where those latches were not restored. This prevents a generic + # COLLECTING_* state from stealing a legitimate parameter answer. + rich_transaction_context = bool( + active_tx.get("tool_name") + or (isinstance(legacy_tx, dict) and legacy_tx.get("tool_name")) + or tx_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"} + ) + fallback_state_lock_context = bool( + transactional_state_lock and not rich_transaction_context + ) + has_transaction_context = bool( + rich_transaction_context or fallback_state_lock_context + ) + + # Some hosts restore the domain state but omit transaction_status. + # Infer only the generic phase needed by the precedence logic; do not + # replace richer transaction data when it exists. + effective_tx_status = tx_status + if not effective_tx_status: + if state_lock_name.startswith("COLLECTING_"): + effective_tx_status = "COLLECTING_PARAMETERS" + elif state_lock_name.startswith("AWAITING_") or "CONFIRMATION" in state_lock_name: + effective_tx_status = "AWAITING_CONFIRMATION" + + precedence_state = state + if effective_tx_status and effective_tx_status != tx_status: + precedence_state = dict(state) + precedence_state["transaction_status"] = effective_tx_status + + # Transactional interruption precedence is semantic, not lexical. + # + # COLLECTING_PARAMETERS is special: the pending parameter gets the + # first opportunity to consume the turn. If the input is pertinent + # to the parameter, extraction returns values and the transaction + # continues. Only when the input is not pertinent do we ask the + # semantic interruption classifier whether the user abandoned the + # active transaction. This preserves inputs such as + # ``motivo é que desisti`` as a legitimate ``reason`` value while + # still allowing ``não quero mais devolver; quero ver meus serviços`` + # to abandon the current operation and start a new objective. + if effective_tx_status == "COLLECTING_PARAMETERS": + if rich_transaction_context: + relevance = await self._classify_transaction_parameter_relevance( + precedence_state, text=str(text) + ) + if relevance is True: + consumed = await self._transaction_parameter_precedence( + precedence_state, text=str(text), state_decision=state_decision + ) + if consumed is not None: + consumed.metadata = { + **(consumed.metadata or {}), + "transaction_parameter_relevant": True, + } + await self._emit(consumed, state) + return consumed + + # Pertinent to the requested parameter, but extraction did + # not produce a structured value. Keep transaction + # ownership and let the agent reprompt/clarify; do not + # reinterpret the same input as ABANDON or SHIFT. + state_decision.metadata = { + **(state_decision.metadata or {}), + "transaction_parameter_relevant": True, + "transaction_parameter_extraction_empty": True, + } + await self._emit(state_decision, state) + return state_decision + + if relevance is None: + # Compatibility/fail-safe when the relevance classifier is + # unavailable: retain the previous extractor-first behavior. + consumed = await self._transaction_parameter_precedence( + precedence_state, text=str(text), state_decision=state_decision + ) + if consumed is not None: + consumed.metadata = { + **(consumed.metadata or {}), + "transaction_parameter_relevance": "unknown", + } + await self._emit(consumed, state) + return consumed + + if has_transaction_context: + abandonment = await self._transaction_state_interruption_candidate( + precedence_state, + text=str(text), + state_decision=state_decision, + abandon_only=True, + ) + if abandonment is not None: + abandonment.metadata = { + **(abandonment.metadata or {}), + "transaction_parameter_relevant": False if rich_transaction_context else None, + } + await self._emit(abandonment, state) + return abandonment + + # In confirmation/waiting phases there is no pending free-form + # parameter whose value can be confused with abandonment. Evaluate + # ABANDON semantically before confirmation/state-lock consumption. + # Plain confirmations (e.g. yes/no) are classified as CONTINUE by + # this probe and then follow the normal confirmation path below. + elif has_transaction_context: + abandonment = await self._transaction_state_interruption_candidate( + precedence_state, + text=str(text), + state_decision=state_decision, + abandon_only=True, + ) + if abandonment is not None: + await self._emit(abandonment, state) + return abandonment + + if effective_tx_status == "AWAITING_CONFIRMATION": consumed = await self._transaction_parameter_precedence( - state, text=str(text), state_decision=state_decision + 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 + precedence_state, text=str(text), state_decision=state_decision ) if interruption is not None: await self._emit(interruption, state) @@ -686,19 +798,61 @@ class EnterpriseRouter: method="state", next_state=tx_status, ) - if tx_status == "AWAITING_CONFIRMATION": - 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 - if tx_status == "COLLECTING_PARAMETERS": + relevance = await self._classify_transaction_parameter_relevance( + state, text=str(text) + ) + if relevance is True: + 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, + "transaction_parameter_relevant": True, + } + await self._emit(consumed, state) + return consumed + synthetic.metadata = { + **(synthetic.metadata or {}), + "transaction_state_recovered": True, + "transaction_parameter_relevant": True, + "transaction_parameter_extraction_empty": True, + } + await self._emit(synthetic, state) + return synthetic + + if relevance is None: + 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, + "transaction_parameter_relevance": "unknown", + } + await self._emit(consumed, state) + return consumed + + # No parameter was consumed (or this is confirmation): now decide + # semantically whether the user abandoned the active transaction. + abandonment = await self._transaction_state_interruption_candidate( + state, + text=str(text), + state_decision=synthetic, + abandon_only=True, + ) + if abandonment is not None: + abandonment.metadata = { + **(abandonment.metadata or {}), + "transaction_state_recovered": True, + } + await self._emit(abandonment, state) + return abandonment + + if tx_status == "AWAITING_CONFIRMATION": consumed = await self._transaction_parameter_precedence( state, text=str(text), state_decision=synthetic ) @@ -837,6 +991,82 @@ class EnterpriseRouter: return decision + async def _classify_transaction_parameter_relevance( + self, state: dict[str, Any], *, text: str + ) -> bool | None: + """Semantically decide whether the turn answers a pending parameter. + + This is intentionally separate from extraction. A parameter extractor + may be able to manufacture a syntactically valid value from unrelated + text; relevance answers the higher-level question first: does the user + appear to be responding to what the active transaction asked for? + + Returns True/False when classification succeeds and None when the + classifier is unavailable or its output cannot be trusted. + """ + if not (self.enable_llm_router and self.llm is not None): + 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 "").strip() + if not tool_name: + return None + payload = { + "message": str(text or ""), + "transaction": { + "intent": active.get("started_from_intent") or state.get("intent"), + "tool_name": tool_name, + "tool_description": active.get("tool_description"), + "missing_parameters": missing, + "parameter_schema": active.get("parameter_schema") or {}, + "known_arguments": active.get("arguments") or {}, + "relevant_conversation_context": ( + active.get("parameter_conversational_context") + or self._collect_transaction_parameter_context(state=state, current_text=str(text)) + ), + }, + } + system = ( + "Você decide somente se a mensagem do usuário é uma resposta pertinente ao(s) " + "parâmetro(s) que a transação ativa está pedindo. Avalie significado e contexto, " + "não palavras isoladas. Considere RELEVANT quando a mensagem fornece, esclarece, " + "corrige ou referencia plausivelmente o dado solicitado, mesmo em linguagem livre. " + "Considere NOT_RELEVANT quando a mensagem abandona a ação, inicia outro objetivo, " + "faz uma pergunta diferente ou não responde ao dado pedido. Não extraia valores e " + "não decida abandono/intent; classifique apenas pertinência. Retorne somente JSON " + "válido: {\"relevance\":\"RELEVANT|NOT_RELEVANT\",\"confidence\":0.0,\"reason\":\"...\"}." + ) + try: + answer = await self.llm.ainvoke( + [ + {"role": "system", "content": system}, + {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}, + ], + temperature=0.0, + max_tokens=256, + profile_name="router", + component_name="router.transaction_parameter_relevance", + generation_name="llm.transaction_parameter_relevance", + ) + data = self._parse_json(answer) + except Exception as exc: + logger.warning("Falha ao classificar pertinência do parâmetro transacional: %s", exc) + return None + relevance = str(data.get("relevance") or "").strip().upper() + try: + confidence = float(data.get("confidence") or 0.0) + except (TypeError, ValueError): + return None + if confidence < self.intent_shift_threshold: + return None + if relevance == "RELEVANT": + return True + if relevance == "NOT_RELEVANT": + return False + return None + async def _transaction_parameter_precedence( self, state: dict[str, Any], @@ -846,11 +1076,12 @@ class EnterpriseRouter: ) -> RouteDecision | None: """Try to consume the turn under the active transaction contract first. - AWAITING_CONFIRMATION consumes an explicit confirmation before any shift - classification. COLLECTING_PARAMETERS also has precedence: if at least one - pending parameter can be extracted, the active transaction keeps ownership - of the turn. Semantic intent-shift is evaluated only when extraction returns - no usable pending parameter. + In AWAITING_CONFIRMATION, an explicit ABANDON candidate is classified + before confirmation consumption; plain yes/no responses still keep + deterministic confirmation precedence because they do not pass the + abandonment gate. In COLLECTING_PARAMETERS, ABANDON is likewise checked + before extraction, while SHIFT remains after parameter extraction so valid + referential/value answers are not stolen from the active transaction. """ tx_status = str(state.get("transaction_status") or "").strip().upper() if tx_status == "AWAITING_CONFIRMATION": @@ -966,13 +1197,14 @@ class EnterpriseRouter: *, text: str, state_decision: RouteDecision, + abandon_only: bool = False, ) -> RouteDecision | None: """Detecta semanticamente mudança de intenção durante uma transação. - Não existe lista de palavras para desistência ou mudança de assunto. Uma - interrupção nasce de uma intent diferente resolvida por uma keyword - configurada no ``routing.yaml`` ou, na ausência dela, por uma decisão - semântica do LLM com o contexto da transação pendente. + Não existe lista de palavras para desistência. ABANDON é sempre uma + decisão semântica do LLM baseada no contexto transacional. Uma possível + nova intenção pode aparecer como hint do ``routing.yaml``, mas esse hint + não decide abandono e não substitui a classificação semântica. """ active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} started_intent = str(active_tx.get("started_from_intent") or "").strip() @@ -1038,18 +1270,32 @@ class EnterpriseRouter: else None ), } - system = ( - "Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " - "Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " - "A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " - "Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " - "Se o usuário passou claramente a perseguir outro objetivo sem desistir explicitamente da ação atual, " - "retorne SHIFT e a nova intent permitida. " - "Se o usuário desistiu explicitamente da ação/transação atual, retorne ABANDON. " - "Quando ABANDON vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. " - "Quando for apenas abandono sem novo objetivo, intent e agent podem ser nulos. " - "Retorne somente JSON válido com decision, intent, agent, confidence, reason." - ) + if abandon_only: + system = ( + "Você decide somente se o usuário desistiu explicitamente da ação/transação ativa. " + "Quando a transação estava coletando parâmetros, a pertinência ao parâmetro já foi avaliada antes desta etapa; " + "portanto não transforme uma resposta pertinente ao parâmetro em abandono. " + "Use o significado completo da mensagem e o contexto transacional; não use palavras isoladas como regra. " + "Se houver desistência explícita da ação atual, retorne ABANDON. " + "Quando o abandono vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. " + "Quando for apenas abandono sem novo objetivo, intent e agent podem ser nulos. " + "Se a mensagem apenas fornece parâmetro, confirma contexto, ou somente muda/adiciona outro objetivo sem " + "desistir explicitamente da ação atual, retorne CONTINUE. " + "Retorne somente JSON válido com decision, intent, agent, confidence, reason." + ) + else: + system = ( + "Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " + "Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " + "A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " + "Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " + "Se o usuário passou claramente a perseguir outro objetivo sem desistir explicitamente da ação atual, " + "retorne SHIFT e a nova intent permitida. " + "Se o usuário desistiu explicitamente da ação/transação atual, retorne ABANDON. " + "Quando ABANDON vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. " + "Quando for apenas abandono sem novo objetivo, intent e agent podem ser nulos. " + "Retorne somente JSON válido com decision, intent, agent, confidence, reason." + ) user = { "message": text, "transaction": transaction_context, @@ -1074,7 +1320,10 @@ class EnterpriseRouter: return None decision_kind = str(data.get("decision") or "").strip().upper() - if decision_kind not in {"SHIFT", "ABANDON"}: + if abandon_only: + if decision_kind != "ABANDON": + return None + elif decision_kind not in {"SHIFT", "ABANDON"}: return None confidence = float(data.get("confidence") or 0.0) if confidence < self.intent_shift_threshold: diff --git a/libs/agent_framework/src/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc index 0a6e6f46..4986b941 100644 Binary files a/libs/agent_framework/src/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/runtime/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc b/libs/agent_framework/src/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc index 2fa877d0..d43e7477 100644 Binary files a/libs/agent_framework/src/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/runtime/__pycache__/agent_runtime.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc b/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc index 359fe755..506ac4d1 100644 Binary files a/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/runtime/__pycache__/transaction_parameters.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc index 1ea33ed2..2653f6d6 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/__init__.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/graph.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/graph.cpython-313.pyc index b7834f57..8cd61175 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/graph.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/graph.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc index e81dd4bf..74036e8a 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/input_contract.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/models.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/models.cpython-313.pyc index b7a0b9f8..4416b6f5 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/models.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/models.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/registry.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/registry.cpython-313.pyc index 3f248383..ff83a894 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/registry.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/registry.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/repository.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/repository.cpython-313.pyc index f14e44c0..2dfadbd1 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/repository.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/repository.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc index 5386cb34..6803758e 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/runtime.cpython-313.pyc differ diff --git a/libs/agent_framework/src/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc b/libs/agent_framework/src/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc index d5b2da55..6a118c90 100644 Binary files a/libs/agent_framework/src/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc and b/libs/agent_framework/src/agent_framework/workflows/__pycache__/tool_executor.cpython-313.pyc differ diff --git a/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc index 9f26c74c..8d2b42e6 100644 Binary files a/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_paused_workflow_resume_precedence.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_paused_workflow_resume_precedence.cpython-313-pytest-9.0.2.pyc index d66326e1..50ebc16d 100644 Binary files a/tests/__pycache__/test_paused_workflow_resume_precedence.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_paused_workflow_resume_precedence.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_route_stickiness_transaction_shift.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_route_stickiness_transaction_shift.cpython-313-pytest-9.0.2.pyc index f1635a55..04946e96 100644 Binary files a/tests/__pycache__/test_route_stickiness_transaction_shift.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_route_stickiness_transaction_shift.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_confirmation_customer_facing.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_confirmation_customer_facing.cpython-313-pytest-9.0.2.pyc index d6311453..1be0638f 100644 Binary files a/tests/__pycache__/test_transaction_confirmation_customer_facing.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_confirmation_customer_facing.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_explicit_abandonment_and_structural_candidate.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_explicit_abandonment_and_structural_candidate.cpython-313-pytest-9.0.2.pyc index ec3b3223..5d522d5b 100644 Binary files a/tests/__pycache__/test_transaction_explicit_abandonment_and_structural_candidate.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_explicit_abandonment_and_structural_candidate.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_parameter_llm_precedence.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_parameter_llm_precedence.cpython-313-pytest-9.0.2.pyc index d728688f..cfa0343c 100644 Binary files a/tests/__pycache__/test_transaction_parameter_llm_precedence.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_parameter_llm_precedence.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_transaction_state_router_interruption.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_transaction_state_router_interruption.cpython-313-pytest-9.0.2.pyc index 5be25124..6cb76039 100644 Binary files a/tests/__pycache__/test_transaction_state_router_interruption.cpython-313-pytest-9.0.2.pyc and b/tests/__pycache__/test_transaction_state_router_interruption.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/test_transaction_explicit_abandonment_and_structural_candidate.py b/tests/test_transaction_explicit_abandonment_and_structural_candidate.py index 9a7caf8d..a16e261b 100644 --- a/tests/test_transaction_explicit_abandonment_and_structural_candidate.py +++ b/tests/test_transaction_explicit_abandonment_and_structural_candidate.py @@ -11,8 +11,25 @@ from agent_framework.runtime.transaction_parameters import reconcile_transaction class _RouterLLM: async def ainvoke(self, messages, **kwargs): prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) + low = prompt.lower() + if kwargs.get("generation_name") == "llm.transaction_parameter_relevance": + if "isso mesmo, pode cancelar" in low or "motivo é que desisti" in low or "motivo e que desisti" in low: + return json.dumps({"relevance": "RELEVANT", "confidence": 0.99, "reason": "responde ao contexto solicitado"}) + return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "não responde ao parâmetro pendente"}) if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: return json.dumps({"subject": None, "valor": None}) + if ( + "nao quero cancelar. quero apenas verificar os meus servicos contratados" in prompt.lower() + or "nao quero cancelar. quero apenas ver meus servicos contratados" in prompt.lower() + or "nao quero mais cancelar. quero apenas ver os servicos que eu contratei" in prompt.lower() + ): + return json.dumps({ + "decision": "ABANDON", + "intent": "product_services_information", + "agent": "product_agent", + "confidence": 0.99, + "reason": "abandono explícito do cancelamento com novo objetivo", + }) if "nao quero mais cancelar" in prompt.lower() or "não quero mais cancelar" in prompt.lower(): return json.dumps({ "decision": "ABANDON", @@ -48,6 +65,8 @@ router: state_policies: - state: COLLECTING_PARAMETERS agent: contestacao_agent + - state: WAITING_CONTESTACAO_CONFIRMATION + agent: contestacao_agent intents: - name: contas_contestation agent: contestacao_agent @@ -61,6 +80,10 @@ intents: agent: billing_agent priority: 30 keywords: [fatura] + - name: product_services_information + agent: product_agent + priority: 40 + keywords: [servicos contratados, serviços contratados] """, encoding="utf-8", ) @@ -123,6 +146,168 @@ async def test_explicit_abandonment_without_new_goal_does_not_rearm_tool(tmp_pat assert decision.metadata["interrupted_intent"] == "contas_contestation" + + +def _awaiting_confirmation_state(message): + return { + "user_text": message, + "sanitized_input": message, + "next_state": "WAITING_CONTESTACAO_CONFIRMATION", + "transaction_status": "AWAITING_CONFIRMATION", + "intent": "state:WAITING_CONTESTACAO_CONFIRMATION", + "active_agent": "contestacao_agent", + "active_transaction": { + "tool_name": "cancelar_vas_avulso", + "arguments": {"subject": "Tamboro Mensal"}, + "status": "AWAITING_CONFIRMATION", + "started_from_intent": "contas_vas_cancel", + }, + "pending_tool_call": { + "tool_name": "cancelar_vas_avulso", + "arguments": {"subject": "Tamboro Mensal"}, + }, + "history": [ + {"role": "user", "content": "quero cancelar tamboro"}, + {"role": "assistant", "content": "Você confirma o cancelamento?"}, + {"role": "user", "content": message}, + ], + } + + +@pytest.mark.asyncio +async def test_explicit_abandonment_precedes_awaiting_confirmation_and_routes_new_goal(tmp_path): + decision = await _router(tmp_path).route( + _awaiting_confirmation_state( + "nao quero cancelar. quero apenas verificar os meus servicos contratados" + ) + ) + assert decision.intent == "product_services_information" + assert decision.agent == "product_agent" + assert decision.metadata["transaction_interruption"] == "explicit_abandonment" + assert decision.metadata["interrupted_intent"] == "contas_vas_cancel" + assert not decision.metadata.get("transaction_turn_consumed") + + + + +@pytest.mark.asyncio +async def test_explicit_abandonment_precedes_generic_state_lock_without_transaction_status(tmp_path): + state = _awaiting_confirmation_state( + "nao quero cancelar. quero apenas ver meus servicos contratados" + ) + # Reproduce the host/checkpoint shape observed in production: the domain + # WAITING_* state is restored, but transaction_status is absent/stale. + state.pop("transaction_status", None) + decision = await _router(tmp_path).route(state) + assert decision.intent == "product_services_information" + assert decision.agent == "product_agent" + assert decision.metadata["transaction_interruption"] == "explicit_abandonment" + assert decision.metadata["interrupted_state"] == "WAITING_CONTESTACAO_CONFIRMATION" + assert not decision.metadata.get("transaction_turn_consumed") + + +@pytest.mark.asyncio +async def test_explicit_abandonment_precedes_state_lock_with_only_domain_state_preserved(tmp_path): + state = _awaiting_confirmation_state( + "nao quero mais cancelar. quero apenas ver os servicos que eu contratei" + ) + # Production checkpoint shape: only the domain WAITING_* lock survives. + # No transaction_status, active_transaction or legacy pending tool latch. + state.pop("transaction_status", None) + state.pop("active_transaction", None) + state.pop("pending_tool_call", None) + state.pop("selected_tool_call", None) + decision = await _router(tmp_path).route(state) + assert decision.intent == "product_services_information" + assert decision.agent == "product_agent" + assert decision.metadata["transaction_interruption"] == "explicit_abandonment" + assert decision.metadata["interrupted_state"] == "WAITING_CONTESTACAO_CONFIRMATION" + assert not decision.metadata.get("transaction_turn_consumed") + + +@pytest.mark.asyncio +async def test_plain_negative_confirmation_remains_confirmation_not_abandon(tmp_path): + decision = await _router(tmp_path).route(_awaiting_confirmation_state("não")) + assert decision.intent == "state:WAITING_CONTESTACAO_CONFIRMATION" + assert decision.agent == "contestacao_agent" + assert decision.metadata["transaction_turn_consumed"] is True + assert decision.metadata["transaction_confirmation_decision"] == "reject" + assert "transaction_interruption" not in decision.metadata + +class _MisleadingParameterRouterLLM(_RouterLLM): + async def ainvoke(self, messages, **kwargs): + prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) + generation = kwargs.get("generation_name") + low = prompt.lower() + # Relevance is a semantic gate before extraction. The extractor below is + # intentionally misleading to prove that NOT_RELEVANT prevents a false + # parameter value from stealing an abandonment turn. + if generation == "llm.transaction_parameter_relevance": + if "motivo é que desisti" in low or "motivo e que desisti" in low: + return json.dumps({"relevance": "RELEVANT", "confidence": 0.99, "reason": "responde ao reason"}) + if "nao quero mais devolver" in low or "não quero mais devolver" in low or "nao quero mais cancelar" in low: + return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "abandona a ação"}) + return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.95, "reason": "não responde ao parâmetro"}) + if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: + if "motivo é que desisti" in low or "motivo e que desisti" in low: + return json.dumps({"valor": "desisti"}) + if "nao quero mais cancelar" in low or "não quero mais cancelar" in low: + return json.dumps({"valor": 14.99}) + if "nao quero mais devolver" in low or "não quero mais devolver" in low: + return json.dumps({ + "decision": "ABANDON", + "intent": "product_services_information", + "agent": "product_agent", + "confidence": 0.99, + "reason": "abandono explícito da devolução com novo objetivo", + }) + return await super().ainvoke(messages, **kwargs) + + +def _router_with_misleading_parameter_extractor(tmp_path): + router = _router(tmp_path) + router.llm = _MisleadingParameterRouterLLM() + return router + + +@pytest.mark.asyncio +async def test_parameter_relevance_precedes_abandonment(tmp_path): + state = _active_state("motivo é que desisti") + state["missing_parameters"] = ["valor"] + state["active_transaction"]["parameter_schema"] = {"valor": {"type": "string", "description": "motivo informado pelo cliente"}} + decision = await _router_with_misleading_parameter_extractor(tmp_path).route(state) + assert decision.intent == "state:COLLECTING_PARAMETERS" + assert decision.metadata["transaction_turn_consumed"] is True + assert decision.metadata["transaction_parameter_relevant"] is True + assert decision.metadata["transaction_parameter_values"] == {"valor": "desisti"} + assert "transaction_interruption" not in decision.metadata + + +@pytest.mark.asyncio +async def test_semantic_not_relevant_prevents_misleading_extractor_and_allows_abandon(tmp_path): + decision = await _router_with_misleading_parameter_extractor(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["transaction_parameter_relevant"] is False + assert not decision.metadata.get("transaction_turn_consumed") + + +@pytest.mark.asyncio +async def test_abandon_plus_new_goal_after_parameter_relevance_fails(tmp_path): + decision = await _router_with_misleading_parameter_extractor(tmp_path).route( + _active_state("nao quero mais devolver. quero apenas ver os meus servicos contratados") + ) + assert decision.intent == "product_services_information" + assert decision.agent == "product_agent" + assert decision.metadata["transaction_interruption"] == "explicit_abandonment" + assert decision.metadata["transaction_parameter_relevant"] is False + + + class _PolicyRouter: def __init__(self): self.registry = SimpleNamespace( @@ -354,3 +539,79 @@ async def test_runtime_abandon_cancels_only_active_transaction_without_rearming_ 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"}] + +class _ReasonParameterRouterLLM(_RouterLLM): + async def ainvoke(self, messages, **kwargs): + prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) + lowered = prompt.lower() + if kwargs.get("generation_name") == "llm.transaction_parameter_relevance": + if "motivo é que desisti" in lowered or "motivo e que desisti" in lowered: + return json.dumps({"relevance": "RELEVANT", "confidence": 0.99, "reason": "responde ao motivo"}) + if "nao quero mais devolver o pedido" in lowered or "não quero mais devolver o pedido" in lowered: + return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "abandona a devolução"}) + if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: + if "motivo é que desisti" in lowered or "motivo e que desisti" in lowered: + return json.dumps({"reason": "desisti"}) + if "nao quero mais devolver o pedido" in lowered or "não quero mais devolver o pedido" in lowered: + return json.dumps({ + "decision": "ABANDON", + "intent": None, + "agent": None, + "confidence": 0.99, + "reason": "abandono explícito da devolução", + }) + return await super().ainvoke(messages, **kwargs) + + +def _reason_collecting_state(message): + return { + "user_text": message, + "sanitized_input": message, + "next_state": "COLLECTING_ORDERS_PARAMETERS", + "transaction_status": "COLLECTING_PARAMETERS", + "missing_parameters": ["reason"], + "intent": "state:COLLECTING_PARAMETERS", + "active_agent": "orders_agent", + "active_transaction": { + "tool_name": "solicitar_devolucao", + "arguments": {"order_id": "PED-1001"}, + "status": "COLLECTING_PARAMETERS", + "started_from_intent": "retail_support_exchange_return", + "parameter_schema": {"reason": {"type": "string", "description": "motivo da devolução"}}, + "tool_description": "Solicita devolução de pedido informando o motivo", + }, + "history": [ + {"role": "user", "content": "quero devolver o pedido PED-1001"}, + {"role": "assistant", "content": "Qual o motivo da devolução?"}, + {"role": "user", "content": message}, + ], + } + + +@pytest.mark.asyncio +async def test_parameter_value_desisti_is_not_abandon_when_reason_is_expected(tmp_path): + router = _router(tmp_path) + router.llm = _ReasonParameterRouterLLM() + decision = await router.route(_reason_collecting_state("motivo é que desisti")) + assert decision.intent == "state:COLLECTING_PARAMETERS" + assert decision.agent == "contestacao_agent" or decision.agent == "orders_agent" + assert decision.metadata["transaction_turn_consumed"] is True + assert decision.metadata["transaction_parameter_values"] == {"reason": "desisti"} + assert "transaction_interruption" not in decision.metadata + + +@pytest.mark.asyncio +async def test_action_scoped_abandon_still_preempts_reason_extraction(tmp_path): + router = _router(tmp_path) + router.llm = _ReasonParameterRouterLLM() + # The action is explicitly abandoned; this must not be consumed as reason. + decision = await router.route(_reason_collecting_state("nao quero mais devolver o pedido")) + assert decision.metadata["transaction_interruption"] == "explicit_abandonment" + + +def test_abandonment_has_no_lexical_gate_in_router_source(): + from pathlib import Path + source = Path("libs/agent_framework/src/agent_framework/routing/enterprise_router.py").read_text(encoding="utf-8") + assert "_looks_like_explicit_abandonment" not in source + assert "_looks_like_action_abandonment" not in source + assert "cues = (" not in source diff --git a/tests/test_transaction_parameter_llm_precedence.py b/tests/test_transaction_parameter_llm_precedence.py index ef57780f..1f6c6e11 100644 --- a/tests/test_transaction_parameter_llm_precedence.py +++ b/tests/test_transaction_parameter_llm_precedence.py @@ -301,7 +301,7 @@ intents: @pytest.mark.asyncio async def test_incompatible_intent_shift_runs_only_when_parameter_extractor_does_not_consume(tmp_path): - """A real new goal still shifts, but only after parameter extraction declines it.""" + """A real new goal shifts after semantic parameter-relevance declines it.""" routing = tmp_path / "routing.yaml" routing.write_text( """ @@ -331,6 +331,8 @@ intents: async def ainvoke(self, messages, **kwargs): prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) + if kwargs.get("generation_name") == "llm.transaction_parameter_relevance": + return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "test relevance"}) if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: self.extraction_calls += 1 # The extractor must not convert a clearly new request into the @@ -374,13 +376,13 @@ intents: assert decision.intent == "retail_order_cancel" assert decision.agent == "orders_agent" assert decision.metadata["transaction_interruption"] == "intent_shift" - assert llm.shift_calls == 1 - assert llm.extraction_calls == 1 + assert llm.shift_calls == 2 # ABANDON probe + SHIFT classification + assert llm.extraction_calls == 0 # NOT_RELEVANT skips parameter extraction @pytest.mark.asyncio async def test_semantic_shift_without_keyword_runs_after_parameter_extractor_declines(tmp_path): - """Semantic SHIFT remains available when no pending parameter is consumed.""" + """Semantic SHIFT remains available after parameter relevance is NOT_RELEVANT.""" routing = tmp_path / "routing.yaml" routing.write_text( """ @@ -410,6 +412,8 @@ intents: async def ainvoke(self, messages, **kwargs): prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) + if kwargs.get("generation_name") == "llm.transaction_parameter_relevance": + return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "test relevance"}) if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: self.extraction_calls += 1 return json.dumps({"reason": None}) @@ -452,8 +456,8 @@ intents: assert decision.agent == "orders_agent" assert decision.metadata["transaction_interruption"] == "intent_shift" assert decision.metadata["interruption_source"] == "semantic_classifier" - assert llm.shift_calls == 1 - assert llm.extraction_calls == 1 + assert llm.shift_calls == 2 # ABANDON probe + SHIFT classification + assert llm.extraction_calls == 0 # NOT_RELEVANT skips parameter extraction @pytest.mark.asyncio @@ -487,6 +491,8 @@ intents: async def ainvoke(self, messages, **kwargs): prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) + if kwargs.get("generation_name") == "llm.transaction_parameter_relevance": + return json.dumps({"relevance": "RELEVANT", "confidence": 0.99, "reason": "test relevance"}) if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: self.extraction_calls += 1 assert "Tamboro Mensal" in prompt diff --git a/tests/unit/__pycache__/test_pending_topics_isolation.cpython-313-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_pending_topics_isolation.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..b0576b03 Binary files /dev/null and b/tests/unit/__pycache__/test_pending_topics_isolation.cpython-313-pytest-9.0.2.pyc differ