Skip to main content

Multi-Agent System

This document explains how the multi-agent architecture works in Hexabot + Prophunt.

What is a Multi-Agent System?

A multi-agent system allows multiple specialized AI agents to coexist in the same chatbot platform, each handling specific domains:

  • BGV Agent - Background Verification
  • Loan Agent - Loan eligibility and processing
  • Listing Agent - Property listing management

Each agent has:

  • Isolated conversation context - Separate history per agent
  • Specialized state machine - Custom workflow for its domain
  • Dedicated message builders - Domain-specific responses
  • External service integration - Calls to webserver APIs

Agent Lifecycle

stateDiagram-v2
[*] --> Registered: Agent registered in AgentRegistry
Registered --> Idle: Waiting for activation
Idle --> Activated: Keyword match or explicit selection
Activated --> Processing: User sends message
Processing --> RespondingTo User: Generate responses
RespondingToUser --> AwaitingInput: Waiting for user
AwaitingInput --> Processing: User responds
AwaitingInput --> Deactivated: User switches agent
Processing --> HandlingEvent: External event (payment, file upload)
HandlingEvent --> Processing: Event processed
RespondingToUser --> Completed: Workflow finished
Completed --> [*]
Deactivated --> [*]

Agent Structure

Every agent implements the Agent interface:

interface Agent {
config: AgentConfig;

processMessage(
message: ChannelMessage,
context: ConversationContext
): Promise<MessageEnvelope[]>;

handleEvent(
event: AgentEvent,
context: ConversationContext
): Promise<void>;

getState(context: ConversationContext): string;

transition(
from: string,
to: string,
context: ConversationContext
): Promise<ConversationContext>;

canHandle(message: ChannelMessage): boolean;
}

Agent Config

interface AgentConfig {
id: string; // 'bgv', 'loan', 'listing'
name: string; // 'Background Verification'
description: string; // Human-readable description
capabilities: string[]; // ['pan_verification', 'address_check']
triggerKeywords: string[]; // ['bgv', 'background check', 'verify']
channelPrefix: string; // 'prophunt_bgv'
}

Agent Activation

Method 1: Keyword Matching

User sends: "I want background verification"

// AgentRouter checks each agent
for (const agent of agents) {
if (agent.canHandle(message)) {
return agent; // BGVAgent activated
}
}

// BGVAgent.canHandle()
canHandle(message: ChannelMessage): boolean {
const text = message.text?.toLowerCase() || '';
return this.config.triggerKeywords.some(keyword =>
text.includes(keyword.toLowerCase())
);
}

Method 2: Explicit Selection

Frontend sends: { channel_id: 'prophunt_bgv', ... }

// ChannelManager extracts agent
mapChannelToAgent(channelId: string): string | null {
const parts = channelId.split('_');
if (parts.length >= 2) {
return parts[parts.length - 1]; // 'bgv'
}
return null;
}

// AgentRegistry retrieves agent by ID
const agent = agentRegistry.getAgent('bgv');

Method 3: Context Persistence

User returns to existing conversation:

// ConversationManager loads conversation
const conversation = await conversationManager.getOrCreateConversation(
'prophunt_bgv',
'user123'
);

// Context contains active agent
const agentId = conversation.context.vars.agentId; // 'bgv'
const agent = agentRegistry.getAgent(agentId);

Agent Isolation

Each agent maintains isolated conversation history:

// Frontend connects to specific channel
this.socket.emit('join_channel', {
channel_id: 'prophunt_bgv', // BGV channel
user_id: 'user123'
});

// Later, user switches to Loan Agent
this.socket.emit('leave_channel', {
channel_id: 'prophunt_bgv'
});

this.socket.emit('join_channel', {
channel_id: 'prophunt_loan', // Loan channel
user_id: 'user123'
});

// Separate conversations in database:
// {
// context: { vars: { channelId: 'prophunt_bgv', agentId: 'bgv' } }
// messages: [/* BGV conversation */]
// }
// {
// context: { vars: { channelId: 'prophunt_loan', agentId: 'loan' } }
// messages: [/* Loan conversation */]
// }

State Machines

Each agent uses a state machine to manage workflow:

BGV Agent States

enum BGVState {
INIT = 'INIT',
PAN_UPLOAD_REQUESTED = 'PAN_UPLOAD_REQUESTED',
PAN_UPLOADED = 'PAN_UPLOADED',
AWAITING_PAYMENT = 'AWAITING_PAYMENT',
PAYMENT_COMPLETED = 'PAYMENT_COMPLETED',
VERIFICATION_IN_PROGRESS = 'VERIFICATION_IN_PROGRESS',
REPORT_READY = 'REPORT_READY',
COMPLETED = 'COMPLETED',
ERROR = 'ERROR'
}

State Transitions

INIT

├─ User: "Start BGV"


PAN_UPLOAD_REQUESTED

├─ Event: FILE_UPLOADED


PAN_UPLOADED

├─ Agent: Generates payment link


AWAITING_PAYMENT

├─ Event: PAYMENT_COMPLETED


PAYMENT_COMPLETED

├─ Agent: Calls webserver /start-verification


VERIFICATION_IN_PROGRESS

├─ Webserver: Calls /agent-api/send-message (verification_completed)


REPORT_READY

├─ User: Downloads report


COMPLETED

Agent Communication

1. Agent → User (via Socket.io)

