Create a bot using adaptive, component, waterfall, and custom dialogs

APPLIES TO: SDK v4

All dialogs derive from a base dialog class. If you use the dialog manager to run your root dialog, all of the dialog classes can work together. This article shows how to use component, waterfall, custom, and adaptive dialogs together in one bot.

This article focuses on the code that allows these dialogs to work together. See the additional information for articles that cover each type of dialog in more detail.

Prerequisites

Preliminary steps to add an adaptive dialog to a bot

You must follow the steps described below to add an adaptive dialog to a bot. These steps are covered in more detail in how to create a bot using adaptive dialogs.

  1. Update all Bot Builder NuGet packages to version 4.9.x.
  2. Add the Microsoft.Bot.Builder.Dialogs.Adaptive package to your bot project.
  3. Update the the bot adapter to add storage and the user and conversation state objects to every turn context.
  4. Use a dialog manager in the bot code to start or continue the root dialog each turn.

About the sample

By way of illustration, this sample combines various dialog types together in one bot. It does not demonstrate best practices for designing conversation flow. The sample:

  • Defines a custom slot filling dialog class.
  • Creates a root component dialog:
    • A waterfall dialog manages the top-level conversation flow.
    • Together, an adaptive dialog and 2 custom slot filling dialogs manage the rest of the conversation flow.

dialog flow

The custom slot-filling dialogs accept a list of properties (slots to fill). Each custom dialog will prompt for any missing values until all of the slots are filled. The sample binds a property to the adaptive dialog to allow the adaptive dialog to also fill slots.

This article focuses on how the various dialog types work together. For information about configuring your bot to use adaptive dialogs, see how to create a bot using adaptive dialogs. For more on using adaptive dialogs to gather user input, see about inputs in adaptive dialogs.

The custom slot-filling dialogs

A custom dialog is any dialog that derives from one of the dialogs classes in the SDK and overrides one or more of the basic dialog methods: begin dialog, continue dialog, resume dialog, or end dialog.

When you create the slot-filling dialog, you provide a list of definitions for the slots the dialog will fill. The dialog overrides the begin, continue, and resume dialog methods to iteratively prompt the user to fill each slot. When all the slots are filled, the dialog ends and returns the collected information.

Each slot definition includes the name of the dialog prompt with which to collect the information.

The root dialog creates 2 slot-filling dialogs, one to collect the user's full name, and one to collect their address. It also creates the text prompt that these two dialogs use to fill their slots.

Dialogs\SlotDetails.cs

The SlotDetails class describes the information to collect and the prompt with which to collect it.

/// <summary>
/// A list of SlotDetails defines the behavior of our SlotFillingDialog.
/// This class represents a description of a single 'slot'. It contains the name of the property we want to gather
/// and the id of the corresponding dialog that should be used to gather that property. The id is that used when the
/// dialog was added to the current DialogSet. Typically this id is that of a prompt but it could also be the id of
/// another slot dialog.
/// </summary>
public class SlotDetails
{
    public SlotDetails(string name, string dialogId, string prompt = null, string retryPrompt = null)
        : this(name, dialogId, new PromptOptions
        {
            Prompt = MessageFactory.Text(prompt),
            RetryPrompt = MessageFactory.Text(retryPrompt),
        })
    {
    }

    public SlotDetails(string name, string dialogId, PromptOptions options)
    {
        Name = name;
        DialogId = dialogId;
        Options = options;
    }

    public string Name { get; set; }

    public string DialogId { get; set; }

    public PromptOptions Options { get; set; }
}

Dialogs\SlotFillingDialog.cs

The SlotFillingDialog class derives from the base Dialog class.

It tracks the values it has collected, which slot it prompted for last, and details for the slots to fill.

// Custom dialogs might define their own custom state.
// Similarly to the Waterfall dialog we will have a set of values in the ConversationState. However, rather than persisting
// an index we will persist the last property we prompted for. This way when we resume this code following a prompt we will
// have remembered what property we were filling.
private const string SlotName = "slot";
private const string PersistedValues = "values";

// The list of slots defines the properties to collect and the dialogs to use to collect them.
private readonly List<SlotDetails> _slots;

public SlotFillingDialog(string dialogId, List<SlotDetails> slots)
    : base(dialogId)
{
    _slots = slots ?? throw new ArgumentNullException(nameof(slots));
}

The core logic for collecting missing information is in the RunPromptAsync helper method. When all the information has been collected, it ends the dialog and returns the information.

