Search code examples
c#.netbotframework

Can class-object bot state be saved in code while using Adaptive Dialogs in Bot Framework?


We are experiencing an issue with the Bot Framework where when one of the first steps is a CodeAction which performs an accessor.GetAsync() or .SetAsync(), the subsequent dialog accessing the property will crash with error:

[OnTurnError] unhandled error : Object of type 'Newtonsoft.Json.Linq.JValue' cannot be converted to type 'System.String'.

The full stack is here:


System.ArgumentException: Object of type 'Newtonsoft.Json.Linq.JValue' cannot be converted to type 'System.String'.
   at System.RuntimeType.TryChangeType(Object value, Binder binder, CultureInfo culture, Boolean needsSpecialCast)
   at System.RuntimeType.CheckValue(Object value, Binder binder, CultureInfo culture, BindingFlags invokeAttr)
   at System.Reflection.MethodBase.CheckArguments(Object[] parameters, Binder binder, BindingFlags invokeAttr, CultureInfo culture, Signature sig)
   at System.Reflection.RuntimeMethodInfo.InvokeArgumentsCheck(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.SetValue(Object obj, Object value, BindingFlags invokeAttr, Binder binder, Object[] index, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.SetValue(Object obj, Object value, Object[] index)
   at System.Reflection.PropertyInfo.SetValue(Object obj, Object value)
   at Microsoft.Bot.Builder.Dialogs.ObjectPath.SetObjectSegment(Object obj, Object segment, Object value, Boolean json)
   at System.Dynamic.UpdateDelegates.UpdateAndExecuteVoid5[T0,T1,T2,T3,T4](CallSite site, T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4)
   at Microsoft.Bot.Builder.Dialogs.ObjectPath.SetPathValue(Object obj, String path, Object value, Boolean json)
   at Microsoft.Bot.Builder.Dialogs.Memory.DialogStateManager.SetValue(String path, Object value)
   at Microsoft.Bot.Builder.Dialogs.Adaptive.Input.InputDialog.ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.Dialogs.DialogContext.ContinueDialogAsync(CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.Dialogs.Adaptive.AdaptiveDialog.ContinueActionsAsync(DialogContext dc, Object options, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.Dialogs.Adaptive.AdaptiveDialog.ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.Dialogs.DialogContext.ContinueDialogAsync(CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.Dialogs.DialogManager.HandleBotOnTurnAsync(DialogContext dc, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.Dialogs.DialogManager.OnTurnAsync(ITurnContext context, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.Dialogs.DialogManager.OnTurnAsync(ITurnContext context, CancellationToken cancellationToken)
   at MyBot.Bot.MyBotBot`1.OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken) in H:\Work\MyBot\backendadaptivebot\api\Bots\MyBotBot.cs:line 50
   at Microsoft.Bot.Builder.RegisterClassMiddleware`1.OnTurnAsync(ITurnContext turnContext, NextDelegate nextTurn, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.RegisterClassMiddleware`1.OnTurnAsync(ITurnContext turnContext, NextDelegate nextTurn, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.RegisterClassMiddleware`1.OnTurnAsync(ITurnContext turnContext, NextDelegate nextTurn, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.BotFrameworkAdapter.TenantIdWorkaroundForTeamsMiddleware.OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.MiddlewareSet.ReceiveActivityWithStatusAsync(ITurnContext turnContext, BotCallbackHandler callback, CancellationToken cancellationToken)
   at Microsoft.Bot.Builder.BotAdapter.RunPipelineAsync(ITurnContext turnContext, BotCallbackHandler callback, CancellationToken cancellationToken)

In the RootDialog class constructor we have:

            participantAccessor = _userState.CreateProperty<Profile>("profile");

            string[] paths = { ".", "Dialogs", $"RootDialog.lg" };
            string fullPath = Path.Combine(paths);

            // These steps are executed when this Adaptive Dialog begins
            Triggers = new List<OnCondition>()
                {
                    // Add a rule to welcome user
                    new OnConversationUpdateActivity()
                    {
                        Actions = WelcomeUserSteps()
                    },

                    // Respond to user on message activity
                    new OnUnknownIntent()
                    {
                        Actions = GetUserDetails()
                    },
                };

And GetUserDetails():

        private static List<Dialog> GetUserDetails() 
        {
            return new List<Dialog>()
            {
     
                new CodeAction(PopulateProfile),
                new TextInput()
                {
                    Prompt = new ActivityTemplate("${RequestPhoneNumber()}"),
                    Property = "user.profile.MobileNumber",
                }

After this TextInput receives input from the user, the crash occurs. However, if the CodeAction is removed, the bot does not crash.

Inside this PopulateProfile method we simply access the property which we created in the constructor, a basic Profile class (containing only simple properties: strings and an integer)

        private static async Task<DialogTurnResult> PopulateProfile(DialogContext dc, System.Object options)
        {
            Profile profile = await participantAccessor.GetAsync(dc.Context, () => new Profile());
            profile.Complete = 0;             
            return await dc.EndDialogAsync(options);
        }

Notably, this creates an entry in the DialogContext State of type 'Profile' object as expected. However if we remove this CodeAction and replace with the following within GetUserDetails():

                new SetProperty() {
                    Property = "user.profile.Complete",
                    Value = 0,
                },    
                new TextInput()
                {
                    Prompt = new ActivityTemplate("${RequestPhoneNumber()}"),
                    Property = "user.profile.MobileNumber",
                }

Then the type of the object in DialogContext.State appears to be a JSON object, and the bot functions as expected.

Does this mean that adaptive dialogs in Bot Framework do not support the writing of properties as a POCO type via code?

So it must be done via simple direct values (e.g. user.profile.completed) or via SetProperty actions?

(I cannot see this documented anywhere)

Edit: Here's the simple Profile class:

    public class Profile
    {
        public string Id { get; set; }
        public string AssociatedAsset { get; set; }
        public string FullName { get; set; }
        public string PreferredName { get; set; }
        public string MobileNumber { get; set; }
        public string Email { get; set; }
        public int Complete { get; set; } = 0;
    }

Solution

  • You're trying to combine two largely-incompatible ways of doing things. State property accessors were the original way of accessing state in Bot Builder v4, but adaptive dialogs are a totally new system and they have their own way of accessing state.

    Here's what's going wrong. Even though TextInput tries to assign a string to this.value, DialogStateManager.SetValue immediately converts that string to a JToken. So even if that JToken got retrieved as a string it would still be converted back to a JToken when assigning to user.profile.MobileNumber. And if user.profile has been serialized with type information then it will be deserialized as that type, which means it will get converted to a Profile object before the JToken is assigned to its MobileNumber property. You could raise this as a bug in the Bot Builder .NET GitHub repo, but be aware that you're trying to do something that goes against the design of adaptive dialogs.

    If you want to use adaptive dialogs, you should be consistent about it and do everything the adaptive dialog way. It's easy to go wrong with code actions so they should be used sparingly. If the thing you're doing with a code action can be done with a different adaptive dialog action then you should use the different one because it will be incorporating builtin adaptive dialog functionality. For example, if you want to set a property then you should use SetProperty like you've seen.

    If you really want to set a property in a code action then rather than using state property accessors you should do it the adaptive dialog way. Adaptive dialogs use SetValue to set their properties, so you should too. This will ensure that the data is formatted in a way that adaptive dialogs can easily consume.

    private static async Task<DialogTurnResult> PopulateProfile(DialogContext dc, object options)
    {
        //var profile = await UserState.CreateProperty<Profile>("profile").GetAsync(dc.Context, () => new Profile());
        //profile.Complete = 0;
    
        dc.State.SetValue("user.profile.Complete", 0);
    
        return await dc.EndDialogAsync(options);
    }