From 09b2b935ae097d97ce4b3d9f7b8d1e293ad6e01e Mon Sep 17 00:00:00 2001 From: Adam Weeks Date: Tue, 1 Sep 2026 10:38:03 -0400 Subject: [PATCH 1/3] feat: add BYOVA handoff routing hints --- README.md | 2 +- docs/BYOVA_HANDOFF_CONTEXT.md | 106 ++++++++++++++++++--------- docs/guides/byova-gecx-setup.md | 22 ++++-- docs/index.md | 4 +- src/connectors/gecx_connector.py | 17 +++-- src/connectors/i_vendor_connector.py | 4 +- src/core/wxcc_gateway_server.py | 34 ++++++++- src/utils/handoff.py | 24 ++++++ tests/test_gecx_connector.py | 43 ++++++++++- tests/test_wxcc_gateway_server.py | 90 ++++++++++++++++++++++- 10 files changed, 285 insertions(+), 61 deletions(-) create mode 100644 src/utils/handoff.py diff --git a/README.md b/README.md index 26f1283..8bee4e0 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ endpoint testing, logs, and troubleshooting. | Install and run the sample locally | [Local Development](docs/LOCAL_DEVELOPMENT.md) | | Configure the gateway and connectors | [Configuration Reference](config/README.md) | | Configure runtime JWT validation | [gRPC JWT Authentication](docs/JWT_AUTHENTICATION.md) | -| Pass a virtual-agent summary to a human agent | [BYOVA Handoff Summary](docs/BYOVA_HANDOFF_CONTEXT.md) | +| Pass virtual-agent summary and routing context to a human agent | [BYOVA Handoff Context](docs/BYOVA_HANDOFF_CONTEXT.md) | | Run automated and service tests | [Testing Guide](docs/TESTING.md) | | Configure the monitoring dashboard | [Monitoring Interface](src/monitoring/README.md) | | Add or configure connectors | [Connector Guide](src/connectors/README.md) | diff --git a/docs/BYOVA_HANDOFF_CONTEXT.md b/docs/BYOVA_HANDOFF_CONTEXT.md index 6e7c613..2067cf2 100644 --- a/docs/BYOVA_HANDOFF_CONTEXT.md +++ b/docs/BYOVA_HANDOFF_CONTEXT.md @@ -1,9 +1,10 @@ -# Passing a Virtual-Agent Handoff Summary to Webex Contact Center +# Passing Virtual-Agent Handoff Context to Webex Contact Center -This document defines a provider-neutral contract for passing a handoff summary from a -BYOVA virtual agent to Webex Contact Center (WxCC) when the call transfers to a human agent. -The goal is to let the receiving agent understand the caller's request without asking the -caller to repeat it. +This document defines a provider-neutral contract for passing an optional handoff summary and +routing hint from a BYOVA virtual agent to Webex Contact Center (WxCC) when the call transfers +to a human agent. The summary helps the receiving agent understand the caller's request without +asking the caller to repeat it. The routing hint lets the customer's flow select an approved +human queue without exposing queue identifiers to the virtual-agent provider. The contract applies to any virtual-agent provider. Each connector remains responsible for translating its provider's terminal response into the canonical gateway fields described @@ -18,18 +19,18 @@ summary: - In the Interaction Control pane after answering - Without depending on a provider-specific Agent Desktop widget -This document covers only the handoff summary behavior verified by this implementation. +This document covers the handoff behavior verified by this implementation. ## Data Flow ```text Virtual-agent provider | - | provider-specific terminal event and generated summary + | provider-specific terminal event, summary, and symbolic classification v Provider connector | - | canonical handoff summary + | canonical handoff data v BYOVA gateway | @@ -37,7 +38,7 @@ BYOVA gateway v WxCC Virtual Agent V2 activity | - | output-event metadata.summary + | output-event metadata.summary and metadata.routing_hint v Agent-viewable flow variable | @@ -49,7 +50,7 @@ The provider may generate the summary itself or return structured facts from whi connector builds a summary. The WxCC-facing response must not depend on which approach the provider uses. -## Canonical Gateway Handoff Summary +## Canonical Gateway Handoff Data Provider connectors should normalize terminal handoff data into one internal shape before the gateway creates the BYOVA response: @@ -59,16 +60,21 @@ the gateway creates the BYOVA response: "message_type": "transfer", "handoff": { "summary": "Caller wants to change the delivery address. The virtual agent did not modify the order. Verify the caller and update the address.", - "language_code": "en-US" + "routing_hint": "delivery_address_specialist" } } ``` -`handoff.summary` contains the text intended for the receiving agent. `language_code` is -optional and identifies the language used by the summary. +`handoff.summary` contains the text intended for the receiving agent. `handoff.routing_hint` +is an optional stable symbolic business classification. It must be a 1-64 character ASCII +identifier that begins with a letter and contains only letters, numbers, `_`, or `-` (for +example, `billing_specialist`). Numeric queue IDs, free-form text, empty values, and malformed +values are omitted. Connectors should not leak their provider's raw terminal payload into the gateway contract. -They should extract only the approved fields and normalize them into this shape. +They should extract only the approved fields and normalize them into this shape. The virtual +agent sends the business classification; the customer owns the mapping from that classification +to WxCC queue IDs. ## BYOVA Transfer Response @@ -87,7 +93,8 @@ The gateway should create one final `VoiceVAResponse` containing one "event_type": "TRANSFER_TO_AGENT", "name": "transfer_requested", "metadata": { - "summary": "Caller wants to change the delivery address. The virtual agent did not modify the order. Verify the caller and update the address." + "summary": "Caller wants to change the delivery address. The virtual agent did not modify the order. Verify the caller and update the address.", + "routing_hint": "delivery_address_specialist" } } ] @@ -99,11 +106,13 @@ The fields serve different purposes: | Field | Purpose | Requirement | | --- | --- | --- | | `output_events[].metadata.summary` | Makes the summary available to the WxCC flow as transfer metadata | Required for the validated Agent Desktop path | +| `output_events[].metadata.routing_hint` | Makes the stable routing classification available to the WxCC flow | Optional; sent only on `TRANSFER_TO_AGENT` | | `session_summary` | Uses the dedicated BYOVA session-summary field | Recommended when a summary is available | The summary is intentionally present in both `session_summary` and transfer metadata. The dedicated field preserves the BYOVA semantic model, while `metadata.summary` supports the -current WxCC flow-variable and Agent Desktop path. +current WxCC flow-variable and Agent Desktop path. `routing_hint` is intentionally not copied +to `session_summary`; it appears only in the one terminal transfer event's metadata. The relevant protocol definitions are: @@ -119,6 +128,12 @@ In Flow Designer, the Virtual Agent V2 activity exposes transfer-event metadata BYOVAHandoffSummary = {{BYOVA_Virtual_Agent.MetaData.summary}} ``` +Map the optional routing hint to a separate String flow variable: + +```text +BYOVARoutingHint = {{BYOVA_Virtual_Agent.MetaData.routing_hint}} +``` + The Virtual Agent activity name is flow-specific; replace `BYOVA_Virtual_Agent` with the actual activity name. Configure the custom variable as: @@ -165,6 +180,19 @@ Keep the human-routing path independent of the optional value: - Do not invent a fallback summary. Leave the agent-viewable variable empty when no summary was supplied. +### Route with an approved customer-owned queue map + +Use a **Case** activity on `BYOVARoutingHint` after the Virtual Agent V2 **Escalated** branch. +Each case value must be an approved symbolic classification, such as +`delivery_address_specialist` or `billing_specialist`, and each branch should lead to the +customer's approved WxCC queue for that classification. Do not ask the virtual agent to send a +WxCC queue ID and do not use a provider-supplied ID directly as a queue target. + +Always connect the Case activity's **Default** branch to the normal fallback human queue. This +default handles a missing, empty, malformed, or unknown hint so the human transfer still +succeeds. The customer can add, remove, or remap approved classifications in Flow Designer +without changing the provider or gateway. + ### 3. Select the Agent Desktop surfaces Open **Variable definition > Desktop viewability & order**. Add @@ -174,8 +202,8 @@ configuration has been validated. ![BYOVAHandoffSummary selected for the incoming popover and Interaction control pane](images/byova-handoff-flow-desktop-viewability.png) -The transfer must continue when the provider does not supply a summary; an absent summary is -not a routing failure. +The transfer must continue when the provider does not supply a summary or routing hint; absent +handoff data is not a routing failure. ## Validated Agent Desktop Behavior @@ -206,17 +234,21 @@ normalization and pass-through. The production gateway implementation should: -1. Accept a normalized handoff summary from every connector that can provide one. +1. Accept normalized `summary` and `routing_hint` fields from every connector that can provide + them. 2. Create exactly one terminal `TRANSFER_TO_AGENT` output event. 3. Copy the summary into that event's `metadata.summary` field. -4. Populate `session_summary` with the same text and language when available. -5. Allowlist supported metadata fields rather than forwarding an arbitrary provider payload. -6. Enforce configured size limits and valid scalar types. -7. Never write summary content to logs, metrics, traces, or error messages. -8. Preserve transfer behavior when the summary is missing, malformed, or too large. +4. Copy the routing hint only into that event's `metadata.routing_hint` field. +5. Populate `session_summary` with the same summary text when available, never with the routing + hint. +6. Allowlist supported metadata fields rather than forwarding an arbitrary provider payload or + raw customer queue ID. +7. Enforce valid scalar types and the symbolic routing-hint format. +8. Never write handoff content to logs, metrics, traces, or error messages. +9. Preserve transfer behavior when either optional field is missing, malformed, or unknown. The gateway should log only safe operational facts such as whether a field was present, its -character count, the selected language, and whether validation accepted or omitted it. +character count, and whether validation accepted or omitted it. ## Summary Content Guidance @@ -237,22 +269,26 @@ text over Markdown because the Agent Desktop variable is rendered as text. Automated coverage should verify: -- A transfer with a summary creates one transfer event containing `metadata.summary`. -- The same value appears in `session_summary`. -- A transfer without a summary still succeeds. +- A transfer with summary and routing hint creates one transfer event containing both allowlisted + metadata keys. +- The summary, but never the routing hint, appears in `session_summary`. +- A transfer without valid handoff data still succeeds. - Non-transfer responses do not receive handoff fields. -- Oversized or invalid values are omitted or truncated according to configuration. -- Summary text does not appear in logs. +- Invalid routing hints and provider queue IDs are omitted. +- Handoff values do not appear in logs. - Provider-specific fields do not escape the connector boundary. End-to-end acceptance should verify: -1. The provider or test connector produces a synthetic handoff summary. +1. The provider or test connector produces synthetic summary and symbolic routing-hint values. 2. The gateway emits a final response with one `TRANSFER_TO_AGENT` event. -3. The WxCC flow assigns `MetaData.summary` to the agent-viewable variable. -4. The incoming offer shows the summary before answer. -5. The active interaction shows the full summary after answer. -6. The call routes and completes normally when the summary is absent. +3. The WxCC flow assigns `MetaData.summary` to the agent-viewable variable and + `MetaData.routing_hint` to `BYOVARoutingHint`. +4. The Case activity maps an approved hint to its approved queue and sends missing or unknown + hints to the default human queue. +5. The incoming offer shows the summary before answer. +6. The active interaction shows the full summary after answer. +7. The call routes and completes normally when the summary or routing hint is absent. Use synthetic content for all nonproduction validation. Disable any terminal test probe after the test and restore the environment's approved gateway release. diff --git a/docs/guides/byova-gecx-setup.md b/docs/guides/byova-gecx-setup.md index 0dcd78a..3598cf9 100644 --- a/docs/guides/byova-gecx-setup.md +++ b/docs/guides/byova-gecx-setup.md @@ -357,22 +357,28 @@ Agent escalates ─► CES EndSession { metadata: {...} } `escalat`, `human`, `live agent`, or `handoff`. Optionally include a `reason` string. -To give the receiving agent context, also pass an optional plain-text `summary` -through the `end_session` system tool's `params` argument: +To give the receiving agent context and an optional customer-controlled routing +classification, pass `summary` and `routing_hint` through the `end_session` system tool's +`params` argument: ```text end_session( reason="caller requested a human agent", session_escalated=true, - params={"summary": "Caller wants to change a delivery address; no change was made."} + params={ + "summary": "Caller wants to change a delivery address; no change was made.", + "routing_hint": "delivery_address_specialist" + } ) ``` -CX Agent Studio returns `params.summary` as `EndSession.metadata.summary`. The -connector normalizes that value, and the gateway copies it to the single BYOVA -`TRANSFER_TO_AGENT.metadata.summary` event and `session_summary`. If `summary` -is absent, empty, or not text, the gateway omits both summary fields and still -transfers the call normally. Other EndSession metadata is not forwarded. +CX Agent Studio returns these values as `EndSession.metadata.summary` and +`EndSession.metadata.routing_hint`. The connector normalizes only these allowlisted fields. +The gateway copies `summary` to the single BYOVA `TRANSFER_TO_AGENT.metadata.summary` event and +`session_summary`; it copies `routing_hint` only to that event's metadata. A routing hint must +be a stable symbolic business classification (for example, `delivery_address_specialist`), not +a WxCC queue ID. If either value is absent, empty, malformed, or not text, the gateway omits it +and still transfers the call normally. Other EndSession metadata is not forwarded. ### 2. Discover exactly what your agent sends diff --git a/docs/index.md b/docs/index.md index e47daa8..8fa703f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,8 +42,8 @@ A fully functional voice AI system where customers can: - **[Local Development](LOCAL_DEVELOPMENT.md)** - Install, run, and troubleshoot the sample locally - **[JWT Authentication](JWT_AUTHENTICATION.md)** - Configure Webex runtime token validation - **[Testing](TESTING.md)** - Run automated, HTTP, gRPC, and end-to-end tests -- **[BYOVA Handoff Summary](BYOVA_HANDOFF_CONTEXT.md)** - Pass a provider-neutral handoff - summary to the receiving WxCC agent +- **[BYOVA Handoff Context](BYOVA_HANDOFF_CONTEXT.md)** - Pass provider-neutral summary and + routing-hint metadata to the receiving WxCC agent - **[Setup Guide](https://developer.webex.com/webex-contact-center/docs/byova-and-aws-lex)** - Complete step-by-step setup - **[AWS Test Deployment Considerations](AWS_TEST_DEPLOYMENT_CONSIDERATIONS.md)** - Sanitized ALB-to-EC2 test topology, Lex access, release flow, and validation boundaries - **[GECX Setup Guide](guides/byova-gecx-setup.md)** - Google CX Agent Studio integration walkthrough diff --git a/src/connectors/gecx_connector.py b/src/connectors/gecx_connector.py index 6bcaa5e..fa4683a 100644 --- a/src/connectors/gecx_connector.py +++ b/src/connectors/gecx_connector.py @@ -50,6 +50,7 @@ Request = None client_options_lib = None +from src.utils.handoff import normalize_routing_hint from src.utils.telephony_audio import G711MulawOutputConverter from .i_vendor_connector import IVendorConnector @@ -1649,13 +1650,17 @@ def _handoff_from_end_session_metadata( metadata: Dict[str, Any], ) -> Dict[str, str]: """Normalize the allowlisted GECX handoff fields for the gateway.""" + handoff: Dict[str, str] = {} raw_summary = metadata.get("summary") - if not isinstance(raw_summary, str): - return {} - summary = raw_summary.strip() - if not summary: - return {} - return {"summary": summary} + if isinstance(raw_summary, str): + summary = raw_summary.strip() + if summary: + handoff["summary"] = summary + + routing_hint = normalize_routing_hint(metadata.get("routing_hint")) + if routing_hint: + handoff["routing_hint"] = routing_hint + return handoff def _detect_transfer(self, metadata: Dict[str, Any]) -> Tuple[bool, str]: """Decide whether an EndSession represents a human handoff. diff --git a/src/connectors/i_vendor_connector.py b/src/connectors/i_vendor_connector.py index e731ee9..18c0656 100644 --- a/src/connectors/i_vendor_connector.py +++ b/src/connectors/i_vendor_connector.py @@ -447,7 +447,9 @@ def create_response(self, conversation_id: str, message_type: str = "silence", barge_in_enabled: Whether barge-in is enabled for this response output_events: List of output events to include **additional_params: Additional canonical parameters to include in the - response. A transfer may include ``handoff.summary`` for WxCC. + response. A transfer may include ``handoff.summary`` and a + symbolic ``handoff.routing_hint`` for WxCC. Do not include raw + provider metadata or customer queue IDs. Returns: Standardized response dictionary with common fields diff --git a/src/core/wxcc_gateway_server.py b/src/core/wxcc_gateway_server.py index 542b618..5219744 100644 --- a/src/core/wxcc_gateway_server.py +++ b/src/core/wxcc_gateway_server.py @@ -32,6 +32,7 @@ ) from src.generated.voicevirtualagent_pb2_grpc import VoiceVirtualAgentServicer from src.utils.audio_normalizer import normalize_wxcc_audio +from src.utils.handoff import normalize_routing_hint from src.utils.silero_speech_boundary import ( SileroSpeechBoundaryObserver, SpeechBoundarySignal, @@ -1175,14 +1176,19 @@ def _convert_connector_response_to_grpc( output_event.event_type = OutputEvent.EventType.TRANSFER_TO_AGENT output_event.name = "transfer_requested" handoff_summary = self._handoff_summary(connector_response) + routing_hint = self._handoff_routing_hint(connector_response) if handoff_summary: output_event.metadata.update({"summary": handoff_summary}) va_response.session_summary.text = handoff_summary + if routing_hint: + output_event.metadata.update({"routing_hint": routing_hint}) + if handoff_summary or routing_hint: self.logger.info( - "Forwarded handoff summary for conversation %s " - "(summary_chars=%d)", + "Forwarded allowlisted handoff data for conversation %s " + "(summary_chars=%d, routing_hint_chars=%d)", self.conversation_id, - len(handoff_summary), + len(handoff_summary or ""), + len(routing_hint or ""), ) va_response.output_events.append(output_event) self.logger.info( @@ -1203,6 +1209,18 @@ def _convert_connector_response_to_grpc( if "output_events" in connector_response: for event in connector_response["output_events"]: event_type = event.get("event_type") + if ( + message_type == "transfer" + and event_type == "TRANSFER_TO_AGENT" + ): + # The canonical transfer above owns the sole terminal + # event and its allowlisted handoff metadata. + self.logger.warning( + "Ignoring duplicate TRANSFER_TO_AGENT event for " + "conversation %s", + self.conversation_id, + ) + continue if event_type in [ "START_OF_INPUT", "END_OF_INPUT", @@ -1326,6 +1344,16 @@ def _handoff_summary( summary = raw_summary.strip() return summary or None + @staticmethod + def _handoff_routing_hint( + connector_response: Dict[str, Any], + ) -> Optional[str]: + """Return an allowlisted symbolic routing hint from canonical handoff data.""" + handoff = connector_response.get("handoff") + if not isinstance(handoff, dict): + return None + return normalize_routing_hint(handoff.get("routing_hint")) + def _create_error_response(self, error_message: str) -> VoiceVAResponse: """Create an error response.""" va_response = VoiceVAResponse() diff --git a/src/utils/handoff.py b/src/utils/handoff.py new file mode 100644 index 0000000..0c1f9e2 --- /dev/null +++ b/src/utils/handoff.py @@ -0,0 +1,24 @@ +"""Provider-neutral validation for optional BYOVA handoff data.""" + +import re +from typing import Any, Optional + +# Routing hints are stable symbolic classifications, not customer-owned WxCC +# queue identifiers. Keep the wire value simple so the Flow Designer mapping +# remains explicit and the gateway never carries a raw provider queue ID. +_ROUTING_HINT_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$") + + +def normalize_routing_hint(value: Any) -> Optional[str]: + """Return a normalized symbolic routing hint or ``None`` when invalid. + + Hints must be short ASCII identifiers beginning with a letter. This permits + stable business classifications such as ``billing_specialist`` while + rejecting empty values, free-form text, and numeric queue identifiers. + """ + if not isinstance(value, str): + return None + routing_hint = value.strip() + if not _ROUTING_HINT_PATTERN.fullmatch(routing_hint): + return None + return routing_hint diff --git a/tests/test_gecx_connector.py b/tests/test_gecx_connector.py index b34d6c8..62a4fee 100644 --- a/tests/test_gecx_connector.py +++ b/tests/test_gecx_connector.py @@ -1101,12 +1101,15 @@ def test_end_session_with_session_escalated_flag_emits_transfer(self, connector) responses = self._end_session(connector, {"session_escalated": True}) assert responses[0]["message_type"] == "transfer" - def test_end_session_with_summary_normalizes_handoff(self, connector): + def test_end_session_with_summary_and_routing_hint_normalizes_handoff( + self, connector + ): responses = self._end_session( connector, { "session_escalated": True, "summary": " Caller needs help changing a delivery address. ", + "routing_hint": " delivery_address_specialist ", }, ) @@ -1121,11 +1124,22 @@ def test_end_session_with_summary_normalizes_handoff(self, connector): "output_events": [], "response_type": "final", "handoff": { - "summary": "Caller needs help changing a delivery address." + "summary": "Caller needs help changing a delivery address.", + "routing_hint": "delivery_address_specialist", }, } ] + def test_end_session_with_routing_hint_normalizes_handoff(self, connector): + responses = self._end_session( + connector, + {"session_escalated": True, "routing_hint": "billing_specialist"}, + ) + + assert len(responses) == 1 + assert responses[0]["message_type"] == "transfer" + assert responses[0]["handoff"] == {"routing_hint": "billing_specialist"} + @pytest.mark.parametrize("summary", [None, "", " ", True, ["not", "text"]]) def test_end_session_without_valid_summary_preserves_transfer( self, connector, summary @@ -1138,6 +1152,27 @@ def test_end_session_without_valid_summary_preserves_transfer( assert responses[0]["message_type"] == "transfer" assert "handoff" not in responses[0] + @pytest.mark.parametrize( + "routing_hint", + [None, "", " ", True, 12345, "12345", "billing queue", "x" * 65], + ) + def test_end_session_with_invalid_routing_hint_preserves_transfer( + self, connector, routing_hint + ): + responses = self._end_session( + connector, + { + "session_escalated": True, + "summary": "Caller needs a human.", + "routing_hint": routing_hint, + "customer_queue_id": "raw-queue-id-98765", + }, + ) + + assert len(responses) == 1 + assert responses[0]["message_type"] == "transfer" + assert responses[0]["handoff"] == {"summary": "Caller needs a human."} + def test_session_output_end_session_uses_nested_metadata(self, connector): session = GECXStreamingSession( connector=connector, @@ -1205,6 +1240,8 @@ def test_terminal_logs_metadata_keys_without_values(self, connector, caplog): "customer_email": "guest@example.com", "reason": "private routing identifier", "summary": "Caller supplied private account information.", + "routing_hint": "private_routing_classification", + "customer_queue_id": "private-queue-id-12345", }, ) @@ -1212,6 +1249,8 @@ def test_terminal_logs_metadata_keys_without_values(self, connector, caplog): assert "guest@example.com" not in caplog.text assert "private routing identifier" not in caplog.text assert "Caller supplied private account information." not in caplog.text + assert "private_routing_classification" not in caplog.text + assert "private-queue-id-12345" not in caplog.text def test_end_session_with_escalation_key_name_emits_transfer(self, connector): # Key-name keyword match catches naming variants generically. diff --git a/tests/test_wxcc_gateway_server.py b/tests/test_wxcc_gateway_server.py index e646e2e..b102a29 100644 --- a/tests/test_wxcc_gateway_server.py +++ b/tests/test_wxcc_gateway_server.py @@ -909,7 +909,9 @@ def test_process_audio_input_with_transfer_to_agent_event(self, processor, mock_ assert dict(event.metadata) == {} assert not response.HasField("session_summary") - def test_transfer_handoff_summary_is_forwarded_to_wxcc(self, processor): + def test_transfer_handoff_summary_and_routing_hint_are_forwarded_to_wxcc( + self, processor + ): response = processor._convert_connector_response_to_grpc( { "message_type": "transfer", @@ -918,7 +920,8 @@ def test_transfer_handoff_summary_is_forwarded_to_wxcc(self, processor): "barge_in_enabled": False, "response_type": "final", "handoff": { - "summary": "Caller needs help changing a delivery address." + "summary": "Caller needs help changing a delivery address.", + "routing_hint": "delivery_address_specialist", }, } ) @@ -931,9 +934,59 @@ def test_transfer_handoff_summary_is_forwarded_to_wxcc(self, processor): assert event.metadata["summary"] == ( "Caller needs help changing a delivery address." ) + assert event.metadata["routing_hint"] == "delivery_address_specialist" assert response.session_summary.text == ( "Caller needs help changing a delivery address." ) + assert response.session_summary.text != event.metadata["routing_hint"] + + def test_transfer_routing_hint_is_only_forwarded_in_event_metadata( + self, processor + ): + response = processor._convert_connector_response_to_grpc( + { + "message_type": "transfer", + "text": "Transferring you to a human agent.", + "audio_content": b"", + "response_type": "final", + "handoff": { + "routing_hint": "billing_specialist", + "customer_queue_id": "raw-queue-id-98765", + }, + } + ) + + assert response is not None + assert len(response.output_events) == 1 + event = response.output_events[0] + assert event.event_type == 2 # TRANSFER_TO_AGENT + assert dict(event.metadata) == {"routing_hint": "billing_specialist"} + assert not response.HasField("session_summary") + + def test_transfer_uses_one_allowlisted_terminal_event(self, processor): + response = processor._convert_connector_response_to_grpc( + { + "message_type": "transfer", + "text": "Transferring you to a human agent.", + "audio_content": b"", + "response_type": "final", + "handoff": {"routing_hint": "billing_specialist"}, + "output_events": [ + { + "event_type": "TRANSFER_TO_AGENT", + "name": "provider_transfer", + "metadata": {"customer_queue_id": "raw-queue-id-98765"}, + } + ], + } + ) + + assert response is not None + assert len(response.output_events) == 1 + event = response.output_events[0] + assert event.event_type == 2 # TRANSFER_TO_AGENT + assert event.name == "transfer_requested" + assert dict(event.metadata) == {"routing_hint": "billing_specialist"} @pytest.mark.parametrize("summary", [None, "", " ", False, {"text": "no"}]) def test_transfer_without_valid_handoff_summary_remains_supported( @@ -954,6 +1007,31 @@ def test_transfer_without_valid_handoff_summary_remains_supported( assert dict(response.output_events[0].metadata) == {} assert not response.HasField("session_summary") + @pytest.mark.parametrize( + "routing_hint", + [None, "", " ", False, 12345, "12345", "billing queue", "x" * 65], + ) + def test_transfer_without_valid_handoff_routing_hint_remains_supported( + self, processor, routing_hint + ): + response = processor._convert_connector_response_to_grpc( + { + "message_type": "transfer", + "text": "Transferring you to a human agent.", + "audio_content": b"", + "response_type": "final", + "handoff": { + "routing_hint": routing_hint, + "customer_queue_id": "raw-queue-id-98765", + }, + } + ) + + assert response is not None + assert len(response.output_events) == 1 + assert dict(response.output_events[0].metadata) == {} + assert not response.HasField("session_summary") + def test_handoff_summary_is_not_written_to_gateway_logs(self, processor, caplog): caplog.set_level(logging.DEBUG) @@ -963,11 +1041,17 @@ def test_handoff_summary_is_not_written_to_gateway_logs(self, processor, caplog) "text": "", "audio_content": b"", "response_type": "final", - "handoff": {"summary": "Private caller handoff details."}, + "handoff": { + "summary": "Private caller handoff details.", + "routing_hint": "private_routing_classification", + "customer_queue_id": "private-queue-id-12345", + }, } ) assert "Private caller handoff details." not in caplog.text + assert "private_routing_classification" not in caplog.text + assert "private-queue-id-12345" not in caplog.text def test_process_audio_input_with_output_events(self, processor, mock_router, mock_audio_input): """Test processing audio input with custom output events from connector.""" From fc2b8e1ac2f44c4187a63852341742c0b7fe47ea Mon Sep 17 00:00:00 2001 From: Adam Weeks Date: Tue, 1 Sep 2026 10:58:55 -0400 Subject: [PATCH 2/3] refactor: share BYOVA handoff normalization --- docs/BYOVA_HANDOFF_CONTEXT.md | 26 +++++++++++++++++++++++ src/connectors/gecx_connector.py | 16 +++----------- src/connectors/i_vendor_connector.py | 3 ++- src/core/wxcc_gateway_server.py | 31 ++++------------------------ src/utils/handoff.py | 27 +++++++++++++++++++++++- tests/test_handoff.py | 25 ++++++++++++++++++++++ 6 files changed, 86 insertions(+), 42 deletions(-) create mode 100644 tests/test_handoff.py diff --git a/docs/BYOVA_HANDOFF_CONTEXT.md b/docs/BYOVA_HANDOFF_CONTEXT.md index 2067cf2..f3c50ed 100644 --- a/docs/BYOVA_HANDOFF_CONTEXT.md +++ b/docs/BYOVA_HANDOFF_CONTEXT.md @@ -76,6 +76,32 @@ They should extract only the approved fields and normalize them into this shape. agent sends the business classification; the customer owns the mapping from that classification to WxCC queue IDs. +### Connector integration contract + +This is a gateway contract, not a GECX feature. Any connector can extract equivalent terminal +data from its provider and attach the canonical handoff object to its `transfer` response: + +```python +from src.utils.handoff import normalize_handoff + +handoff = normalize_handoff( + { + "summary": provider_summary, + "routing_hint": provider_business_classification, + } +) +return self.create_response( + conversation_id=conversation_id, + message_type="transfer", + handoff=handoff, +) +``` + +`normalize_handoff()` is the shared gateway allowlist. It discards provider-specific fields, +invalid values, and raw queue IDs; connectors must never pass the full provider terminal +payload. GECX uses this helper for `EndSession.metadata`, but no GECX dependency exists in the +gateway contract. + ## BYOVA Transfer Response The gateway should create one final `VoiceVAResponse` containing one diff --git a/src/connectors/gecx_connector.py b/src/connectors/gecx_connector.py index fa4683a..dc9a00c 100644 --- a/src/connectors/gecx_connector.py +++ b/src/connectors/gecx_connector.py @@ -50,7 +50,7 @@ Request = None client_options_lib = None -from src.utils.handoff import normalize_routing_hint +from src.utils.handoff import normalize_handoff from src.utils.telephony_audio import G711MulawOutputConverter from .i_vendor_connector import IVendorConnector @@ -1649,18 +1649,8 @@ def _metadata_to_dict(end_obj: Any) -> Dict[str, Any]: def _handoff_from_end_session_metadata( metadata: Dict[str, Any], ) -> Dict[str, str]: - """Normalize the allowlisted GECX handoff fields for the gateway.""" - handoff: Dict[str, str] = {} - raw_summary = metadata.get("summary") - if isinstance(raw_summary, str): - summary = raw_summary.strip() - if summary: - handoff["summary"] = summary - - routing_hint = normalize_routing_hint(metadata.get("routing_hint")) - if routing_hint: - handoff["routing_hint"] = routing_hint - return handoff + """Map GECX terminal metadata through the shared handoff contract.""" + return normalize_handoff(metadata) def _detect_transfer(self, metadata: Dict[str, Any]) -> Tuple[bool, str]: """Decide whether an EndSession represents a human handoff. diff --git a/src/connectors/i_vendor_connector.py b/src/connectors/i_vendor_connector.py index 18c0656..5ece023 100644 --- a/src/connectors/i_vendor_connector.py +++ b/src/connectors/i_vendor_connector.py @@ -449,7 +449,8 @@ def create_response(self, conversation_id: str, message_type: str = "silence", **additional_params: Additional canonical parameters to include in the response. A transfer may include ``handoff.summary`` and a symbolic ``handoff.routing_hint`` for WxCC. Do not include raw - provider metadata or customer queue IDs. + provider metadata or customer queue IDs; use + ``src.utils.handoff.normalize_handoff`` to build this object. Returns: Standardized response dictionary with common fields diff --git a/src/core/wxcc_gateway_server.py b/src/core/wxcc_gateway_server.py index 5219744..4b3942e 100644 --- a/src/core/wxcc_gateway_server.py +++ b/src/core/wxcc_gateway_server.py @@ -32,7 +32,7 @@ ) from src.generated.voicevirtualagent_pb2_grpc import VoiceVirtualAgentServicer from src.utils.audio_normalizer import normalize_wxcc_audio -from src.utils.handoff import normalize_routing_hint +from src.utils.handoff import normalize_handoff from src.utils.silero_speech_boundary import ( SileroSpeechBoundaryObserver, SpeechBoundarySignal, @@ -1175,8 +1175,9 @@ def _convert_connector_response_to_grpc( output_event = OutputEvent() output_event.event_type = OutputEvent.EventType.TRANSFER_TO_AGENT output_event.name = "transfer_requested" - handoff_summary = self._handoff_summary(connector_response) - routing_hint = self._handoff_routing_hint(connector_response) + handoff = normalize_handoff(connector_response.get("handoff")) + handoff_summary = handoff.get("summary") + routing_hint = handoff.get("routing_hint") if handoff_summary: output_event.metadata.update({"summary": handoff_summary}) va_response.session_summary.text = handoff_summary @@ -1330,30 +1331,6 @@ def _convert_connector_response_to_grpc( self.logger.error(f"Traceback: {traceback.format_exc()}") return self._create_error_response(f"Response conversion error: {str(e)}") - @staticmethod - def _handoff_summary( - connector_response: Dict[str, Any], - ) -> Optional[str]: - """Return a non-empty allowlisted summary from canonical handoff data.""" - handoff = connector_response.get("handoff") - if not isinstance(handoff, dict): - return None - raw_summary = handoff.get("summary") - if not isinstance(raw_summary, str): - return None - summary = raw_summary.strip() - return summary or None - - @staticmethod - def _handoff_routing_hint( - connector_response: Dict[str, Any], - ) -> Optional[str]: - """Return an allowlisted symbolic routing hint from canonical handoff data.""" - handoff = connector_response.get("handoff") - if not isinstance(handoff, dict): - return None - return normalize_routing_hint(handoff.get("routing_hint")) - def _create_error_response(self, error_message: str) -> VoiceVAResponse: """Create an error response.""" va_response = VoiceVAResponse() diff --git a/src/utils/handoff.py b/src/utils/handoff.py index 0c1f9e2..e4b0ef3 100644 --- a/src/utils/handoff.py +++ b/src/utils/handoff.py @@ -1,7 +1,8 @@ """Provider-neutral validation for optional BYOVA handoff data.""" import re -from typing import Any, Optional +from collections.abc import Mapping +from typing import Any, Dict, Optional # Routing hints are stable symbolic classifications, not customer-owned WxCC # queue identifiers. Keep the wire value simple so the Flow Designer mapping @@ -22,3 +23,27 @@ def normalize_routing_hint(value: Any) -> Optional[str]: if not _ROUTING_HINT_PATTERN.fullmatch(routing_hint): return None return routing_hint + + +def normalize_handoff(value: Any) -> Dict[str, str]: + """Return the canonical allowlisted handoff shape for any connector. + + A connector extracts provider-specific terminal data, passes a mapping with + the canonical field names, and receives only valid ``summary`` and + ``routing_hint`` values. All other provider fields are intentionally + discarded at this trust boundary. + """ + if not isinstance(value, Mapping): + return {} + + handoff: Dict[str, str] = {} + raw_summary = value.get("summary") + if isinstance(raw_summary, str): + summary = raw_summary.strip() + if summary: + handoff["summary"] = summary + + routing_hint = normalize_routing_hint(value.get("routing_hint")) + if routing_hint: + handoff["routing_hint"] = routing_hint + return handoff diff --git a/tests/test_handoff.py b/tests/test_handoff.py new file mode 100644 index 0000000..c3f604a --- /dev/null +++ b/tests/test_handoff.py @@ -0,0 +1,25 @@ +"""Tests for provider-neutral BYOVA handoff normalization.""" + +from src.utils.handoff import normalize_handoff + + +def test_normalize_handoff_keeps_only_canonical_fields(): + handoff = normalize_handoff( + { + "summary": " Caller needs billing help. ", + "routing_hint": " billing_specialist ", + "provider_queue_id": "raw-queue-id-98765", + "provider_diagnostics": {"internal": True}, + } + ) + + assert handoff == { + "summary": "Caller needs billing help.", + "routing_hint": "billing_specialist", + } + + +def test_normalize_handoff_omits_invalid_optional_fields(): + assert normalize_handoff( + {"summary": ["not", "text"], "routing_hint": "billing queue"} + ) == {} From b9881cbde23aafcbced2793d2e7c73c8c7eff370 Mon Sep 17 00:00:00 2001 From: Adam Weeks Date: Tue, 1 Sep 2026 12:01:22 -0400 Subject: [PATCH 3/3] docs: clarify BYOVA handoff flow routing --- docs/BYOVA_HANDOFF_CONTEXT.md | 55 +++++++++++++++++++++++++++------ docs/README.md | 5 +-- docs/guides/byova-gecx-setup.md | 3 ++ 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/BYOVA_HANDOFF_CONTEXT.md b/docs/BYOVA_HANDOFF_CONTEXT.md index f3c50ed..533a7ba 100644 --- a/docs/BYOVA_HANDOFF_CONTEXT.md +++ b/docs/BYOVA_HANDOFF_CONTEXT.md @@ -161,7 +161,7 @@ BYOVARoutingHint = {{BYOVA_Virtual_Agent.MetaData.routing_hint}} ``` The Virtual Agent activity name is flow-specific; replace `BYOVA_Virtual_Agent` with the -actual activity name. Configure the custom variable as: +actual activity name. Configure the summary variable as: | Setting | Value | | --- | --- | @@ -173,6 +173,19 @@ actual activity name. Configure the custom variable as: These settings live in the flow's **Global flow properties**. They do not require a custom Agent Desktop JSON layout. +Configure `BYOVARoutingHint` separately in the same location: + +| Setting | Value | +| --- | --- | +| Type | String | +| Default value | Empty | +| Agent viewable | Disabled | +| Agent editable | Disabled | + +`BYOVARoutingHint` is a routing-only value. Do not add it to an Agent Desktop surface or use +it as a customer-facing label. The flow owns the mapping from its symbolic value to an approved +queue. + ### 1. Create the agent-viewable variable Open **Variable definition > Configuration**, create the String variable, and leave its @@ -206,20 +219,34 @@ Keep the human-routing path independent of the optional value: - Do not invent a fallback summary. Leave the agent-viewable variable empty when no summary was supplied. -### Route with an approved customer-owned queue map +### 3. Route with an approved customer-owned queue map -Use a **Case** activity on `BYOVARoutingHint` after the Virtual Agent V2 **Escalated** branch. -Each case value must be an approved symbolic classification, such as -`delivery_address_specialist` or `billing_specialist`, and each branch should lead to the -customer's approved WxCC queue for that classification. Do not ask the virtual agent to send a -WxCC queue ID and do not use a provider-supplied ID directly as a queue target. +On the Virtual Agent V2 **Escalated** branch, add a second **Set Variable** activity after the +summary assignment. Select `BYOVARoutingHint`, choose **Set value**, and enter the routing-hint +expression shown above. Connect its success path to a **Case** activity whose input is +`BYOVARoutingHint`. + +Configure the Case branches only with classifications approved by the customer. For example: + +| Case value | Flow action | +| --- | --- | +| `billing_specialist` | Queue Contact to the approved billing queue | +| `delivery_address_specialist` | Queue Contact to the approved delivery-address queue | +| Default | Queue Contact to the normal fallback human queue | + +The labels above are examples, not gateway configuration. Do not ask the virtual agent to send +a WxCC queue ID and do not use a provider-supplied ID directly as a queue target. The customer +can add, remove, or remap classifications in Flow Designer without changing the provider or +gateway. Always connect the Case activity's **Default** branch to the normal fallback human queue. This default handles a missing, empty, malformed, or unknown hint so the human transfer still -succeeds. The customer can add, remove, or remap approved classifications in Flow Designer -without changing the provider or gateway. +succeeds. If a missing nested `routing_hint` key produces an **Undefined Error** in the Set +Variable activity, connect that error path to the same fallback human queue, or guard the +assignment with an equivalent condition. Do not send the contact to a Virtual Agent failure +branch only because the optional routing hint is absent. -### 3. Select the Agent Desktop surfaces +### 4. Select the Agent Desktop surfaces Open **Variable definition > Desktop viewability & order**. Add `BYOVAHandoffSummary` to both **Incoming popover** and **Interaction control pane and @@ -231,6 +258,14 @@ configuration has been validated. The transfer must continue when the provider does not supply a summary or routing hint; absent handoff data is not a routing failure. +### 5. Validate before publishing + +Before publishing the flow, use synthetic handoff values to verify each approved Case branch, +the Default branch for an unknown hint, and the fallback path for a missing hint. Confirm that +only `BYOVAHandoffSummary` appears in the incoming popover and interaction control pane, and +that both missing-field error paths still reach a human queue. Do not add real queue IDs, +customer data, or provider diagnostics to the test values. + ## Validated Agent Desktop Behavior The following screenshots were captured in a nonproduction WxCC organization with synthetic diff --git a/docs/README.md b/docs/README.md index f192a4e..36993a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,8 +24,9 @@ code, not a managed connector or a production-ready service. connectors, including known sample-only placeholders. - [Testing Guide](TESTING.md): Automated tests, HTTP smoke tests, gRPC health checks, and end-to-end validation. -- [BYOVA Handoff Summary](BYOVA_HANDOFF_CONTEXT.md): Provider-neutral summary metadata - contract for human-agent transfer, including validated Agent Desktop behavior. +- [BYOVA Handoff Context](BYOVA_HANDOFF_CONTEXT.md): Provider-neutral summary and routing-hint + contract for human-agent transfer, including Flow Designer mapping and validated Agent Desktop + behavior. - [Connector Guide](../src/connectors/README.md): Connector interface, available connectors, and extension pattern. - [Monitoring Interface](../src/monitoring/README.md): Dashboard behavior, Webex OAuth, APIs, diff --git a/docs/guides/byova-gecx-setup.md b/docs/guides/byova-gecx-setup.md index 3598cf9..e74c03c 100644 --- a/docs/guides/byova-gecx-setup.md +++ b/docs/guides/byova-gecx-setup.md @@ -136,6 +136,9 @@ Open `http://localhost:8080` and confirm **My GECX Agent** appears in the dashbo 2. Use schema `5397013b-7920-4ffc-807c-e8a3e0a18f43`. 3. In your WxCC flow, add the Virtual Agent / BYOVA element. 4. Select agent name **My GECX Agent** (must match `agents` in config). +5. For human escalation, follow the provider-neutral [BYOVA Handoff Context guide](../BYOVA_HANDOFF_CONTEXT.md) + to map the optional summary and routing hint into Flow Designer. GECX `EndSession` metadata + is one source for those fields; the Flow Designer mapping is the same for every connector. ## Deploying to Google Cloud Run