/// <summary>
/// This helper function contains the core logic of this dialog. The main idea is to compare the state we have gathered with the
/// list of slots we have been asked to fill. When we find an empty slot we execute the corresponding prompt.
/// </summary>
/// <param name="dialogContext">A handle on the runtime.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A DialogTurnResult indicating the state of this dialog to the caller.</returns>
private Task<DialogTurnResult> RunPromptAsync(DialogContext dialogContext, CancellationToken cancellationToken)
{
    var state = GetPersistedValues(dialogContext.ActiveDialog);

    // Run through the list of slots until we find one that hasn't been filled yet.
    var unfilledSlot = _slots.FirstOrDefault((item) => !state.ContainsKey(item.Name));

    // If we have an unfilled slot we will try to fill it
    if (unfilledSlot != null)
    {
        // The name of the slot we will be prompting to fill.
        dialogContext.ActiveDialog.State[SlotName] = unfilledSlot.Name;

        // If the slot contains prompt text create the PromptOptions.

        // Run the child dialog
        return dialogContext.BeginDialogAsync(unfilledSlot.DialogId, unfilledSlot.Options, cancellationToken);
    }
    else
    {
        // No more slots to fill so end the dialog.
        return dialogContext.EndDialogAsync(state);
    }
}

For a more about implementing custom dialogs, see the discussion of the cancel and help dialog in how to handle user interruptions.

The root component dialog

The root dialog:

  • Defines all the slots to fill, for itself, the 2 slot-filling dialogs, and the adaptive dialog.
  • Creates a user state property accessor in which to save the collected information.
  • Creates the adaptive dialog, the 2 slot-filling dialogs, a waterfall dialog, and the prompts to use with the waterfall and slot-filling dialogs.
  • Sets the waterfall dialog as the initial dialog to run when the component first starts.

The waterfall will aggregate all the collected information and save it user state.

The waterfall and adaptive dialog are described in the following sections.

Dialogs\RootDialog.cs

The RootDialog class is a ComponentDialog. It defines the user state property in which to save the collected information.

public class RootDialog : ComponentDialog
{
    private IStatePropertyAccessor<JObject> _userStateAccessor;

    public RootDialog(UserState userState)
        : base("root")
    {
        _userStateAccessor = userState.CreateProperty<JObject>("result");

Its constructor creates all the dialogs it needs, including an adaptive dialog adaptiveSlotFillingDialog.

It then adds all these dialogs to its dialog set.

    // Add the various dialogs that will be used to the DialogSet.
    AddDialog(new SlotFillingDialog("address", address_slots));
    AddDialog(new SlotFillingDialog("fullname", fullname_slots));
    AddDialog(new TextPrompt("text"));
    AddDialog(new NumberPrompt<int>("number", defaultLocale: Culture.English));
    AddDialog(new NumberPrompt<float>("shoesize", ShoeSizeAsync, defaultLocale: Culture.English));

    // We will instead have adaptive dialog do the slot filling by invoking the custom dialog
    // AddDialog(new SlotFillingDialog("slot-dialog", slots));

    // Add adaptive dialog
    AddDialog(adaptiveSlotFillingDialog);

    // Defines a simple two step Waterfall to test the slot dialog.
    AddDialog(new WaterfallDialog("waterfall", new WaterfallStep[] { StartDialogAsync, DoAdaptiveDialog, ProcessResultsAsync }));

    // The initial child Dialog to run.
    InitialDialogId = "waterfall";
}

The waterfall dialog

The waterfall dialog contains 3 steps:

  1. Start the "fullname" slot-filling dialog, which will gather and return the user's full name.
  2. Record the user's name and start the adaptive dialog, which will gather the rest of the user's information.
  3. Write the user's information to the user state property accessor and summarize to the user the collected information.

Dialogs\RootDialog.cs

private async Task<DialogTurnResult> StartDialogAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // Start the child dialog. This will just get the user's first and last name.
    return await stepContext.BeginDialogAsync("fullname", null, cancellationToken);
}

private async Task<DialogTurnResult> DoAdaptiveDialog(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    object adaptiveOptions = null;
    if (stepContext.Result is IDictionary<string, object> result && result.Count > 0)
    {
        adaptiveOptions = new { fullname = result };
    }
    // begin the adaptive dialog. This in-turn will get user's age, shoe-size using adaptive inputs and subsequently
    // call the custom slot filling dialog to fill user address.
    return await stepContext.BeginDialogAsync(nameof(AdaptiveDialog), adaptiveOptions, cancellationToken);
}

