UNPKG

@microsoft/agents-copilotstudio-client

Version:

Microsoft Copilot Studio Client for JavaScript. Copilot Studio Client.

520 lines (465 loc) 20.2 kB
/** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { createEventSource, EventSourceClient } from 'eventsource-client' import { ConnectionSettings } from './connectionSettings' import { getCopilotStudioConnectionUrl, getCopilotStudioSubscribeUrl } from './powerPlatformEnvironment' import { Activity, ActivityTypes, ConversationAccount } from '@microsoft/agents-activity' import { ExecuteTurnRequest } from './executeTurnRequest' import { debug, trace } from '@microsoft/agents-telemetry' import { UserAgentHelper } from './userAgentHelper' import { ScopeHelper } from './scopeHelper' import { StartRequest } from './startRequest' import { StartResponse, ExecuteTurnResponse, createStartResponse, createExecuteTurnResponse } from './responses' import { SubscribeEvent } from './subscribeEvent' import { CopilotStudioClientTraceDefinitions } from './observability' const logger = debug('copilot-studio:client') /** * Client for interacting with Microsoft Copilot Studio services. * Provides functionality to start conversations and send messages to Copilot Studio bots. */ export class CopilotStudioClient { /** Header key for conversation ID. */ private static readonly conversationIdHeaderKey: string = 'x-ms-conversationid' /** Island Header key */ private static readonly islandExperimentalUrlHeaderKey: string = 'x-ms-d2e-experimental' /** The ID of the current conversation. */ private conversationId: string = '' /** The connection settings for the client. */ private readonly settings: ConnectionSettings /** The authenticaton token. */ private readonly token: string /** * Returns the scope URL needed to connect to Copilot Studio from the connection settings. * This is used for authentication token audience configuration. * @param settings Copilot Studio connection settings. * @returns The scope URL for token audience. * @deprecated Use ScopeHelper.getScopeFromSettings instead. */ static scopeFromSettings: (settings: ConnectionSettings) => string = ScopeHelper.getScopeFromSettings /** * Creates an instance of CopilotStudioClient. * @param settings The connection settings. * @param token The authentication token. */ constructor (settings: ConnectionSettings, token: string) { this.settings = settings this.token = token } /** * Logs a diagnostic message if diagnostics are enabled. * @param message The message to log. * @param args Additional arguments to log. */ private logDiagnostic (message: string, ...args: any[]): void { if (this.settings.enableDiagnostics) { logger.info(`[DIAGNOSTICS] ${message}`, ...args) } } /** * Streams activities from the Copilot Studio service using eventsource-client. * @param url The connection URL for Copilot Studio. * @param body Optional. The request body (for POST). * @param method Optional. The HTTP method (default: POST). * @returns An async generator yielding the Agent's Activities. */ private async * postRequestAsync (url: string, body?: any, method: string = 'POST'): AsyncGenerator<Activity> { const managed = trace(CopilotStudioClientTraceDefinitions.postRequest) managed.record({ url, method }) try { this.logDiagnostic(`Request URL: ${url}`) this.logDiagnostic(`Request Method: ${method}`) this.logDiagnostic('Request Body:', body ? JSON.stringify(body, null, 2) : 'none') logger.debug(`>>> SEND TO ${url}`) const streamMap = new Map<string, { text: string, sequence: number }[]>() const eventSource: EventSourceClient = createEventSource({ url, headers: { Authorization: `Bearer ${this.token}`, 'User-Agent': UserAgentHelper.getProductInfo(), 'Content-Type': 'application/json', Accept: 'text/event-stream' }, body: body ? JSON.stringify(body) : undefined, method, fetch: async (url, init) => { const response = await fetch(url, init) this.processResponseHeaders(response.headers) return response } }) try { for await (const { data, event } of eventSource) { if (data && event === 'activity') { try { const activity = Activity.fromJson(data) managed.actions.receivedFromCopilot(activity) // check to see if this activity is part of the streamed response, in which case we need to accumulate the text const streamingEntity = activity.entities?.find(e => e.type === 'streaminfo' && e.streamType === 'streaming') switch (activity.type) { case ActivityTypes.Message: if (!this.conversationId.trim()) { // Did not get it from the header. this.conversationId = activity.conversation?.id ?? '' logger.debug(`Conversation ID: ${this.conversationId}`) } yield activity break case ActivityTypes.Typing: logger.debug(`Activity type: ${activity.type}`) // Accumulate the text as it comes in from the stream. // This also accounts for the "old style" of streaming where the stream info is in channelData. if (streamingEntity || activity.channelData?.streamType === 'streaming') { const text = activity.text ?? '' const id = (streamingEntity?.streamId ?? activity.channelData?.streamId) const sequence = (streamingEntity?.streamSequence ?? activity.channelData?.streamSequence) // Accumulate the text chunks based on stream ID and sequence number. if (id && sequence) { if (streamMap.has(id)) { const existing = streamMap.get(id)! existing.push({ text, sequence }) streamMap.set(id, existing) } else { streamMap.set(id, [{ text, sequence }]) } activity.text = streamMap.get(id)?.sort((a, b) => a.sequence - b.sequence).map(item => item.text).join('') || '' } } yield activity break default: logger.debug(`Activity type: ${activity.type}`) yield activity break } } catch (error) { logger.error('Failed to parse activity:', error) } } else if (event === 'end') { logger.debug('Stream complete') break } if (eventSource.readyState === 'closed') { logger.debug('Connection closed') break } } } finally { eventSource.close() } } catch (error) { throw managed.fail(error) } finally { managed.end() } } private processResponseHeaders (responseHeaders: Headers): void { if (this.settings.useExperimentalEndpoint && !this.settings.directConnectUrl?.trim()) { const islandExperimentalUrl = responseHeaders?.get(CopilotStudioClient.islandExperimentalUrlHeaderKey) if (islandExperimentalUrl) { this.settings.directConnectUrl = islandExperimentalUrl logger.debug(`Island Experimental URL: ${islandExperimentalUrl}`) } } this.conversationId = responseHeaders?.get(CopilotStudioClient.conversationIdHeaderKey) ?? '' if (this.conversationId) { logger.debug(`Conversation ID: ${this.conversationId}`) } const sanitizedHeaders = new Headers() responseHeaders.forEach((value, key) => { if (key.toLowerCase() !== 'authorization' && key.toLowerCase() !== CopilotStudioClient.conversationIdHeaderKey.toLowerCase()) { sanitizedHeaders.set(key, value) } }) this.logDiagnostic('Response Headers:', sanitizedHeaders) } /** * Starts a new conversation with the Copilot Studio service using a StartRequest. * @param request The request parameters for starting the conversation. * @returns An async generator yielding the Agent's Activities. */ public startConversationStreaming (request: StartRequest): AsyncGenerator<Activity> /** * Starts a new conversation with the Copilot Studio service. * @param emitStartConversationEvent Whether to emit a start conversation event. Defaults to true. * @returns An async generator yielding the Agent's Activities. */ public startConversationStreaming (emitStartConversationEvent?: boolean): AsyncGenerator<Activity> /** * Implementation of startConversationStreaming with overloads. */ public async * startConversationStreaming ( requestOrFlag?: StartRequest | boolean ): AsyncGenerator<Activity> { const managed = trace(CopilotStudioClientTraceDefinitions.startConversation) try { // Normalize input to StartRequest let request: StartRequest if (typeof requestOrFlag === 'boolean' || requestOrFlag === undefined) { // Legacy call: startConversationStreaming(true/false) managed.record({ shouldEmitStartEvent: requestOrFlag ?? true }) request = { emitStartConversationEvent: requestOrFlag ?? true } } else { // New call: startConversationStreaming({ locale: 'en-US', ... }) request = requestOrFlag managed.record({ shouldEmitStartEvent: request.emitStartConversationEvent ?? true }) } const uriStart: string = getCopilotStudioConnectionUrl(this.settings, request.conversationId) const body: any = { emitStartConversationEvent: request.emitStartConversationEvent ?? true } // Add locale to body if provided if (request.locale) { body.locale = request.locale } logger.info('Starting conversation ...', request) this.logDiagnostic('Start conversation request:', body) yield * this.postRequestAsync(uriStart, body, 'POST') } catch (error) { throw managed.fail(error) } finally { managed.end() } } /** * Sends an activity to the Copilot Studio service and retrieves the response activities. * @param activity The activity to send. * @param conversationId The ID of the conversation. Defaults to the current conversation ID. * @returns An async generator yielding the Agent's Activities. */ public async * sendActivityStreaming (activity: Activity, conversationId: string = this.conversationId) : AsyncGenerator<Activity> { const managed = trace(CopilotStudioClientTraceDefinitions.sendActivity) managed.record({ activity }) try { const localConversationId = activity.conversation?.id ?? conversationId const uriExecute = getCopilotStudioConnectionUrl(this.settings, localConversationId) const qbody: ExecuteTurnRequest = new ExecuteTurnRequest(activity) logger.info('Sending activity...', activity) yield * this.postRequestAsync(uriExecute, qbody, 'POST') } catch (error) { throw managed.fail(error) } finally { managed.end() } } /** * Executes a turn in an existing conversation by sending an activity. * This method provides explicit control over the conversation ID. * @param activity The activity to send. * @param conversationId The ID of the conversation. Required. * @returns An async generator yielding the Agent's Activities. * @throws Error if conversationId is not provided. */ public async * executeStreaming ( activity: Activity, conversationId: string ): AsyncGenerator<Activity> { const managed = trace(CopilotStudioClientTraceDefinitions.executeStreaming) managed.record({ activity, conversationId }) try { if (!conversationId || !conversationId.trim()) { throw new Error('conversationId is required for executeStreaming') } const uriExecute = getCopilotStudioConnectionUrl(this.settings, conversationId) const request: ExecuteTurnRequest = new ExecuteTurnRequest(activity, conversationId) logger.info('Executing turn with conversation ID:', conversationId) this.logDiagnostic('Execute turn request:', { conversationId, activityType: activity.type, activityText: activity.text }) yield * this.postRequestAsync(uriExecute, request, 'POST') } catch (error) { throw managed.fail(error) } finally { managed.end() } } /** * Executes a turn in an existing conversation by sending an activity. * @param activity The activity to send. * @param conversationId The ID of the conversation. Required. * @returns A promise yielding an array of activities. * @throws Error if conversationId is not provided. * @deprecated Use executeStreaming instead. */ public async execute ( activity: Activity, conversationId: string ): Promise<Activity[]> { const result: Activity[] = [] for await (const value of this.executeStreaming(activity, conversationId)) { result.push(value) } return result } /** * Starts a new conversation with the Copilot Studio service using a StartRequest. * @param request The request parameters for starting the conversation. * @returns A promise yielding an array of activities. * @deprecated Use startConversationStreaming instead. */ public async startConversationAsync (request: StartRequest): Promise<Activity[]> /** * Starts a new conversation with the Copilot Studio service. * @param emitStartConversationEvent Whether to emit a start conversation event. Defaults to true. * @returns A promise yielding an array of activities. * @deprecated Use startConversationStreaming instead. */ public async startConversationAsync (emitStartConversationEvent?: boolean): Promise<Activity[]> /** * Implementation of startConversationAsync with overloads. */ public async startConversationAsync ( requestOrFlag?: StartRequest | boolean ): Promise<Activity[]> { const result: Activity[] = [] for await (const value of this.startConversationStreaming(requestOrFlag as any)) { result.push(value) } return result } /** * Sends a question to the Copilot Studio service and retrieves the response activities. * @param question The question to ask. * @param conversationId The ID of the conversation. Defaults to the current conversation ID. * @returns A promise yielding an array of activities. * @deprecated Use sendActivityStreaming instead. */ public async askQuestionAsync (question: string, conversationId?: string) : Promise<Activity[]> { const localConversationId = conversationId?.trim() ? conversationId : this.conversationId const conversationAccount: ConversationAccount = { id: localConversationId } const activityObj = { type: 'message', text: question, conversation: conversationAccount } const activity = Activity.fromObject(activityObj) const result: Activity[] = [] for await (const value of this.sendActivityStreaming(activity, conversationId)) { result.push(value) } return result } /** * Sends an activity to the Copilot Studio service and retrieves the response activities. * @param activity The activity to send. * @param conversationId The ID of the conversation. Defaults to the current conversation ID. * @returns A promise yielding an array of activities. * @deprecated Use sendActivityStreaming instead. */ public async sendActivity (activity: Activity, conversationId: string = this.conversationId) : Promise<Activity[]> { const result: Activity[] = [] for await (const value of this.sendActivityStreaming(activity, conversationId)) { result.push(value) } return result } /** * Starts a new conversation and returns a typed response. * @param request The request parameters for starting the conversation. * @returns A promise yielding a StartResponse with activities and conversation metadata. */ public async startConversationWithResponse (request?: StartRequest | boolean): Promise<StartResponse> { const activities: Activity[] = [] let finalConversationId = '' for await (const activity of this.startConversationStreaming(request as any)) { activities.push(activity) if (activity.conversation?.id) { finalConversationId = activity.conversation.id } } // Fall back to instance conversationId if not found in activities finalConversationId = finalConversationId || this.conversationId return createStartResponse(activities, finalConversationId) } /** * Executes a turn and returns a typed response. * @param activity The activity to send. * @param conversationId The conversation ID. * @returns A promise yielding an ExecuteTurnResponse with activities and metadata. */ public async executeWithResponse ( activity: Activity, conversationId: string ): Promise<ExecuteTurnResponse> { const activities: Activity[] = [] for await (const value of this.executeStreaming(activity, conversationId)) { activities.push(value) } return createExecuteTurnResponse(activities, conversationId) } /** * Subscribes to a conversation to receive events via Server-Sent Events (SSE). * This method allows resumption from a specific event ID. * @param conversationId The ID of the conversation to subscribe to. * @param lastReceivedEventId Optional. The last received event ID for resumption. * @returns An async generator yielding SubscribeEvent objects containing activities and event IDs. */ public async * subscribeAsync ( conversationId: string, lastReceivedEventId?: string ): AsyncGenerator<SubscribeEvent> { const managed = trace(CopilotStudioClientTraceDefinitions.subscribeAsync) managed.record({ conversationId, lastReceivedEventId }) try { if (!conversationId || !conversationId.trim()) { throw new Error('conversationId is required for subscribeAsync') } const url = getCopilotStudioSubscribeUrl(this.settings, conversationId) logger.info('Subscribing to conversation:', conversationId) this.logDiagnostic('Subscribe request:', { conversationId, lastReceivedEventId, url }) const eventSource: EventSourceClient = createEventSource({ url, headers: { Authorization: `Bearer ${this.token}`, 'User-Agent': UserAgentHelper.getProductInfo(), Accept: 'text/event-stream', ...(lastReceivedEventId && { 'Last-Event-ID': lastReceivedEventId }) }, method: 'GET', fetch: async (url, init) => { const response = await fetch(url, init) this.processResponseHeaders(response.headers) return response } }) try { for await (const { data, event, id } of eventSource) { if (data && event === 'activity') { try { const activity = Activity.fromJson(data) const subscribeEvent: SubscribeEvent = { activity, eventId: id } managed.actions.eventReceivedFromCopilot(subscribeEvent) logger.debug(`Received activity via subscription, event ID: ${id}`) this.logDiagnostic('Subscribe event received:', { eventId: id, activityType: activity.type }) yield subscribeEvent } catch (error) { logger.error('Failed to parse activity in subscription:', error) } } else if (event === 'end') { logger.debug('Subscription stream complete') break } if (eventSource.readyState === 'closed') { logger.debug('Subscription connection closed') break } } } finally { eventSource.close() } } catch (error) { throw managed.fail(error) } finally { managed.end() } } }