// In BGVAgent.processMessage()
const responses = [
{
type: MessageType.TEXT,
text: 'Please upload your PAN card'
},
{
type: MessageType.CUSTOM,
custom_payload: {
type: 'OPEN_MODAL',
modal_id: 'file_upload_modal'
}
}
];

// ConversationManager persists and emits
for (const response of responses) {
await conversationManager.sendMessage(
conversationId,
channelId,
response,
'bgv-agent'
);
}

// SocketAdapter emits to frontend
socketAdapter.emitToChannel(channelId, response);

2. Agent → Webserver (HTTP API)

// In BGVService (agent module)
async startVerification(taskId: string): Promise<any> {
const response = await axios.post(
`${this.prophuntApiUrl}/bgv/start-verification`,
{ taskId }
);
return response.data;
}

// Called from BGVAgent
await this.service.startVerification(context.taskId);

3. Webserver → Agent (via AgentApiController)

// Prophunt Webserver
await axios.post('http://hexabot:4000/agent-api/send-message', {
taskId: 'bgv_user123_1234567890',
agentId: 'bgv',
messageType: 'verification_completed',
data: { reportUrl: 'https://...' }
});

// AgentApiController routes to ConversationManager
// Message sent to user via Socket.io

4. User → Agent (via ChatGateway)

// Frontend sends
socket.emit('message', {
channel_id: 'prophunt_bgv',
user_id: 'user123',
text: 'Check my BGV status'
});

// ChatGateway receives and routes
const agent = agentRouter.routeMessage(message, context);
const responses = await agent.processMessage(message, context);

Agent Registry

The AgentRegistry is the central repository for all agents:

@Injectable()
export class AgentRegistry {
private agents: Map<string, Agent> = new Map();

/**
* Register an agent
*/
register(agent: Agent): void {
this.agents.set(agent.config.id, agent);
}

/**
* Get agent by ID
*/
getAgent(agentId: string): Agent | null {
return this.agents.get(agentId) || null;
}

/**
* Get all registered agents
*/
getAllAgents(): Agent[] {
return Array.from(this.agents.values());
}
}

Registration Process

// In AgentsModule
@Module({
providers: [
BGVAgent,
LoanAgent,
ListingAgent
]
})
export class AgentsModule {
constructor(
private readonly agentRegistry: AgentRegistry,
private readonly bgvAgent: BGVAgent,
private readonly loanAgent: LoanAgent,
private readonly listingAgent: ListingAgent
) {
// Register all agents
this.agentRegistry.register(this.bgvAgent);
this.agentRegistry.register(this.loanAgent);
this.agentRegistry.register(this.listingAgent);
}
}

Context Management

Agent context is stored in conversation.context.vars:

{
_id: ObjectId('...'),
sender: 'user123',
active: true,
context: {
vars: {
// Agent-specific fields
channelId: 'prophunt_bgv',
agentId: 'bgv',
taskId: 'bgv_user123_1234567890',
verificationId: 'VER_12345', // Linked externally
state: 'AWAITING_PAYMENT',

// Custom workflow data
panCardUploaded: true,
panNumber: 'ABCDE1234F',
paymentAmount: 499,
paymentStatus: 'pending',

// Metadata
lastMessageAt: Date('2025-12-05T10:30:00Z'),
lastMessageHandler: 'bgv-agent'
},
// Hexabot standard fields
user_location: { lat: 0, lon: 0 },
skip: {},
attempt: 0
}
}

Agent Switching

User can switch between agents mid-conversation:

// User clicks "Switch to Loan Agent" in UI
frontend.leaveChannel('prophunt_bgv');
frontend.join Channel('prophunt_loan');

// ChatGateway handles
@SubscribeMessage('leave_channel')
handleLeaveChannel(client: Socket, payload: { channel_id: string }) {
this.socketAdapter.leaveChannel(client.id, payload.channel_id);
}

@SubscribeMessage('join_channel')
handleJoinChannel(client: Socket, payload: { channel_id, user_id }) {
// Get/create conversation for new agent
const conversation = await this.conversationManager.getOrCreateConversation(
payload.channel_id, // 'prophunt_loan'
payload.user_id
);

// Join Socket.io room
this.socketAdapter.joinChannel(client.id, payload.channel_id);

// Agent context switched
const agentId = conversation.context.vars.agentId; // 'loan'
}

Event-Driven Agent Coordination

Agents listen to events via EventBus:

// BGVAgent subscribes on initialization
constructor(
private readonly eventBus: EventBus,
private readonly service: BGVService
) {
this.eventBus.subscribe('FILE_UPLOADED', this.onFileUploaded.bind(this));
this.eventBus.subscribe('PAYMENT_COMPLETED', this.onPaymentCompleted.bind(this));
}

// Event handler
async onFileUploaded(event: AgentEvent) {
if (event.agentId === 'bgv') {
await this.service.notifyPANUpload(event.taskId, event.data.fileUrl);
}
}

// EventHandler emits events
await this.eventBus.emit('FILE_UPLOADED', {
type: AgentEventType.FILE_UPLOADED,
agentId: 'bgv',
taskId: context.taskId,
data: { fileUrl: 'https://...' },
timestamp: new Date()
});

Best Practices

Keep agents stateless - All state in context.vars
Use state machines - Clear workflow progression
Isolate conversations - One channel per agent
Persist everything - Don't rely on in-memory state
Event-driven communication - Decouple via EventBus
Type-safe messages - Use TypeScript enums and interfaces

Next Steps