Handle user interruptions

APPLIES TO: SDK v4

Handling interruptions is an important aspect of a robust bot. Users won't always follow your defined conversation flow, step by step. They may try to ask a question in the middle of the process, or simply want to cancel it instead of completing it. This article describes some common ways to handle user interruptions in your bot.

Note

The Bot Framework JavaScript, C#, and Python SDKs will continue to be supported, however, the Java SDK is being retired with final long-term support ending in November 2023.

Existing bots built with the Java SDK will continue to function.

For new bot building, consider using Microsoft Copilot Studio and read about choosing the right copilot solution.

For more information, see The future of bot building.

Prerequisites

The core bot sample uses Language Understanding (LUIS) to identify user intents; however, identifying user intent isn't the focus of this article. For information about identifying user intents, see Natural language understanding and Add natural language understanding to your bot.

Note

Language Understanding (LUIS) will be retired on 1 October 2025. Beginning 1 April 2023, you won't be able to create new LUIS resources. A newer version of language understanding is now available as part of Azure AI Language.

Conversational language understanding (CLU), a feature of Azure AI Language, is the updated version of LUIS. For more information about language understanding support in the Bot Framework SDK, see Natural language understanding.

About this sample

The sample used in this article models a flight booking bot that uses dialogs to get flight information from the user. At any time during the conversation with the bot, the user can issue help or cancel commands to cause an interruption. There are two types of interruptions handled:

  • Turn level: Bypass processing at the turn level but leave the dialog on the stack with the information that was provided. In the next turn, continue from where the conversation left off.
  • Dialog level: Cancel the processing completely, so the bot can start all over again.

Define and implement the interruption logic

First, define and implement the help and cancel interruptions.

To use dialogs, install the Microsoft.Bot.Builder.Dialogs NuGet package.

Dialogs\CancelAndHelpDialog.cs

Implement the CancelAndHelpDialog class to handle user interruptions. The cancelable dialogs, BookingDialog and DateResolverDialog derive from this class.

public class CancelAndHelpDialog : ComponentDialog

In the CancelAndHelpDialog class, the OnContinueDialogAsync method calls the InterruptAsync method to check if the user has interrupted the normal flow. If the flow is interrupted, base class methods are called; otherwise, the return value from the InterruptAsync is returned.

protected override async Task<DialogTurnResult> OnContinueDialogAsync(DialogContext innerDc, CancellationToken cancellationToken = default)
{
    var result = await InterruptAsync(innerDc, cancellationToken);
    if (result != null)
    {
        return result;
    }

    return await base.OnContinueDialogAsync(innerDc, cancellationToken);
}

If the user types "help", the InterruptAsync method sends a message and then calls DialogTurnResult (DialogTurnStatus.Waiting) to indicate that the dialog on top is waiting for a response from the user. In this way, the conversation flow is interrupted for a turn only, and the next turn continues from where the conversation left off.

If the user types "cancel", it calls CancelAllDialogsAsync on its inner dialog context, which clears its dialog stack and causes it to exit with a canceled status and no result value. To the MainDialog (shown later on), it will appear that the booking dialog ended and returned null, similar to when the user chooses not to confirm their booking.

private async Task<DialogTurnResult> InterruptAsync(DialogContext innerDc, CancellationToken cancellationToken)
{
    if (innerDc.Context.Activity.Type == ActivityTypes.Message)
    {
        var text = innerDc.Context.Activity.Text.ToLowerInvariant();

        switch (text)
        {
            case "help":
            case "?":
                var helpMessage = MessageFactory.Text(HelpMsgText, HelpMsgText, InputHints.ExpectingInput);
                await innerDc.Context.SendActivityAsync(helpMessage, cancellationToken);
                return new DialogTurnResult(DialogTurnStatus.Waiting);

            case "cancel":
            case "quit":
                var cancelMessage = MessageFactory.Text(CancelMsgText, CancelMsgText, InputHints.IgnoringInput);
                await innerDc.Context.SendActivityAsync(cancelMessage, cancellationToken);
                return await innerDc.CancelAllDialogsAsync(cancellationToken);
        }
    }

    return null;
}

Check for interruptions each turn

Once the interrupt handling class is implemented, review what happens when this bot receives a new message from the user.

Dialogs\MainDialog.cs

As the new message activity arrives, the bot runs the MainDialog. The MainDialog prompts the user for what it can help with. And then it starts the BookingDialog in the MainDialog.ActStepAsync method, with a call to BeginDialogAsync as shown below.

