@openfloor/protocol
Version:
Open Floor Protocol implementation for JavaScript/TypeScript - enables interoperable multi-agent conversations
485 lines • 17.1 kB
JavaScript
/**
* @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