Skip to content
Merged
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
12 changes: 12 additions & 0 deletions src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,17 @@ public class AssistantMessage
/// What of the reply is said once the turn is over must play to the end, as the flow asked when the reply came in
/// </summary>
public bool KeepUninterrupted { get; set; }

/// <summary>
/// The caller is busy on their phone and silence is expected (<see cref="ConversationRelayStates.QuietWait"/>), as
/// the flow had it when the reply came in
/// </summary>
public bool QuietWait { get; set; }

/// <summary>
/// What to say when hanging up on a caller who stays quiet from now on, in place of the idle-goodbye prompt
/// (<see cref="ConversationRelayStates.GoodbyeOnSilence"/>); null for the prompt
/// </summary>
public string? GoodbyeOnSilence { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ public static class ConversationRelayLabels

/// <summary>
/// Said to a caller gone quiet: the last question asked again ("Sorry, I didn't catch that. ..."), "Are you still
/// there?", or "Sorry, can you say it again?". It checks the caller is there, so it is no line of the agent's to say
/// again when they ask what was said.
/// there?", or "Sorry, can you say it again?"; while the flow waits quietly, the soft check-in ("I'm still here
/// whenever you're ready."). It checks the caller is there, so it is no line of the agent's to say again when they ask
/// what was said.
/// </summary>
public const string IdlePrompt = "twilio_relay_idle_prompt";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,19 @@ public static class ConversationRelayStates
/// that text whole, so this is the only sign. Set by the session at the start of every turn.
/// </summary>
public const string LastReplyCutOff = "twilio_relay_last_reply_cut_off";

/// <summary>
/// "true" while the caller is busy with something the agent asked them to do on their phone, such as filling in a form
/// it texted them, and the agent waits quietly: silence is expected, so instead of the idle prompts the caller hears a
/// soft check-in (prompt quiet-wait-check-in) each QuietWaitSeconds, and the call is hung up only after
/// MaxQuietWaitPrompts of them. Read when a turn's reply is in; a flow clears it once the wait is over.
/// </summary>
public const string QuietWait = "twilio_relay_quiet_wait";

/// <summary>
/// What is said as the session hangs up on a caller who stayed quiet, in place of the idle-goodbye prompt: a flow sets
/// it while something is still to be told, such as the confirmation number of a request already made, and clears it
/// once that is said. Read when a turn's reply is in.
/// </summary>
public const string GoodbyeOnSilence = "twilio_relay_goodbye_on_silence";
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class ConversationRelaySession
private const string DefaultAskAgainText = "Sorry, I didn't catch that. {0}";
private const string DefaultStillThereText = "Are you still there?";
private const string DefaultIdleGoodbyeText = "It sounds like we got disconnected. Please call back anytime.";
private const string DefaultQuietWaitCheckInText = "I'm still here whenever you're ready.";
private const double SpokenCharsPerSecond = 15;
private static readonly List<string> DefaultWatchdogPrompts = ["hold-on-1", "hold-on-2", "hold-on-3", "hold-on-4", "hold-on-5"];
private static readonly List<string> DefaultWatchdogLongPrompts = Enumerable.Range(1, 10).Select(i => $"hold-on-long-{i}").ToList();
Expand Down Expand Up @@ -95,6 +96,11 @@ public class ConversationRelaySession
// turns: until a turn has read it, the last turn's answer is the best guess.
private bool _keptUninterrupted;

// What the flow said, as the last reply came in, about a caller going quiet (ConversationRelayStates.QuietWait and
// GoodbyeOnSilence): kept until a later reply says otherwise, as a turn that says nothing still reads them
private bool _quietWait;
private string? _goodbyeOnSilence;

// The caller went on talking before the current turn said anything: its reply is dropped, and their words so far
// are answered together in the next turn
private bool _superseded;
Expand Down Expand Up @@ -638,6 +644,16 @@ private async Task ProcessPendingAsync()
}

