-
Notifications
You must be signed in to change notification settings - Fork 208
/
Copy pathTurnState.ts
534 lines (474 loc) · 16.4 KB
/
TurnState.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
/**
* @module teams-ai
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { TurnContext, Storage, StoreItems } from 'botbuilder';
import { Memory } from './MemoryFork';
import { InputFile } from './InputFileDownloader';
/**
* @private
*/
const CONVERSATION_SCOPE = 'conversation';
/**
* @private
*/
const USER_SCOPE = 'user';
/**
* @private
*/
const TEMP_SCOPE = 'temp';
/**
* Default conversation state
* @remarks
* Inherit a new interface from this base interface to strongly type the applications conversation
* state.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface DefaultConversationState {}
/**
* Default user state
* @remarks
* Inherit a new interface from this base interface to strongly type the applications user
* state.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface DefaultUserState {}
/**
* Default temp state
* @remarks
* Inherit a new interface from this base interface to strongly type the applications temp
* state.
*/
export interface DefaultTempState {
/**
* Input passed from the user to the AI Library
*/
input: string;
/**
* Downloaded files passed by the user to the AI Library
*/
inputFiles: InputFile[];
/**
* Output returned from the last executed action
*/
lastOutput: string;
/**
* All outputs returned from the action sequence that was executed
*/
actionOutputs: Record<string, string>;
/**
* User authentication tokens
*/
authTokens: { [key: string]: string };
/**
* Flag indicating whether a token exchange event has already been processed
*/
duplicateTokenExchange?: boolean
}
/**
* Base class defining a collection of turn state scopes.
* @remarks
* Developers can create a derived class that extends `TurnState` to add additional state scopes.
* ```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 loadState() first.`);
* }
* return scope.value;
* }
*
* public set myScope(value) {
* const scope = this.getScope('myScope');
* if (!scope) {
* throw new Error(`MyTurnState hasn't been loaded. Call loadState() first.`);
* }
* scope.replace(value);
* }
* }
* ```
*/
export class TurnState<
TConversationState = DefaultConversationState,
TUserState = DefaultUserState,
TTempState = DefaultTempState
> implements Memory
{
private _scopes: Record<string, TurnStateEntry> = {};
private _isLoaded = false;
private _loadingPromise?: Promise<boolean>;
/**
* Accessor for the conversation state.
*/
public get conversation(): TConversationState {
const scope = this.getScope(CONVERSATION_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
return scope.value as TConversationState;
}
/**
* Replaces the conversation state with a new value.
* @param value New value to replace the conversation state with.
*/
public set conversation(value: TConversationState) {
const scope = this.getScope(CONVERSATION_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
scope.replace(value as Record<string, unknown>);
}
/**
* Gets a value indicating whether the applications turn state has been loaded.
*/
public get isLoaded(): boolean {
return this._isLoaded;
}
/**
* Accessor for the temp state.
*/
public get temp(): TTempState {
const scope = this.getScope(TEMP_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
return scope.value as TTempState;
}
/**
* Replaces the temp state with a new value.
* @param value New value to replace the temp state with.
*/
public set temp(value: TTempState) {
const scope = this.getScope(TEMP_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
scope.replace(value as Record<string, unknown>);
}
/**
* Accessor for the user state.
*/
public get user(): TUserState {
const scope = this.getScope(USER_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
return scope.value as TUserState;
}
/**
* Replaces the user state with a new value.
*/
public set user(value: TUserState) {
const scope = this.getScope(USER_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
scope.replace(value as Record<string, unknown>);
}
/**
* Deletes the state object for the current conversation from storage.
*/
public deleteConversationState(): void {
const scope = this.getScope(CONVERSATION_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
scope.delete();
}
/**
* Deletes the temp state object.
*/
public deleteTempState(): void {
const scope = this.getScope(TEMP_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
scope.delete();
}
/**
* Deletes the state object for the current user from storage.
*/
public deleteUserState(): void {
const scope = this.getScope(USER_SCOPE);
if (!scope) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
scope.delete();
}
/**
* Gets a state scope by name.
* @param scope Name of the state scope to return. (i.e. 'conversation', 'user', or 'temp')
* @returns The state scope or undefined if not found.
*/
public getScope(scope: string): TurnStateEntry | undefined {
return this._scopes[scope];
}
/**
* Deletes a value from the memory.
* @param path Path to the value to delete in the form of `[scope].property`. If scope is omitted, the value is deleted from the temporary scope.
*/
public deleteValue(path: string): void {
const { scope, name } = this.getScopeAndName(path);
if (scope.value.hasOwnProperty(name)) {
delete scope.value[name];
}
}
/**
* Checks if a value exists in the memory.
* @param path Path to the value to check in the form of `[scope].property`. If scope is omitted, the value is checked in the temporary scope.
* @returns True if the value exists, false otherwise.
*/
public hasValue(path: string): boolean {
const { scope, name } = this.getScopeAndName(path);
return scope.value.hasOwnProperty(name);
}
/**
* Retrieves a value from the memory.
* @param path Path to the value to retrieve in the form of `[scope].property`. If scope is omitted, the value is retrieved from the temporary scope.
* @returns The value or undefined if not found.
*/
public getValue<TValue = unknown>(path: string): TValue {
const { scope, name } = this.getScopeAndName(path);
return scope.value[name] as TValue;
}
/**
* Assigns a value to the memory.
* @param path Path to the value to assign in the form of `[scope].property`. If scope is omitted, the value is assigned to the temporary scope.
* @param value Value to assign.
*/
public setValue(path: string, value: unknown): void {
const { scope, name } = this.getScopeAndName(path);
scope.value[name] = value;
}
/**
* Loads all of the state scopes for the current turn.
* @param context Context for the current turn of conversation with the user.
* @param storage Optional. Storage provider to load state scopes from.
* @returns True if the states needed to be loaded.
*/
public load(context: TurnContext, storage?: Storage): Promise<boolean> {
// Only load on first call
if (this._isLoaded) {
return Promise.resolve(false);
}
// Check for existing load operation
if (!this._loadingPromise) {
this._loadingPromise = new Promise<boolean>(async (resolve, reject) => {
try {
// Prevent additional load attempts
this._isLoaded = true;
// Compute state keys
const keys: string[] = [];
const scopes = await this.onComputeStorageKeys(context);
for (const key in scopes) {
if (scopes.hasOwnProperty(key)) {
keys.push(scopes[key]);
}
}
// Read items from storage provider (if configured)
const items = storage ? await storage.read(keys) : {};
// Create scopes for items
for (const key in scopes) {
if (scopes.hasOwnProperty(key)) {
const storageKey = scopes[key];
const value = items[storageKey];
this._scopes[key] = new TurnStateEntry(value, storageKey);
}
}
// Add the temp scope
this._scopes[TEMP_SCOPE] = new TurnStateEntry({});
// Clear loading promise
this._isLoaded = true;
this._loadingPromise = undefined;
resolve(true);
} catch (err) {
this._loadingPromise = undefined;
reject(err);
}
});
}
return this._loadingPromise;
}
/**
* Saves all of the state scopes for the current turn.
* @param context Context for the current turn of conversation with the user.
* @param storage Optional. Storage provider to save state scopes to.
*/
public async save(context: TurnContext, storage?: Storage): Promise<void> {
// Check for existing load operation
if (!this._isLoaded && this._loadingPromise) {
// Wait for load to finish
await this._loadingPromise;
}
// Ensure loaded
if (!this._isLoaded) {
throw new Error(`TurnState hasn't been loaded. Call loadState() first.`);
}
// Find changes and deletions
let changes: StoreItems | undefined;
let deletions: string[] | undefined;
for (const key in this._scopes) {
if (!this._scopes.hasOwnProperty(key)) {
continue;
}
const entry = this._scopes[key];
if (entry.storageKey) {
if (entry.isDeleted) {
// Add to deletion list
if (deletions) {
deletions.push(entry.storageKey);
} else {
deletions = [entry.storageKey];
}
} else if (entry.hasChanged) {
// Add to change set
if (!changes) {
changes = {};
}
changes[entry.storageKey] = entry.value;
}
}
}
// Do we have a storage provider?
if (storage) {
// Apply changes
const promises: Promise<void>[] = [];
if (changes) {
promises.push(storage.write(changes));
}
// Apply deletions
if (deletions) {
promises.push(storage.delete(deletions));
}
// Wait for completion
if (promises.length > 0) {
await Promise.all(promises);
}
}
}
/**
* Computes the storage keys for the state scopes being persisted.
* @remarks
* Can be overridden in derived classes to add additional storage scopes.
* @param context Context for the current turn of conversation with the user.
* @returns A dictionary of scope names -> storage keys.
*/
protected onComputeStorageKeys(context: TurnContext): Promise<Record<string, string>> {
// Compute state keys
const activity = context.activity;
const channelId = activity?.channelId;
const botId = activity?.recipient?.id;
const conversationId = activity?.conversation?.id;
const userId = activity?.from?.id;
if (!channelId) {
throw new Error('missing context.activity.channelId');
}
if (!botId) {
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}/${botId}/conversations/${conversationId}`;
keys[USER_SCOPE] = `${channelId}/${botId}/users/${userId}`;
return Promise.resolve(keys);
}
/**
* @private
*/
private getScopeAndName(path: string): { scope: TurnStateEntry; name: string } {
// Get variable scope and name
const parts = path.split('.');
if (parts.length > 2) {
throw new Error(`Invalid state path: ${path}`);
} else if (parts.length === 1) {
parts.unshift(TEMP_SCOPE);
}
// Validate scope
const scope = this.getScope(parts[0]);
if (scope === undefined) {
throw new Error(`Invalid state scope: ${parts[0]}`);
}
return { scope, name: parts[1] };
}
}
/**
* Accessor class for managing an individual state scope.
* @template TValue Optional. Strongly typed value of the state scope.
*/
export class TurnStateEntry {
private _value: Record<string, unknown>;
private _storageKey?: string;
private _deleted = false;
private _hash: string;
/**
* Creates a new instance of the `TurnStateEntry` class.
* @param {TValue} value Optional. Value to initialize the state scope with. The default is an {} object.
* @param {string} storageKey Optional. Storage key to use when persisting the state scope.
*/
public constructor(value?: Record<string, unknown>, storageKey?: string) {
this._value = value || {};
this._storageKey = storageKey;
this._hash = JSON.stringify(this._value);
}
/**
* Gets a value indicating whether the state scope has changed since it was last loaded.
* @returns A value indicating whether the state scope has changed.
*/
public get hasChanged(): boolean {
return JSON.stringify(this._value) != this._hash;
}
/**
* Gets a value indicating whether the state scope has been deleted.
* @returns A value indicating whether the state scope has been deleted.
*/
public get isDeleted(): boolean {
return this._deleted;
}
/**
* Gets the value of the state scope.
* @returns The value of the state scope.
*/
public get value(): Record<string, unknown> {
if (this.isDeleted) {
// Switch to a replace scenario
this._value = {};
this._deleted = false;
}
return this._value;
}
/**
* Gets the storage key used to persist the state scope.
* @returns {string | undefined} The storage key used to persist the state scope.
*/
public get storageKey(): string | undefined {
return this._storageKey;
}
/**
* Clears the state scope.
*/
public delete(): void {
this._deleted = true;
}
/**
* Replaces the state scope with a new value.
* @param {TValue} value New value to replace the state scope with.
*/
public replace(value?: Record<string, unknown>): void {
this._value = value || {};
}
}