/** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { ComponentMemoryScopes, isComponentMemoryScopes } from './componentMemoryScopes' import { ComponentPathResolvers, isComponentPathResolvers } from './componentPathResolvers' import { ComponentRegistration } from '../componentRegistration' import { ExceptionHelper } from '@microsoft/agents-activity' import { Errors } from '../errorHelper' import { DialogContext } from '../dialogContext' import { DialogPath } from './dialogPath' import { DialogsComponentRegistration } from '../dialogsComponentRegistration' import { MemoryScope } from './scopes' import { PathResolver } from './pathResolvers' /** * Configuration options for the DialogStateManager. * * @remarks * This interface defines the configuration settings that control how the DialogStateManager * resolves memory paths and manages different memory scopes within a dialog context. * The configuration is shared across all DialogStateManager instances within a dialog chain * and is cached in turn state for performance optimization. */ export interface DialogStateManagerConfiguration { /** * List of path resolvers used to evaluate and transform memory path expressions. * * @remarks * Path resolvers provide shortcut behavior for mapping path expressions to their * actual memory locations. For example, a resolver might transform '$foo' to 'dialog.foo' * or 'user.name' to 'user.profile.name'. Resolvers are applied in order during path * transformation, allowing for complex path mapping scenarios. * * Common path resolver types include: * - Dollar sign resolvers (e.g., $variable -> dialog.variable) * - Scope alias resolvers (e.g., this -> dialog) * - Custom application-specific resolvers */ readonly pathResolvers: PathResolver[]; /** * List of the supported memory scopes available to the dialog state manager. * * @remarks * Memory scopes are named root-level objects that provide access to different * types of persistent and transient data within the dialog system. Each scope * manages its own data lifecycle including loading, saving, and deletion operations. * * Common memory scope types include: * - dialog: Dialog-specific data that persists across turns * - user: User-specific data that persists across conversations * - conversation: Conversation-specific data * - turn: Turn-specific data (transient) * - settings: Application configuration data * * Scopes can exist either in the dialog context or in turn state, and each * scope determines whether it should be included in memory snapshots for debugging. */ readonly memoryScopes: MemoryScope[]; } const PATH_TRACKER = 'dialog._tracker.paths' const DIALOG_STATE_MANAGER_CONFIGURATION = 'DialogStateManagerConfiguration' /** * Manages memory scopes and path resolvers. * * @remarks * MemoryScopes are named root level objects, which can exist either in the dialog context or off * of turn state. Path resolvers allow for shortcut behavior for mapping things like * $foo -> dialog.foo */ export class DialogStateManager { private readonly dialogContext: DialogContext /** * Initializes a new instance of the DialogStateManager class. * * @param dc The dialog context for the current turn of the conversation. * @param configuration Configuration for the dialog state manager. */ constructor (dc: DialogContext, configuration?: DialogStateManagerConfiguration) { ComponentRegistration.add(new DialogsComponentRegistration()) this.dialogContext = dc this.configuration = configuration ?? dc.context.turnState.get(DIALOG_STATE_MANAGER_CONFIGURATION) if (!this.configuration) { this.configuration = { memoryScopes: [], pathResolvers: [] } // get all of the component memory scopes. ComponentRegistration.components .filter((component: ComponentRegistration) => isComponentMemoryScopes(component)) .forEach((component: ComponentMemoryScopes) => { this.configuration.memoryScopes.push(...component.getMemoryScopes()) }) // merge in turn state memory scopes const memoryScopes = dc.context.turnState.get('memoryScopes') ?? [] this.configuration.memoryScopes.push(...memoryScopes) // get all of the component path resolvers. ComponentRegistration.components .filter((component: ComponentRegistration) => isComponentPathResolvers(component)) .forEach((component: ComponentPathResolvers) => { this.configuration.pathResolvers.push(...component.getPathResolvers()) }) // merge in turn state ones path resolvers const pathResolvers = dc.context.turnState.get('pathResolvers') ?? [] this.configuration.pathResolvers.push(...pathResolvers) // cache for any other new dialogStateManager instances in this turn dc.context.turnState.set(DIALOG_STATE_MANAGER_CONFIGURATION, this.configuration) } } /** * Gets or sets the configured path resolvers and memory scopes for the dialog state manager. * * @remarks * There is a single set of configuration information for a given chain of dialog contexts. * Assigning a new configuration to any DialogStateManager within the chain will update the * configuration for the entire chain. */ configuration: DialogStateManagerConfiguration /** * Get the value from memory using path expression. * * @typeParam T The value type to return. * @param pathExpression Path expression to use. * @param defaultValue (Optional) default value to use if the path isn't found. May be a function that returns the default value to use. * @returns The found value or undefined if not found and no `defaultValue` specified. * * @remarks * This always returns a CLONE of the memory, any modifications to the result will not affect memory. * */ getValue(pathExpression: string, defaultValue?: T | (() => T)): T { function returnDefault (): T { return typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue } // Get path segments const segments = this.parsePath(this.transformPath(pathExpression)) if (segments.length < 1) { return returnDefault() } // Get memory scope to search over const scope = this.getMemoryScope(segments[0].toString()) if (scope === undefined) { console.warn( `DialogStateManager.getValue: a scope of '${segments[0]}' wasn't found for '${pathExpression}'.` ) return returnDefault() } // Search over path const memory = this.resolveSegments(scope.getMemory(this.dialogContext), segments, false) // Return default value if nothing found return memory !== undefined ? memory : returnDefault() } /** * Set memory to value. * * @param pathExpression Path to memory. * @param value Value to set. */ setValue (pathExpression: string, value: any): void { // Get path segments const tpath = this.transformPath(pathExpression) const segments = this.parsePath(tpath) if (segments.length < 1) { throw ExceptionHelper.generateException( Error, Errors.PathNotSpecified ) } // Track changes this.trackChange(tpath) // Get memory scope to update const scope = this.getMemoryScope(segments[0].toString()) if (scope === undefined) { throw ExceptionHelper.generateException( Error, Errors.ScopeNotFound, undefined, { scope: segments[0].toString() } ) } // Update memory if (segments.length > 1) { // Find value up to last key // - Missing paths will be populated as needed let memory = scope.getMemory(this.dialogContext) memory = this.resolveSegments(memory, segments, true) // Update value let key = segments[segments.length - 1] if (key === 'first()') { key = 0 } if (typeof key === 'number' && Array.isArray(memory)) { // Only allow positive indexes if (key < 0) { throw ExceptionHelper.generateException( Error, Errors.NegativeIndexNotAllowed, undefined, { path: pathExpression } ) } // Expand array as needed and update array const l = key + 1 while (memory.length < l) { memory.push(undefined) } memory[key] = value } else if ( typeof key === 'string' && key.length > 0 && typeof memory === 'object' && !Array.isArray(memory) ) { // Find key to use and update object key = this.findObjectKey(memory, key) || key memory[key] = value } else { throw ExceptionHelper.generateException( Error, Errors.UnableToUpdateValue, undefined, { path: pathExpression } ) } } else { // Just update memory scope scope.setMemory(this.dialogContext, value) } } /** * Delete property from memory * * @param pathExpression The leaf property to remove. */ deleteValue (pathExpression: string): void { // Get path segments const tpath = this.transformPath(pathExpression) const segments = this.parsePath(tpath) if (segments.length < 2) { throw ExceptionHelper.generateException( Error, Errors.InvalidDeletePath, undefined, { path: pathExpression } ) } // Track change this.trackChange(tpath) // Get memory scope to update const scope = this.getMemoryScope(segments[0].toString()) if (scope === undefined) { throw ExceptionHelper.generateException( Error, Errors.ScopeNotFoundForDelete, undefined, { scope: segments[0].toString() } ) } // Find value up to last key const key = segments.pop() const memory = this.resolveSegments(scope.getMemory(this.dialogContext), segments, false) // Update value if (typeof key === 'number' && Array.isArray(memory)) { if (key < memory.length) { memory.splice(key, 1) } } else if (typeof key === 'string' && key.length > 0 && typeof memory === 'object' && !Array.isArray(memory)) { const found = this.findObjectKey(memory, key) if (found) { delete memory[found] } } } /** * Ensures that all memory scopes have been loaded for the current turn. * * @remarks * This should be called at the beginning of the turn. * */ async loadAllScopes (): Promise { const scopes = this.configuration.memoryScopes for (let i = 0; i < scopes.length; i++) { await scopes[i].load(this.dialogContext) } } /** * Saves any changes made to memory scopes. * * @remarks * This should be called at the end of the turn. * */ async saveAllChanges (): Promise { const scopes = this.configuration.memoryScopes for (let i = 0; i < scopes.length; i++) { await scopes[i].saveChanges(this.dialogContext) } } /** * Deletes all of the backing memory for a given scope. * * @param name Name of the scope. */ async deleteScopesMemory (name: string): Promise { name = name.toLowerCase() const scopes = this.configuration.memoryScopes for (let i = 0; i < scopes.length; i++) { const scope = scopes[i] if (scope.name.toLowerCase() === name) { await scope.delete(this.dialogContext) break } } } /** * Normalizes the path segments of a passed in path. * * @param pathExpression The path to normalize. * @param allowNestedPaths Optional. If `false` then detection of a nested path will cause an empty path to be returned. Defaults to 'true'. * @returns The normalized path. * * @remarks * A path of `profile.address[0]` will be normalized to `profile.address.0`. */ parsePath (pathExpression: string, allowNestedPaths = true): (string | number)[] { // Expand path segments let segment = '' let depth = 0 let quote = '' const output: (string | number)[] = [] for (let i = 0; i < pathExpression.length; i++) { const c = pathExpression[i] if (depth > 0) { // We're in a set of brackets if (quote.length) { // We're in a string switch (c) { case '\\': // Escape code detected i++ segment += pathExpression[i] break default: segment += c if (c === quote) { quote = '' } break } } else { // We're in a bracket switch (c) { case '[': depth++ segment += c break case ']': depth-- if (depth > 0) { segment += c } break case "'": case '"': quote = c segment += c break default: segment += c break } // Are we out of the brackets if (depth === 0) { if (isQuoted(segment)) { // Quoted segment output.push(segment.length > 2 ? segment.substr(1, segment.length - 2) : '') } else if (isIndex(segment)) { // Array index output.push(parseInt(segment)) } else if (allowNestedPaths) { // Resolve nested value const val = this.getValue(segment) const t = typeof val output.push(t === 'string' || t === 'number' ? val : '') } else { // Abort parsing and return empty path (used for change tracking.) return [] } segment = '' } } } else { // We're parsing the outer path switch (c) { case '[': if (segment.length > 0) { output.push(segment) segment = '' } depth++ break case '.': if (segment.length > 0) { output.push(segment) segment = '' } else if (i === 0 || i === pathExpression.length - 1) { // Special case a "." at beginning or end of path output.push('') } else if (pathExpression[i - 1] === '.') { // Special case ".." output.push('') } break default: if (i > 1 && pathExpression[i - 1] === '.' && c === '$') { segment += c // x.$foo should be valid } else if (isValidPathChar(c)) { segment += c } else { throw ExceptionHelper.generateException( Error, Errors.InvalidPathCharacters, undefined, { path: pathExpression } ) } break } } } if (depth > 0) { throw ExceptionHelper.generateException( Error, Errors.InvalidPathCharacters, undefined, { path: pathExpression } ) } else if (segment.length > 0) { output.push(segment) } return output } /** * Transform the path using the registered path transformers. * * @param pathExpression The path to transform. * @returns The transformed path. */ transformPath (pathExpression: string): string { // Run path through registered resolvers. const resolvers = this.configuration.pathResolvers for (let i = 0; i < resolvers.length; i++) { pathExpression = resolvers[i].transformPath(pathExpression) } return pathExpression } /** * Gets all memory scopes suitable for logging. * * @returns Object which represents all memory scopes. */ getMemorySnapshot (): object { const output = {} this.configuration.memoryScopes.forEach((scope) => { if (scope.includeInSnapshot) { output[scope.name] = scope.getMemory(this.dialogContext) } }) return output } /** * Track when specific paths are changed. * * @param paths Paths to track. * @returns Normalized paths to pass to `anyPathChanged` method. */ trackPaths (paths: string[]): string[] { const allPaths: string[] = [] paths.forEach((path) => { const tpath = this.transformPath(path) const segments = this.parsePath(tpath, false) if (segments.length > 0 && (segments.length === 1 || !segments[1].toString().startsWith('_'))) { // Normalize path and initialize change tracker const npath = segments.join('_').toLowerCase() this.setValue(`${PATH_TRACKER}.${npath}`, 0) // Return normalized path allPaths.push(npath) } }) return allPaths } /** * Check to see if any path has changed since watermark. * * @param counter Time counter to compare to. * @param paths Paths from `trackPaths` method to check. * @returns True if any path has changed since counter. */ anyPathChanged (counter: number, paths: string[]): boolean { let found = false if (paths) { for (let i = 0; i < paths.length; i++) { if (this.getValue(`${PATH_TRACKER}.${paths[i]}`, 0) > counter) { found = true break } } } return found } /** * @private * @param path Track path to change. */ private trackChange (path: string): void { // Normalize path and scan for any matches or children that match. // - We're appending an extra '_' so that we can do substring matches and // avoid any false positives. let counter: number const npath = this.parsePath(path, false).join('_') + '_' const tracking: object = this.getValue(PATH_TRACKER) || {} for (const key in tracking) { if (`${key}_`.startsWith(npath)) { // Populate counter on first use if (counter === undefined) { counter = this.getValue(DialogPath.eventCounter) } // Update tracking watermark this.setValue(`${PATH_TRACKER}.${key}`, counter) } } } /** * @private * @param memory Object memory to resolve. * @param segments Segments of the memory to resolve. * @param assignment Optional. * @returns The value of the memory segment. */ private resolveSegments (memory: object, segments: (string | number)[], assignment?: boolean): any { let value: any = memory const l = assignment ? segments.length - 1 : segments.length for (let i = 1; i < l && value !== undefined; i++) { const key = segments[i] if (typeof key === 'number') { // Key is an array index if (Array.isArray(value)) { value = value[key] } else { value = undefined } } else if (key === 'first()') { // Special case returning the first entity in an array of entities. if (Array.isArray(value) && value.length > 0) { value = value[0] if (Array.isArray(value)) { // Nested array detected if (value.length > 0) { value = value[0] } else { value = undefined } } } else { value = undefined } } else if (typeof key === 'string' && key.length > 0) { // Key is an object index if (typeof value === 'object' && !Array.isArray(value)) { // Case-insensitive search for prop let found = this.findObjectKey(value, key) // Ensure path exists as needed if (assignment) { const nextKey = segments[i + 1] if (typeof nextKey === 'number' || nextKey === 'first()') { // Ensure prop contains an array if (found) { if (value[found] === undefined) { value[found] = [] } } else { found = key value[found] = [] } } else if (typeof nextKey === 'string' && nextKey.length > 0) { // Ensure prop contains an object if (found) { if (value[found] === undefined) { value[found] = {} } } else { found = key value[found] = {} } } else { // We can't determine type so return undefined found = undefined } } value = found ? value[found] : undefined } else { value = undefined } } else { // Key is missing value = undefined } } return value } /** * @private */ private findObjectKey (obj: object, key: string): string | undefined { const k = key.toLowerCase() for (const prop in obj) { if (prop.toLowerCase() === k) { return prop } } return undefined } /** * @private * Gets MemoryScope by name. * @param name Name of scope. * @returns The MemoryScope. */ private getMemoryScope (name: string): MemoryScope | undefined { const key = name.toLowerCase() const scopes = this.configuration.memoryScopes for (let i = 0; i < scopes.length; i++) { const scope = scopes[i] if (scope.name.toLowerCase() === key) { return scope } } return undefined } /** * Gets the version number. * * @returns A string with the version number. */ version (): string { return '0' } } /** * @private */ function isIndex (segment: string): boolean { const digits = '0123456789' for (let i = 0; i < segment.length; i++) { const c = segment[i] if (digits.indexOf(c) < 0) { // Check for negative sign if (c !== '-' || i > 0 || segment.length < 2) { return false } } } return segment.length > 0 } /** * @private */ function isQuoted (segment: string): boolean { return ( (segment.length > 1 && segment.startsWith("'") && segment.endsWith("'")) || (segment.startsWith('"') && segment.endsWith('"')) ) } /** * @private */ function isValidPathChar (c: string): boolean { return '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-()'.indexOf(c) >= 0 }