UNPKG

@microsoft/agents-hosting

Version:

Microsoft 365 Agents SDK for JavaScript

214 lines (187 loc) 7.93 kB
/** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { Storage, StorageKeyFactory, StoreItem } from '../storage/storage' import { TurnContext } from '../turnContext' import { createHash } from 'node:crypto' import { AgentStatePropertyAccessor } from './agentStatePropertyAccesor' import { debug } from '@microsoft/agents-activity/logger' const logger = debug('agents:state') /** * Represents agent state that has been cached in the turn context. * Used internally to track state changes and avoid unnecessary storage operations. */ export interface CachedAgentState { /** * The state object containing all properties and their values */ state: { [id: string]: any }; /** * Hash of the state used to detect changes */ hash: string; } /** * Represents a custom key for storing state in a specific location. * Allows state to be persisted with channel and conversation identifiers * independent of the current context. */ export interface CustomKey { /** * The ID of the channel where the state should be stored */ channelId: string; /** * The ID of the conversation where the state should be stored */ conversationId: string; // TODO: namespace needs to be added } /** * @summary Manages the state of an Agent across turns in a conversation. * @remarks * AgentState provides functionality to persist and retrieve state data using * a storage provider. It handles caching state in the turn context for performance, * calculating change hashes to detect modifications, and managing property accessors * for typed access to state properties. */ export class AgentState { private readonly stateKey = Symbol('state') /** * Creates a new instance of AgentState. * * @param storage The storage provider used to persist state between turns * @param storageKey A factory function that generates keys for storing state data */ constructor (protected storage: Storage, protected storageKey: StorageKeyFactory) { } /** * Creates a property accessor for the specified property. * Property accessors provide typed access to properties within the state object. * * @param name The name of the property to access * @returns A property accessor for the specified property */ createProperty<T = any>(name: string): AgentStatePropertyAccessor<T> { const prop: AgentStatePropertyAccessor<T> = new AgentStatePropertyAccessor<T>(this, name) return prop } /** * Loads the state from storage into the turn context. * If state is already cached in the turn context and force is not set, the cached version will be used. * * @param context The turn context to load state into * @param force If true, forces a reload from storage even if state is cached * @param customKey Optional custom storage key to use instead of the default * @returns A promise that resolves to the loaded state object */ public async load (context: TurnContext, force = false, customKey?: CustomKey): Promise<any> { const cached: CachedAgentState = context.turnState.get(this.stateKey) if (force || !cached || !cached.state) { const key: string = await this.getStorageOrCustomKey(customKey, context) logger.info(`Reading storage with key ${key}`) const storedItem = await this.storage.read([key]) const state: any = storedItem[key] || {} const hash: string = this.calculateChangeHash(state) context.turnState.set(this.stateKey, { state, hash }) return state } return cached.state } /** * Saves the state to storage if it has changed since it was loaded. * Change detection uses a hash of the state object to determine if saving is necessary. * * @param context The turn context containing the state to save * @param force If true, forces a save to storage even if no changes are detected * @param customKey Optional custom storage key to use instead of the default * @returns A promise that resolves when the save operation is complete */ public async saveChanges (context: TurnContext, force = false, customKey?: CustomKey): Promise<void> { let cached: CachedAgentState = context.turnState.get(this.stateKey) if (force || (cached && cached.hash !== this.calculateChangeHash(cached?.state))) { if (!cached) { cached = { state: {}, hash: '' } } cached.state.eTag = '*' const changes: StoreItem = {} as StoreItem const key: string = await this.getStorageOrCustomKey(customKey, context) changes[key] = cached.state logger.info(`Writing storage with key ${key}`) await this.storage.write(changes) cached.hash = this.calculateChangeHash(cached.state) context.turnState.set(this.stateKey, cached) } } /** * Determines whether to use a custom key or generate one from the context. * * @param customKey Optional custom key with channel and conversation IDs * @param context The turn context used to generate a key if no custom key is provided * @returns The storage key to use * @private */ private async getStorageOrCustomKey (customKey: CustomKey | undefined, context: TurnContext) { let key: string | undefined if (customKey && customKey.channelId && customKey.conversationId) { // TODO check ConversationState.ts line 40. This line below should follow the same pattern key = `${customKey!.channelId}/conversations/${customKey!.conversationId}` } else { key = await this.storageKey(context) } return key } /** * Clears the state by setting it to an empty object in the turn context. * Note: This does not remove the state from storage, it only clears the in-memory representation. * Call saveChanges() after this to persist the empty state to storage. * * @param context The turn context containing the state to clear * @returns A promise that resolves when the clear operation is complete */ public async clear (context: TurnContext): Promise<void> { const emptyObjectToForceSave = { state: {}, hash: '' } context.turnState.set(this.stateKey, emptyObjectToForceSave) } /** * Deletes the state from both the turn context and storage. * * @param context The turn context containing the state to delete * @param customKey Optional custom storage key to use instead of the default * @returns A promise that resolves when the delete operation is complete */ public async delete (context: TurnContext, customKey?: CustomKey): Promise<void> { if (context.turnState.has(this.stateKey)) { context.turnState.delete(this.stateKey) } const key = await this.getStorageOrCustomKey(customKey, context) logger.info(`Deleting storage with key ${key}`) await this.storage.delete([key]) } /** * Gets the state from the turn context without loading it from storage. * * @param context The turn context containing the state to get * @returns The state object, or undefined if no state is found in the turn context */ public get (context: TurnContext): any | undefined { const cached: CachedAgentState = context.turnState.get(this.stateKey) return typeof cached === 'object' && typeof cached.state === 'object' ? cached.state : undefined } /** * Calculates a hash for the specified state object to detect changes. * The eTag property is excluded from the hash calculation. * * @param item The state object to calculate the hash for * @returns A string hash representing the state * @private */ private readonly calculateChangeHash = (item: StoreItem): string => { const { eTag, ...rest } = item // TODO review circular json structure const result = JSON.stringify(rest) const hash = createHash('sha256', { encoding: 'utf-8' }) const hashed = hash.update(result).digest('hex') return hashed } }