private async Task<DialogTurnResult> ProcessResultsAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // To demonstrate that the slot dialog collected all the properties we will echo them back to the user.
    if (stepContext.Result is IDictionary<string, object> result && result.Count > 0)
    {
        // Now the waterfall is complete, save the data we have gathered into UserState.
        // This includes data returned by the adaptive dialog.
        var obj = await _userStateAccessor.GetAsync(stepContext.Context, () => new JObject());
        obj["data"] = new JObject
        {
            { "fullname",  $"{result["fullname"]}" },
            { "shoesize", $"{result["shoesize"]}" },
            { "address", $"{result["address"]}" },
        };

        await stepContext.Context.SendActivityAsync(MessageFactory.Text(obj["data"]["fullname"].Value<string>()), cancellationToken);
        await stepContext.Context.SendActivityAsync(MessageFactory.Text(obj["data"]["shoesize"].Value<string>()), cancellationToken);
        await stepContext.Context.SendActivityAsync(MessageFactory.Text(obj["data"]["address"].Value<string>()), cancellationToken);
    }

    // Remember to call EndAsync to indicate to the runtime that this is the end of our waterfall.
    return await stepContext.EndDialogAsync();
}

The adaptive dialog

The adaptive dialog defines one trigger that runs when the dialog starts. The trigger will run these actions:

  1. Use an input dialog to ask for the user's age.
  2. Use an input dialog to ask for the user's shoe size.
  3. Start the "address" slot-filling dialog to collect the user's address.
  4. Set trigger's result value and end.

Since no other actions will be queued, the adaptive dialog will also end and return this result value.

The adaptive dialog uses a language generator to format text and include values from bot and dialog state. (See about using generators in adaptive dialogs For more information.)

Dialogs\RootDialog.cs

// define adaptive dialog
var adaptiveSlotFillingDialog = new AdaptiveDialog();
adaptiveSlotFillingDialog.Id = nameof(AdaptiveDialog);

// Set a language generator
// You can see other adaptive dialog samples to learn how to externalize generation resources into .lg files.
adaptiveSlotFillingDialog.Generator = new TemplateEngineLanguageGenerator();

// add set of actions to perform when the adaptive dialog begins.
adaptiveSlotFillingDialog.Triggers.Add(new OnBeginDialog()
{
    Actions = new List<Dialog>()
    {
        // any options passed into adaptive dialog is automatically available under dialog.xxx
        // get user age
        new NumberInput()
        {
            Property = "dialog.userage",

            // use information passed in to the adaptive dialog.
            // See https://aka.ms/language-generation to learn more.
            Prompt = new ActivityTemplate("Hello ${dialog.fullname.first}, what is your age?"),
            Validations = new List<BoolExpression>()
            {
                // Conditions are written using Adaptive Expressions.
                // See https://aka.ms/adaptive-expressions to learn more.
                "int(this.value) >= 1",
                "int(this.value) <= 150"
            },
            InvalidPrompt = new ActivityTemplate("Sorry, ${this.value} does not work. Looking for age to be between 1-150. What is your age?"),
            UnrecognizedPrompt = new ActivityTemplate("Sorry, I did not understand ${this.value}. What is your age?"),
            MaxTurnCount = 3,
            DefaultValue = "=30",
            DefaultValueResponse = new ActivityTemplate("Sorry, this is not working. For now, I'm setting your age to ${this.defaultValue}"),
            AllowInterruptions = false
        },
        new NumberInput()
        {
            Property = "dialog.shoesize",
            Prompt = new ActivityTemplate("Please enter your shoe size."),
            InvalidPrompt = new ActivityTemplate("Sorry ${this.value} does not work. You must enter a size between 0 and 16. Half sizes are acceptable."),
            Validations = new List<BoolExpression>()
            {
                // size can only between 0-16
                "int(this.value) >= 0 && int(this.value) <= 16",
                // can only full or half size
                "isMatch(string(this.value), '^[0-9]+(\\.5)*$')"
            },
            AllowInterruptions = false
        },
        // get address - adaptive is calling the custom slot filling dialog here.
        new BeginDialog()
        {
            Dialog = "address",
            ResultProperty = "dialog.address"
        },
        // return everything under dialog scope. 
        new EndDialog()
        {
            Value = "=dialog"
        }
    }
}) ;

To test the bot

  1. If you have not 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 respond to the prompts: first and last name, shoe size, street, city, and zip.
  4. The bot will display the information it collected.
  5. Send the bot any message to start the process over again.

Additional information

For more information on how to use each dialog type, see:

Dialog type Article
Adaptive and input dialogs Create a bot using adaptive dialogs.
Component dialogs Manage dialog complexity
Custom dialogs Handle user interruptions
Waterfall and prompt dialogs Implement sequential conversation flow