Skip to content

Twilio: inbound calls on ConversationRelay and a response watchdog - #1461

Open
yuyixg wants to merge 10 commits into
SciSharp:masterfrom
yuyixg:features/inbound-relay-watchdog
Open

yuyixg wants to merge 10 commits into
SciSharp:masterfrom
yuyixg:features/inbound-relay-watchdog

Conversation

@yuyixg

@yuyixg yuyixg commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1459. Until that merges this PR also lists its four commits; the changes here are the last two
(3e7973b0a, 644ef6944), touching only BotSharp.Plugin.Twilio.

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)

  • Agents listed in ConversationRelay.AgentIds now get ConversationRelay TwiML on twilio/inbound, instead of the Gather flow.
  • ConversationRelay speaks text only, so the prompts session hooks queue in SpeechPaths become the welcome greeting through
    their Twilio:PromptTexts text (TwilioService.GetPromptText).
  • Calls that arrive with an Intent keep the Gather flow, which already has their first message queued.
  • Note: with Enabled: true and an empty AgentIds, 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.
  • The first filler comes from ResponseWatchdogPrompts (default hold-on-1..5), later ones from
    ResponseWatchdogLongPrompts (default hold-on-long-1..10); no sentence repeats in a turn while unused ones remain.
  • ResponseWatchdogAgentIds limits it to some agents.
  • A filler is decided while holding the send lock, so it never lands inside a streamed reply, and nothing is said once
    the turn's reply is in.

