UNPKG

@openfloor/protocol

Version:

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

485 lines 17.1 kB
/** * @fileoverview Agent implementation for the Open Floor Protocol * Implements agent behaviors from Section 2 of the Inter-Agent Message Specification v1.0.0 * @author Open Voice Interoperability Initiative * @version 0.0.1 * @license Apache-2.0 */ import { Envelope, Manifest, Conversation } from './envelope'; import { UtteranceEvent, InviteEvent, UninviteEvent, PublishManifestsEvent, GrantFloorEvent, RevokeFloorEvent, isUtteranceEvent, isContextEvent, isInviteEvent, isUninviteEvent, isByeEvent, isGetManifestsEvent, isRequestFloorEvent, isGrantFloorEvent, isRevokeFloorEvent } from './events'; import { createValidationError, hasRequiredProperties } from './utils'; /** * Base class for Open Floor Protocol agents * Provides event handling infrastructure and basic agent behaviors * * @example * ```typescript * class MyAgent extends OpenFloorAgent { * constructor(manifest) { * super(manifest); * this.on('utterance', this.handleUtterance.bind(this)); * } * * async handleUtterance(event, envelope, outEnvelope) { * // Handle utterance event * } * } * ``` */ export class OpenFloorAgent extends EventTarget { _manifest; /** * Creates a new OpenFloorAgent instance * @param manifest - Agent manifest defining capabilities and identification * @throws Error if manifest is invalid */ constructor(manifest) { super(); if (!manifest) { throw new Error(createValidationError('OpenFloorAgent.manifest', manifest, 'valid ManifestOptions object')); } this._manifest = new Manifest(manifest); } /** * Get the agent's speaker URI from the manifest */ get speakerUri() { return this._manifest.identification.speakerUri; } /** * Get the agent's service URL from the manifest */ get serviceUrl() { return this._manifest.identification.serviceUrl; } /** * Get the agent's manifest */ get manifest() { return this._manifest; } /** * Process an incoming envelope and generate a response * This is the main entry point for agent message processing * * @param inEnvelope - Incoming envelope to process * @returns Promise resolving to response envelope */ async processEnvelope(inEnvelope) { // Create response envelope with same schema, conversation, and this agent as sender const outEnvelope = new Envelope({ schema: { version: inEnvelope.schema.version, ...(inEnvelope.schema.url ? { url: inEnvelope.schema.url } : {}) }, conversation: { id: inEnvelope.conversation.id, ...(inEnvelope.conversation.conversants && inEnvelope.conversation.conversants.length > 0 ? { conversants: inEnvelope.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.speakerUri, serviceUrl: this.serviceUrl }, events: [] }); // Dispatch envelope event (use a valid key from AgentEventHandlers, e.g., 'onEnvelope') await this.dispatchAgentEvent('onEnvelope', { inEnvelope, outEnvelope }); return outEnvelope; } /** * Add metadata to events indicating whether they are addressed to this agent * @param events - Array of events to analyze * @returns Array of events with metadata */ addMetadata(events) { return events.map(event => [ event, { addressedToMe: (!event.to || event.to.speakerUri === this.speakerUri || event.to.serviceUrl === this.serviceUrl) } ]); } /** * Dispatch an agent-specific event * @param eventType - Type of event to dispatch * @param detail - Event detail data */ async dispatchAgentEvent(eventType, detail) { const event = new CustomEvent(`agent:${eventType}`, { detail }); this.dispatchEvent(event); } /** * Add an event handler for a specific event type * @param eventType - Type of event to handle * @param handler - Handler function */ on(eventType, handler) { this.addEventListener(`agent:${eventType}`, handler); } /** * Remove an event handler * @param eventType - Type of event * @param handler - Handler function to remove */ off(eventType, handler) { this.removeEventListener(`agent:${eventType}`, handler); } } /** * Bot agent implementation providing default behaviors per specification Section 2.1 * Handles conversation state and implements minimal required behaviors * * @example * ```typescript * const bot = new BotAgent({ * identification: { * speakerUri: 'tag:example.com,2025:bot1', * serviceUrl: 'https://example.com/bot', * organization: 'Example Corp', * conversationalName: 'Assistant' * }, * capabilities: [] * }); * * const response = await bot.processEnvelope(incomingEnvelope); * ``` */ export class BotAgent extends OpenFloorAgent { _currentContext = []; _activeConversation = null; _hasFloor = false; /** * Creates a new BotAgent instance * @param manifest - Agent manifest */ constructor(manifest) { super(manifest); this._setupEventHandlers(); } /** * Get current conversation state */ get activeConversation() { return this._activeConversation; } /** * Check if agent currently has the floor */ get hasFloor() { return this._hasFloor; } /** * Get current context events */ get currentContext() { return [...this._currentContext]; } /** * Set up default event handlers */ _setupEventHandlers() { this.on('onEnvelope', this._handleEnvelope.bind(this)); } /** * Main envelope processing logic */ async _handleEnvelope(event) { const { inEnvelope, outEnvelope } = event.detail; // Clear current context this._currentContext = []; // Check for conversation conflicts if (this._activeConversation && this._activeConversation.id !== inEnvelope.conversation.id) { throw new Error('Bot is already in a different conversation'); } // Filter events addressed to this agent const eventsWithMetadata = this.addMetadata(inEnvelope.events); const myEvents = eventsWithMetadata.filter(([, metadata]) => metadata.addressedToMe); // Process events in order for (const [eventObj] of myEvents) { await this._handleEvent(eventObj, inEnvelope, outEnvelope); } } /** * Handle individual events based on type */ async _handleEvent(event, inEnvelope, outEnvelope) { if (isInviteEvent(event)) { await this._handleInvite(event, inEnvelope, outEnvelope); } else if (isUtteranceEvent(event)) { await this._handleUtterance(event, inEnvelope, outEnvelope); } else if (isContextEvent(event)) { await this._handleContext(event, inEnvelope, outEnvelope); } else if (isUninviteEvent(event)) { await this._handleUninvite(event, inEnvelope, outEnvelope); } else if (isGrantFloorEvent(event)) { await this._handleGrantFloor(event, inEnvelope, outEnvelope); } else if (isRevokeFloorEvent(event)) { await this._handleRevokeFloor(event, inEnvelope, outEnvelope); } else if (isGetManifestsEvent(event)) { await this._handleGetManifests(event, inEnvelope, outEnvelope); } // Other events are ignored per spec Section 2.1 } /** * Handle invite events - accept invitation and automatically grant floor */ async _handleInvite(event, inEnvelope, outEnvelope) { // Accept invitation this._activeConversation = new Conversation({ id: inEnvelope.conversation.id }); // Automatically grant floor (per spec) await this._handleGrantFloor(new GrantFloorEvent({ reason: 'Automatic floor grant as a result of invitation' }), inEnvelope, outEnvelope); } /** * Handle grant floor events */ async _handleGrantFloor(event, inEnvelope, outEnvelope) { this._hasFloor = true; } /** * Handle revoke floor events */ async _handleRevokeFloor(event, inEnvelope, outEnvelope) { this._hasFloor = false; } /** * Handle utterance events - provide default response * Subclasses should override this method to provide meaningful responses */ async _handleUtterance(event, inEnvelope, outEnvelope) { const responseEvent = new UtteranceEvent({ dialogEvent: { speakerUri: this.speakerUri, features: { text: { mimeType: 'text/plain', tokens: [{ value: "Sorry! I'm a simple bot that has not been programmed to do anything yet." }] } } } }); // Add to output envelope (mutate the events array) outEnvelope._events = [...outEnvelope.events, responseEvent]; } /** * Handle context events - store context for future use */ async _handleContext(event, inEnvelope, outEnvelope) { this._currentContext.push(event); } /** * Handle uninvite events - leave conversation */ async _handleUninvite(event, inEnvelope, outEnvelope) { this._activeConversation = null; this._hasFloor = false; } /** * Handle getManifests events - return own manifest */ async _handleGetManifests(event, inEnvelope, outEnvelope) { const responseEvent = new PublishManifestsEvent({ servicingManifests: [this._manifest.toObject()], discoveryManifests: [] }); // Add to output envelope outEnvelope._events = [...outEnvelope.events, responseEvent]; } } /** * Floor manager agent implementation per specification Section 2.2 * Manages multi-party conversations and event forwarding * * @example * ```typescript * const floorManager = new FloorManager({ * identification: { * speakerUri: 'tag:example.com,2025:floor-manager', * serviceUrl: 'https://example.com/floor', * organization: 'Example Corp', * conversationalName: 'Floor Manager' * } * }); * ``` */ export class FloorManager extends OpenFloorAgent { _activeConversants = new Map(); _currentSpeaker = null; /** * Creates a new FloorManager instance * @param manifest - Floor manager manifest */ constructor(manifest) { super(manifest); this._setupEventHandlers(); } /** * Get current speaker URI */ get currentSpeaker() { return this._currentSpeaker; } /** * Get list of active conversant URIs */ get activeConversants() { return Array.from(this._activeConversants.keys()); } /** * Set up floor manager event handlers */ _setupEventHandlers() { this.on('onEnvelope', this._handleEnvelope.bind(this)); } /** * Floor manager envelope processing - forwards events as appropriate */ async _handleEnvelope(event) { const { inEnvelope, outEnvelope } = event.detail; // Process each event for forwarding logic for (const eventObj of inEnvelope.events) { await this._forwardEvent(eventObj, inEnvelope, outEnvelope); } } /** * Forward events according to their targeting and floor management rules */ async _forwardEvent(event, inEnvelope, outEnvelope) { if (isByeEvent(event)) { // Remove agent from active conversants const senderUri = inEnvelope.sender.speakerUri; this._activeConversants.delete(senderUri); if (this._currentSpeaker === senderUri) { this._currentSpeaker = null; } } else if (isRequestFloorEvent(event)) { // Grant floor automatically (minimal implementation) const grantEvent = new GrantFloorEvent({ to: { speakerUri: inEnvelope.sender.speakerUri } }); outEnvelope._events = [...outEnvelope.events, grantEvent]; this._currentSpeaker = inEnvelope.sender.speakerUri; } outEnvelope._events = [...outEnvelope.events, event]; } /** * Add a conversant to the active conversation * @param manifest - Conversant's manifest */ addConversant(manifest) { this._activeConversants.set(manifest.identification.speakerUri, manifest); } /** * Remove a conversant from the active conversation * @param speakerUri - Speaker URI to remove */ removeConversant(speakerUri) { this._activeConversants.delete(speakerUri); if (this._currentSpeaker === speakerUri) { this._currentSpeaker = null; } } } /** * Convener agent with special privileges for managing multi-party conversations * Extends BotAgent with floor management capabilities * * @example * ```typescript * const convener = new ConvenerAgent({ * identification: { * speakerUri: 'tag:example.com,2025:convener', * serviceUrl: 'https://example.com/convener', * organization: 'Example Corp', * conversationalName: 'Convener' * } * }); * * await convener.grantFloor('tag:example.com,2025:agent1'); * ``` */ export class ConvenerAgent extends BotAgent { /** * Grant the floor to a specific agent * @param speakerUri - URI of agent to grant floor to * @param reason - Optional reason for granting floor * @returns GrantFloorEvent that can be sent */ grantFloor(speakerUri, reason) { const options = { to: { speakerUri } }; if (reason !== undefined) options.reason = reason; return new GrantFloorEvent(options); } /** * Revoke the floor from a specific agent * @param speakerUri - URI of agent to revoke floor from * @param reason - Reason for revoking floor (should include reason token) * @returns RevokeFloorEvent that can be sent */ revokeFloor(speakerUri, reason) { const options = { to: { speakerUri } }; if (reason !== undefined) options.reason = reason; return new RevokeFloorEvent(options); } /** * Uninvite an agent from the conversation * @param speakerUri - URI of agent to uninvite * @param reason - Reason for uninviting (should include reason token) * @returns UninviteEvent that can be sent */ uninviteAgent(speakerUri, reason) { const options = { to: { speakerUri } }; if (reason !== undefined) options.reason = reason; return new UninviteEvent(options); } /** * Invite an agent to join the conversation * @param serviceUrl - Service URL of agent to invite * @param speakerUri - Optional specific speaker URI * @param reason - Optional reason for invitation * @returns InviteEvent that can be sent */ inviteAgent(serviceUrl, speakerUri, reason) { const to = { serviceUrl }; if (speakerUri !== undefined) to.speakerUri = speakerUri; const options = { to }; if (reason !== undefined) options.reason = reason; return new InviteEvent(options); } } //# sourceMappingURL=agents.js.map