@microsoft/botbuilder-m365
Version:
M365 extensions for Microsoft BotBuilder, Alpha release.
404 lines (359 loc) • 16.1 kB
text/typescript
/**
* @module botbuilder-m365
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { CardFactory, Channels, MessageFactory, TurnContext } from 'botbuilder';
import { ConversationHistory } from './ConversationHistory';
import { DefaultModerator } from './DefaultModerator';
import { DefaultTempState, DefaultTurnState } from './DefaultTurnStateManager';
import { Moderator } from './Moderator';
import { PredictedDoCommand, PredictedSayCommand, Planner, Plan } from './Planner';
import { PromptManager, PromptTemplate } from './Prompts';
import { ResponseParser } from './ResponseParser';
import { TurnState } from './TurnState';
export type PromptSelector<TState extends TurnState> = (
Context: TurnContext,
state: TState
) => Promise<string | PromptTemplate>;
export interface PredictedDoCommandAndHandler<TState> extends PredictedDoCommand {
handler: (context: TurnContext, state: TState, data?: Record<string, any>, action?: string) => Promise<boolean>;
}
export interface AIOptions<TState extends TurnState> {
planner: Planner<TState>;
promptManager: PromptManager<TState>;
moderator?: Moderator<TState>;
prompt?: string | PromptTemplate | PromptSelector<TState>;
history?: Partial<AIHistoryOptions>;
}
export interface AIHistoryOptions {
trackHistory: boolean;
maxTurns: number;
maxTokens: number;
lineSeparator: string;
userPrefix: string;
assistantPrefix: string;
assistantHistoryType: 'text' | 'planObject';
}
export interface ConfiguredAIOptions<TState extends TurnState> {
planner: Planner<TState>;
promptManager: PromptManager<TState>;
moderator: Moderator<TState>;
prompt?: string | PromptTemplate | ((Context: TurnContext, state: TState) => Promise<string | PromptTemplate>);
history: AIHistoryOptions;
}
export class AI<TState extends TurnState = DefaultTurnState> {
private readonly _actions: Map<string, ActionEntry<TState>> = new Map();
private readonly _options: ConfiguredAIOptions<TState>;
public static readonly UnknownActionName = '___UnknownAction___';
public static readonly FlaggedInputActionName = '___FlaggedInput___';
public static readonly FlaggedOutputActionName = '___FlaggedOutput___';
public static readonly RateLimitedActionName = '___RateLimited___';
public static readonly PlanReadyActionName = '___PlanReady___';
public static readonly DoCommandActionName = '___DO___';
public static readonly SayCommandActionName = '___SAY___';
public constructor(options: AIOptions<TState>) {
this._options = Object.assign({}, options) as ConfiguredAIOptions<TState>;
// Create moderator if needed
if (!this._options.moderator) {
this._options.moderator = new DefaultModerator<TState>();
}
// Initialize history options
this._options.history = Object.assign(
{
trackHistory: true,
maxTurns: 3,
maxTokens: 1000,
lineSeparator: '\n',
userPrefix: 'User:',
assistantPrefix: 'Assistant:',
assistantHistoryType: 'planObject'
} as AIHistoryOptions,
this._options.history
);
// Register default UnknownAction handler
this.action(
AI.UnknownActionName,
(context, state, data, action?) => {
console.error(`An AI action named "${action}" was predicted but no handler was registered.`);
return Promise.resolve(true);
},
true
);
// Register default FlaggedInputAction handler
this.action(
AI.FlaggedInputActionName,
(context, state, data, action) => {
console.error(
`The users input has been moderated but no handler was registered for 'AI.FlaggedInputActionName'.`
);
return Promise.resolve(true);
},
true
);
// Register default FlaggedOutputAction handler
this.action(
AI.FlaggedOutputActionName,
(context, state, data, action) => {
console.error(
`The bots output has been moderated but no handler was registered for 'AI.FlaggedOutputActionName'.`
);
return Promise.resolve(true);
},
true
);
// Register default RateLimitedActionName
this.action(
AI.RateLimitedActionName,
(context, state, data, action) => {
throw new Error(`An AI request failed because it was rate limited`);
},
true
);
// Register default PlanReadyActionName
this.action<Plan>(
AI.PlanReadyActionName,
async (context, state, plan) => {
return Array.isArray(plan.commands) && plan.commands.length > 0;
},
true
);
// Register default DoCommandActionName
this.action<PredictedDoCommandAndHandler<TState>>(
AI.DoCommandActionName,
async (context, state, data, action) => {
const { entities, handler } = data;
return await handler(context, state, entities, action);
},
true
);
// Register default SayCommandActionName
this.action<PredictedSayCommand>(
AI.SayCommandActionName,
async (context, state, data, action) => {
const response = data.response;
const card = ResponseParser.parseAdaptiveCard(response);
if (card) {
const attachment = CardFactory.adaptiveCard(card);
const activity = MessageFactory.attachment(attachment);
await context.sendActivity(activity);
} else if (context.activity.channelId == Channels.Msteams) {
await context.sendActivity(response.split('\n').join('<br>'));
} else {
await context.sendActivity(response);
}
return true;
},
true
);
}
public get moderator(): Moderator<TState> {
return this._options.moderator;
}
public get options(): ConfiguredAIOptions<TState> {
return this._options;
}
public get planner(): Planner<TState> {
return this._options.planner;
}
public get prompts(): PromptManager<TState> {
return this._options.promptManager;
}
/**
* Registers a handler for a named action.
*
*
* Actions can be triggered by a planner returning a DO command.
*
* @param {string | string[]} name Unique name of the action.
* @param {Promise<boolean>} handler Function to call when the action is triggered.
* @param {boolean} allowOverrides Optional. If true, default and/or existing handlers can be overridden.
* @returns {this} The application instance for chaining purposes.
*/
public action<TEntities extends Record<string, any> | undefined>(
name: string | string[],
handler: (context: TurnContext, state: TState, entities: TEntities, action?: string) => Promise<boolean>,
allowOverrides = false
): this {
(Array.isArray(name) ? name : [name]).forEach((n) => {
if (!this._actions.has(n) || allowOverrides) {
this._actions.set(n, { handler, allowOverrides });
} else {
const entry = this._actions.get(n);
if (entry!.allowOverrides) {
entry!.handler = handler;
} else {
throw new Error(
`The AI.action() method was called with a previously registered action named "${n}".`
);
}
}
});
return this;
}
public async chain(
context: TurnContext,
state: TState,
prompt?: string | PromptTemplate,
options?: Partial<AIOptions<TState>>
): Promise<boolean> {
// Configure options
const opts = this.configureOptions(options);
// Select prompt
if (!prompt) {
if (opts.prompt == undefined) {
throw new Error(`AI.chain() was called without a prompt and no default prompt was configured.`);
} else if (typeof opts.prompt == 'function') {
prompt = await opts.prompt(context, state);
} else {
prompt = opts.prompt;
}
}
// Populate {{$temp.input}}
const temp = (state as any as DefaultTurnState)?.temp?.value ?? ({} as DefaultTempState);
if (typeof temp.input != 'string') {
// Use the received activity text
temp.input = context.activity.text;
}
// Populate {{$temp.history}}
if (typeof temp.history != 'string' && opts.history.trackHistory) {
temp.history = ConversationHistory.toString(state, opts.history.maxTokens, opts.history.lineSeparator);
}
// Render the prompt
const renderedPrompt = await opts.promptManager.renderPrompt(context, state, prompt);
// Generate plan
let plan = await opts.moderator.reviewPrompt(context, state, renderedPrompt, opts);
if (!plan) {
plan = await opts.planner.generatePlan(context, state, renderedPrompt, opts);
plan = await opts.moderator.reviewPlan(context, state, plan);
}
// Process generated plan
let continueChain = await this._actions.get(AI.PlanReadyActionName)!.handler(context, state, plan, '');
if (continueChain) {
// Update conversation history
if (opts.history.trackHistory) {
ConversationHistory.addLine(
state,
`${opts.history.userPrefix.trim()} ${temp.input.trim()}`,
opts.history.maxTurns * 2
);
switch (opts.history.assistantHistoryType) {
case 'text': {
// Extract only the things the assistant has said
const text = plan.commands
.filter((v) => v.type == 'SAY')
.map((v) => (v as PredictedSayCommand).response)
.join('\n');
ConversationHistory.addLine(
state,
`${opts.history.assistantPrefix.trim()} ${text}`,
opts.history.maxTurns * 2
);
break;
}
case 'planObject':
default:
// Embed the plan object to re-enforce the model
// - TODO: add support for XML as well
ConversationHistory.addLine(
state,
`${opts.history.assistantPrefix.trim()} ${JSON.stringify(plan)}`,
opts.history.maxTurns * 2
);
break;
}
}
// Run predicted commands
for (let i = 0; i < plan.commands.length && continueChain; i++) {
// TODO
// eslint-disable-next-line security/detect-object-injection
const cmd = plan.commands[i];
switch (cmd.type) {
case 'DO': {
const { action } = cmd as PredictedDoCommand;
if (this._actions.has(action)) {
// Call action handler
const handler = this._actions.get(action)!.handler;
continueChain = await this._actions
.get(AI.DoCommandActionName)!
.handler(context, state, { handler, ...(cmd as PredictedDoCommand) }, action);
} else {
// Redirect to UnknownAction handler
continueChain = await this._actions
.get(AI.UnknownActionName)!
.handler(context, state, plan, action);
}
break;
}
case 'SAY':
continueChain = await this._actions
.get(AI.SayCommandActionName)!
.handler(context, state, cmd, AI.SayCommandActionName);
break;
default:
throw new Error(`Application.run(): unknown command of '${cmd.type}' predicted.`);
}
}
}
return continueChain;
}
public async completePrompt(
context: TurnContext,
state: TState,
prompt: string | PromptTemplate,
options?: Partial<AIOptions<TState>>
): Promise<string | undefined> {
// Configure options
const opts = this.configureOptions(options);
// Render the prompt
const renderedPrompt = await opts.promptManager.renderPrompt(context, state, prompt);
// Complete the prompt
return await opts.planner.completePrompt(context, state, renderedPrompt, opts);
}
public createSemanticFunction(
name: string,
template?: PromptTemplate,
options?: Partial<AIOptions<TState>>
): (context: TurnContext, state: TState) => Promise<any> {
// Cache prompt template if being dynamically assigned
if (template) {
this._options.promptManager.addPromptTemplate(name, template);
}
return (context: TurnContext, state: TState) => this.completePrompt(context, state, name, options);
}
public doAction<TData = Record<string, any>>(
context: TurnContext,
state: TState,
action: string,
data?: TData
): Promise<boolean> {
if (!this._actions.has(action)) {
throw new Error(`Can't find an action named '${action}'.`);
}
const handler = this._actions.get(action)!.handler;
return handler(context, state, data, action);
}
private configureOptions(options?: Partial<AIOptions<TState>>): ConfiguredAIOptions<TState> {
let configuredOptions: ConfiguredAIOptions<TState>;
if (options) {
configuredOptions = Object.assign({}, this._options, options) as ConfiguredAIOptions<TState>;
if (options.history) {
// Just inherit any missing history settings
options.history = Object.assign({}, this._options.history, options.history);
} else {
// Disable history tracking by default
options.history = Object.assign({}, this._options.history, { trackHistory: false });
}
} else {
configuredOptions = this._options;
}
return configuredOptions;
}
}
/* TData in a handler is of type Record<string, any>, specified by the developer. However, since by default TData is not set, we use type `any` here. */
interface ActionEntry<TState> {
handler: (context: TurnContext, state: TState, data?: any, action?: string) => Promise<boolean>;
allowOverrides: boolean;
}