diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs index f731a9842..b67b9dc18 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs @@ -56,7 +56,7 @@ private async Task InnerGetChatCompletions(Agent agent, List InnerGetChatCompletionsAsync(Agent agent, } else if (reason == ChatFinishReason.Length) { - _logger.LogWarning($"Action: {nameof(InnerGetChatCompletionsAsync)}, Reason: {reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{text}"); + _logger.LogWarning($"Action: {nameof(InnerGetChatCompletionsAsync)}, Reason: {reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{DescribeContent(text)}, ToolCalls: {DescribeToolCalls(ToLlmToolCalls(value.ToolCalls))}"); msg = new RoleDialogModel(AgentRole.Assistant, $"AI response exceeded max output length") { @@ -321,6 +321,13 @@ private async Task InnerGetChatCompletionsStreamingAsync(Agent } else if (choice.FinishReason.HasValue) { + if (choice.FinishReason == ChatFinishReason.Length) + { + // What the model wrote before the limit is replaced by the notice below, so this is the only record of it + _logger.LogWarning($"Action: {nameof(InnerGetChatCompletionsStreamingAsync)}, Reason: {choice.FinishReason}, Agent: {agent.Name}, " + + $"MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{DescribeContent(textStream.GetText())}, ToolCalls: {DescribeToolCalls(ReconstructToolCalls(toolCalls))}"); + } + var text = choice.FinishReason == ChatFinishReason.Length ? "Model reached the maximum number of tokens allowed." : choice.FinishReason == ChatFinishReason.ContentFilter ? "Content is omitted due to content filter rule." : choice.FinishReason.Value.ToString(); @@ -759,6 +766,20 @@ private static List ToLlmToolCalls(IEnumerable? toolC .Select(x => new LlmToolCall(x.Id, x.FunctionName, x.FunctionArguments?.ToString())) .ToList(); + /// + /// The tool calls of a reply cut off by the output limit: each name and how long its arguments got. The arguments + /// themselves are left out, since they can hold the user's personal data. + /// + internal static string DescribeToolCalls(List calls) + => string.Join(", ", calls.Select(x => $"{x.FunctionName}({x.FunctionArgs?.Length ?? 0} chars)")); + + /// + /// What a reply cut off by the output limit said: its length and only its start, enough to tell what the model was + /// writing without logging the whole reply. + /// + internal static string DescribeContent(string? text) + => string.IsNullOrEmpty(text) ? "(none)" : $"{text.Length} chars: {(text.Length > 200 ? text[..200] + "..." : text)}"; + /// /// Rebuilds the tool calls a streaming reply asked for. /// diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs index 7c1f083e4..d1ee441a1 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs @@ -93,6 +93,23 @@ await HookEmitter.Emit(_services, { response = twilio.ReturnBidirectionalMediaStreamsInstructions(instruction, agent); } + // A call that arrives with an intent already has its first message queued for the Gather flow + else if (string.IsNullOrWhiteSpace(request.Intent) && _settings.ConversationRelay.IsEnabledFor(request.AgentId)) + { + // ConversationRelay speaks text only: a greeting a session hook wrote (e.g. with the caller's name) is + // said as it is, otherwise the prompts the session hooks queued become the greeting + var greeting = !string.IsNullOrWhiteSpace(instruction.Text) + ? instruction.Text + : string.Join(" ", instruction.SpeechPaths + .Select(twilio.GetPromptText) + .Where(text => !string.IsNullOrWhiteSpace(text))); + if (greeting.Length == 0 && instruction.SpeechPaths.Count > 0) + { + _logger.LogWarning($"No prompt text for the welcome speech of agent {request.AgentId}; " + + "starting ConversationRelay without a greeting."); + } + response = twilio.ReturnConversationRelay(request.AgentId, request.ConversationId, greeting); + } else { if (string.IsNullOrWhiteSpace(request.Intent)) diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Models/AgentTurnCallbacks.cs b/src/Plugins/BotSharp.Plugin.Twilio/Models/AgentTurnCallbacks.cs index f5735f168..fcbb782b7 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Models/AgentTurnCallbacks.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Models/AgentTurnCallbacks.cs @@ -21,4 +21,10 @@ public class AgentTurnCallbacks /// Called when the model starts writing a new response, e.g. after a tool call /// public Func? OnStreamStarted { get; init; } + + /// + /// Called when a response the model wrote ends in a tool call: what it said before the call is complete, + /// and the tool may take a while before anything else is said + /// + public Func? OnStreamEndedWithToolCall { get; init; } } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs index 61f204ff4..5f21e14a9 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs @@ -1,4 +1,4 @@ -using BotSharp.Plugin.Twilio.Models; +using BotSharp.Plugin.Twilio.Models; using BotSharp.Plugin.Twilio.Models.Relay; using System.IO; using System.Net.WebSockets; @@ -15,7 +15,11 @@ public class ConversationRelaySession { private const string DefaultSayItAgainText = "Sorry, can you say it again?"; private const string DefaultErrorText = "Sorry, something went wrong. Please call back later."; + private const string DefaultHoldOnText = "One moment, please."; private const double SpokenCharsPerSecond = 15; + private static readonly List DefaultWatchdogPrompts = ["hold-on-1", "hold-on-2", "hold-on-3", "hold-on-4", "hold-on-5"]; + private static readonly List DefaultWatchdogLongPrompts = Enumerable.Range(1, 10).Select(i => $"hold-on-long-{i}").ToList(); + private static readonly TimeSpan WatchdogPollInterval = TimeSpan.FromMilliseconds(100); private readonly WebSocket _socket; private readonly string _agentId; @@ -48,6 +52,10 @@ public class ConversationRelaySession private bool _streamedThisTurn; private bool _dropStream; + // The current turn's reply is in; the response watchdog says nothing more once it is set + private bool _turnReplied; + private DateTime _turnStartedUtc; + private string? _callSid; private string? _from; private string? _to; @@ -175,6 +183,15 @@ private async Task HandleMessageAsync(string json) var text = GetString(root, "voicePrompt") ?? string.Empty; var last = !root.TryGetProperty("last", out var lastElement) || lastElement.ValueKind != JsonValueKind.False; _partialPrompt.Append(text); + if (!last && text.Length > 0) + { + // With partialPrompts on, the caller is mid-sentence: not silent, and not to be asked to repeat + lock (_sync) + { + _quietSinceUtc = DateTime.UtcNow; + _activity++; + } + } if (last) { var prompt = _partialPrompt.ToString().Trim(); @@ -299,14 +316,24 @@ private async Task ProcessPendingAsync() _utteranceOpen = false; _streamedThisTurn = false; _dropStream = false; + _turnReplied = false; + _turnStartedUtc = DateTime.UtcNow; } + using var watchdogCts = new CancellationTokenSource(); + var watchdog = _settings.ConversationRelay.IsResponseWatchdogEnabledFor(_agentId) + ? WatchResponseAsync(message.SeqNumber, watchdogCts.Token) + : Task.CompletedTask; + var stream = _settings.ConversationRelay.StreamReplies; var callbacks = new AgentTurnCallbacks { OnIndication = SpeakIndicationAsync, OnStreamText = stream ? StreamTextAsync : null, - OnStreamStarted = stream ? CloseUtteranceAsync : null + OnStreamStarted = stream ? CloseUtteranceAsync : null, + // A line said before a slow tool ("Okay, that's everything I need") is spoken now, and the watchdog + // counts the silence after it instead of waiting for the next response to close it + OnStreamEndedWithToolCall = stream ? CloseUtteranceAsync : null }; AssistantMessage? reply; @@ -335,6 +362,16 @@ private async Task ProcessPendingAsync() await FailAsync(); return; } + finally + { + // The reply is in: a filler that already passed its delay must not slip in ahead of it + lock (_sync) + { + _turnReplied = true; + } + watchdogCts.Cancel(); + await SwallowAsync(watchdog); + } await CloseUtteranceAsync(); if (reply == null @@ -357,6 +394,88 @@ private async Task ProcessPendingAsync() } } + /// + /// While the agent works on a reply, says a filler each time the line has been silent for the next + /// ResponseWatchdogIntervals value, so the caller never sits in a long silence while the model thinks or a tool runs. + /// Silence counts from the end of whatever was said last (the caller, a filler, an indication), and no filler is + /// said twice in a turn while unused ones remain. + /// + private async Task WatchResponseAsync(int seqNumber, CancellationToken cancellationToken) + { + var relay = _settings.ConversationRelay; + var said = new HashSet(); + var step = 0; + while (true) + { + var interval = TimeSpan.FromSeconds(relay.ResponseWatchdogIntervals[Math.Min(step, relay.ResponseWatchdogIntervals.Count - 1)]); + await Task.Delay(WatchdogPollInterval, cancellationToken); + lock (_sync) + { + if (IsTurnOver(seqNumber)) + { + return; + } + if (!IsSilentFor(interval)) + { + continue; + } + } + + var text = PickFillerText(step == 0 ? relay.ResponseWatchdogPrompts : relay.ResponseWatchdogLongPrompts, + step == 0 ? DefaultWatchdogPrompts : DefaultWatchdogLongPrompts, said); + if (await SpeakFillerAsync(seqNumber, text, interval)) + { + said.Add(text); + step++; + } + } + } + + private async Task SpeakFillerAsync(int seqNumber, string text, TimeSpan interval) + { + // Decided again once the send lock is held: while waiting for it the reply may have come in, or a streamed + // line may have started or been closed, and then the silence this filler was meant for is over + return await SpeakIfAsync(() => !IsTurnOver(seqNumber) && IsSilentFor(interval), + new { type = "text", token = text, last = true }, EstimateSpeech(text.Length)); + } + + /// + /// The turn the response watchdog was started for has its reply, or the call moved on. Call under . + /// + private bool IsTurnOver(int seqNumber) + { + return _ended || _seqNum != seqNumber || _turnReplied; + } + + /// + /// Nothing has been said, by either side, for . Call under . + /// + private bool IsSilentFor(TimeSpan interval) + { + // _quietSinceUtc is the estimated end of what was last said, or the caller's last input + var quietSince = _quietSinceUtc == DateTime.MaxValue || _quietSinceUtc < _turnStartedUtc ? _turnStartedUtc : _quietSinceUtc; + return !_utteranceOpen && DateTime.UtcNow - quietSince >= interval; + } + + private string PickFillerText(List names, List defaultNames, HashSet said) + { + var texts = (names.Count > 0 ? names : defaultNames) + .Select(name => _settings.PromptTexts.TryGetValue(name, out var text) ? text : null) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Select(text => text!) + .Distinct() + .ToList(); + if (texts.Count == 0) + { + return DefaultHoldOnText; + } + + // A different sentence each time; repeat only once every one has been said + var unsaid = texts.Where(text => !said.Contains(text)).ToList(); + var pool = unsaid.Count > 0 ? unsaid : texts; + return pool[Random.Shared.Next(pool.Count)]; + } + private async Task WatchIdleAsync(CancellationToken cancellationToken) { var relay = _settings.ConversationRelay; @@ -391,9 +510,12 @@ private async Task WatchIdleAsync(CancellationToken cancellationToken) _quietSinceUtc = DateTime.UtcNow; } - if (askAgain && IsStillQuiet(activity)) + if (askAgain) { - await SpeakPromptAsync($"say-it-again-{Random.Shared.Next(1, 5)}", DefaultSayItAgainText); + // 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 + await SpeakPromptAsync($"say-it-again-{Random.Shared.Next(1, 5)}", DefaultSayItAgainText, + canSpeak: () => !_isProcessing && _activity == activity); } else if (hangUp) { @@ -420,18 +542,19 @@ private async Task FailAsync() /// private async Task StreamTextAsync(string text) { - lock (_sync) + // Decided once the send lock is held: a piece queued behind another send must not go out after the + // caller cut in (_dropStream) or the session ended meanwhile + await SendIfAsync(() => { if (_ended || _dropStream) { - return; + return false; } _streamSegment.Append(text); _utteranceOpen = true; _streamedThisTurn = true; - } - - await SendAsync(new { type = "text", token = text, last = false }); + return true; + }, new { type = "text", token = text, last = false }); } /// @@ -439,21 +562,29 @@ private async Task StreamTextAsync(string text) /// private async Task CloseUtteranceAsync() { - int length; lock (_sync) { if (!_utteranceOpen) { return; } + } + + // Closed only once the send lock is held, so nothing else (a filler) can go out between the utterance being + // marked closed and its closing frame, and the silence clock already counts the closed line when a filler + // next looks at it + await SendIfAsync(() => + { + if (!_utteranceOpen) + { + return false; + } _utteranceOpen = false; _lastStreamSegment = _streamSegment.ToString(); _streamSegment.Clear(); - length = _lastStreamSegment.Length; - } - - await SendAsync(new { type = "text", token = string.Empty, last = true }); - MarkSpeaking(EstimateSpeech(length)); + MarkSpeaking(EstimateSpeech(_lastStreamSegment.Length)); + return true; + }, new { type = "text", token = string.Empty, last = true }); } /// @@ -516,40 +647,52 @@ private async Task SpeakIndicationAsync(string indication) /// /// Speaks a prerecorded prompt's configured text so the voice matches; without one, plays the recording. /// - private async Task SpeakPromptAsync(string name, string? fallbackText) + /// Extra condition, checked under once the send lock is held + private async Task SpeakPromptAsync(string name, string? fallbackText, Func? canSpeak = null) { if (_settings.PromptTexts.TryGetValue(name, out var text) && !string.IsNullOrWhiteSpace(text)) { - await SpeakAsync(text); + await SpeakAsync(text, canSpeak); } else if (fallbackText != null) { - await SpeakAsync(fallbackText); + await SpeakAsync(fallbackText, canSpeak); } else { - await SendAsync(new { type = "play", source = $"{_settings.CallbackHost}/twilio/{name}.mp3" }); - MarkSpeaking(TimeSpan.FromSeconds(3)); + await SpeakIfAsync(() => !_ended && (canSpeak?.Invoke() ?? true), + new { type = "play", source = $"{_settings.CallbackHost}/twilio/{name}.mp3" }, TimeSpan.FromSeconds(3)); } } - private async Task SpeakAsync(string? text) + /// Extra condition, checked under once the send lock is held + private async Task SpeakAsync(string? text, Func? canSpeak = null) { if (string.IsNullOrWhiteSpace(text)) { return; } - lock (_sync) + await SpeakIfAsync(() => !_ended && (canSpeak?.Invoke() ?? true), + new { type = "text", token = text, last = true }, EstimateSpeech(text.Length)); + } + + /// + /// Sends something the caller hears, if still holds once the send lock is held. How long + /// it takes to say is recorded before the frame goes out, so a cut-in arriving once it plays restarts the silence + /// clock instead of being overwritten by it. + /// + private async Task SpeakIfAsync(Func canSpeak, object payload, TimeSpan duration) + { + return await SendIfAsync(() => { - if (_ended) + if (!canSpeak()) { - return; + return false; } - } - - await SendAsync(new { type = "text", token = text, last = true }); - MarkSpeaking(EstimateSpeech(text.Length)); + MarkSpeaking(duration); + return true; + }, payload); } private async Task EndAsync(string action) @@ -574,24 +717,53 @@ private async Task SendEndAsync(string action) await SendAsync(new { type = "end", handoffData = JsonSerializer.Serialize(new { action }) }); } + private record PendingInput(string Content, string? Digits); + + private async Task SendAsync(object payload) + { + await _sendLock.WaitAsync(); + try + { + await SendLockedAsync(payload); + } + finally + { + _sendLock.Release(); + } + } + /// - /// True when the caller has not spoken or pressed a key since was read - /// and nothing else took over. + /// Sends only if still holds once the send lock is held, so a decision taken + /// before waiting for the lock cannot go stale. It runs under and may update session + /// state that must change only when the send goes ahead. /// - private bool IsStillQuiet(int activity) + private async Task SendIfAsync(Func canSend, object payload) { - lock (_sync) + await _sendLock.WaitAsync(); + try { - return !_ended && !_isProcessing && _activity == activity; + lock (_sync) + { + if (!canSend()) + { + return false; + } + } + await SendLockedAsync(payload); + return true; + } + finally + { + _sendLock.Release(); } } - private record PendingInput(string Content, string? Digits); - - private async Task SendAsync(object payload) + /// + /// Sends with already held by the caller. + /// + private async Task SendLockedAsync(object payload) { var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload)); - await _sendLock.WaitAsync(); try { if (_socket.State == WebSocketState.Open) @@ -605,20 +777,17 @@ private async Task SendAsync(object payload) // The call already moved on, e.g. a hang-up or transfer redirected it _logger.LogInformation($"ConversationRelay socket for conversation {_conversationId} is gone: {ex.Message}"); } - finally - { - _sendLock.Release(); - } } + /// + /// Moves the start of caller silence past something about to be said. Call under , before the + /// frame is sent. + /// private void MarkSpeaking(TimeSpan duration) { - lock (_sync) - { - var now = DateTime.UtcNow; - var start = _quietSinceUtc == DateTime.MaxValue || _quietSinceUtc < now ? now : _quietSinceUtc; - _quietSinceUtc = start + duration; - } + var now = DateTime.UtcNow; + var start = _quietSinceUtc == DateTime.MaxValue || _quietSinceUtc < now ? now : _quietSinceUtc; + _quietSinceUtc = start + duration; } private static TimeSpan EstimateSpeech(int characters) diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs index 213c024c0..6ae5ce0b9 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs @@ -71,6 +71,13 @@ public static class TwilioConversationProcessor await callbacks!.OnStreamText!(data.Data.Content); } }; + listeners[ChatEvent.AfterReceiveLlmStreamMessage] = async data => + { + if (data.Data.Role == AgentRole.Function && callbacks!.OnStreamEndedWithToolCall != null) + { + await callbacks.OnStreamEndedWithToolCall(); + } + }; } using var container = observer.SubscribeObservers>( diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs index 07c5caeac..9c1ef3e3b 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs @@ -464,6 +464,15 @@ public void AppendSpeech(TwiML parent, string? conversationId, string speechPath parent.Append(CreateSay(data.ToString())); } + /// + /// The configured text of a prerecorded prompt, or null when the path has none (e.g. a per-conversation file). + /// ConversationRelay can only speak text, so this is how its greeting reuses the prompts a hook queued. + /// + public string? GetPromptText(string speechPath) + { + return TryGetPromptText(speechPath, out var text) ? text : null; + } + /// /// Looks up the configured text of a prerecorded prompt such as "twilio/say-it-again-1.mp3". /// Files generated per conversation (twilio/voice/speeches/...) never match. diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs b/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs index 0fbc419d8..d447b3d48 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs @@ -9,7 +9,7 @@ public class ConversationRelaySetting public bool Enabled { get; set; } /// - /// Agents that use ConversationRelay for outbound calls. Empty means every agent. + /// Agents that use ConversationRelay for their calls, outbound and inbound. Empty means every agent. /// public List AgentIds { get; set; } = []; @@ -65,12 +65,41 @@ public class ConversationRelaySetting public int MaxReconnects { get; set; } = 2; /// - /// Verify the X-Twilio-Signature header of the WebSocket handshake + /// Verify the X-Twilio-Signature header of the WebSocket handshake. Needs Twilio:RequestValidation:AuthToken; + /// without it every handshake is refused /// public bool ValidateSignature { get; set; } = true; + /// + /// Seconds of silence on the line, while the agent works on its reply, before each filler such as "One moment, please.": + /// the first counts from when the caller finished, each next one from when the previous filler (or anything else + /// we said) finished. The last value repeats until the reply comes. Empty turns the watchdog off. + /// + public List ResponseWatchdogIntervals { get; set; } = []; + + /// + /// Agents the response watchdog applies to. Empty means every ConversationRelay agent. + /// + public List ResponseWatchdogAgentIds { get; set; } = []; + + /// + /// Names of Twilio:PromptTexts entries the first filler of a turn is picked from. Empty uses hold-on-1 to hold-on-5. + /// + public List ResponseWatchdogPrompts { get; set; } = []; + + /// + /// Names of Twilio:PromptTexts entries the later fillers of a long wait are picked from. Empty uses hold-on-long-1 to hold-on-long-10. + /// + public List ResponseWatchdogLongPrompts { get; set; } = []; + public bool IsEnabledFor(string? agentId) { return Enabled && (AgentIds.Count == 0 || (agentId != null && AgentIds.Contains(agentId))); } + + public bool IsResponseWatchdogEnabledFor(string? agentId) + { + return ResponseWatchdogIntervals.Count > 0 && ResponseWatchdogIntervals.All(seconds => seconds > 0) + && (ResponseWatchdogAgentIds.Count == 0 || (agentId != null && ResponseWatchdogAgentIds.Contains(agentId))); + } } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/TwilioConversationRelayMiddleware.cs b/src/Plugins/BotSharp.Plugin.Twilio/TwilioConversationRelayMiddleware.cs index eb2e863a9..7a9dd8f86 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/TwilioConversationRelayMiddleware.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/TwilioConversationRelayMiddleware.cs @@ -69,8 +69,11 @@ private bool IsSignatureValid(HttpContext httpContext, string agentId, string co var authToken = services.GetRequiredService()["Twilio:RequestValidation:AuthToken"]; if (string.IsNullOrEmpty(authToken)) { - _logger.LogWarning("Twilio auth token is not configured; skipping ConversationRelay signature check."); - return true; + // Fail closed: a deployment missing the token must not open the relay to unsigned handshakes + _logger.LogError("Rejected ConversationRelay connection: ConversationRelay.ValidateSignature is on but " + + "Twilio:RequestValidation:AuthToken is not configured. Configure the token, or turn ValidateSignature off " + + "for local testing."); + return false; } var signature = httpContext.Request.Headers["X-Twilio-Signature"].ToString(); diff --git a/tests/BotSharp.Core.UnitTests/Routing/OutputLimitLogTests.cs b/tests/BotSharp.Core.UnitTests/Routing/OutputLimitLogTests.cs new file mode 100644 index 000000000..2ce3fc6db --- /dev/null +++ b/tests/BotSharp.Core.UnitTests/Routing/OutputLimitLogTests.cs @@ -0,0 +1,42 @@ +using BotSharp.Abstraction.Functions.Models; +using BotSharp.Plugin.OpenAI.Providers.Chat; +using Xunit; + +namespace BotSharp.Core.UnitTests.Routing; + +/// +/// What the OpenAI provider logs when a reply runs into the output limit. The log has to show what the model was doing +/// (which tools, how far it got) without carrying what it wrote: tool arguments and replies can hold the user's +/// personal data, and the line is a warning that goes to the shared log store. +/// +public class OutputLimitLogTests +{ + [Fact] + public void Tool_calls_are_logged_by_name_and_argument_length_only() + { + var calls = new List + { + new("call_1", "create_request", "{\"contact_phone\":\"675-555-0118\",\"note\":\"gate code 4411\"}"), + new("call_2", "get_result", null) + }; + + var logged = ChatCompletionProvider.DescribeToolCalls(calls); + + Assert.Equal("create_request(56 chars), get_result(0 chars)", logged); + Assert.DoesNotContain("675-555-0118", logged); + } + + [Fact] + public void A_reply_is_logged_by_its_length_and_its_first_200_characters() + { + var reply = new string('a', 200) + "675-555-0118"; + + var logged = ChatCompletionProvider.DescribeContent(reply); + + Assert.Equal($"212 chars: {new string('a', 200)}...", logged); + Assert.DoesNotContain("675-555-0118", logged); + Assert.Equal("5 chars: Hello", ChatCompletionProvider.DescribeContent("Hello")); + Assert.Equal("(none)", ChatCompletionProvider.DescribeContent(null)); + Assert.Equal("(none)", ChatCompletionProvider.DescribeContent(string.Empty)); + } +}