Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 43 additions & 3 deletions src/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
'<Response><SpeakSentence>'
"Sorry, we didn't get a response in time. Goodbye."
"</SpeakSentence><Hangup /></Response>"
)


def _bxml_response(bxml: str) -> Response:
Expand All @@ -21,6 +40,25 @@ def _redirect_bxml(call_id: str) -> str:
return f'<Response><Redirect redirectUrl="/callbacks/voice/continue/{call_id}" /></Response>'


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."""

Expand Down Expand Up @@ -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:
Expand All @@ -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"])
Expand All @@ -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))
15 changes: 15 additions & 0 deletions src/event_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()})
Expand All @@ -28,13 +33,23 @@ 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]:
bxml = self.pending_bxml
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):
Expand Down
17 changes: 17 additions & 0 deletions src/specs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 73 additions & 1 deletion test/test_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<Redirect" in response.text

def test_continue_stops_redirecting_after_timeout(self, client, event_store, monkeypatch):
"""Regression for the infinite-redirect-loop bug reported by the
voice platform team: a call with no agent response ever queued must
eventually get a terminal response (SpeakSentence + Hangup), not
redirect to /continue forever."""
import src.callbacks as callbacks_module

monkeypatch.setattr(callbacks_module, "_REDIRECT_TIMEOUT_SECONDS", 0)

event_store.create_call("call-300", "+111****1111", "+122****2222", "app-1")
response = client.post("/callbacks/voice/continue/call-300")
assert response.status_code == 200
assert "<Redirect" not in response.text
assert "<Hangup" in response.text

def test_answer_stops_redirecting_after_timeout(self, client, event_store, monkeypatch):
"""Same bound applies starting from the very first /answer redirect,
not just /continue -- a call that never even gets a first agent
response must still terminate instead of looping."""
import src.callbacks as callbacks_module

monkeypatch.setattr(callbacks_module, "_REDIRECT_TIMEOUT_SECONDS", 0)

payload = {
"eventType": "answer",
"callId": "call-301",
"from": "+191****1234",
"to": "+191****4321",
"applicationId": "app-1",
}
response = client.post("/callbacks/voice/answer", json=payload)
assert response.status_code == 200
assert "<Redirect" not in response.text
assert "<Hangup" in response.text

def test_gather_stops_redirecting_after_timeout(self, client, event_store, monkeypatch):
"""Same bound applies to the gather loop -- a caller who keeps
talking but never gets an agent response must still eventually get
hung up on rather than looping forever."""
import src.callbacks as callbacks_module

monkeypatch.setattr(callbacks_module, "_REDIRECT_TIMEOUT_SECONDS", 0)

event_store.create_call("call-302", "+191****1234", "+191****4321", "app-1")
payload = {
"eventType": "gather",
"callId": "call-302",
"digits": "",
"terminatingDigit": "",
"speech": {"transcript": "hello?", "confidence": 0.9},
}
response = client.post("/callbacks/voice/gather", json=payload)
assert response.status_code == 200
assert "<Redirect" not in response.text
assert "<Hangup" in response.text

def test_redirect_wait_resets_once_bxml_is_queued(self, event_store):
"""waiting_since must clear once an agent's BXML actually lands, so a
later, unrelated stall (e.g. a second conversational turn) starts a
fresh timeout window instead of inheriting an old one."""
from src.event_store import CallState

call = event_store.create_call("call-400", "+1", "+2", "app-1")
assert call.start_waiting_if_unset() == 0.0
assert call.waiting_since is not None

assert call.try_set_bxml("<Response><Hangup /></Response>")
assert call.waiting_since is None

call.consume_pending_bxml()
assert call.start_waiting_if_unset() == 0.0