From a2fe158d62b7cea32231800e5e7591ce1cdec81f Mon Sep 17 00:00:00 2001 From: "cristiano.hoshikawa" Date: Fri, 11 Sep 2026 11:59:46 -0300 Subject: [PATCH] new feature: frontend with separated responses --- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../lib/agent_framework/guardrails/rails.py | 36 +++++++++- .../src/agent_framework/guardrails/rails.py | 36 ++++++---- .../agent_framework/routing/multi_intent.py | 19 +++++- .../agent_framework/routing/pending_topics.py | 66 ++++++++++++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- .../app/workflows/agent_graph.py | 31 ++++++++- tests/unit/test_multi_intent_planner.py | 22 +++++++ tests/unit/test_pending_topics_isolation.py | 49 ++++++++++++++ 18 files changed, 567 insertions(+), 33 deletions(-) diff --git a/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/agent_graph.py b/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/agent_graph.py index 0dcd6b49..ed100f5c 100644 --- a/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/agent_graph.py +++ b/Tuning-Performance/Authentication/agent_template_backend_authentication/app/workflows/agent_graph.py @@ -248,6 +248,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -266,6 +288,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -412,7 +438,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -576,6 +603,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -667,6 +695,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/agent_graph.py index 0dcd6b49..ed100f5c 100644 --- a/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Deterministic_Transactional_Workflow/agent_template_backend/app/workflows/agent_graph.py @@ -248,6 +248,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -266,6 +288,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -412,7 +438,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -576,6 +603,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -667,6 +695,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/agent_graph.py index 8be1347c..343ddf4c 100644 --- a/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/External_Guardrails_Judges/agent_template_backend/app/workflows/agent_graph.py @@ -246,6 +246,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -264,6 +286,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -410,7 +436,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -574,6 +601,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -665,6 +693,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/agent_graph.py index 0dcd6b49..ed100f5c 100644 --- a/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Long_Term_Memory/templates/agent_template_backend/app/workflows/agent_graph.py @@ -248,6 +248,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -266,6 +288,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -412,7 +438,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -576,6 +603,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -667,6 +695,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/agent_graph.py index 8be1347c..343ddf4c 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend/app/workflows/agent_graph.py @@ -246,6 +246,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -264,6 +286,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -410,7 +436,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -574,6 +601,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -665,6 +693,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py index 8be1347c..343ddf4c 100644 --- a/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py +++ b/Tuning-Performance/Normal/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py @@ -246,6 +246,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -264,6 +286,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -410,7 +436,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -574,6 +601,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -665,6 +693,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/agent_graph.py index 8be1347c..343ddf4c 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend/app/workflows/agent_graph.py @@ -246,6 +246,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -264,6 +286,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -410,7 +436,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -574,6 +601,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -665,6 +693,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py index 8be1347c..343ddf4c 100644 --- a/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py +++ b/Tuning-Performance/Route_Stickness/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py @@ -246,6 +246,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -264,6 +286,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -410,7 +436,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -574,6 +601,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -665,6 +693,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/agent_graph.py index cd16da66..4df9b201 100644 --- a/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Transaction_Evidence/agent_template_backend/app/workflows/agent_graph.py @@ -248,6 +248,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -266,6 +288,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -412,7 +438,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -576,6 +603,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -667,6 +695,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/agent_graph.py b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/agent_graph.py index cd16da66..4df9b201 100644 --- a/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/agent_graph.py +++ b/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend/app/workflows/agent_graph.py @@ -248,6 +248,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -266,6 +288,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -412,7 +438,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -576,6 +603,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -667,6 +695,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/libs/agent_framework/build/lib/agent_framework/guardrails/rails.py b/libs/agent_framework/build/lib/agent_framework/guardrails/rails.py index 187e5570..18b3fe9c 100644 --- a/libs/agent_framework/build/lib/agent_framework/guardrails/rails.py +++ b/libs/agent_framework/build/lib/agent_framework/guardrails/rails.py @@ -338,9 +338,43 @@ class LoopRail(Guardrail): code = "VLOOP" stage = "input" + @staticmethod + def _transaction_status(ctx: dict[str, Any]) -> str: + nonterminal = { + "COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "EXECUTING", + "PAUSED", "WAITING_INPUT", + } + for result in reversed(list(ctx.get("mcp_results") or [])): + if isinstance(result, dict): + result_status = str(result.get("transaction_status") or "").strip().upper() + if result_status in nonterminal: + return result_status + active_transaction = ctx.get("active_transaction") + if isinstance(active_transaction, dict): + active_status = str(active_transaction.get("status") or "").strip().upper() + if active_status in nonterminal: + return active_status + return str(ctx.get("transaction_status") or "").strip().upper() + async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: + ctx = _ctx(context) + transaction_status = self._transaction_status(ctx) + if transaction_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + return RailDecision( + code=self.code, + allowed=True, + reason=f"continuidade_transacional:{transaction_status}", + sanitized_text=text, + metadata={ + "history_window": len(list(ctx.get("history_texts") or [])[-6:]), + "repeated": False, + "mechanism": "deterministic_transaction_bypass", + "transaction_status": transaction_status, + "calibrated": True, + }, + ) normalized = _lower(text).strip() - history = [_lower(h).strip() for h in _ctx(context).get("history_texts", [])[-6:]] + history = [_lower(h).strip() for h in ctx.get("history_texts", [])[-6:]] repeated = history.count(normalized) >= 2 if normalized else False return RailDecision( code=self.code, diff --git a/libs/agent_framework/src/agent_framework/guardrails/rails.py b/libs/agent_framework/src/agent_framework/guardrails/rails.py index 9a32e5bb..676683b5 100644 --- a/libs/agent_framework/src/agent_framework/guardrails/rails.py +++ b/libs/agent_framework/src/agent_framework/guardrails/rails.py @@ -358,30 +358,38 @@ class LoopRail(Guardrail): @staticmethod def _transaction_status(ctx: dict[str, Any]) -> str: - status = str(ctx.get("transaction_status") or "").strip().upper() - if status: - return status + nonterminal = { + "COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "EXECUTING", + "PAUSED", "WAITING_INPUT", + } + # A queued multi-intent transaction may be promoted immediately after + # the previous one completed. Prefer the newest live MCP evidence over + # a stale top-level COMPLETED status left by checkpoint merge order. + for result in reversed(list(ctx.get("mcp_results") or [])): + if isinstance(result, dict): + result_status = str(result.get("transaction_status") or "").strip().upper() + if result_status in nonterminal: + return result_status active_transaction = ctx.get("active_transaction") if isinstance(active_transaction, dict): - return str(active_transaction.get("status") or "").strip().upper() - return "" + active_status = str(active_transaction.get("status") or "").strip().upper() + if active_status in nonterminal: + return active_status + return str(ctx.get("transaction_status") or "").strip().upper() async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: ctx = _ctx(context) transaction_status = self._transaction_status(ctx) - # A short confirmation (for example ``sim``/``não``) may legitimately - # appear several times in the same session because each transaction has - # its own confirmation boundary. VLOOP protects against conversational - # repetition; it must not consume the input that belongs to an active - # transaction awaiting confirmation. The transaction runtime/classifier - # remains responsible for deciding whether the utterance is actually a - # valid confirm/reject response. - if transaction_status == "AWAITING_CONFIRMATION": + # Parameter values and short confirmations may legitimately repeat in + # the same session. VLOOP must not consume input owned by the active + # transaction contract; extraction/confirmation remains responsible + # for deciding whether the value is valid. + if transaction_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: return RailDecision( code=self.code, allowed=True, - reason="continuidade_transacional:AWAITING_CONFIRMATION", + reason=f"continuidade_transacional:{transaction_status}", sanitized_text=text, metadata={ "history_window": len(list(ctx.get("history_texts") or [])[-6:]), diff --git a/libs/agent_framework/src/agent_framework/routing/multi_intent.py b/libs/agent_framework/src/agent_framework/routing/multi_intent.py index ed64cf8a..c35ec95b 100644 --- a/libs/agent_framework/src/agent_framework/routing/multi_intent.py +++ b/libs/agent_framework/src/agent_framework/routing/multi_intent.py @@ -69,17 +69,30 @@ class MultiIntentPlanner: def _intent_for_clause(self, clause: str) -> IntentDefinition | None: normalized = self._normalize(clause) - clause_tokens = set(re.findall(r"[\w]+", normalized, flags=re.UNICODE)) + ordered_clause_tokens = re.findall(r"[\w]+", normalized, flags=re.UNICODE) + clause_tokens = set(ordered_clause_tokens) matches: list[tuple[int, int, IntentDefinition]] = [] for intent in self.intents: for keyword in intent.keywords: token = self._normalize(keyword) keyword_tokens = re.findall(r"[\w]+", token, flags=re.UNICODE) + # Expressões configuradas representam conceitos, não somente + # substrings literais. Preserve a ordem dos termos e aceite + # qualificadores intermediários: "cancelar pedido" também + # reconhece "cancelar meu pedido", sem enumerar variações no YAML. + cursor = 0 + ordered_match = bool(keyword_tokens) + for keyword_token in keyword_tokens: + try: + cursor = ordered_clause_tokens.index(keyword_token, cursor) + 1 + except ValueError: + ordered_match = False + break matched = bool( keyword_tokens and ( (len(keyword_tokens) == 1 and keyword_tokens[0] in clause_tokens) - or (len(keyword_tokens) > 1 and token in normalized) + or (len(keyword_tokens) > 1 and ordered_match) ) ) if matched: @@ -146,7 +159,7 @@ class MultiIntentPlanner: if len(operations) < 2: return None transactional = [item for item in operations if self._is_transactional(item)] - if len(transactional) == 1: + if transactional: primary_operation = transactional[0] operations.sort(key=lambda item: item is not primary_operation) for operation in operations[1:]: diff --git a/libs/agent_framework/src/agent_framework/routing/pending_topics.py b/libs/agent_framework/src/agent_framework/routing/pending_topics.py index 37693c48..628b6647 100644 --- a/libs/agent_framework/src/agent_framework/routing/pending_topics.py +++ b/libs/agent_framework/src/agent_framework/routing/pending_topics.py @@ -16,6 +16,29 @@ class PendingTopicDrain: handled_topics: list[dict[str, Any]] mcp_results: list[dict[str, Any]] rag_results: list[dict[str, Any]] + state_patch: dict[str, Any] + + +_LIVE_TRANSACTION_STATUSES = { + "COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "WAITING_CONFIRMATION", + "PENDING", "RUNNING", "IN_PROGRESS", +} + +_TRANSACTION_STATE_KEYS = { + "active_transaction", "transaction_status", "transaction_pre_validation", + "confirmation_required", "confirmation_received", "selected_tool_call", + "pending_tool_call", "pending_tool_clarification", "pending_domain_workflow", + "missing_parameters", "tool_policy_result", "next_state", "workflow_id", +} + + +def _has_live_transaction(state: dict[str, Any]) -> bool: + active = state.get("active_transaction") + active_status = active.get("status") if isinstance(active, dict) else None + status = str(active_status or state.get("transaction_status") or "").upper() + if status: + return status in _LIVE_TRANSACTION_STATUSES + return bool(state.get("confirmation_required") or state.get("pending_tool_call")) async def drain_pending_topics( @@ -34,6 +57,8 @@ async def drain_pending_topics( combined_mcp_results = list(state.get("mcp_results") or []) combined_rag_results = list(state.get("rag_results") or []) secondary_answers: list[str] = [] + state_patch: dict[str, Any] = {} + may_promote_deferred = not _has_live_transaction(state) agent_responses = list(state.get("agent_responses") or []) if not agent_responses and str(candidate or "").strip(): primary_agent = str( @@ -53,9 +78,15 @@ async def drain_pending_topics( }) for topic in topics: disposition = topic.get("disposition") + promoted = False if disposition == "defer": - remaining.append(topic) - continue + if not may_promote_deferred: + remaining.append(topic) + continue + topic = {**topic, "disposition": "execute", "status": "pending"} + disposition = "execute" + promoted = True + may_promote_deferred = False if disposition != "execute": handled.append({**topic, "status": "completed"}) continue @@ -123,14 +154,42 @@ async def drain_pending_topics( if rag_evidence not in combined_rag_results: combined_rag_results.append(rag_evidence) secondary_answers.append(answer) + if promoted: + agent_responses = [{**item, "primary": False} for item in agent_responses] agent_responses.append({ "operation_id": topic.get("operation_id"), "agent": topic.get("agent"), "intent": topic.get("intent"), "answer": answer, - "primary": False, + "primary": promoted, "status": "completed", }) + if promoted: + for key in _TRANSACTION_STATE_KEYS: + if key in (result or {}): + state_patch[key] = result[key] + state_patch.update({ + "route": topic.get("agent"), + "active_agent": topic.get("agent"), + "intent": topic.get("intent"), + "domain": topic.get("domain"), + "mcp_tools": topic.get("tools") or [], + # Replace the completed transaction's continuity snapshot so + # the next reply cannot be routed back to the previous agent. + "route_decision": { + "route": topic.get("agent"), + "agent": topic.get("agent"), + "intent": topic.get("intent"), + "domain": topic.get("domain"), + "mcp_tools": topic.get("tools") or [], + "next_state": (result or {}).get("next_state"), + "metadata": { + "multi_intent_secondary": True, + "promoted_from_pending_topics": True, + "operation_id": topic.get("operation_id"), + }, + }, + }) handled.append({**topic, "status": "completed"}) secondary_answers.extend(MultiIntentPlanner.public_messages(plan)) @@ -148,4 +207,5 @@ async def drain_pending_topics( handled_topics=handled, mcp_results=combined_mcp_results, rag_results=combined_rag_results, + state_patch=state_patch, ) diff --git a/templates/agent_template_backend/app/workflows/agent_graph.py b/templates/agent_template_backend/app/workflows/agent_graph.py index 33f65688..3a42d1c1 100644 --- a/templates/agent_template_backend/app/workflows/agent_graph.py +++ b/templates/agent_template_backend/app/workflows/agent_graph.py @@ -249,6 +249,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -267,6 +289,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -412,7 +438,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -576,6 +603,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -655,6 +683,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py b/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py index bcc4e6a6..9cb626ad 100644 --- a/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py +++ b/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py @@ -247,6 +247,28 @@ class AgentWorkflow: input=state.get("user_text"), ): history_texts = [m.get("content", "") for m in state.get("history", [])] + guardrail_active_transaction = ( + state.get("active_transaction") + if isinstance(state.get("active_transaction"), dict) + else {} + ) + guardrail_transaction_status = str( + guardrail_active_transaction.get("status") + or state.get("transaction_status") + or "" + ).strip().upper() + pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {} + if state.get("confirmation_required") and pending_call.get("tool_name"): + guardrail_transaction_status = "AWAITING_CONFIRMATION" + elif state.get("missing_parameters") and pending_call.get("tool_name"): + guardrail_transaction_status = "COLLECTING_PARAMETERS" + for mcp_result in reversed(list(state.get("mcp_results") or [])): + if not isinstance(mcp_result, dict): + continue + mcp_status = str(mcp_result.get("transaction_status") or "").strip().upper() + if mcp_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}: + guardrail_transaction_status = mcp_status + break await self.observer.emit_grl( "001", { @@ -265,6 +287,10 @@ class AgentWorkflow: "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}, + "transaction_status": guardrail_transaction_status, + "missing_parameters": list(state.get("missing_parameters") or []), + "active_transaction": guardrail_active_transaction, + "mcp_results": list(state.get("mcp_results") or []), }, ) for _decision in decisions: @@ -410,7 +436,8 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, "pending_topics": ( (decision.metadata or {}).get("multi_intent_plan", {}).get("operations", [])[1:] - if (decision.metadata or {}).get("multi_intent_plan") else [] + if (decision.metadata or {}).get("multi_intent_plan") + else list(state.get("pending_topics") or []) ), } @@ -574,6 +601,7 @@ class AgentWorkflow: "output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + **drained.state_patch, } context = { @@ -653,6 +681,7 @@ class AgentWorkflow: "agent_responses": drained.agent_responses, "mcp_results": drained.mcp_results, "rag_results": drained.rag_results, + **drained.state_patch, "guardrail_decisions": state.get("guardrail_decisions", []) + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], } diff --git a/tests/unit/test_multi_intent_planner.py b/tests/unit/test_multi_intent_planner.py index d463d6bd..e452983e 100644 --- a/tests/unit/test_multi_intent_planner.py +++ b/tests/unit/test_multi_intent_planner.py @@ -40,6 +40,28 @@ def test_transactional_intent_is_primary_even_when_mentioned_second(): assert [item.intent for item in plan.operations] == ["cancel", "invoice"] +def test_multiword_keyword_accepts_intermediate_qualifier_without_yaml_variant(): + plan = _planner().plan("quero cancelar meu pedido e preciso da segunda via") + assert plan is not None + assert [item.intent for item in plan.operations] == ["cancel", "invoice"] + + +def test_first_of_multiple_transactions_is_primary_and_next_is_deferred(): + planner = MultiIntentPlanner([ + IntentDefinition(name="invoice", agent="billing", priority=30, keywords=["fatura"], mcp_tools=["consultar_fatura"]), + IntentDefinition(name="return", agent="support", priority=20, keywords=["devolver pedido"], mcp_tools=["solicitar_devolucao"]), + IntentDefinition(name="cancel", agent="orders", priority=10, keywords=["cancelar pedido"], mcp_tools=["cancelar_pedido"]), + ], transactional_tools={"solicitar_devolucao", "cancelar_pedido"}) + + plan = planner.plan( + "quero consultar minha fatura. quero devolver pedido. quero cancelar pedido" + ) + + assert plan is not None + assert [item.intent for item in plan.operations] == ["return", "invoice", "cancel"] + assert [item.disposition for item in plan.operations] == ["execute", "execute", "defer"] + + def test_sentence_boundary_does_not_leak_previous_intent_into_knowledge_query(): planner = MultiIntentPlanner([ IntentDefinition(name="invoice", agent="billing", priority=20, keywords=["fatura"]), diff --git a/tests/unit/test_pending_topics_isolation.py b/tests/unit/test_pending_topics_isolation.py index 618fad2a..37d18ed6 100644 --- a/tests/unit/test_pending_topics_isolation.py +++ b/tests/unit/test_pending_topics_isolation.py @@ -66,3 +66,52 @@ async def test_secondary_topic_does_not_inherit_primary_transaction_state(): ] assert result.rag_results[0]["metadata"]["provider"] == "kbdb" assert result.rag_results[0]["context"] == "Documento recuperado da base de conhecimento." + + +@pytest.mark.asyncio +async def test_deferred_transaction_is_promoted_after_primary_completes(): + async def cancellation_agent(state): + assert state["user_text"] == "quero cancelar meu pedido" + return { + "answer": "Você confirma o cancelamento do pedido PED-1001?", + "transaction_status": "AWAITING_CONFIRMATION", + "confirmation_required": True, + "pending_tool_call": { + "tool_name": "cancelar_pedido", + "arguments": {"order_id": "PED-1001"}, + }, + "active_transaction": { + "tool_name": "cancelar_pedido", + "status": "AWAITING_CONFIRMATION", + }, + } + + state = { + "answer": "A devolução foi registrada com sucesso.", + "route": "support_agent", + "active_agent": "support_agent", + "intent": "retail_support_exchange_return", + "transaction_status": "COMPLETED", + "pending_topics": [{ + "operation_id": "op-3", + "intent": "retail_order_cancel", + "agent": "orders_agent", + "domain": "retail", + "tools": ["consultar_pedido", "cancelar_pedido"], + "source_text": "quero cancelar meu pedido", + "disposition": "defer", + "status": "pending", + }], + } + + result = await drain_pending_topics( + state, state["answer"], {"orders_agent": cancellation_agent} + ) + + assert result.pending_topics == [] + assert result.state_patch["route"] == "orders_agent" + assert result.state_patch["intent"] == "retail_order_cancel" + assert result.state_patch["transaction_status"] == "AWAITING_CONFIRMATION" + assert result.state_patch["pending_tool_call"]["tool_name"] == "cancelar_pedido" + assert result.agent_responses[-1]["primary"] is True + assert result.agent_responses[-1]["agent"] == "orders_agent"