@microsoft/agents-hosting
Version:
Microsoft 365 Agents SDK for JavaScript
437 lines (390 loc) • 13.5 kB
text/typescript
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { Storage, StoreItems } from '../storage'
import { AppMemory } from './appMemory'
import { InputFile } from './inputFileDownloader'
import { TurnStateEntry } from './turnStateEntry'
import { TurnContext } from '../turnContext'
import { debug } from '@microsoft/agents-activity/logger'
const logger = debug('agents:turnState')
const CONVERSATION_SCOPE = 'conversation'
const USER_SCOPE = 'user'
const TEMP_SCOPE = 'temp'
/**
* Default interface for conversation state.
* Extend this interface to define custom conversation state properties.
*/
export interface DefaultConversationState {}
/**
* Default interface for user state.
* Extend this interface to define custom user state properties.
*/
export interface DefaultUserState {}
/**
* Default interface for temporary state that persists only during the current turn.
* Contains properties used for handling user input, file attachments, and OAuth flows.
*/
export interface DefaultTempState {
/** Collection of files attached to the current message */
inputFiles: InputFile[];
}
/**
* @summary Base class defining a collection of turn state scopes.
* @remarks
* Developers can create a derived class that extends `TurnState` to add additional state scopes.
*
* @example
* ```JavaScript
* class MyTurnState extends TurnState {
* protected async onComputeStorageKeys(context) {
* const keys = await super.onComputeStorageKeys(context);
* keys['myScope'] = `myScopeKey`;
* return keys;
* }
*
* public get myScope() {
* const scope = this.getScope('myScope');
* if (!scope) {
* throw new Error(`MyTurnState hasn't been loaded. Call load() first.`);
* }
* return scope.value;
* }
*
* public set myScope(value) {
* const scope = this.getScope('myScope');
* if (!scope) {
* throw new Error(`MyTurnState hasn't been loaded. Call load() first.`);
* }
* scope.replace(value);
* }
* }
* ```
* @typeParam TConversationState - Type for conversation-scoped state
* @typeParam TUserState - Type for user-scoped state
* @typeParam TTempState - Type for temporary state that exists only for the current turn
* @typeParam TSSOState - Type for Single Sign-On (SSO) state
*/
export class TurnState<
TConversationState = DefaultConversationState,
TUserState = DefaultUserState,
TTempState = DefaultTempState
> implements AppMemory {
private _scopes: Record<string, TurnStateEntry> = {}
private _isLoaded = false
private _loadingPromise?: Promise<boolean>
private _stateNotLoadedString = 'TurnState hasn\'t been loaded. Call load() first.'
/**
* Gets the conversation-scoped state.
* This state is shared by all users in the same conversation.
* @returns The conversation state object
* @throws Error if state hasn't been loaded
*/
public get conversation (): TConversationState {
const scope = this.getScope(CONVERSATION_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
return scope.value as TConversationState
}
/**
* Sets the conversation-scoped state.
* @param value - The new conversation state object
* @throws Error if state hasn't been loaded
*/
public set conversation (value: TConversationState) {
const scope = this.getScope(CONVERSATION_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
scope.replace(value as Record<string, unknown>)
}
/**
* Gets whether the state has been loaded from storage
* @returns True if the state has been loaded, false otherwise
*/
public get isLoaded (): boolean {
return this._isLoaded
}
/**
* Gets the temporary state for the current turn.
* This state is not persisted between turns.
* @returns The temporary state object
* @throws Error if state hasn't been loaded
*/
public get temp (): TTempState {
const scope = this.getScope(TEMP_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
return scope.value as TTempState
}
/**
* Sets the temporary state for the current turn.
* @param value - The new temporary state object
* @throws Error if state hasn't been loaded
*/
public set temp (value: TTempState) {
const scope = this.getScope(TEMP_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
scope.replace(value as Record<string, unknown>)
}
/**
* Gets the user-scoped state.
* This state is unique to each user and persists across conversations.
* @returns The user state object
* @throws Error if state hasn't been loaded
*/
public get user (): TUserState {
const scope = this.getScope(USER_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
return scope.value as TUserState
}
/**
* Sets the user-scoped state.
* @param value - The new user state object
* @throws Error if state hasn't been loaded
*/
public set user (value: TUserState) {
const scope = this.getScope(USER_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
scope.replace(value as Record<string, unknown>)
}
/**
* Marks the conversation state for deletion.
* The state will be deleted from storage on the next call to save().
* @throws Error if state hasn't been loaded
*/
public deleteConversationState (): void {
const scope = this.getScope(CONVERSATION_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
scope.delete()
}
/**
* Marks the temporary state for deletion.
* Since temporary state is not persisted, this just clears the in-memory object.
* @throws Error if state hasn't been loaded
*/
public deleteTempState (): void {
const scope = this.getScope(TEMP_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
scope.delete()
}
/**
* Marks the user state for deletion.
* The state will be deleted from storage on the next call to save().
* @throws Error if state hasn't been loaded
*/
public deleteUserState (): void {
const scope = this.getScope(USER_SCOPE)
if (!scope) {
throw new Error(this._stateNotLoadedString)
}
scope.delete()
}
/**
* Gets a specific state scope by name.
* @param scope - The name of the scope to retrieve
* @returns The state entry for the scope, or undefined if not found
*/
public getScope (scope: string): TurnStateEntry | undefined {
return this._scopes[scope]
}
/**
* Deletes a value from state by dot-notation path.
* Format: "scope.property" or just "property" (defaults to temp scope)
* @param path - The path to the value to delete
*/
public deleteValue (path: string): void {
const { scope, name } = this.getScopeAndName(path)
if (Object.prototype.hasOwnProperty.call(scope.value, name)) {
delete scope.value[name]
}
}
/**
* Checks if a value exists in state by dot-notation path.
* Format: "scope.property" or just "property" (defaults to temp scope)
* @param path - The path to check
* @returns True if the value exists, false otherwise
*/
public hasValue (path: string): boolean {
const { scope, name } = this.getScopeAndName(path)
return Object.prototype.hasOwnProperty.call(scope.value, name)
}
/**
* Gets a value from state by dot-notation path.
* Format: "scope.property" or just "property" (defaults to temp scope)
* @typeParam TValue - The type of the value to retrieve
* @param path - The path to the value
* @returns The value at the specified path
*/
public getValue<TValue = unknown>(path: string): TValue {
const { scope, name } = this.getScopeAndName(path)
return scope.value[name] as TValue
}
/**
* Sets a value in state by dot-notation path.
* Format: "scope.property" or just "property" (defaults to temp scope)
* @param path - The path to set
* @param value - The value to set
*/
public setValue (path: string, value: unknown): void {
const { scope, name } = this.getScopeAndName(path)
scope.value[name] = value
}
/**
* Loads state from storage into memory.
* @param context - The turn context
* @param storage - Optional storage provider (if not provided, state will be in-memory only)
* @param force - If true, forces a reload from storage even if state is already loaded
* @returns Promise that resolves to true if state was loaded, false if it was already loaded
*/
public load (context: TurnContext, storage?: Storage, force: boolean = false): Promise<boolean> {
if (this._isLoaded && !force) {
return Promise.resolve(false)
}
if (!this._loadingPromise) {
this._loadingPromise = new Promise<boolean>((resolve, reject) => {
this._isLoaded = true
const keys: string[] = []
this.onComputeStorageKeys(context)
.then(async (scopes) => {
for (const key in scopes) {
if (Object.prototype.hasOwnProperty.call(scopes, key)) {
keys.push(scopes[key])
}
}
const items = storage ? await storage.read(keys) : {}
for (const key in scopes) {
if (Object.prototype.hasOwnProperty.call(scopes, key)) {
const storageKey = scopes[key]
const value = items[storageKey]
this._scopes[key] = new TurnStateEntry(value, storageKey)
}
}
this._scopes[TEMP_SCOPE] = new TurnStateEntry({})
this._isLoaded = true
this._loadingPromise = undefined
resolve(true)
})
.catch((err) => {
logger.error(err)
this._loadingPromise = undefined
reject(err)
})
})
}
return this._loadingPromise
}
/**
* Saves state changes to storage.
* Only changed scopes will be persisted.
* @param context - The turn context
* @param storage - Optional storage provider (if not provided, state changes won't be persisted)
* @returns Promise that resolves when the save operation is complete
* @throws Error if state hasn't been loaded
*/
public async save (context: TurnContext, storage?: Storage): Promise<void> {
if (!this._isLoaded && this._loadingPromise) {
await this._loadingPromise
}
if (!this._isLoaded) {
throw new Error(this._stateNotLoadedString)
}
let changes: StoreItems | undefined
let deletions: string[] | undefined
for (const key in this._scopes) {
if (!Object.prototype.hasOwnProperty.call(this._scopes, key)) {
continue
}
const entry = this._scopes[key]
if (entry.storageKey) {
if (entry.isDeleted) {
if (deletions) {
deletions.push(entry.storageKey)
} else {
deletions = [entry.storageKey]
}
} else if (entry.hasChanged) {
if (!changes) {
changes = {}
}
changes[entry.storageKey] = entry.value
}
}
}
if (storage) {
const promises: Promise<void>[] = []
if (changes) {
promises.push(storage.write(changes))
}
if (deletions) {
promises.push(storage.delete(deletions))
}
if (promises.length > 0) {
await Promise.all(promises)
}
}
}
/**
* Computes the storage keys for each scope based on the turn context.
* Override this method in derived classes to add or modify storage keys.
* @param context - The turn context
* @returns Promise that resolves to a dictionary of scope names to storage keys
* @protected
*/
protected onComputeStorageKeys (context: TurnContext): Promise<Record<string, string>> {
const activity = context.activity
const channelId = activity?.channelId
const agentId = activity?.recipient?.id
const conversationId = activity?.conversation?.id
const userId = activity?.from?.id
if (!channelId) {
throw new Error('missing context.activity.channelId')
}
if (!agentId) {
throw new Error('missing context.activity.recipient.id')
}
if (!conversationId) {
throw new Error('missing context.activity.conversation.id')
}
if (!userId) {
throw new Error('missing context.activity.from.id')
}
const keys: Record<string, string> = {}
keys[CONVERSATION_SCOPE] = `${channelId}/${agentId}/conversations/${conversationId}`
keys[USER_SCOPE] = `${channelId}/${agentId}/users/${userId}`
return Promise.resolve(keys)
}
/**
* Parses a dot-notation path into scope and property name.
* If no scope is specified, defaults to the temp scope.
* @param path - The path to parse (format: "scope.property" or just "property")
* @returns Object containing the scope entry and property name
* @private
*/
private getScopeAndName (path: string): { scope: TurnStateEntry; name: string } {
const parts = path.split('.')
if (parts.length > 2) {
throw new Error(`Invalid state path: ${path}`)
} else if (parts.length === 1) {
parts.unshift(TEMP_SCOPE)
}
const scope = this.getScope(parts[0])
if (scope === undefined) {
throw new Error(`Invalid state scope: ${parts[0]}`)
}
return { scope, name: parts[1] }
}
}