diff --git a/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/rails.py b/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/rails.py index 87f4d73..810e694 100644 --- a/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/rails.py +++ b/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/rails.py @@ -328,6 +328,47 @@ class ProactiveOfferRail(Guardrail): ) +def _sanitize_low_risk_phraseology(text: str, reason: str) -> str | None: + """Remove apenas fechamentos/redirecionamentos de baixo risco. + + FRASEOLOGIA continua fail-closed para conteúdo material. Para B4 e ofertas + genéricas de continuação, porém, bloquear toda uma resposta grounded piora a + UX; nesses casos removemos somente a sentença ofensora. + """ + normalized_reason = (reason or "").casefold() + low_risk = any(token in normalized_reason for token in ( + "viola b4", "outro canal", "atendimento especializado", + "realizar alguma ação", "oferta de ação", "orienta o cliente", + )) + if not low_risk: + return None + + forbidden = ( + "entre em contato", "fale com um atendente", "procure uma loja", + "acesse o app", "acesse o site", "atendimento especializado", + "área de planos", "area de planos", "é só me avisar", + "e so me avisar", "realizar alguma ação", "realizar alguma acao", + "gerenciar esses serviços", "gerenciar esses servicos", + ) + sentences = re.split(r"(?<=[.!?])\s+", (text or "").strip()) + kept: list[str] = [] + removed = False + for sentence in sentences: + normalized = sentence.casefold() + proactive = ( + ("se quiser" in normalized or "caso queira" in normalized or "se desejar" in normalized) + and any(token in normalized for token in ("realizar", "gerenciar", "cancelar", "contratar", "alterar", "ação", "acao")) + ) + if proactive or any(token in normalized for token in forbidden): + removed = True + continue + kept.append(sentence.strip()) + sanitized = " ".join(x for x in kept if x).strip() + if removed and sanitized: + return sanitized + return None + + class PhraseologyRail(Guardrail): """FRASEOLOGIA calibrado: bloqueia fraseados proibidos do agente.""" code = "FRASEOLOGIA" @@ -339,9 +380,26 @@ class PhraseologyRail(Guardrail): _llm(ctx), "FRASEOLOGIA", {"text": text or "", "context": ctx}, profile_name="grl", component_name="guardrail.fraseologia", generation_name="guardrail.fraseologia", ) + allowed = bool(out.get("allowed", True)) + reason = str(out.get("reason") or out.get("label") or "FRASEOLOGIA avaliado") + if not allowed: + sanitized = _sanitize_low_risk_phraseology(text or "", reason) + if sanitized: + return RailDecision( + code=self.code, + allowed=True, + reason=f"FRASEOLOGIA sanitizada: {reason}", + sanitized_text=sanitized, + metadata={ + "mechanism": "llm_rail+deterministic_sanitize", + "data": out, + "calibrated": True, + "original_allowed": False, + }, + ) return RailDecision( - code=self.code, allowed=bool(out.get("allowed", True)), - reason=str(out.get("reason") or out.get("label") or "FRASEOLOGIA avaliado"), + code=self.code, allowed=allowed, + reason=reason, sanitized_text=text, metadata={"mechanism": "llm_rail", "data": out, "calibrated": True}, ) diff --git a/agent_framework_oci/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py b/agent_framework_oci/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py index d625df3..2580f43 100644 --- a/agent_framework_oci/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py +++ b/agent_framework_oci/libs/agent_framework/src/agent_framework/runtime/agent_runtime.py @@ -1167,7 +1167,25 @@ class AgentRuntimeMixin: return None def _select_transactional_tool(self, tools: list[str], text: str) -> str | None: - return self._transactional_action_match(text, tools) + """Seleciona a ação transacional da intent atual. + + O match por ``selection_keywords`` continua tendo precedência. Porém, depois + que o EnterpriseRouter já restringiu ``tools`` às capabilities da intent, + uma única tool transacional é uma escolha determinística e segura. Isso + evita perder frases naturais como ``quero cancelar TIM Fashion Mensal`` ou + ``não contratei esse serviço`` só porque elas não repetem literalmente uma + keyword de ``tools.yaml``. + """ + matched = self._transactional_action_match(text, tools) + if matched: + return matched + + transactional = [ + tool + for tool in tools + if self._resolve_tool_execution_policy(tool).get("operation_type") == "transactional" + ] + return transactional[0] if len(transactional) == 1 else None @staticmethod def _agent_state_prefix(agent_name: str | None) -> str: @@ -1521,6 +1539,11 @@ class AgentRuntimeMixin: **previous_args, **{k: v for k, v in new_args.items() if v not in (None, "", [], {})}, } + # Execute parameter extraction before deciding whether the workflow + # must enter COLLECTING_PARAMETERS. Otherwise parameters declared + # with strategy=llm in mcp_parameter_mapping.yaml are invisible to + # the deterministic transaction state machine. + arguments = await self._extract_mcp_parameters(tool_name, arguments, state) policy = self._resolve_tool_execution_policy(tool_name, arguments) missing = self._missing_required_arguments(policy, arguments) if missing: @@ -1653,6 +1676,9 @@ class AgentRuntimeMixin: aliases=aliases, extra_args=self._extract_action_arguments(text), ) + # Extract parameters (including LLM-declared extraction rules) before + # validating required fields and before persisting the pending call. + action_args = await self._extract_mcp_parameters(selected_action, action_args, state) policy = self._resolve_tool_execution_policy(selected_action, action_args) selected = {"tool_name": selected_action, "arguments": action_args} state["selected_tool_call"] = selected