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