await CloseUtteranceAsync();
if (reply != null)
{
// Before a reply with nothing to say is passed over: a flow waiting quietly answers the caller's asides
// with nothing
lock (_sync)
{
_quietWait = reply.QuietWait;
_goodbyeOnSilence = reply.GoodbyeOnSilence;
}
}
if (reply == null
|| string.IsNullOrWhiteSpace(reply.Content) && !reply.ConversationEnd && !reply.HumanIntervationNeeded)
{
Expand Down Expand Up @@ -823,22 +839,26 @@ private async Task WatchIdleAsync(CancellationToken cancellationToken)
var activity = 0;
var stage = 0;
string? question = null;
var quietWait = false;
string? goodbyeOnSilence = null;
lock (_sync)
{
var quietFor = DateTime.UtcNow - _quietSinceUtc;
if (_ended || _isProcessing || _quietSinceUtc == DateTime.MaxValue || _resumeLine != null
|| quietFor < IdleTimeout(_idlePrompts))
|| quietFor < IdleTimeout(_idlePrompts, _quietWait))
{
continue;
}

stage = _idlePrompts;
if (_idlePrompts < relay.MaxIdlePrompts)
quietWait = _quietWait;
goodbyeOnSilence = _goodbyeOnSilence;
if (_idlePrompts < (quietWait ? relay.MaxQuietWaitPrompts : relay.MaxIdlePrompts))
{
_idlePrompts++;
askAgain = true;
activity = _activity;
question = relay.IdleAsksAgain && stage == 0 ? ConversationRelayLines.LastQuestion(_lastLine) : null;
question = !quietWait && relay.IdleAsksAgain && stage == 0 ? ConversationRelayLines.LastQuestion(_lastLine) : null;
Comment on lines +856 to +861
}
else
{
Expand All @@ -852,7 +872,15 @@ private async Task WatchIdleAsync(CancellationToken cancellationToken)
// Rechecked once the send lock is held: caller input arriving meanwhile starts a turn, and the prompt must not
// then be said over or after that turn's reply
bool StillSilent() => !_isProcessing && _activity == activity;
if (askAgain && question != null)
// The caller is busy on their phone: nothing to say again and no "Are you still there?", only that the agent
// is still on the line. Why: the previous build read that silence as a dead call and hung up on callers
// filling in the form it had texted them
if (askAgain && quietWait)
{
await SayOwnLineAsync(PromptText("quiet-wait-check-in", DefaultQuietWaitCheckInText), ConversationRelayLabels.IdlePrompt,
StillSilent);
}
else if (askAgain && question != null)
{
await SayOwnLineAsync(PromptText("say-it-again-question", DefaultAskAgainText).Replace("{0}", question),
ConversationRelayLabels.IdlePrompt, StillSilent);
Expand All @@ -873,11 +901,13 @@ await SayOwnLineAsync(PromptText($"say-it-again-{Random.Shared.Next(1, 5)}", Def
_logger.LogInformation($"Caller stayed silent, hanging up conversation {_conversationId}.");
string? goodbye = null;
var goodbyeUtc = DateTime.MinValue;
if (relay.IdleAsksAgain)
// The flow's own goodbye is said whether or not the idle prompts say one: it tells what the caller still has
// to hear, such as a confirmation number
if (goodbyeOnSilence != null || relay.IdleAsksAgain)
{
// The session is marked ended already, so nothing else can be said after it, and nothing the caller
// says over it is answered: it plays to the end
goodbye = PromptText("idle-goodbye", DefaultIdleGoodbyeText);
goodbye = goodbyeOnSilence ?? PromptText("idle-goodbye", DefaultIdleGoodbyeText);
await SendAsync(TextFrame(goodbye, last: true, keepUninterrupted: true));
goodbyeUtc = DateTime.UtcNow;
}
Expand All @@ -892,8 +922,12 @@ await SayOwnLineAsync(PromptText($"say-it-again-{Random.Shared.Next(1, 5)}", Def
}

// How long the caller may stay silent before the idle prompt after this many (the last is before the hang-up)
private TimeSpan IdleTimeout(int prompts)
private TimeSpan IdleTimeout(int prompts, bool quietWait)
{
if (quietWait)
{
return TimeSpan.FromSeconds(_relay.QuietWaitSeconds);
}
var timeouts = _relay.IdleTimeouts;
return TimeSpan.FromSeconds(timeouts.Count > 0 ? timeouts[Math.Min(prompts, timeouts.Count - 1)] : _relay.IdleTimeoutSeconds);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ await KeepSaidLines(sp, message.AgentId, message.ConversationId, inputMsg.Messag
HumanIntervationNeeded = string.Equals("human_intervention_needed", msg.FunctionName),
Content = spoken,
MessageId = msg.MessageId,
KeepUninterrupted = callbacks?.KeepUninterrupted?.Invoke() ?? false
KeepUninterrupted = callbacks?.KeepUninterrupted?.Invoke() ?? false,
QuietWait = conv.States.GetState(ConversationRelayStates.QuietWait) == "true",
GoodbyeOnSilence = conv.States.GetState(ConversationRelayStates.GoodbyeOnSilence) is { Length: > 0 } goodbye
? goodbye : null
};
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public class ConversationRelayAgentSetting
public int? MaxIdlePrompts { get; set; }
public List<double>? IdleTimeouts { get; set; }
public bool? IdleAsksAgain { get; set; }
public double? QuietWaitSeconds { get; set; }
public int? MaxQuietWaitPrompts { get; set; }
public double? ResumeAfterCutInSeconds { get; set; }
public bool? DropBackchannelTails { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ public class ConversationRelaySetting
/// </summary>
public bool IdleAsksAgain { get; set; }

/// <summary>
/// Seconds of caller silence before each soft check-in ("I'm still here whenever you're ready.", prompt
/// quiet-wait-check-in) while a flow waits for the caller to do something on their phone
/// (<see cref="Models.ConversationRelayStates.QuietWait"/>); the last one is before hanging up
/// </summary>
public double QuietWaitSeconds { get; set; } = 60;

/// <summary>
/// How many soft check-ins a caller who stays silent while the flow waits quietly hears before the call is hung up
/// </summary>
public int MaxQuietWaitPrompts { get; set; } = 4;

/// <summary>
/// Drop a caller's words that came in before they could hear the line just said (TooSoonToAnswer) when they only
/// acknowledge ("I agree.", "Okay.") and that line asks something: the tail of what they said before, which the
Expand Down Expand Up @@ -156,6 +168,8 @@ public ConversationRelaySetting For(string? agentId)
settings.MaxIdlePrompts = agent.MaxIdlePrompts ?? MaxIdlePrompts;
settings.IdleTimeouts = agent.IdleTimeouts ?? IdleTimeouts;
settings.IdleAsksAgain = agent.IdleAsksAgain ?? IdleAsksAgain;
settings.QuietWaitSeconds = agent.QuietWaitSeconds ?? QuietWaitSeconds;
settings.MaxQuietWaitPrompts = agent.MaxQuietWaitPrompts ?? MaxQuietWaitPrompts;
settings.ResumeAfterCutInSeconds = agent.ResumeAfterCutInSeconds ?? ResumeAfterCutInSeconds;
settings.DropBackchannelTails = agent.DropBackchannelTails ?? DropBackchannelTails;
return settings;
Expand Down
Loading