diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index d40b45fa..197cf4a0 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.AI; +using System.Threading; public class FunctionCallingChat : IAsyncDisposable { @@ -60,7 +61,9 @@ public async Task CompleteChatStreamingAsync( Action>? messageCallback = null, Action? streamingCallback = null, Func? approveFunctionCall = null, - Action? functionCallCallback = null) + Action? functionCallCallback = null, + CancellationToken cancellationToken = default, + Func? getDisplayBuffer = null) { return await CompleteChatStreamingAsync( userPrompt, @@ -68,7 +71,9 @@ public async Task CompleteChatStreamingAsync( messageCallback, streamingCallback, approveFunctionCall, - functionCallCallback); + functionCallCallback, + cancellationToken, + getDisplayBuffer); } public async Task CompleteChatStreamingAsync( @@ -77,10 +82,11 @@ public async Task CompleteChatStreamingAsync( Action>? messageCallback = null, Action? streamingCallback = null, Func? approveFunctionCall = null, - Action? functionCallCallback = null) + Action? functionCallCallback = null, + CancellationToken cancellationToken = default, + Func? getDisplayBuffer = null) { var message = CreateUserMessageWithImages(userPrompt, imageFiles); - Conversation.Messages.Add(message); messageCallback?.Invoke(Conversation.Messages); @@ -88,40 +94,102 @@ public async Task CompleteChatStreamingAsync( while (true) { var responseContent = string.Empty; - await foreach (var update in _chatClient.GetStreamingResponseAsync(Conversation.Messages, _options)) + + // Surround streaming with try/catch to handle user interruption via OperationCancelledException + try { - _functionCallDetector.CheckForFunctionCall(update); + await foreach (var update in _chatClient.GetStreamingResponseAsync(Conversation.Messages, _options, cancellationToken)) + { + // Check for cancellation before processing each update + cancellationToken.ThrowIfCancellationRequested(); + + _functionCallDetector.CheckForFunctionCall(update); + + var content = string.Join("", update.Contents + .Where(c => c is TextContent) + .Cast() + .Select(c => c.Text) + .ToList()); - var content = string.Join("", update.Contents - .Where(c => c is TextContent) - .Cast() - .Select(c => c.Text) - .ToList()); + if (update.FinishReason == ChatFinishReason.ContentFilter) + { + content = $"{content}\nWARNING: Content filtered!"; + } + + responseContent += content; + contentToReturn += content; + + streamingCallback?.Invoke(update); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User interrupted - trim content to match what was actually displayed + var displayBuffer = getDisplayBuffer?.Invoke() ?? ""; - if (update.FinishReason == ChatFinishReason.ContentFilter) + // If we have both the display buffer and the content to return, trim the content to match what was displayed + // This avoids showing content that was generated but not actually seen by the user due to the slight delay in cancellation + if (!string.IsNullOrEmpty(displayBuffer) && !string.IsNullOrEmpty(contentToReturn)) { - content = $"{content}\nWARNING: Content filtered!"; + var trimmedContent = TrimContentToDisplayBuffer(contentToReturn, displayBuffer); + if (!string.IsNullOrEmpty(trimmedContent)) + { + Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, trimmedContent)); + messageCallback?.Invoke(Conversation.Messages); + } } + throw; + } - responseContent += content; - contentToReturn += content; + // Surround assistant response handling with try/catch to handle user interruption via ChatCommand.UserWantsControlException + try + { + if (TryCallFunctions(responseContent, approveFunctionCall, functionCallCallback, messageCallback)) + { + _functionCallDetector.Clear(); + continue; + } - streamingCallback?.Invoke(update); + Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent)); + messageCallback?.Invoke(Conversation.Messages); } - - if (TryCallFunctions(responseContent, approveFunctionCall, functionCallCallback, messageCallback)) + catch (ChatCommand.UserWantsControlException) { + // User cancelled function call - exit streaming entirely and return control to user _functionCallDetector.Clear(); - continue; + return ""; // Empty response - will show blank Assistant line } - Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent)); - messageCallback?.Invoke(Conversation.Messages); - return contentToReturn; } } + /// + /// Trims the full content to match what was actually displayed in the display buffer. + /// This is used to ensure that if the user cancels the response, we only show the content that was actually seen by the user, + /// and not any additional content that may have been generated but not displayed due to cancellation. + /// + /// Content generated by the AI so far (usually more than what has been displayed) + /// The tail end of the content that was actually displayed to the user + /// All content that was shown on screen to the user + private string TrimContentToDisplayBuffer(string fullContent, string displayBuffer) + { + if (string.IsNullOrEmpty(displayBuffer) || string.IsNullOrEmpty(fullContent)) + return ""; + + // Find where the display buffer content appears in the full content + var displayBufferIndex = fullContent.LastIndexOf(displayBuffer); + if (displayBufferIndex >= 0) + { + // Trim to end where display buffer ends + return fullContent.Substring(0, displayBufferIndex + displayBuffer.Length); + } + + // If we can't find the display buffer in the content, return the display buffer + // This handles edge cases where the content might have been modified + return displayBuffer; + } + private bool TryCallFunctions(string responseContent, Func? approveFunctionCall, Action? functionCallCallback, Action>? messageCallback) { var noFunctionsToCall = !_functionCallDetector.HasFunctionCalls(); @@ -139,7 +207,30 @@ private bool TryCallFunctions(string responseContent, Func functionCallResults; + try + { + functionCallResults = CallFunctions(readyToCallFunctionCalls, approveFunctionCall, functionCallCallback); + } + // If the user cancels during function call approval, we need to handle that gracefully by + // using the same logic as denying function calls + catch (ChatCommand.UserWantsControlException) + { + var functionResultContents = new List(); + + foreach (var functionCall in readyToCallFunctionCalls) + { + var cancelResult = DontCallFunction(functionCall, functionCallCallback); + functionResultContents.Add(new FunctionResultContent(functionCall.CallId, cancelResult)); + } + + Conversation.Messages.Add(new ChatMessage(ChatRole.Tool, functionResultContents)); + messageCallback?.Invoke(Conversation.Messages); + + // Re-throw so the main loop (CompleteChatStreamingAsync()) knows + // to stop processing and return control to the user + throw; + } var attachToToolMessage = functionCallResults .Where(c => c is FunctionResultContent) @@ -174,7 +265,16 @@ private List CallFunctions(List(); foreach (var functionCall in readyToCallFunctionCalls) { - var approved = approveFunctionCall?.Invoke(functionCall.Name, functionCall.Arguments) ?? true; + bool approved; + try + { + approved = approveFunctionCall?.Invoke(functionCall.Name, functionCall.Arguments) ?? true; + } + catch (ChatCommand.UserWantsControlException) + { + // Re-throw so TryCallFunctions can handle it + throw; + } var functionResult = approved ? CallFunction(functionCall, functionCallCallback) @@ -215,7 +315,7 @@ private object DontCallFunction(FunctionCallDetector.ReadyToCallFunctionCall fun functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, null); ConsoleHelpers.WriteDebugLine($"Function call not approved: {functionCall.Name} with arguments: {functionCall.Arguments}"); - var functionResult = "User did not approve function call"; + var functionResult = ChatCommand.CallDeniedMessage; functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, functionResult); diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index 8af66d64..5c553773 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -5,6 +5,26 @@ public class ChatCommand : CommandWithVariables { + // Public constants for function call results + public const string CallDeniedMessage = "User did not approve function call"; + public const string CancelledFunctionResultMessage = "User did not approve function call"; + + // Function call decision enum + public enum FunctionCallDecision + { + Approved, + Denied, + UserWantsControl + } + + // Inner exception class for user control requests + public class UserWantsControlException : Exception + { + public UserWantsControlException() : base("User requested control") + { + } + } + public ChatCommand() { } @@ -185,8 +205,9 @@ public override async Task ExecuteAsync(bool interactive) var response = await CompleteChatStreamingAsync(chat, giveAssistant, imageFiles, (messages) => HandleUpdateMessages(messages), (update) => HandleStreamingChatCompletionUpdate(update), - (name, args) => HandleFunctionCallApproval(factory, name, args!), - (name, args, result) => HandleFunctionCallCompleted(name, args, result)); + (name, args) => ConvertFunctionCallDecision(HandleFunctionCallApproval(factory, name, args!)), + (name, args, result) => DisplayAssistantFunctionCall(name, args, result)); + // Check for notifications that may have been generated during the assistant's response ConsoleHelpers.WriteLine("\n", overrideQuiet: true); @@ -507,15 +528,57 @@ private async Task CompleteChatStreamingAsync( streamingCallback = TryCatchHelpers.NoThrowWrap(streamingCallback); functionCallCallback = TryCatchHelpers.NoThrowWrap(functionCallCallback); + // Create cancellation token source and interrupt manager for this streaming session + _interruptTokenSource = new CancellationTokenSource(); + _suppressAssistantDisplay = false; // Reset display suppression + _displayBuffer = ""; // Reset display buffer for new response + + using var interruptManager = new SimpleInterruptManager(); + try { - var response = await chat.CompleteChatStreamingAsync(userPrompt, imageFiles, + // Start monitoring for interrupts + interruptManager.StartMonitoring(); + + // Start the AI streaming task + var streamingTask = chat.CompleteChatStreamingAsync(userPrompt, imageFiles, (messages) => messageCallback?.Invoke(messages), (update) => streamingCallback?.Invoke(update), (name, args) => approveFunctionCall?.Invoke(name, args) ?? true, - (name, args, result) => functionCallCallback?.Invoke(name, args, result)); - - return response; + (name, args, result) => functionCallCallback?.Invoke(name, args, result), + _interruptTokenSource.Token, + () => _displayBuffer); + + // Wait for either streaming completion or user interrupt + var interruptTask = interruptManager.WaitForInterruptAsync(); + var completedTask = await Task.WhenAny(streamingTask, interruptTask); + + if (completedTask == interruptTask) + { + // User interrupt detected + var interruptResult = await interruptTask; + HandleInterrupt(interruptResult); + _interruptTokenSource.Cancel(); + + try + { + // Wait for streaming to finish cancellation and get partial result + return await streamingTask; + } + catch (OperationCanceledException) + { + // Clean cancellation - return empty + return ""; + } + } + + // Normal completion + return await streamingTask; + } + catch (OperationCanceledException) when (_interruptTokenSource.Token.IsCancellationRequested) + { + // Interruption handled - just return empty + return ""; } catch (Exception ex) { @@ -523,6 +586,14 @@ private async Task CompleteChatStreamingAsync( SaveExceptionHistory(chat); throw; } + finally + { + // Clean up the cancellation token source and reset state + _interruptTokenSource?.Dispose(); + _interruptTokenSource = null; + _suppressAssistantDisplay = false; + _displayBuffer = ""; + } } private string? ReadLineOrSimulateInput(List inputInstructions, string? defaultOnEndOfInput = null) @@ -716,6 +787,12 @@ private void CheckAndShowPendingNotifications(FunctionCallingChat chat) private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) { + // If display is suppressed due to cancellation, don't display anything + if (_suppressAssistantDisplay) + { + return; + } + var usageUpdate = update.Contents .Where(x => x is UsageContent) .Cast() @@ -738,16 +815,57 @@ private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) .Select(x => x.Text) .ToList()); DisplayAssistantResponse(text); + UpdateDisplayBuffer(text); + } + + private void HandleInterrupt(InterruptResult interruptResult) + { + switch (interruptResult.Type) + { + case InterruptType.DoubleEscape: + ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); + _suppressAssistantDisplay = true; + break; + default: + ConsoleHelpers.Write("[Interrupt]", ConsoleColor.Yellow); + _suppressAssistantDisplay = true; + break; + } + } + + private void UpdateDisplayBuffer(string displayedText) + { + if (string.IsNullOrEmpty(displayedText)) + return; + + _displayBuffer += displayedText; + + // Keep only the last DisplayBufferSize characters + if (_displayBuffer.Length > DisplayBufferSize) + { + _displayBuffer = _displayBuffer.Substring(_displayBuffer.Length - DisplayBufferSize); + } + } + + private bool ConvertFunctionCallDecision(FunctionCallDecision decision) + { + return decision switch + { + FunctionCallDecision.Approved => true, + FunctionCallDecision.Denied => false, + FunctionCallDecision.UserWantsControl => throw new UserWantsControlException(), + _ => false + }; } - private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, string args) + private FunctionCallDecision HandleFunctionCallApproval(McpFunctionFactory factory, string name, string args) { Logger.Info($"HandleFunctionCallApproval: Function '{name}' called with args: {args}"); var autoApprove = ShouldAutoApprove(factory, name); Logger.Info($"HandleFunctionCallApproval: Auto-approve result for '{name}': {autoApprove}"); - if (autoApprove) return true; + if (autoApprove) return FunctionCallDecision.Approved; while (true) { @@ -769,23 +887,28 @@ private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, { ConsoleHelpers.WriteLine($"\b\b\b\b Approved (session)", ConsoleColor.Yellow); _approvedFunctionCallNames.Add(name); - return true; + return FunctionCallDecision.Approved; } else if (key == null || key?.KeyChar == 'N') { _deniedFunctionCallNames.Add(name); ConsoleHelpers.WriteLine($"\b\b\b\b Declined (session)", ConsoleColor.Red); - return false; + return FunctionCallDecision.Denied; } else if (key?.KeyChar == 'y') { ConsoleHelpers.WriteLine($"\b\b\b\b Approved (once)", ConsoleColor.Yellow); - return true; + return FunctionCallDecision.Approved; } else if (key?.KeyChar == 'n') { ConsoleHelpers.WriteLine($"\b\b\b\b Declined (once)", ConsoleColor.Red); - return false; + return FunctionCallDecision.Denied; + } + else if (key?.Key == ConsoleKey.Escape) + { + ConsoleHelpers.WriteLine($"\b\b\b\b Cancelled", ConsoleColor.Yellow); + return FunctionCallDecision.UserWantsControl; } else if (key?.KeyChar == '?') { @@ -795,6 +918,7 @@ private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, ConsoleHelpers.WriteLine(" y: Approve this function call for this one time"); ConsoleHelpers.WriteLine(" N: Decline this function call for this session"); ConsoleHelpers.WriteLine(" n: Decline this function call for this one time"); + ConsoleHelpers.WriteLine(" ESC: Cancel function call and return to conversation"); ConsoleHelpers.WriteLine(" ?: Show this help message\n"); ConsoleHelpers.Write(" See "); ConsoleHelpers.Write("cycod help function calls", ConsoleColor.Yellow); @@ -807,11 +931,6 @@ private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, } } - private void HandleFunctionCallCompleted(string name, string args, object? result) - { - DisplayAssistantFunctionCall(name, args, result); - } - private void DisplayUserPrompt() { ConsoleHelpers.Write("\rUser: ", ConsoleColor.Green); @@ -1231,7 +1350,11 @@ private void AddAutoDenyToolDefaults() private HashSet _approvedFunctionCallNames = new HashSet(); private HashSet _deniedFunctionCallNames = new HashSet(); - - + + // Event-driven interrupt tracking + private CancellationTokenSource? _interruptTokenSource = null; + private bool _suppressAssistantDisplay = false; + private string _displayBuffer = ""; // Track last displayed content for accurate saving + private const int DisplayBufferSize = 50; // Track last 50 characters displayed } diff --git a/src/cycod/FunctionCallingTools/CycoDmdCliWrapper.cs b/src/cycod/FunctionCallingTools/CycoDmdCliWrapper.cs index 528adf6d..813a7733 100644 --- a/src/cycod/FunctionCallingTools/CycoDmdCliWrapper.cs +++ b/src/cycod/FunctionCallingTools/CycoDmdCliWrapper.cs @@ -30,6 +30,12 @@ public class CycoDmdCliWrapper /// private const string TimeoutErrorSuffix = "timed out"; + /// + /// Error message pattern when FileLogger fails in subprocess. + /// This can contaminate title generation output when cycodmd subprocess encounters logging issues. + /// + private const string FileLoggerWarningPattern = "WARNING: FileLogger failed"; + #endregion /// /// Truncates command output according to line and total character limits. @@ -588,6 +594,14 @@ private string BuildRunCommandsAndFormatOutputArguments( return null; } + // Check if output is contaminated with FileLogger warnings from subprocess + // TODO: Improve subprocess logging to prevent this contamination in the first place + if (rawOutput.Contains(FileLoggerWarningPattern)) + { + Logger.Warning("Title generation output contaminated with FileLogger warning - returning null"); + return null; + } + return TruncateCommandOutput(rawOutput, maxCharsPerLine, maxTotalChars); } finally diff --git a/src/cycod/Helpers/SimpleInterruptManager.cs b/src/cycod/Helpers/SimpleInterruptManager.cs new file mode 100644 index 00000000..9e6ca379 --- /dev/null +++ b/src/cycod/Helpers/SimpleInterruptManager.cs @@ -0,0 +1,161 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Lightweight event-driven interrupt manager for handling user interrupts during AI streaming. +/// Replaces the polling-based approach with a more efficient event-driven system. +/// +public class SimpleInterruptManager : IDisposable +{ + private readonly TaskCompletionSource _interruptSignal = new(); + private readonly CancellationTokenSource _shutdownToken = new(); + private Task? _monitorTask; + private bool _disposed = false; + + /// + /// Waits asynchronously for an interrupt to occur. + /// + /// A task that completes when an interrupt is detected. + public Task WaitForInterruptAsync() + { + return _interruptSignal.Task; + } + + /// + /// Starts monitoring for user interrupts in a background task. + /// + public void StartMonitoring() + { + if (_disposed) + throw new ObjectDisposedException(nameof(SimpleInterruptManager)); + + _monitorTask = Task.Run(MonitorForInterruptsAsync, _shutdownToken.Token); + } + + /// + /// Background task that monitors for interrupt conditions. + /// + private async Task MonitorForInterruptsAsync() + { + var escTracker = new EscKeyTracker(); + + try + { + while (!_shutdownToken.Token.IsCancellationRequested) + { + // Only check for input if console is available and not redirected + if (!Console.IsInputRedirected && Console.KeyAvailable) + { + var keyInfo = Console.ReadKey(true); + + if (escTracker.ProcessKey(keyInfo)) + { + var result = new InterruptResult + { + Type = InterruptType.DoubleEscape, + Timestamp = DateTime.UtcNow + }; + + _interruptSignal.TrySetResult(result); + return; + } + } + + // Short delay - much more responsive than 50ms polling + await Task.Delay(10, _shutdownToken.Token); + } + } + catch (OperationCanceledException) + { + // Expected when shutting down + } + catch (Exception ex) + { + // Signal error through the task completion source + _interruptSignal.TrySetException(ex); + } + } + + /// + /// Disposes resources and stops monitoring. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + try + { + _shutdownToken.Cancel(); + _monitorTask?.Wait(TimeSpan.FromMilliseconds(100)); + } + catch (Exception) + { + // Ignore cleanup exceptions + } + finally + { + _shutdownToken.Dispose(); + _monitorTask?.Dispose(); + } + } +} + +/// +/// Tracks ESC key presses to detect double-ESC interrupt pattern. +/// +public class EscKeyTracker +{ + private DateTime? _lastEscTime; + private const int DoubleEscTimeoutMs = 500; + + /// + /// Processes a key press and returns true if double-ESC is detected. + /// + /// The key that was pressed. + /// True if double-ESC pattern is detected, false otherwise. + public bool ProcessKey(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Escape) + { + var currentTime = DateTime.UtcNow; + + if (_lastEscTime.HasValue && + (currentTime - _lastEscTime.Value).TotalMilliseconds <= DoubleEscTimeoutMs) + { + // Double ESC detected + _lastEscTime = null; // Reset for next sequence + return true; + } + + _lastEscTime = currentTime; + } + else + { + // Reset ESC tracking if any other key is pressed + _lastEscTime = null; + } + + return false; + } +} + +/// +/// Represents the result of an interrupt detection. +/// +public class InterruptResult +{ + public InterruptType Type { get; set; } + public DateTime Timestamp { get; set; } +} + +/// +/// Types of interrupts that can be detected. +/// +public enum InterruptType +{ + DoubleEscape +} \ No newline at end of file