Testing

  • Unit tests (in the onebrain host, driving ConversationRelaySession over 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.
  • End to end locally: signed twilio/inbound plus the relay WebSocket in text against a dev environment: greeting from
    the prompt text, fillers at the configured intervals with different sentences, and the expected handoff on transfer.

🤖 Generated with Claude Code

marsyusms and others added 6 commits October 20, 2026 14:47
GenerateSpeechAsync used the SDK defaults: 100s per attempt and 3 retries.
In production some /v1/audio/speech requests never return, so a failed call
blocked the caller for about 7 minutes ("Retry failed after 4 tries").

AudioSynthesisProvider now waits at most 30s per attempt and keeps 3 retries.
Both can be set per model under Audio.Synthesis (TimeoutSeconds, MaxRetries).
ProviderHelper.GetClient takes the timeout and retry count as optional
arguments; other callers keep the SDK defaults.

Verified against a local server that never answers: 4 attempts about 30s
apart, failing after 125.7s (was ~400s); a hang followed by a good response
recovers on the retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Say mode (Twilio:SayGeneratedSpeech, off by default)
- Generated speech is saved as a text file and spoken with <Say voice=SayVoice>
  instead of pre-generating an mp3 with the audio synthesizer: the opening line
  (no longer synthesized before dialing), replies, hang-up, transfer, voicemail
  and tool-call indications.
- Prerecorded prompts (twilio/{name}.mp3) are spoken from Twilio:PromptTexts
  so the whole call uses one voice; prompts without text keep playing.
- TwilioService.SaveSpeechAsync / AppendSpeech are the single places that
  write and play speech. With the switch off, behaviour is unchanged.

ConversationRelay (Twilio:ConversationRelay, off by default)
- Outbound calls for enabled agents (AgentIds, empty = all) are answered with
  <Connect><ConversationRelay>: Twilio transcribes and speaks, and exchanges
  text with TwilioConversationRelayMiddleware at /twilio/relay/{agent}/{conv}.
- ConversationRelaySession runs one agent turn at a time (later words are
  merged into the next turn), speaks tool-call indications, re-prompts on
  caller silence, hangs up after MaxIdlePrompts or AiResponseTimeoutSeconds,
  and ends with handoffData hangup/transfer.
- twilio/relay/action dials the CSR on transfer, hangs up otherwise, and
  reconnects a failed session up to MaxReconnects times.
- The WebSocket handshake is checked against X-Twilio-Signature.
- The caller-message handling used by the webhook queue moved to
  TwilioConversationProcessor so both flows share it.

Tested with in-process harnesses: 24 TwiML checks for Say mode and 28 checks
driving the relay WebSocket (turns, merging, DTMF, indications, silence,
timeouts, signature, TwiML, action callback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… them

With Twilio:ConversationRelay:StreamReplies on (off by default), a relay turn
asks BotSharp to stream (use_stream_message, and language English when unset)
and forwards each OnReceiveLlmStreamMessage piece to Twilio as a text token
with last=false, so Twilio starts speaking with the first words instead of
after the whole reply.

- Each model response is closed with last=true when the next one starts,
  before a tool-call indication is spoken, and at the end of the turn.
- The final reply is not spoken again; only the part not streamed is (text
  set by a tool, or a reply rewritten after the model wrote it).
- When the caller cuts in, the rest of the streamed reply is dropped; when the
  turn times out, nothing streamed afterwards reaches the call.
- TwilioConversationProcessor takes AgentTurnCallbacks (indication, stream
  start, stream text); the webhook queue passes only the indication, so that
  flow does not stream.

Needs a chat provider that streams (OpenAI, Azure OpenAI, Anthropic, Google);
non-English conversations get the whole reply as before.

Tested with in-process harnesses: relay harness 42/42 (13 new: pieces and
close, first words before the model finishes, tool-call boundary, remainder,
rewritten reply, tool-set reply, interrupt, timeout, and the processor's state
and event wiring against stubbed BotSharp services); Say harness passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Stop dequeuing caller input once the call is gone: when the relay
  socket closes, a turn already running finishes (so the conversation is
  saved) but words queued during it no longer start another agent turn.
- Keep each keypad entry's Digits: inputs are queued with their digits, and
  a keypad entry ends the merge of words said while the agent was busy, so
  two entries during one turn become two turns instead of losing the first.
- Decide and end the silence hang-up atomically, so caller input arriving
  at that moment cannot start a turn on a call being hung up; skip the
  "say it again" prompt when the caller spoke since it was decided.
- Drop the unused conversation lookup on every caller message (moved from
  the webhook queue, never read).
- Escape agent and conversation ids in the relay action query and as single
  path segments of the WebSocket URL; the signature check rebuilds the same
  escaped URL.
- Move ConversationRelayHandoff to its own file and wrap lines over 125
  characters.

Tested with the relay harness, 49/49: seven new checks for these fixes,
six of which fail on the previous code (the hang-up race itself cannot be
forced, its check guards the outcome). Say harness passes. Downstream app:
build clean, unit and integration suites at their known baselines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@qodo-code-review

qodo-code-review Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Code Review by Qodo

Grey Divider

Sorry, something went wrong

We weren't able to complete the code review on our side. Please try again manually by commenting /agentic_review on this PR.

Grey Divider

Qodo Logo

@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Add Twilio ConversationRelay calls and a response watchdog

✨ Enhancement 🐞 Bug fix ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Route eligible inbound and outbound calls through ConversationRelay for text-based, optionally
 streamed conversations.
• Add an opt-in response watchdog to speak varied fillers during slow agent turns.
• Support TwiML Say for generated speech and bound OpenAI speech-synthesis request time.
Diagram

sequenceDiagram
    actor Caller
    participant Twilio
    participant Entry as Call webhook
    participant Service as Twilio service
    participant Socket as Relay middleware
    participant Session as Relay session
    participant Agent as Conversation processor
    participant Action as Action webhook
    Caller->>Twilio: Starts call
    Twilio->>Entry: Requests instructions
    Entry->>Service: Selects relay for eligible agent
    Service-->>Twilio: Connect ConversationRelay
    Twilio->>Socket: Opens WebSocket
    Socket->>Session: Starts call session
    Twilio->>Session: Sends caller text
    Session->>Agent: Processes caller turn
    Agent-->>Session: Streams reply and indications
    Session->>Session: Watches for response silence
    Session-->>Twilio: Sends speech text or handoff
    Twilio->>Action: Reports session end
    Action-->>Twilio: Hangs up, transfers, or reconnects
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Extend the Gather webhook flow
  • ➕ Reuses the established call flow and its session storage.
  • ➕ Avoids a new long-lived WebSocket session.
  • ➖ Cannot deliver the same low-latency streamed text interaction.
  • ➖ Repeated webhooks complicate silence-aware filler timing.

Recommendation: Keep ConversationRelay as an opt-in path and retain Gather for intent-bearing and non-eligible calls. Extracting a shared conversation processor limits duplicated agent logic; extending Gather alone would not meet the streaming goal. Review WebSocket middleware registration in the host and the concurrency boundaries carefully, since neither test files nor middleware registration are shown in this diff.

Files changed (22) +1575 / -146

Enhancement (16) +1276 / -62
ProviderHelper.csAllow optional OpenAI client timeout and retry policy +17/-3

Allow optional OpenAI client timeout and retry policy

• Accepts network timeout and retry-count overrides when creating an OpenAI client while leaving other callers on SDK defaults.

src/Plugins/BotSharp.Plugin.OpenAI/Providers/ProviderHelper.cs

TwilioConversationRelayController.csHandle ConversationRelay end callbacks +89/-0

Handle ConversationRelay end callbacks

• Authenticates session-end callbacks and returns TwiML to transfer, retry a failed relay session within limits, or hang up. Emits the corresponding session hooks for transfer and requested hang-up.

src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioConversationRelayController.cs

TwilioInboundController.csRoute eligible inbound calls to ConversationRelay +16/-2

Route eligible inbound calls to ConversationRelay

• Uses queued prompt text as the relay greeting for eligible calls without an intent, preserving the existing realtime and intent-bearing paths. Plays or says voicemail according to its saved format.

src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs

TwilioOutboundController.csRoute eligible outbound calls to ConversationRelay +14/-2

Route eligible outbound calls to ConversationRelay

• Uses the stored initial assistant reply as the relay welcome greeting. Updates voicemail playback to support saved text as well as audio.

src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioOutboundController.cs

TwilioReconnectController.csReconnect using format-aware speech output +1/-2

Reconnect using format-aware speech output

• Appends the saved initial speech as Say or Play rather than always playing an audio URL.

src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioReconnectController.cs

AgentTurnCallbacks.csDefine agent-turn speech callbacks +24/-0

Define agent-turn speech callbacks

• Introduces callbacks for tool indications, streamed reply text, and the start of a new streamed response.

src/Plugins/BotSharp.Plugin.Twilio/Models/AgentTurnCallbacks.cs

ConversationRelayActionRequest.csModel relay termination callbacks +33/-0

Model relay termination callbacks

• Binds Twilio's session status, handoff, error, and reconnect fields for the relay action endpoint.

src/Plugins/BotSharp.Plugin.Twilio/Models/Relay/ConversationRelayActionRequest.cs

ConversationRelayHandoff.csDefine relay handoff actions +10/-0

Define relay handoff actions

• Names the hang-up and transfer actions exchanged between the relay session and its action callback.

src/Plugins/BotSharp.Plugin.Twilio/Models/Relay/ConversationRelayHandoff.cs

ConversationRelayParameters.csName the welcome-greeting length parameter +9/-0

Name the welcome-greeting length parameter

• Defines the custom TwiML parameter used to pass greeting length into relay setup for silence timing.

src/Plugins/BotSharp.Plugin.Twilio/Models/Relay/ConversationRelayParameters.cs

HangupPhoneCallFn.csSave closing speech in the configured format +3/-6

Save closing speech in the configured format

• Uses the Twilio speech helper for the optional ending message instead of always synthesizing an MP3.

src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs

LeaveVoicemailFn.csSave voicemail in the configured format +3/-6

Save voicemail in the configured format

• Delegates voicemail speech storage to the shared Twilio helper, supporting either saved text or synthesized audio.

src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/LeaveVoicemailFn.cs

OutboundPhoneCallFn.csSkip greeting synthesis for relay outbound calls +12/-12

Skip greeting synthesis for relay outbound calls

• Checks agent eligibility before placing the call, allowing ConversationRelay to speak the stored initial message. Non-relay calls save initial speech through the format-aware helper.

src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs

TransferPhoneCallFn.csSave transfer speech in the configured format +3/-5

Save transfer speech in the configured format

• Uses the shared speech helper for the optional transfer message instead of always creating an MP3.

src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TransferPhoneCallFn.cs

ConversationRelaySession.csManage relay turns, streaming, and silence watchdogs +765/-0

Manage relay turns, streaming, and silence watchdogs

• Processes WebSocket prompts and keypad input, serializes agent turns and speech sends, streams replies, and handles interruptions and call endings. Adds idle-call handling and an opt-in response watchdog that chooses nonrepeating fillers during slow turns.

src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs

TwilioService.csBuild relay TwiML and support text-based speech +190/-24

Build relay TwiML and support text-based speech

• Builds ConversationRelay connection instructions and URLs, including welcome-greeting timing metadata. Centralizes saving and rendering speech as TwiML Say or Play and resolves configured text for prerecorded prompts.

src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs

TwilioConversationRelayMiddleware.csAccept and validate relay WebSocket connections +87/-0

Accept and validate relay WebSocket connections

• Parses relay URL identifiers, optionally validates Twilio's handshake signature, and starts a session on the accepted socket. Creates a fresh service scope for each caller turn.

src/Plugins/BotSharp.Plugin.Twilio/TwilioConversationRelayMiddleware.cs

Bug fix (1) +12 / -1
AudioSynthesisProvider.csBound speech-synthesis attempts +12/-1

Bound speech-synthesis attempts

• Uses a 30-second per-attempt timeout and three retries by default, with per-model overrides, to limit stalled speech-generation calls.

src/Plugins/BotSharp.Plugin.OpenAI/Providers/Audio/AudioSynthesisProvider.cs

Refactor (2) +148 / -83
TwilioConversationProcessor.csShare agent-turn processing across phone flows +136/-0

Share agent-turn processing across phone flows

• Extracts conversation initialization, postback handling, and agent messaging for both queued webhook and relay calls. Exposes indication and optional streaming callbacks to the calling flow.

src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs

TwilioMessageQueueService.csUse shared processing in the queued call flow +12/-83

Use shared processing in the queued call flow

• Replaces duplicated conversation processing with the shared processor while retaining queued indications, reply audio generation, hints, and session storage. Uses the format-aware speech helper for replies.

src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioMessageQueueService.cs

Other (3) +139 / -0
LlmModelSetting.csAdd per-model audio synthesis settings +17/-0

Add per-model audio synthesis settings

• Adds optional timeout and retry settings under a model's audio synthesis configuration.

src/Infrastructure/BotSharp.Abstraction/MLTasks/Settings/LlmModelSetting.cs

ConversationRelaySetting.csConfigure relay eligibility and response timing +104/-0

Configure relay eligibility and response timing

• Adds agent selection, transcription and speech options, streaming, idle timeouts, reconnect limits, and signature validation. Configures opt-in watchdog intervals, eligible agents, and first-versus-later filler prompt lists.

src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs

TwilioSetting.csExpose Say and ConversationRelay options +18/-0

Expose Say and ConversationRelay options

• Adds the Say mode switch, voice and prompt-text mappings, and nested ConversationRelay settings.

src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs

marsyusms and others added 4 commits October 23, 2026 14:32
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants