Repository navigation
Twilio: inbound calls on ConversationRelay and a response watchdog - #1461
Conversation
Inbound calls for agents listed in ConversationRelay.AgentIds now connect to ConversationRelay (streamed replies) instead of the Gather flow. The welcome prompts queued by session hooks become the relay greeting through their Twilio:PromptTexts text. Calls that arrive with an intent keep the Gather flow, which already has their first message queued. ConversationRelay.ResponseWatchdogSeconds (off by default) says one short filler from ResponseWatchdogPrompts (default hold-on-1..5) when the agent has said nothing that long after the caller finished, at most once per turn. ResponseWatchdogAgentIds limits it to some agents. The filler is decided while holding the send lock, so it never lands inside a streamed reply, and a turn whose reply is already in never gets one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the same twice ResponseWatchdogSeconds becomes ResponseWatchdogIntervals: seconds of silence before each filler while the agent works on a reply. The first counts from when the caller finished, each next one from when whatever was said last (a filler, an indication, a streamed reply) ends; the last value repeats until the reply comes. The first filler of a turn comes from ResponseWatchdogPrompts (default hold-on-1..5), later ones from ResponseWatchdogLongPrompts (default hold-on-long-1..10), and no sentence is repeated in a turn while unused ones remain. Still nothing while a reply is streaming, and nothing once the turn's reply is in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR Summary by QodoAdd Twilio ConversationRelay calls and a response watchdog
AI Description
Diagram
High-Level Assessment
Files changed (22)
|
…tool call
A line the model says before a slow tool ("Okay, that's everything I need")
stayed an open utterance until the next response started streaming, so the
response watchdog never filled the silence while the tool ran. The relay now
closes it as soon as the response turns out to be a tool call
(AgentTurnCallbacks.OnStreamEndedWithToolCall), and the watchdog counts from
the end of that line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reply cut off by max_output_tokens is replaced by a fixed notice, so the log is the only record of what the model wrote. The streaming path logged nothing, and the non-streaming paths left out the tool calls, which is where a cut-off usually happens. All three now log the text and the tool calls with their arguments as far as the model got. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inbound ConversationRelay greeting could only come from the prompt texts behind the speech paths the session hooks queue, which are fixed. A hook that writes ConversationalVoiceResponse.Text (e.g. a greeting with the caller's name) now has that said instead; without it the greeting is built as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A streamed piece, an idle "say it again" prompt or a spoken line was checked first and sent after waiting for the send lock, so the decision could go stale meanwhile: - a streamed piece queued behind a filler could go out after the caller cut in or the session ended, leaving one fragment of the reply; - an idle prompt could be sent after caller input had started a turn, landing before or over that turn's reply. SendIfAsync now takes the send lock first and evaluates the condition under _sync right before sending; streaming bookkeeping changes only when the piece is actually sent. Every nonempty partial prompt (partialPrompts) now counts as caller activity, so a caller mid-sentence is never asked to repeat or treated as silent. Tests (onebrain host): a reply queued behind a filler goes out whole, a cut-in on a streamed reply stops the rest, and a caller sending partial prompts past the idle timeout is not prompted. The first and last fail on the previous code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a reply hits the output limit the provider logged every tool call's arguments and, on the streaming path, the whole reply. Both can hold the user's personal data (phone numbers, emails, addresses) and the line is a warning that reaches the shared log store, for every agent on this provider. Tool calls are now logged by name and argument length, and the reply by its length and first 200 characters, on all three paths. That still shows which tool the model was writing and how far it got. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d send ordering - Refuse the relay handshake when ValidateSignature is on but the auth token is missing, instead of letting every handshake through. - Record how long a frame takes to say before it is sent, so a cut-in that arrives while it plays restarts the silence clock instead of being overwritten. - Close a streamed utterance only once the send lock is held, and recheck the silence when a filler gets the lock, so no filler slips in right after (or inside) a line closed before a tool call. A filler that is no longer due waits for the next silence instead of ending the watchdog for the turn. - Mark the session ended before waiting on the idle watchdog, so a turn finishing during shutdown cannot pick up another caller input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
master's 04cefd8 and this branch's 7efa760 both moved marking the relay session ended into the finally around the receive loop, before waiting on the idle watcher. Kept master's version, which also logs the dropped inputs there, and removed the branch's now duplicate declaration of dropped. BotSharp.Core.UnitTests 476/477 (SharpCacheAgentMemoryTests fails on master too); onebrain relay and RSP tests 106/106 against this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
An inbound phone agent that creates work orders needs the same streamed ConversationRelay experience #1459 gives outbound
calls, and callers should not sit in silence while the model thinks or a slow tool (a ~13s create-work-order API) runs.
What
Inbound calls on ConversationRelay (
TwilioInboundController)ConversationRelay.AgentIdsnow get ConversationRelay TwiML ontwilio/inbound, instead of the Gather flow.SpeechPathsbecome the welcome greeting throughtheir
Twilio:PromptTextstext (TwilioService.GetPromptText).Intentkeep the Gather flow, which already has their first message queued.Enabled: trueand an emptyAgentIds, inbound calls of every agent switch to ConversationRelay.Response watchdog (
ConversationRelaySession, off by default)ResponseWatchdogIntervals, e.g.[1.5, 3, 5]: seconds of silence before each filler while the agent works on a reply.The first counts from when the caller finished, each next one from when whatever was said last (a filler, a tool
indication, a streamed reply) ends; the last value repeats until the reply comes.
ResponseWatchdogPrompts(defaulthold-on-1..5), later ones fromResponseWatchdogLongPrompts(defaulthold-on-long-1..10); no sentence repeats in a turn while unused ones remain.ResponseWatchdogAgentIdslimits it to some agents.the turn's reply is in.
Testing
ConversationRelaySessionover a fake WebSocket): slow reply gets one filler,fast reply none, unlisted agent none, an indication keeps it quiet, a filler never splits a streamed reply, and a long
wait gets a different filler at each interval. Repeated runs are stable.
twilio/inboundplus the relay WebSocket in text against a dev environment: greeting fromthe prompt text, fillers at the configured intervals with different sentences, and the expected handoff on transfer.
🤖 Generated with Claude Code