private async Task<DialogTurnResult> ActStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    if (!_luisRecognizer.IsConfigured)
    {
        // LUIS is not configured, we just run the BookingDialog path with an empty BookingDetailsInstance.
        return await stepContext.BeginDialogAsync(nameof(BookingDialog), new BookingDetails(), cancellationToken);
    }

    // Call LUIS and gather any potential booking details. (Note the TurnContext has the response to the prompt.)
    var luisResult = await _luisRecognizer.RecognizeAsync<FlightBooking>(stepContext.Context, cancellationToken);
    switch (luisResult.TopIntent().intent)
    {
        case FlightBooking.Intent.BookFlight:
            await ShowWarningForUnsupportedCities(stepContext.Context, luisResult, cancellationToken);

            // Initialize BookingDetails with any entities we may have found in the response.
            var bookingDetails = new BookingDetails()
            {
                // Get destination and origin from the composite entities arrays.
                Destination = luisResult.ToEntities.Airport,
                Origin = luisResult.FromEntities.Airport,
                TravelDate = luisResult.TravelDate,
            };

            // Run the BookingDialog giving it whatever details we have from the LUIS call, it will fill out the remainder.
            return await stepContext.BeginDialogAsync(nameof(BookingDialog), bookingDetails, cancellationToken);

        case FlightBooking.Intent.GetWeather:
            // We haven't implemented the GetWeatherDialog so we just display a TODO message.
            var getWeatherMessageText = "TODO: get weather flow here";
            var getWeatherMessage = MessageFactory.Text(getWeatherMessageText, getWeatherMessageText, InputHints.IgnoringInput);
            await stepContext.Context.SendActivityAsync(getWeatherMessage, cancellationToken);
            break;

        default:
            // Catch all for unhandled intents
            var didntUnderstandMessageText = $"Sorry, I didn't get that. Please try asking in a different way (intent was {luisResult.TopIntent().intent})";
            var didntUnderstandMessage = MessageFactory.Text(didntUnderstandMessageText, didntUnderstandMessageText, InputHints.IgnoringInput);
            await stepContext.Context.SendActivityAsync(didntUnderstandMessage, cancellationToken);
            break;
    }

    return await stepContext.NextAsync(null, cancellationToken);
}

Next, in the FinalStepAsync method of the MainDialog class, the booking dialog ended and the booking is considered to be complete or canceled.

private async Task<DialogTurnResult> FinalStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // If the child dialog ("BookingDialog") was cancelled, the user failed to confirm or if the intent wasn't BookFlight
    // the Result here will be null.
    if (stepContext.Result is BookingDetails result)
    {
        // Now we have all the booking details call the booking service.

        // If the call to the booking service was successful tell the user.

        var timeProperty = new TimexProperty(result.TravelDate);
        var travelDateMsg = timeProperty.ToNaturalLanguage(DateTime.Now);
        var messageText = $"I have you booked to {result.Destination} from {result.Origin} on {travelDateMsg}";
        var message = MessageFactory.Text(messageText, messageText, InputHints.IgnoringInput);
        await stepContext.Context.SendActivityAsync(message, cancellationToken);
    }

    // Restart the main dialog with a different message the second time around
    var promptMessage = "What else can I do for you?";
    return await stepContext.ReplaceDialogAsync(InitialDialogId, promptMessage, cancellationToken);
}

The code in BookingDialog isn't shown here as it's not directly related to interruption handling. It's used to prompt users for booking details. You can find that code in Dialogs\BookingDialogs.cs.

Handle unexpected errors

The adapter's error handler handles any exceptions that weren't caught in the bot.

AdapterWithErrorHandler.cs

In the sample, the adapter's OnTurnError handler receives any exceptions thrown by your bot's turn logic. If there's an exception thrown, the handler deletes the conversation state for the current conversation to prevent the bot from getting stuck in an error loop caused by being in a bad state.

    {
        // Log any leaked exception from the application.
        // NOTE: In production environment, you should consider logging this to
        // Azure Application Insights. Visit https://aka.ms/bottelemetry to see how
        // to add telemetry capture to your bot.
        logger.LogError(exception, $"[OnTurnError] unhandled error : {exception.Message}");

        // Send a message to the user
        var errorMessageText = "The bot encountered an error or bug.";
        var errorMessage = MessageFactory.Text(errorMessageText, errorMessageText, InputHints.IgnoringInput);
        await turnContext.SendActivityAsync(errorMessage);

        errorMessageText = "To continue to run this bot, please fix the bot source code.";
        errorMessage = MessageFactory.Text(errorMessageText, errorMessageText, InputHints.ExpectingInput);
        await turnContext.SendActivityAsync(errorMessage);

        if (conversationState != null)
        {
            try
            {
                // Delete the conversationState for the current conversation to prevent the
                // bot from getting stuck in a error-loop caused by being in a bad state.
                // ConversationState should be thought of as similar to "cookie-state" in a Web pages.
                await conversationState.DeleteAsync(turnContext);
            }
            catch (Exception e)
            {
                logger.LogError(e, $"Exception caught on attempting to Delete ConversationState : {e.Message}");
            }
        }

        // Send a trace activity, which will be displayed in the Bot Framework Emulator
        await turnContext.TraceActivityAsync("OnTurnError Trace", exception.Message, "https://www.botframework.com/schemas/error", "TurnError");
    };
}

Register services

Startup.cs

Finally, in Startup.cs, the bot is created as a transient, and on every turn, a new instance of the bot is created.


// Register the BookingDialog.

For reference, here are the class definitions that are used in the call to create the bot above.

public class DialogAndWelcomeBot<T> : DialogBot<T>
public class DialogBot<T> : ActivityHandler
    where T : Dialog
public class MainDialog : ComponentDialog

Test the bot

  1. If you haven't done so already, install the Bot Framework Emulator.
  2. Run the sample locally on your machine.
  3. Start the Emulator, connect to your bot, and send messages as shown below.

Additional information

  • The 24.bot-authentication-msgraph sample in C#, JavaScript, Python, or Java shows how to handle a logout request. It uses a pattern similar to the one shown here for handling interruptions.

  • You should send a default response instead of doing nothing and leaving the user wondering what is going on. The default response should tell the user what commands the bot understands so the user can get back on track.

  • At any point in the turn, the turn context's responded property indicates whether the bot has sent a message to the user this turn. Before the turn ends, your bot should send some message to the user, even if it's a simple acknowledgment of their input.