UNPKG

@openfloor/protocol

Version:

Open Floor Protocol implementation for JavaScript/TypeScript - enables interoperable multi-agent conversations

902 lines 33.8 kB
/** * @fileoverview Envelope and Manifest implementation for the Open Floor Protocol * Implements the Inter-Agent Message Specification v1.0.0 and Assistant Manifest Specification v1.0.0 * @author Open Voice Interoperability Initiative * @version 0.0.1 * @license Apache-2.0 */ import { isValidUri, createValidationError, hasRequiredProperties } from './utils'; /** * Represents schema information for Open Floor protocol messages * * @example * ```typescript * const schema = new Schema({ version: '1.0.0' }); * const schemaWithUrl = new Schema({ * version: '1.0.0', * url: 'https://example.com/schema.json' * }); * ``` */ export class Schema { version; url; /** * Creates a new Schema instance * @param options - Schema configuration options */ constructor(options) { this.version = options.version; if (options.url !== undefined) this.url = options.url; } toObject() { const result = { version: this.version }; if (this.url) { result.url = this.url; } return result; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['version'])) { throw new Error('Schema requires version'); } return new Schema({ version: data.version, ...(data.url !== undefined ? { url: data.url } : {}) }); } } /** * Represents identification information for an agent or conversant * * @example * ```typescript * const identification = new Identification({ * speakerUri: 'tag:example.com,2025:agent1', * serviceUrl: 'https://example.com/agent', * organization: 'Example Corp', * conversationalName: 'Assistant', * synopsis: 'Helpful AI assistant' * }); * ``` */ export class Identification { speakerUri; serviceUrl; organization; conversationalName; department; role; synopsis; /** * Creates a new Identification instance * @param options - Identification configuration options * @throws Error if required fields are missing or invalid */ constructor(options) { const { speakerUri, serviceUrl, organization, conversationalName, synopsis, department, role } = options; if (!speakerUri) throw new Error(createValidationError('Identification.speakerUri', speakerUri, 'non-empty string')); if (!serviceUrl) throw new Error(createValidationError('Identification.serviceUrl', serviceUrl, 'non-empty string')); if (!organization) throw new Error(createValidationError('Identification.organization', organization, 'non-empty string')); if (!conversationalName) throw new Error(createValidationError('Identification.conversationalName', conversationalName, 'non-empty string')); if (!synopsis) throw new Error(createValidationError('Identification.synopsis', synopsis, 'non-empty string')); this.speakerUri = speakerUri; this.serviceUrl = serviceUrl; this.organization = organization; this.conversationalName = conversationalName; this.synopsis = synopsis; if (department) this.department = department; if (role) this.role = role; } toObject() { const result = { speakerUri: this.speakerUri, serviceUrl: this.serviceUrl }; if (this.organization) result.organization = this.organization; if (this.conversationalName) result.conversationalName = this.conversationalName; if (this.department) result.department = this.department; if (this.role) result.role = this.role; if (this.synopsis) result.synopsis = this.synopsis; return result; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['speakerUri', 'serviceUrl', 'organization', 'conversationalName', 'synopsis'])) { throw new Error('Identification requires speakerUri, serviceUrl, organization, conversationalName, and synopsis'); } return new Identification({ speakerUri: data.speakerUri, serviceUrl: data.serviceUrl, organization: data.organization, conversationalName: data.conversationalName, synopsis: data.synopsis, ...(data.department !== undefined ? { department: data.department } : {}), ...(data.role !== undefined ? { role: data.role } : {}) }); } } /** * Represents supported input/output layers for agent capabilities * * @example * ```typescript * const layers = new SupportedLayers({ * input: ['text', 'audio'], * output: ['text', 'ssml', 'audio'] * }); * ``` */ export class SupportedLayers { input; output; /** * Creates a new SupportedLayers instance * @param options - SupportedLayers configuration options */ constructor(options = {}) { this.input = Object.freeze(options.input || ['text']); this.output = Object.freeze(options.output || ['text']); } toObject() { return { input: [...this.input], output: [...this.output] }; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { return new SupportedLayers({ input: data.input, output: data.output }); } } /** * Represents a single capability of an agent * * @example * ```typescript * const capability = new Capability({ * keyphrases: ['weather', 'forecast', 'temperature'], * descriptions: ['Provides weather information and forecasts'], * languages: ['en-US', 'en-GB'], * supportedLayers: { input: ['text'], output: ['text', 'ssml'] } * }); * ``` */ export class Capability { keyphrases; descriptions; languages; supportedLayers; /** * Creates a new Capability instance * @param options - Capability configuration options * @throws Error if required fields are missing */ constructor(options) { const { keyphrases, descriptions, languages, supportedLayers } = options; if (!keyphrases || keyphrases.length === 0) throw new Error(createValidationError('Capability.keyphrases', keyphrases, 'non-empty array of strings')); if (!descriptions || descriptions.length === 0) throw new Error(createValidationError('Capability.descriptions', descriptions, 'non-empty array of strings')); this.keyphrases = Object.freeze([...keyphrases]); this.descriptions = Object.freeze([...descriptions]); if (languages) this.languages = Object.freeze([...languages]); if (supportedLayers) this.supportedLayers = new SupportedLayers(supportedLayers); else this.supportedLayers = new SupportedLayers({}); } toObject() { const result = { keyphrases: [...this.keyphrases], descriptions: [...this.descriptions], supportedLayers: this.supportedLayers.toObject() }; if (this.languages) { result.languages = [...this.languages]; } return result; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['keyphrases', 'descriptions'])) { throw new Error('Capability requires keyphrases and descriptions'); } return new Capability({ keyphrases: data.keyphrases, descriptions: data.descriptions, ...(data.languages !== undefined ? { languages: data.languages } : {}), ...(data.supportedLayers !== undefined ? { supportedLayers: SupportedLayers.fromObject(data.supportedLayers).toObject() } : {}) }); } } /** * Represents an agent manifest containing identification and capabilities * * @example * ```typescript * const manifest = new Manifest({ * identification: { * speakerUri: 'tag:example.com,2025:weather-bot', * serviceUrl: 'https://example.com/weather-bot', * organization: 'Weather Corp', * conversationalName: 'WeatherBot', * synopsis: 'Weather information assistant' * }, * capabilities: [{ * keyphrases: ['weather', 'forecast'], * descriptions: ['Provides weather forecasts and current conditions'] * }] * }); * ``` */ export class Manifest { identification; capabilities; /** * Creates a new Manifest instance * @param options - Manifest configuration options * @throws Error if required fields are missing */ constructor(options) { const { identification, capabilities = [] } = options; if (!identification) { throw new Error(createValidationError('Manifest.identification', identification, 'Identification object')); } this.identification = new Identification(identification); this.capabilities = Object.freeze(capabilities.map(cap => new Capability(cap))); } toObject() { return { identification: this.identification.toObject(), capabilities: this.capabilities.map(cap => cap.toObject()) }; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['identification', 'capabilities'])) { throw new Error('Manifest requires identification and capabilities'); } const idObj = data.identification; if (!hasRequiredProperties(idObj, ['speakerUri', 'serviceUrl', 'organization', 'conversationalName', 'synopsis'])) { throw new Error('Identification requires all required fields'); } const capabilitiesArr = Array.isArray(data.capabilities) ? data.capabilities.map(capData => { const capObj = capData; if (!hasRequiredProperties(capObj, ['keyphrases', 'descriptions'])) { throw new Error('Capability requires keyphrases and descriptions'); } return { keyphrases: capObj.keyphrases, descriptions: capObj.descriptions, ...(capObj.languages !== undefined ? { languages: capObj.languages } : {}), ...(capObj.supportedLayers !== undefined ? { supportedLayers: capObj.supportedLayers } : {}) }; }) : []; return new Manifest({ identification: { speakerUri: idObj.speakerUri, serviceUrl: idObj.serviceUrl, organization: idObj.organization, conversationalName: idObj.conversationalName, synopsis: idObj.synopsis, ...(idObj.department !== undefined ? { department: idObj.department } : {}), ...(idObj.role !== undefined ? { role: idObj.role } : {}) }, capabilities: capabilitiesArr }); } } /** * Represents a conversant in a conversation with identification and persistent state * * @example * ```typescript * const conversant = new Conversant({ * identification: { * speakerUri: 'tag:example.com,2025:user1', * serviceUrl: 'https://example.com/user-proxy', * conversationalName: 'User' * }, * persistentState: { preferences: { language: 'en-US' } } * }); * ``` */ export class Conversant { identification; persistentState; /** * Creates a new Conversant instance * @param options - Conversant configuration options * @throws Error if identification is missing */ constructor(options) { const { identification, persistentState = {} } = options; if (!identification) { throw new Error(createValidationError('Conversant.identification', identification, 'Identification object')); } this.identification = new Identification(identification); this.persistentState = { ...persistentState }; } toObject() { const result = { identification: this.identification.toObject() }; if (Object.keys(this.persistentState).length > 0) { result.persistentState = { ...this.persistentState }; } return result; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['identification'])) { throw new Error('Conversant requires identification'); } const idObj = data.identification; if (!hasRequiredProperties(idObj, ['speakerUri', 'serviceUrl', 'organization', 'conversationalName', 'synopsis'])) { throw new Error('Identification requires all required fields'); } return new Conversant({ identification: { speakerUri: idObj.speakerUri, serviceUrl: idObj.serviceUrl, organization: idObj.organization, conversationalName: idObj.conversationalName, synopsis: idObj.synopsis, ...(idObj.department !== undefined ? { department: idObj.department } : {}), ...(idObj.role !== undefined ? { role: idObj.role } : {}) }, ...(data.persistentState !== undefined ? { persistentState: data.persistentState } : {}) }); } } /** * Represents conversation metadata including ID and participants * * @example * ```typescript * const conversation = new Conversation({ * id: 'conv:12345', * conversants: [{ * identification: { * speakerUri: 'tag:example.com,2025:user1', * serviceUrl: 'https://example.com/user-proxy' * } * }] * }); * ``` */ export class Conversation { id; conversants; /** * Creates a new Conversation instance * @param options - Conversation configuration options */ constructor(options) { const { id, conversants = [] } = options; if (!id) throw new Error('Conversation.id is required'); this.id = id; this.conversants = Object.freeze(conversants.map(conv => new Conversant(conv))); } toObject() { const result = { id: this.id }; if (this.conversants.length > 0) { result.conversants = this.conversants.map(conv => conv.toObject()); } return result; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['id'])) { throw new Error('Conversation requires id'); } let conversantsArr = undefined; if (Array.isArray(data.conversants)) { conversantsArr = data.conversants.map(convData => { const convObj = convData; if (!hasRequiredProperties(convObj, ['identification'])) { throw new Error('Conversant requires identification'); } const idObj = convObj.identification; if (!hasRequiredProperties(idObj, ['speakerUri', 'serviceUrl', 'organization', 'conversationalName', 'synopsis'])) { throw new Error('Identification requires all required fields'); } return { identification: { speakerUri: idObj.speakerUri, serviceUrl: idObj.serviceUrl, organization: idObj.organization, conversationalName: idObj.conversationalName, synopsis: idObj.synopsis, ...(idObj.department !== undefined ? { department: idObj.department } : {}), ...(idObj.role !== undefined ? { role: idObj.role } : {}) }, ...(convObj.persistentState !== undefined ? { persistentState: convObj.persistentState } : {}) }; }); } return new Conversation({ id: data.id, ...(conversantsArr ? { conversants: conversantsArr } : {}) }); } } /** * Represents sender information for envelope messages * * @example * ```typescript * const sender = new Sender({ * speakerUri: 'tag:example.com,2025:agent1', * serviceUrl: 'https://example.com/agent' * }); * ``` */ export class Sender { speakerUri; serviceUrl; /** * Creates a new Sender instance * @param options - Sender configuration options * @throws Error if speakerUri is missing or invalid */ constructor(options) { const { speakerUri, serviceUrl } = options; if (!speakerUri) { throw new Error(createValidationError('Sender.speakerUri', speakerUri, 'non-empty string')); } if (!isValidUri(speakerUri)) { throw new Error(createValidationError('Sender.speakerUri', speakerUri, 'valid URI format')); } this.speakerUri = speakerUri; if (serviceUrl !== undefined) this.serviceUrl = serviceUrl; } toObject() { const result = { speakerUri: this.speakerUri }; if (this.serviceUrl) { result.serviceUrl = this.serviceUrl; } return result; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['speakerUri'])) { throw new Error('Sender requires speakerUri'); } return new Sender({ speakerUri: data.speakerUri, ...(data.serviceUrl !== undefined ? { serviceUrl: data.serviceUrl } : {}) }); } } /** * Represents targeting information for events (who the event is addressed to) * * @example * ```typescript * // Address to specific agent * const to1 = new To({ * speakerUri: 'tag:example.com,2025:agent1' * }); * * // Private message to specific service * const to2 = new To({ * serviceUrl: 'https://example.com/agent', * private: true * }); * ``` */ export class To { speakerUri; serviceUrl; private; /** * Creates a new To instance * @param options - To configuration options * @throws Error if neither speakerUri nor serviceUrl is provided */ constructor(options) { const { speakerUri, serviceUrl, private: isPrivate } = options; if (!speakerUri && !serviceUrl) { throw new Error('To requires at least speakerUri or serviceUrl'); } if (speakerUri !== undefined) this.speakerUri = speakerUri; if (serviceUrl !== undefined) this.serviceUrl = serviceUrl; this.private = !!isPrivate; } toObject() { const result = {}; if (this.speakerUri) { result.speakerUri = this.speakerUri; } if (this.serviceUrl) { result.serviceUrl = this.serviceUrl; } if (this.private) { result.private = this.private; } return result; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!data.speakerUri && !data.serviceUrl) { throw new Error('To requires at least speakerUri or serviceUrl'); } return new To({ ...(data.speakerUri !== undefined ? { speakerUri: data.speakerUri } : {}), ...(data.serviceUrl !== undefined ? { serviceUrl: data.serviceUrl } : {}), ...(data.private !== undefined ? { private: data.private } : {}) }); } } /** * Base class for all Open Floor Protocol events * * @example * ```typescript * const event = new Event({ * eventType: 'utterance', * to: { speakerUri: 'tag:example.com,2025:agent1' }, * reason: 'User query', * parameters: { dialogEvent: {...} } * }); * ``` */ export class Event { eventType; to; reason; parameters; /** * Creates a new Event instance * @param options - Event configuration options * @throws Error if eventType is missing or invalid */ constructor(options) { const allowedEventTypes = [ 'utterance', 'context', 'invite', 'uninvite', 'declineInvite', 'bye', 'getManifests', 'publishManifests', 'requestFloor', 'grantFloor', 'revokeFloor', 'yieldFloor' ]; if (!options.eventType || !allowedEventTypes.includes(options.eventType)) { throw new Error(`Invalid eventType: ${options.eventType}`); } const { eventType, to, reason, parameters } = options; this.eventType = eventType; if (to !== undefined) this.to = new To(to); if (reason !== undefined) this.reason = reason; this.parameters = parameters ? { ...parameters } : {}; } toObject() { const result = { eventType: this.eventType }; if (this.to) { result.to = this.to.toObject(); } if (this.reason) { result.reason = this.reason; } if (Object.keys(this.parameters).length > 0) { result.parameters = { ...this.parameters }; } return result; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['eventType'])) { throw new Error('Event requires eventType'); } const eventTypeValue = data.eventType; const validEventTypes = [ 'utterance', 'context', 'invite', 'uninvite', 'declineInvite', 'bye', 'getManifests', 'publishManifests', 'requestFloor', 'grantFloor', 'revokeFloor', 'yieldFloor' ]; if (!validEventTypes.includes(eventTypeValue)) { throw new Error('Invalid eventType'); } const options = { eventType: eventTypeValue, ...(data.to && typeof data.to === 'object' ? { to: To.fromObject(data.to).toObject() } : {}), ...(data.reason !== undefined ? { reason: data.reason } : {}), ...(data.parameters !== undefined ? { parameters: data.parameters } : {}) }; return new Event(options); } } /** * Represents an Open Floor Protocol message envelope * Contains schema, conversation, sender, and events information * * @example * ```typescript * const envelope = new Envelope({ * conversation: { id: 'conv:12345' }, * sender: { speakerUri: 'tag:example.com,2025:agent1' }, * events: [{ * eventType: 'utterance', * parameters: { dialogEvent: {...} } * }] * }); * ``` */ export class Envelope { schema; conversation; sender; events; /** * Creates a new Envelope instance * @param options - Envelope configuration options * @throws Error if required fields are missing */ constructor(options) { const { schema, conversation, sender, events } = options; if (!schema) throw new Error('Envelope.schema is required'); if (!conversation) throw new Error('Envelope.conversation is required'); if (!sender) throw new Error('Envelope.sender is required'); if (!events) throw new Error('Envelope.events is required'); this.schema = new Schema(schema); this.conversation = new Conversation(conversation); this.sender = new Sender(sender); this.events = Object.freeze(events.map(eventOpts => new Event(eventOpts))); } toObject() { return { schema: this.schema.toObject(), conversation: this.conversation.toObject(), sender: this.sender.toObject(), events: this.events.map(event => event.toObject()) }; } toJSON() { return JSON.stringify(this.toObject()); } /** * Creates a wrapped payload for the envelope (adds openFloor wrapper) */ toPayload() { return new Payload({ openFloor: { schema: { version: this.schema.version, ...(this.schema.url ? { url: this.schema.url } : {}) }, conversation: { id: this.conversation.id, ...(this.conversation.conversants && this.conversation.conversants.length > 0 ? { conversants: this.conversation.conversants.map(c => { const obj = c.toObject(); if (!obj.identification || typeof obj.identification !== 'object' || !hasRequiredProperties(obj.identification, ['speakerUri', 'serviceUrl', 'organization', 'conversationalName', 'synopsis'])) { throw new Error('Conversant.identification is missing required fields'); } const idObj = obj.identification; return { identification: { speakerUri: idObj.speakerUri, serviceUrl: idObj.serviceUrl, organization: idObj.organization, conversationalName: idObj.conversationalName, synopsis: idObj.synopsis, ...(idObj.department !== undefined ? { department: idObj.department } : {}), ...(idObj.role !== undefined ? { role: idObj.role } : {}) }, ...(obj.persistentState !== undefined ? { persistentState: obj.persistentState } : {}) }; }) } : {}) }, sender: { speakerUri: this.sender.speakerUri, ...(this.sender.serviceUrl ? { serviceUrl: this.sender.serviceUrl } : {}) }, events: this.events.map(e => { const base = { eventType: e.eventType, parameters: e.parameters }; if (e.to) base.to = e.to.toObject(); if (e.reason) base.reason = e.reason; return base; }) } }); } static fromObject(data) { if (!hasRequiredProperties(data, ['schema', 'conversation', 'sender', 'events'])) { throw new Error('Envelope requires schema, conversation, sender, and events'); } const schemaObj = Schema.fromObject(data.schema); const conversationObj = Conversation.fromObject(data.conversation); const senderObj = Sender.fromObject(data.sender); const eventsArr = Array.isArray(data.events) ? data.events.map(eventData => Event.fromObject(eventData)) : []; return new Envelope({ schema: { version: schemaObj.version, ...(schemaObj.url ? { url: schemaObj.url } : {}) }, conversation: { id: conversationObj.id, ...(conversationObj.conversants && conversationObj.conversants.length > 0 ? { conversants: conversationObj.conversants.map(c => { const obj = c.toObject(); if (!obj.identification || typeof obj.identification !== 'object' || !hasRequiredProperties(obj.identification, ['speakerUri', 'serviceUrl', 'organization', 'conversationalName', 'synopsis'])) { throw new Error('Conversant.identification is missing required fields'); } const idObj = obj.identification; return { identification: { speakerUri: idObj.speakerUri, serviceUrl: idObj.serviceUrl, organization: idObj.organization, conversationalName: idObj.conversationalName, synopsis: idObj.synopsis, ...(idObj.department !== undefined ? { department: idObj.department } : {}), ...(idObj.role !== undefined ? { role: idObj.role } : {}) }, ...(obj.persistentState !== undefined ? { persistentState: obj.persistentState } : {}) }; }) } : {}) }, sender: { speakerUri: senderObj.speakerUri, ...(senderObj.serviceUrl ? { serviceUrl: senderObj.serviceUrl } : {}) }, events: eventsArr.map(e => { const base = { eventType: e.eventType, parameters: e.parameters }; if (e.to) base.to = e.to.toObject(); if (e.reason) base.reason = e.reason; return base; }) }); } } /** * Represents a payload wrapper that contains an Open Floor envelope * This is the top-level structure as defined in the specification * * @example * ```typescript * const payload = new Payload({ * openFloor: { * conversation: { id: 'conv:12345' }, * sender: { speakerUri: 'tag:example.com,2025:agent1' }, * events: [] * } * }); * ``` */ export class Payload { openFloor; /** * Creates a new Payload instance * @param options - Payload configuration options * @throws Error if openFloor is missing */ constructor(options) { const { openFloor } = options; if (!openFloor) throw new Error('Payload.openFloor is required'); this.openFloor = new Envelope(openFloor); } toObject() { return { openFloor: this.openFloor.toObject() }; } toJSON() { return JSON.stringify(this.toObject()); } static fromObject(data) { if (!hasRequiredProperties(data, ['openFloor'])) { throw new Error('Payload requires openFloor'); } const openFloorObj = Envelope.fromObject(data.openFloor); return new Payload({ openFloor: { schema: { version: openFloorObj.schema.version, ...(openFloorObj.schema.url ? { url: openFloorObj.schema.url } : {}) }, conversation: { id: openFloorObj.conversation.id, ...(openFloorObj.conversation.conversants && openFloorObj.conversation.conversants.length > 0 ? { conversants: openFloorObj.conversation.conversants.map(c => { const obj = c.toObject(); if (!obj.identification || typeof obj.identification !== 'object' || !hasRequiredProperties(obj.identification, ['speakerUri', 'serviceUrl', 'organization', 'conversationalName', 'synopsis'])) { throw new Error('Conversant.identification is missing required fields'); } const idObj = obj.identification; return { identification: { speakerUri: idObj.speakerUri, serviceUrl: idObj.serviceUrl, organization: idObj.organization, conversationalName: idObj.conversationalName, synopsis: idObj.synopsis, ...(idObj.department !== undefined ? { department: idObj.department } : {}), ...(idObj.role !== undefined ? { role: idObj.role } : {}) }, ...(obj.persistentState !== undefined ? { persistentState: obj.persistentState } : {}) }; }) } : {}) }, sender: { speakerUri: openFloorObj.sender.speakerUri, ...(openFloorObj.sender.serviceUrl ? { serviceUrl: openFloorObj.sender.serviceUrl } : {}) }, events: openFloorObj.events.map(e => { const base = { eventType: e.eventType, parameters: e.parameters }; if (e.to) base.to = e.to.toObject(); if (e.reason) base.reason = e.reason; return base; }) } }); } /** * Creates a Payload from a JSON string */ static fromJSON(jsonString) { try { const data = JSON.parse(jsonString); return Payload.fromObject(data); } catch (error) { throw new Error(`Failed to parse JSON payload: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } //# sourceMappingURL=envelope.js.map