@happyvertical/smrt-agents

Build autonomous actors with persistent state, inter-agent communication via DispatchBus, and comprehensive lifecycle management.

v0.29.34AgentsDispatchBus

Overview

The @happyvertical/smrt-agents package provides a base Agent class for building autonomous actors in the SMRT ecosystem. Agents are persistent, state-managing objects that extend SmrtObject with automatic database persistence, lifecycle management, inter-agent communication, and admin panel UI integration.

Key Features

  • Persistent State: Automatic database persistence via SmrtObject
  • Lifecycle Management: initialize()validate()run()shutdown()
  • Inter-Agent Communication: DispatchBus for async messaging with wildcard patterns
  • Interest-Based Queries: Declarative object discovery with optional AI qualify() post-filter
  • Lazy agent_config: $env sentinels + static configResolvers unfreeze env-derived values at task pickup (#1161)
  • Tenant Alignment: TenantAgent walks the tenant hierarchy with merged manifest + override permissions (#1208)
  • Status Tracking: Five states (idle, initializing, running, error, shutdown)
  • UI Slots: Admin panel component declarations for configuration
  • Opt-in Signal Handlers: manageProcessSignals: true for single-agent processes (off by default — the host owns lifecycle)
  • Guarded Background Execution: when run via smrt-jobs, @backgroundEligible() method allowlists and a per-tenant in-flight job cap bound the dispatch surface
  • smrt-prompts Adoption: AI methods register templates via definePrompt() for runtime overrides

Tenancy

Agents use @TenantScoped({ mode: 'optional' }): an agent may be global (no tenant) or bound to a tenant via TenantAgent. The tenant_agents junction resolves bindings by walking the tenant hierarchy and merging manifest defaults with per-tenant permission overrides.

Architecture

┌─────────────────────────────────────────────────────────────┐
│                      SMRT Agent Framework                    │
├─────────────────────────────────────────────────────────────┤
│  ┌──────────────────────────────────────────────────────┐  │
│  │              Agent (extends SmrtObject)              │  │
│  ├──────────────────────────────────────────────────────┤  │
│  │ • Status tracking (5 states)                         │  │
│  │ • Lifecycle: initialize → validate → run → shutdown  │  │
│  │ • Opt-in signal handlers (manageProcessSignals)      │  │
│  │ • DispatchBus, interests, lazy agent_config          │  │
│  │ • Tenant-aware (optional, hierarchy-resolved)        │  │
│  └──────────────────────────────────────────────────────┘  │
│           ▲                                                  │
│           │  extends                                         │
│  ┌────────┴─────────────────────────────────────┐          │
│  │ Praeco │ Suasor │ BillingAgent │ ...          │          │
│  └──────────────────────────────────────────────┘          │
└─────────────────────────────────────────────────────────────┘
				

Use Cases

  • Scheduled batch processing (ETL, data migrations)
  • Web scraping and content aggregation (Praeco / Caelus)
  • Event-driven workflows (billing, notifications)
  • Background jobs and long-running tasks
  • Report generation and analytics
  • Data synchronization between systems
  • Content moderation and analysis

Installation

Using pnpm (recommended)

bash
pnpm add @happyvertical/smrt-agents

Using npm

bash
npm install @happyvertical/smrt-agents

Peer Dependencies

  • svelte@^5.0.0 (optional, for UI components)
  • @happyvertical/smrt-prompts (optional, for prompt overrides)

Quick Start (5 Minutes)

1. Create Your First Agent

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

@smrt()
class DataProcessorAgent extends Agent {
  protected config = {
    batchSize: 100,
    maxRetries: 3
  };

  itemsProcessed: number = 0;
  lastRunAt?: Date;

  async run(): Promise<void> {
    this.logger.info('Starting data processing');

    const items = await this.fetchDataBatch();
    for (const item of items) {
      await this.processItem(item);
      this.itemsProcessed++;
    }

    this.lastRunAt = new Date();
    await this.save(); // Persist state
  }

  private async fetchDataBatch() { return []; }
  private async processItem(item: any) { /* ... */ }
}

2. Execute the Agent

typescript
const agent = new DataProcessorAgent({
  name: 'data-processor-1',
});

await agent.execute();
// initialize() → validate() → run() → idle

3. Query Agent State

typescript
// Agents are persisted as SmrtObjects -- query via a collection
import { SmrtCollection } from '@happyvertical/smrt-core';

class DataProcessorAgentCollection extends SmrtCollection<DataProcessorAgent> {
  static readonly _itemClass = DataProcessorAgent;
}

const agents = await DataProcessorAgentCollection.create({ db });
const agent = await agents.findOne({ where: { name: 'data-processor-1' } });
console.log(agent.status);         // 'idle', 'running', 'error', etc.
console.log(agent.itemsProcessed); // 150
console.log(agent.lastRunAt);

Core Concepts

1. Agent Lifecycle

execute() calls:
  initialize() ──► validate() ──► run() ──► [idle]
     (idle)        (validates)   (running)    │
       │                                       │
       └─► [error] ◄───────────────────────── ┘

shutdown() ◄── (opt-in: manageProcessSignals on SIGTERM/SIGINT)
  (shutdown)
				

Lifecycle Methods

  • initialize(): setup phase — connect to external services, load dependencies
  • validate(): validate configuration and prerequisites
  • run(): main execution logic (abstract — must implement)
  • shutdown(): cleanup — close connections, clear timers, deregister signals

2. Agent Status (5 States)

StatusDescription
idleAgent created, not running
initializinginitialize() in progress
runningrun() executing
errorException occurred during execution
shutdownGraceful shutdown in progress

3. Configuration Management

Three-layer configuration with priority order:

  1. Database-persisted config (highest): user-modified via admin panels (AgentConfig rows)
  2. File-based config: getModuleConfig('agent-name', defaults) from smrt.config.ts + env
  3. Agent class defaults: hardcoded defaults in constructor
typescript
import { getModuleConfig } from '@happyvertical/smrt-config';

@smrt()
class MyAgent extends Agent {
  protected config = getModuleConfig('my-agent', {
    enabled: true,
    timeout: 30000
  });

  async run() {
    // DB overrides file overrides defaults
    const merged = await this.getMergedConfig('settings');
    console.log(merged.timeout);
  }
}

Lazy agent_config Resolution (#1161)

Persisted agent_config snapshots env-derived values at sync time, so rotated env vars don't reach already-stored schedule rows. Two complementary mechanisms unfreeze them at task pickup:

typescript
// 1. $env sentinels + a global resolver
import { registerConfigResolver } from '@happyvertical/smrt-agents';

registerConfigResolver('sharedAssetStorage', () => resolveSharedAssetStorage());
// persisted: { "assetStorage": { "$env": "sharedAssetStorage" } }

// 2. static configResolvers on the agent class -- declarative, discoverable
class Praeco extends Agent {
  static override configResolvers = {
    assetStorage: () => resolveSharedAssetStorage(),
  };
}

// The TaskRunner calls resolveLazyConfig() immediately before constructing
// the agent, so live values always win over snapshotted ones.

resolveLazyConfig, registerConfigResolver, and getClassConfigResolvers are re-exported from @happyvertical/smrt-core for cases where agents isn't on the import path.

4. State Persistence

  • Any public property on Agent is automatically persisted to database
  • Agents share a single agents table via Single Table Inheritance (STI)
  • Call await this.save() to persist state changes

DispatchBus Communication

Agents communicate asynchronously via DispatchBus, an event-driven messaging system with persistent subscriptions and wildcard pattern matching.

Persistent Subscriptions

typescript
// Emitting (in any agent)
const bus = await this.getDispatch();
await bus.emit('campaign.completed', { campaignId: '123' }, { source: 'Suasor' });

// Subscribing (in receiving agent)
async handleDispatch(payload: unknown, metadata: DispatchMetadata): Promise<void> {
  if (metadata.type === 'campaign.completed') {
    await this.recordRevenue(payload as { campaignId: string; revenue: number });
  }
}

async run() {
  await this.processDispatches(); // dispatches handleDispatch() per pending row
}

Wildcard Pattern Matching

PatternMatches
campaign.*campaign.started, campaign.completed
agent.*.completedagent.suasor.completed, agent.fiscus.completed
*All single-segment events
*.*.completedMulti-level events with 'completed' suffix

CLI Commands

bash
smrt dispatch:list --status pending --source Suasor
smrt dispatch:process --subscriber Fiscus
smrt dispatch:retry --max-attempts 3
smrt dispatch:cleanup --completed-older-than 30

Interest-Based Queries

The interests system provides a declarative way to query objects the agent is interested in, with filters, sorting, limiting, custom handlers, and an optional AI qualify() post-filter.

typescript
constructor(options: AgentOptions = {}) {
  super({
    ...options,
    interests: {
      objects: {
        Meeting: {
          filter: { status: 'upcoming' },
          handler: async (m) => ({ action: 'recap', meeting: m }),
        },
      },
      qualify: async (items) => items.filter(/* AI-based post-filter */),
    },
  });
}

async run() {
  for (const { type, data, handled } of await this.interesting()) {
    if (handled?.action === 'recap') await this.recap(data);
  }
}

TenantAgent -- Multi-Tenant Bindings

The TenantAgent model provides a junction table (tenant_agents) binding agents to tenants with permission overrides and hierarchy resolution. As of v0.29.x (#1208) the inheritance walk is aligned with smrt-users tenant resolution: an agent enabled on a parent tenant is implicitly enabled on every descendant unless overridden.

  • Explicit binding: row exists for tenant (source: 'explicit')
  • Inherited: walks up tenant hierarchy (source: 'inherited')
  • Permissions: manifest defaults merged with per-tenant overrides
typescript
import { TenantAgentCollection } from '@happyvertical/smrt-agents';

const bindings = await TenantAgentCollection.create({ db });

// Explicit binding (rows live in the tenant_agents table)
await bindings.create({
  agentClass: 'DataProcessorAgent',  // canonical agent type name
  tenantId: tenant.id,
  status: 'active',                  // TenantAgentStatus
  permissions: {                     // Record<string, boolean> override map
    'agents.run': true,
    'agents.schedule': false,
  },
});

// Look up the explicit binding for a tenant + agent class
const binding = await bindings.findByTenantAndClass(tenant.id, 'DataProcessorAgent');

// Resolve effective availability across the hierarchy (walks ancestors).
// Returns ResolvedAgentAvailability[] -- each has { source, permissions, ... }.
const availability = await bindings.resolveForTenant(tenant.id, getAncestorIds);
for (const entry of availability) {
  console.log(entry.source);      // 'explicit' | 'inherited'
  console.log(entry.permissions); // merged manifest + override snapshot
}

AgentSchedule

Cron-based scheduling stored in _smrt_agent_schedules. Executed by the ScheduleRunner from smrt-jobs — see that page for the runner wiring.

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

const schedules = await AgentScheduleCollection.create({ db });
await schedules.create({
  agentType: 'DataProcessorAgent',
  cron: '0 2 * * *',     // 02:00 UTC daily (cron is UTC, not timezone-aware)
  method: 'run',          // default
  maxConcurrent: 1,
  timeout: 300000,        // 5 minutes (ms)
});

Signal Handler Infrastructure

Signal handling is opt-in: pass manageProcessSignals: true in agent options for single-agent processes. The base shutdown() deregisters those handlers, so always call super.shutdown() from overrides. Multi-agent processes (e.g. running under TaskRunner) should leave this off and let the runner orchestrate shutdown.

typescript
@smrt()
class MyAgent extends Agent {
  constructor(options: AgentOptions = {}) {
    super({ ...options, manageProcessSignals: true });
  }

  async shutdown(): Promise<void> {
    this.logger.info('Cleaning up connections...');
    await this.closeConnections();
    await super.shutdown(); // Tears down signal handlers
  }
}

Background Execution & Safety Limits

When an agent runs as a scheduled or queued background job it goes through the smrt-jobs runtime, which adds two opt-in guards around the method-dispatch surface. Both ship in @happyvertical/smrt-jobs (and are re-exported from its package root); they apply to any SmrtObject method the runner can invoke from a persisted job row, agents included. The agents package dispatches methods through an equivalent path, so the same allowlist marker governs it.

backgroundEligible() — method allowlist

The runner only ever invokes methods that already exist on the prototype (no eval, no dynamic import). A class can narrow that to an explicit contract with the @backgroundEligible() decorator — a legacy (experimentalDecorators) method decorator, the mode the SMRT monorepo compiles with. Applying it builds up a static backgroundEligibleMethods set on the class. Once any method is marked, the runner refuses to dispatch a job whose method is not in the set. For non-decorator code, markBackgroundEligible(ctor, ...methods) does the same.

typescript
import { backgroundEligible } from '@happyvertical/smrt-jobs';
import { Agent } from '@happyvertical/smrt-agents';
import { smrt } from '@happyvertical/smrt-core';

@smrt()
class ReportAgent extends Agent {
  protected config = {};

  @backgroundEligible()
  async regenerate(): Promise<void> {} // reachable from a job

  async deleteEverything(): Promise<void> {} // NOT reachable -- not on the allowlist

  async run(): Promise<void> {}
}

The runner gates dispatch via isBackgroundEligibleMethod(ctor, method): it returns true when the class declared no allowlist (the default, back-compatible behaviour) or the method is on the list, and false otherwise.

Per-tenant in-flight job cap

So one tenant can't exhaust the shared worker pool (a cross-tenant denial of service), the jobs collection bounds how many non-terminal (pending/running) jobs a single tenant may hold at once. The default is DEFAULT_TENANT_JOB_CAP (10,000), enforced in one place — assertWithinTenantCreationCap() — shared by the bg() builder and the ScheduleRunner. Exceeding it throws TenantJobCapExceededError.

  • The cap applies to the ambient tenant; global (no-context) jobs are exempt.
  • Override per enqueue with .tenantJobCap(max).
  • Pass 0 (or a negative value) to disable it for trusted internal callers.
  • A separate ceiling, MAX_JOB_RETRIES (25), clamps requested retries so a misconfigured .retries(n) can't pin a worker on a poison job.
typescript
import { bg } from '@happyvertical/smrt-jobs';

// Background-run an agent method with a tighter per-tenant cap.
await bg(reportAgent)
  .regenerate()
  .tenantJobCap(500) // refuse a 501st in-flight job for this tenant
  .enqueue();

// Trusted internal caller: disable the cap entirely.
await bg(reportAgent).regenerate().tenantJobCap(0).enqueue();

SummaryArticleResult Type

SummaryArticleResult is the canonical return shape for agents that generate article summaries (e.g. Praeco). It pairs the article body with structured image descriptors and surfaces the prompt key used, so callers can re-run with a different template via the prompt registry.

typescript
import type {
  SummaryArticleResult,
  SummaryArticleOptions,
  SummaryArticleImage,
} from '@happyvertical/smrt-agents';

const result: SummaryArticleResult = await praeco.summarizeArticle({
  sourceUrl,
  promptKey: 'smrtAgents.praeco.summary', // overridable via smrt-prompts
});

Prompt Registry

Agent AI methods that talk to an LLM register their templates with @happyvertical/smrt-prompts so tenants can override the template, model, and params at runtime without forking the agent.

Prompt KeyVariables (PII-conscious)
smrtAgents.praeco.summarytitle, sourceUrl, bodyExcerpt, language
smrtAgents.interests.qualifytype, candidates (id + label only)

Internal foreign-key fields (tenantId, ownerId) and raw metadata blobs are intentionally excluded from the variable surface. See smrt-prompts for the override workflow.

Best Practices

  1. Always call super from overridden lifecycle methods
  2. Persist state regularly with await this.save() — long-running agents lose progress on crash otherwise
  3. Use manageProcessSignals only on single-agent entry points; let TaskRunner handle multi-agent shutdown
  4. Prefer static configResolvers over $env sentinels when the resolver is class-specific (it's discoverable)
  5. For tenant-scoped agents, always check the effective TenantAgent binding before running privileged actions
  6. Register prompts via definePrompt() so tenants can override without code changes