diff --git a/README.md b/README.md index 6914d7e..70ed06b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ BW_VOICE_APPLICATION_ID # A Bandwidth Voice Application ID. Used to auto-con BW_MCP_PROFILE # Named tool preset (voice, messaging, lookup, onboarding, recordings, full). Comma-separated to combine. BW_MCP_TOOLS # Explicit tool allowlist (comma-separated operationIds). Overrides BW_MCP_PROFILE. BW_MCP_EXCLUDE_TOOLS # Explicit tool denylist (comma-separated). Takes priority over BW_MCP_TOOLS and profiles. +BW_MCP_REDIRECT_TIMEOUT_SECONDS # Seconds a call waits with no agent response queued before the server gives up and hangs up instead of redirecting forever. Default 12. BW_ENVIRONMENT # `test` or `uat` to target Bandwidth's test environment. Defaults to prod. BW_API_URL # API gateway override. Also serves the Dashboard XML API under /api/v2. BW_VOICE_URL # Voice API base override. diff --git a/src/callbacks.py b/src/callbacks.py index 941ac5a..070cc93 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -7,10 +7,29 @@ so they're served on the same HTTP transport as MCP tools. """ +import os + from starlette.requests import Request from starlette.responses import JSONResponse, Response -from event_store import EventStore +from event_store import CallState, EventStore + +# How long a call is allowed to sit in the redirect-wait loop with no agent +# response queued before the server gives up and hangs up on its own, +# instead of redirecting forever. Bandwidth's own callback retry behavior is +# not something this server controls or has empirically measured (see the +# open question in the conversational-voice spike proposal) -- this bound +# exists purely so *this server* stops perpetuating the loop past a sane +# point, regardless of how long Bandwidth itself would keep retrying. +# Configurable because that real-world number is unknown; 12s is a +# deliberately conservative guess, not a measured value. +_REDIRECT_TIMEOUT_SECONDS = float(os.environ.get("BW_MCP_REDIRECT_TIMEOUT_SECONDS", "12")) + +_TIMEOUT_BXML = ( + '' + "Sorry, we didn't get a response in time. Goodbye." + "" +) def _bxml_response(bxml: str) -> Response: @@ -21,6 +40,25 @@ def _redirect_bxml(call_id: str) -> str: return f'' +def _wait_or_give_up(call: CallState) -> str: + """Return the BXML for a call with no pending agent response yet: either + another bounded redirect, or -- once _REDIRECT_TIMEOUT_SECONDS has + elapsed with nobody queuing a response -- a real terminal response that + ends the call instead of looping back to /continue forever. + + Without this, a call whose agent never shows up (crashed, still + reasoning, never listening) redirects to itself indefinitely: Bandwidth + keeps POSTing to /continue, this server keeps replying with another + redirect to the same URL, and the call just hangs there until Bandwidth's + own side eventually gives up (if it ever does) -- reported directly by + the voice platform team as an infinite redirect loop. + """ + waited = call.start_waiting_if_unset() + if waited >= _REDIRECT_TIMEOUT_SECONDS: + return _TIMEOUT_BXML + return _redirect_bxml(call.call_id) + + def register_callback_routes(mcp, event_store: EventStore) -> None: """Register callback HTTP routes on the FastMCP server.""" @@ -56,13 +94,13 @@ async def voice_answer(request: Request) -> Response: return _bxml_response(bxml) # No pre-queued BXML — create call state and redirect to wait for agent - event_store.create_call( + call = event_store.create_call( call_id=call_id, from_number=payload.get("from", ""), to_number=payload.get("to", ""), application_id=payload.get("applicationId", ""), ) - return _bxml_response(_redirect_bxml(call_id)) + return _bxml_response(_wait_or_give_up(call)) @mcp.custom_route("/callbacks/voice/gather", methods=["POST"]) async def voice_gather(request: Request) -> Response: @@ -76,6 +114,7 @@ async def voice_gather(request: Request) -> Response: digits = payload.get("digits", "") text = transcript or digits or "(no input)" call.add_turn("caller", text) + return _bxml_response(_wait_or_give_up(call)) return _bxml_response(_redirect_bxml(call_id)) @mcp.custom_route("/callbacks/voice/disconnect", methods=["POST"]) @@ -94,4 +133,5 @@ async def voice_continue(request: Request) -> Response: bxml = call.consume_pending_bxml() if bxml: return _bxml_response(bxml) + return _bxml_response(_wait_or_give_up(call)) return _bxml_response(_redirect_bxml(call_id)) diff --git a/src/event_store.py b/src/event_store.py index 65d84a5..d4fd609 100644 --- a/src/event_store.py +++ b/src/event_store.py @@ -20,6 +20,11 @@ class CallState: turns: list[dict] = field(default_factory=list) pending_bxml: Optional[str] = None metadata: dict = field(default_factory=dict) + # Tracks how long this call has been sitting in the redirect loop waiting + # for an agent to queue a response, so the loop can be bounded instead of + # infinite. Reset to None once BXML is delivered; set on the first + # redirect and read on every subsequent one. + waiting_since: Optional[float] = None def add_turn(self, role: str, text: str) -> None: self.turns.append({"role": role, "text": text, "timestamp": time.time()}) @@ -28,6 +33,7 @@ def try_set_bxml(self, bxml: str) -> bool: if self.pending_bxml is not None: return False self.pending_bxml = bxml + self.waiting_since = None return True def consume_pending_bxml(self) -> Optional[str]: @@ -35,6 +41,15 @@ def consume_pending_bxml(self) -> Optional[str]: self.pending_bxml = None return bxml + def start_waiting_if_unset(self) -> float: + """Mark the start of a redirect-wait period if one isn't already + running, and return how long the call has been waiting.""" + now = time.time() + if self.waiting_since is None: + self.waiting_since = now + return 0.0 + return now - self.waiting_since + class EventStore: def __init__(self, max_events: int = 1000, ttl_seconds: int = 3600): diff --git a/src/specs/AGENTS.md b/src/specs/AGENTS.md index 3421a48..952ea89 100644 --- a/src/specs/AGENTS.md +++ b/src/specs/AGENTS.md @@ -268,6 +268,23 @@ The EventStore (the in-memory queue feeding `getCallbackEvents` and an hour. Don't rely on it as durable storage; pull events as soon as you need them and persist anything you care about long-term. +### Redirect-wait timeout + +A call with no BXML queued yet redirects to `/callbacks/voice/continue/{call_id}` +so the agent has time to respond. That wait is bounded: if +`BW_MCP_REDIRECT_TIMEOUT_SECONDS` (default 12) elapses with nothing queued, +the server stops redirecting and returns a terminal response (a short +apology + `Hangup`) instead of looping forever. This applies at every point +a call can be waiting on an agent — the initial answer, a gather turn, or a +bare `/continue` poll. + +If your agent needs longer than 12 seconds to generate a response (e.g. it's +doing multi-step reasoning before its first `respondToCallback` / +`respondToGather` call), raise `BW_MCP_REDIRECT_TIMEOUT_SECONDS` accordingly. +The default is a conservative guess, not a measured bound on how long +Bandwidth itself will keep retrying — treat it as this server's own +patience, not Bandwidth's. + ## Provisioning workflows ### Build Registration diff --git a/test/test_callbacks.py b/test/test_callbacks.py index 0cc3760..357aa2d 100644 --- a/test/test_callbacks.py +++ b/test/test_callbacks.py @@ -102,7 +102,79 @@ def test_continue_returns_pending_bxml(self, client, event_store): assert call.pending_bxml is None def test_continue_redirects_when_no_bxml(self, client, event_store): - event_store.create_call("call-200", "+11111111111", "+12222222222", "app-1") + event_store.create_call("call-200", "+111****1111", "+122****2222", "app-1") response = client.post("/callbacks/voice/continue/call-200") assert response.status_code == 200 assert "") + assert call.waiting_since is None + + call.consume_pending_bxml() + assert call.start_waiting_if_unset() == 0.0