Skip to main content

DispatchBus: Inter-Agent Communication

License: MIT

Asynchronous messaging system for agent-to-agent communication in the SMRT framework.

Overview​

The DispatchBus provides asynchronous messaging between agents. It enables loose coupling where agents like Suasor (marketing) can emit events that Fiscus (accounting) processes later.

┌─────────────┐ emit() ┌──────────────────┐
│ Suasor │ ───────────────▶│ _smrt_dispatch │
│ Agent │ │ (pending) │
└─────────────┘ └────────┬─────────┘
│
│ process('fiscus')
▼
┌─────────────┐ handleDispatch() ┌──────────────────┐
│ Fiscus │ ◀───────────────────│ DispatchBus │
│ Agent │ └──────────────────┘
└─────────────┘

Features​

  • Asynchronous Messaging: Emit events for later processing
  • Persistent Subscriptions: Survive application restarts
  • Wildcard Patterns: Subscribe to event families (campaign.*)
  • Retry Mechanism: Automatic retry for failed dispatches
  • Tracing Support: Built-in metadata for observability
  • CLI Commands: Manage dispatches from the command line
  • Agent Integration: Built into the Agent base class

Installation​

The DispatchBus is included in @happyvertical/smrt-core:

npm install @happyvertical/smrt-core

Quick Start​

import { createDispatchBus } from '@happyvertical/smrt-core';

// Create a dispatch bus
const bus = await createDispatchBus({
db: { type: 'sqlite', url: 'app.db' }
});

// Subscribe to events (persistent - survives restarts)
await bus.subscribe({
signalType: 'campaign.completed',
subscriber: 'Fiscus',
handler: 'handleCampaignRevenue'
});

// Emit an event
await bus.emit(
'campaign.completed',
{ campaignId: 'camp-123', revenue: 5000.00 },
{ source: 'Suasor' }
);

// Process pending dispatches
await bus.process('Fiscus', async (payload, metadata) => {
console.log(`Processing ${metadata.type} from ${metadata.source}`);
// Handle the campaign completion...
});

Two Types of Handlers​

In-Memory Handlers (Immediate)​

Called synchronously when emit() is invoked. Useful for real-time reactions:

// Register in-memory handler
bus.on('campaign.completed', async (payload, metadata) => {
console.log(`Campaign ${payload.campaignId} completed!`);
await sendSlackNotification(payload);
});

// Unregister when done
bus.off('campaign.completed', handler);

Persistent Subscriptions (Processed Later)​

Stored in database, processed when process() is called. Survives restarts:

// Create persistent subscription
await bus.subscribe({
signalType: 'campaign.*', // Wildcard pattern
subscriber: 'Fiscus',
handler: 'handleCampaign'
});

// Later, process all pending dispatches
await bus.process('Fiscus', async (payload, metadata) => {
// Handle each dispatch
});

// Remove subscription
await bus.unsubscribe('campaign.*', 'Fiscus');

Wildcard Subscriptions​

Subscribe to event families using wildcard patterns:

// Match all campaign events
await bus.subscribe({ signalType: 'campaign.*', subscriber: 'Monitor' });

// Matches: campaign.started, campaign.paused, campaign.completed
// Does NOT match: campaign.summer.completed (single segment only)

// Match events with wildcards in the middle
await bus.subscribe({ signalType: 'agent.*.completed', subscriber: 'Logger' });

// Matches: agent.suasor.completed, agent.fiscus.completed

Wildcard Rules:

  • * matches exactly one segment (anything except .)
  • campaign.* matches campaign.started but NOT campaign.summer.started
  • Multiple wildcards allowed: *.*.completed

Dispatch Lifecycle​

pending → processing → completed
↘ failed → (retry) → pending

Status Values:

  • pending: Waiting to be processed
  • processing: Currently being handled
  • completed: Successfully processed
  • failed: Handler threw an error

Retry Failed Dispatches​

// Retry failed dispatches (up to 3 attempts)
const count = await bus.retry({ maxAttempts: 3 });
console.log(`Reset ${count} dispatches for retry`);

// Then process again
await bus.process('Fiscus', handler);

Cleanup Old Dispatches​

// Delete completed dispatches older than 30 days
const result = await bus.cleanup({
completedOlderThanDays: 30,
failedOlderThanDays: 90
});
console.log(`Deleted ${result.completedDeleted} completed, ${result.failedDeleted} failed`);

Query Dispatches​

// List all pending dispatches
const pending = await bus.list({ status: 'pending' });

// Filter by source
const fromSuasor = await bus.list({
status: 'completed',
source: 'Suasor'
});

// Filter by type
const campaigns = await bus.list({ type: 'campaign.completed' });

// Get single dispatch by ID
const dispatch = await bus.get('dispatch-uuid');

Agent Integration​

Agents have built-in dispatch support:

import { Agent } from '@happyvertical/smrt-agents';

class Fiscus extends Agent {
// Override to handle dispatches
async handleDispatch(payload: unknown, metadata: DispatchMetadata): Promise<void> {
if (metadata.type === 'campaign.completed') {
await this.recordRevenue(payload);
}
}

async processIncomingDispatches(): Promise<void> {
// Get the dispatch bus (lazy-created)
const bus = await this.getDispatch();

// Subscribe to relevant events
await bus.subscribe({
signalType: 'campaign.*',
subscriber: this.constructor.name
});

// Process with the built-in handler
await this.processDispatches();
}
}

Agent Methods:

  • getDispatch(): Get or create DispatchBus instance
  • handleDispatch(payload, metadata): Override to handle dispatches
  • processDispatches(options): Process pending dispatches for this agent

Metadata and Tracing​

Include metadata for observability:

await bus.emit(
'order.placed',
{ orderId: 'ord-123', total: 99.99 },
{
source: 'Checkout',
sourceId: 'checkout-instance-1',
metadata: {
traceId: 'trace-abc123',
spanId: 'span-xyz789',
environment: 'production'
}
}
);

// Access metadata in handler
await bus.process('Billing', async (payload, metadata) => {
console.log(`Trace: ${metadata.metadata.traceId}`);
console.log(`Source: ${metadata.source} (${metadata.sourceId})`);
});

CLI Commands​

# List dispatches
smrt dispatch:list
smrt dispatch:list --status pending
smrt dispatch:list --source Suasor

# Process pending for a subscriber
smrt dispatch:process --subscriber Fiscus

# Retry failed dispatches
smrt dispatch:retry --max-attempts 3

# Cleanup old dispatches
smrt dispatch:cleanup --completed-older-than 30

# Manage subscriptions
smrt dispatch:subscriptions
smrt dispatch:subscriptions --subscriber Fiscus
smrt dispatch:subscribe --signal-type campaign.* --subscriber Fiscus
smrt dispatch:unsubscribe --signal-type campaign.* --subscriber Fiscus

Database Tables​

The dispatch system uses two system tables:

_smrt_dispatch​

Stores dispatch messages:

ColumnTypeDescription
idTEXTUnique identifier
typeTEXTSignal type (e.g., 'campaign.completed')
sourceTEXTEmitting agent name
source_idTEXTOptional instance identifier
payloadTEXTJSON-encoded payload
statusTEXTpending, processing, completed, failed
attemptsINTEGERNumber of processing attempts
last_errorTEXTError message from last failure
processed_atDATETIMEWhen processing completed
processed_byTEXTSubscriber that processed
metadataTEXTJSON-encoded trace metadata

_smrt_dispatch_subscriptions​

Stores persistent subscriptions:

ColumnTypeDescription
idTEXTUnique identifier
signal_typeTEXTPattern to match (supports wildcards)
subscriberTEXTAgent name
handlerTEXTMethod name to invoke
enabledINTEGER1 = active, 0 = disabled

API Reference​

createDispatchBus(options)​

Creates a new DispatchBus instance.

const bus = await createDispatchBus({
db: { type: 'sqlite', url: 'app.db' }
// or: db: existingDatabaseInterface
});

bus.emit(type, payload, options)​

Emit a dispatch message.

await bus.emit(
'campaign.completed',
{ campaignId: '123', revenue: 5000 },
{
source: 'Suasor', // Required: emitting agent
sourceId: 'instance-1', // Optional: instance identifier
metadata: { traceId: 'x' } // Optional: trace metadata
}
);

bus.subscribe(options)​

Create a persistent subscription.

await bus.subscribe({
signalType: 'campaign.*', // Pattern (supports wildcards)
subscriber: 'Fiscus', // Agent name
handler: 'handleCampaign', // Optional: method name
enabled: true // Optional: default true
});

bus.process(subscriber, handler, options)​

Process pending dispatches for a subscriber.

const count = await bus.process(
'Fiscus',
async (payload, metadata) => {
// Handle dispatch
},
{
limit: 100, // Optional: max to process
signalTypes: ['campaign.completed'] // Optional: filter by type
}
);

bus.retry(options)​

Retry failed dispatches.

const count = await bus.retry({
maxAttempts: 3, // Only retry if under this limit
signalTypes: ['campaign.*'] // Optional: filter by type
});

bus.cleanup(options)​

Delete old dispatches.

const result = await bus.cleanup({
completedOlderThanDays: 30,
failedOlderThanDays: 90
});
// result: { completedDeleted: 150, failedDeleted: 5 }

bus.list(options)​

List dispatches with filtering.

const dispatches = await bus.list({
status: 'pending', // Optional: filter by status
source: 'Suasor', // Optional: filter by source
type: 'campaign.completed', // Optional: filter by type
limit: 50, // Optional: max results
offset: 0 // Optional: pagination offset
});

Best Practices​

1. Use Descriptive Signal Types​

// ✅ GOOD - Clear hierarchy
'campaign.completed'
'order.payment.failed'
'agent.fiscus.ready'

// ❌ BAD - Ambiguous
'done'
'error'
'data'

2. Include Source in Emit Options​

// ✅ GOOD - Traceable
await bus.emit('event', payload, { source: 'Suasor', sourceId: 'instance-1' });

// ❌ BAD - Unknown origin
await bus.emit('event', payload);

3. Handle Failures Gracefully​

await bus.process('Fiscus', async (payload, metadata) => {
try {
await processPayload(payload);
} catch (error) {
// Log but rethrow to mark as failed
console.error(`Failed to process ${metadata.id}:`, error);
throw error;
}
});

4. Use Wildcards for Monitoring​

// Monitor all events for logging/metrics
await bus.subscribe({
signalType: '*',
subscriber: 'MetricsCollector'
});

5. Clean Up Regularly​

// In a scheduled job
await bus.cleanup({
completedOlderThanDays: 7,
failedOlderThanDays: 30
});