SMRT Agents

The Agent framework provides a base class for building autonomous actors in the SMRT ecosystem. Agents extend SmrtObject, inheriting automatic database persistence, AI-powered methods, and code generation capabilities.

Agent Class

Agents are designed for long-running processes, scheduled tasks, and autonomous operations requiring state management.

import { Agent, type AgentOptions } from '@happyvertical/smrt-agents';
import { smrt } from '@happyvertical/smrt-core';
import { getModuleConfig } from '@happyvertical/smrt-config';

@smrt()
class DataProcessor extends Agent {
  protected config = getModuleConfig('data-processor', {
    batchSize: 100,
    maxRetries: 3
  });

  // State properties (auto-persisted)
  lastProcessedId: string = '';
  itemsProcessed: number = 0;

  async run(): Promise<void> {
    // Main agent logic
  }
}

Key characteristics:

  • Abstract config property must be defined by subclasses
  • All properties auto-persist to database via SmrtObject
  • Uses Single Table Inheritance (STI) - all agents share the agents table
  • Must apply @smrt() decorator on subclasses
  • Opt-in SIGTERM/SIGINT signal handling for graceful shutdown β€” pass manageProcessSignals: true in agent options (off by default)

Agent Lifecycle

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     execute()                            β”‚
β”‚                                                          β”‚
β”‚   initialize() ──► validate() ──► run() ──► [idle]     β”‚
β”‚        β”‚                            β”‚                    β”‚
β”‚        β”‚                            β–Ό                    β”‚
β”‚        β”‚                        [error]                  β”‚
β”‚                                                          β”‚
β”‚   shutdown() ◄─── SIGTERM/SIGINT (only when             β”‚
β”‚                   manageProcessSignals: true)           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
MethodPurpose
initialize()Prepare resources; registers signal handlers only when manageProcessSignals: true
validate()Check configuration and dependencies
run()Main agent logic (abstract, must implement)
shutdown()Cleanup resources; deregisters any signal handlers that were registered
execute()Orchestrates full lifecycle
@smrt()
class WebScraper extends Agent {
  protected config = getModuleConfig('web-scraper', {
    targetUrl: 'https://example.com'
  });

  async initialize(): Promise<this> {
    await super.initialize();
    this.logger.info('Initializing scraper');
    return this;
  }

  async validate(): Promise<void> {
    if (!this.config.targetUrl) {
      throw new Error('targetUrl is required');
    }
  }

  async run(): Promise<void> {
    // Scraping logic
    await this.save(); // Persist state
  }

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

// Execute
const agent = new WebScraper({ name: 'scraper-1' });
await agent.execute();

Signal Handling (Graceful Shutdown)

Signal handling is opt-in, not automatic. Agents register SIGTERM and SIGINT handlers during initialize() only when the agent is constructed with manageProcessSignals: true. When enabled and a signal is received, the agent transitions to shutdown status and calls shutdown() for cleanup; the base shutdown() then deregisters those handlers (so always call super.shutdown() from an override). The first handler to finish calls process.exit(), so enable this on single-agent entry points only β€” when several agents share a process (e.g. under the smrt-jobs runtime) leave it off and let the host own process lifecycle.

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

  constructor(options: AgentOptions = {}) {
    // Opt in to SIGTERM/SIGINT handling for this single-agent process.
    super({ ...options, manageProcessSignals: true });
  }

  async run(): Promise<void> {
    while (this.status !== 'shutdown') {
      await this.processNextBatch();
      await this.save(); // Persist progress
    }
  }

  async shutdown(): Promise<void> {
    this.logger.info('Graceful shutdown initiated');
    // Finish current work, flush buffers, etc.
    await super.shutdown(); // Tears down the opt-in signal handlers
  }
}

Agent Status

type AgentStatusType = 'idle' | 'initializing' | 'running' | 'error' | 'shutdown';

Status transitions automatically during lifecycle:

  • idle - Agent created, not running
  • initializing - initialize() in progress
  • running - run() executing
  • error - Exception occurred
  • shutdown - Graceful shutdown in progress

Agent State

Agent state is persisted via SmrtObject inheritance:

@smrt()
class Crawler extends Agent {
  protected config = getModuleConfig('crawler', { maxPages: 50 });

  // These persist to database
  lastCrawledUrl: string = '';
  pagesProcessed: number = 0;
  errors: Array<{ url: string; error: string }> = [];

  async run(): Promise<void> {
    this.lastCrawledUrl = 'https://example.com';
    this.pagesProcessed += 1;
    await this.save(); // Persist changes
  }
}

TenantAgent -- Multi-Tenant Bindings

The TenantAgent model provides a junction table (tenant_agents) binding agents to tenants with permission overrides and hierarchy resolution:

  • Explicit binding: Row exists for tenant (source: 'explicit')
  • Inherited: Walks up the tenant hierarchy (source: 'inherited')
  • Permissions: Manifest defaults merged with per-tenant overrides

Resolving effective availability is not a plain list() query β€” a flat list would miss inherited bindings. Use resolveForTenant(tenantId, getAncestorIds), which walks the ancestor chain (you supply the ancestor-id resolver, typically from smrt-users tenant resolution) and returns one ResolvedAgentAvailability per available agent, each tagged source: 'explicit' | 'inherited' with the merged permission snapshot. For a single explicit row, findByTenantAndClass(tenantId, agentClass) is the direct lookup.

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

const tenantAgents = await TenantAgentCollection.create({ db: 'app.db' });

// Direct lookup of the explicit binding (no hierarchy walk).
const binding = await tenantAgents.findByTenantAndClass('tenant-123', 'Praeco');

// Effective availability across the hierarchy. getAncestorIds returns the
// tenant's ancestors (parent β†’ root); wire it to your tenant resolver.
const availability = await tenantAgents.resolveForTenant(
  'tenant-123',
  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 the _smrt_agent_schedules table. Executed by ScheduleRunner from smrt-jobs.

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

// Fields: agentType, cron, method (default: 'run'),
//         maxConcurrent, timeout

AgentConfig -- DB-Persisted Configuration

The AgentConfig model stores slot-based configuration in the database, merged with file-based config at runtime:

// Save config for a UI slot
await agent.saveSlotConfig('sources', {
  scrapers: ['civicweb', 'govstack'],
  refreshInterval: 3600
});

// Load merged config (file + db)
const config = await agent.getMergedConfig('sources');

// Export all config (for static builds)
const exported = await agent.exportConfig({ includeSecrets: false });

Agent Communication

Agents communicate via the DispatchBus. Built-in methods:

class Fiscus extends Agent {
  protected config = {};

  async processIncomingDispatches(): Promise<void> {
    const bus = await this.getDispatch();

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

    // Process pending dispatches
    await this.processDispatches();
  }

  // Override to handle dispatches
  async handleDispatch(payload: unknown, metadata: DispatchMetadata): Promise<void> {
    if (metadata.type === 'campaign.completed') {
      await this.recordRevenue(payload);
    }
  }
}
MethodPurpose
getDispatch()Get or create DispatchBus instance
handleDispatch(payload, metadata)Override to process incoming dispatches
processDispatches()Process all pending dispatches for this agent

Agent Interests

Agents can declaratively query objects they're interested in:

const agent = new MyAgent({
  name: 'my-agent',
  interests: {
    filter: { status: 'active' },
    sort: 'created_at DESC',
    objects: {
      Meeting: {
        filter: { 'scheduled_at >': new Date() },
        sort: 'scheduled_at ASC',
        limit: 10,
        handler: async (meeting, agent) => ({
          action: 'recap',
          meeting
        })
      },
      Document: [
        {
          name: 'needs-review',
          filter: { status: 'pending' }
        },
        {
          name: 'expired',
          query: (t) => [
            `${t}.expires_at < datetime('now')`,
            []
          ]
        }
      ]
    }
  }
});

// Query all interesting items
const items = await agent.interesting();
for (const { type, data, name, handled } of items) {
  console.log(`${type} from "${name}": action=${handled?.action}`);
}

Interest Filter Options

OptionTypePurpose
filterRecord<string, any>SQL filter with operators
query(tableName) => [sql, params]Custom SQL for complex patterns
sortstring | string[]ORDER BY clause
limitnumberMax results
qualify(items) => Promise<items>Post-filter async processing
handler(item, agent) => anyAction for each matched item

Agent UI Slots

Agents declare admin panel slots for host applications:

@smrt()
class Praeco extends Agent {
  static override uiSlots: AgentUISlots = {
    sources: {
      id: 'sources',
      label: 'News Sources',
      description: 'Configure scrapers and data sources',
      icon: 'database',
      order: 1
    },
    settings: {
      id: 'settings',
      label: 'Agent Settings',
      icon: 'settings',
      order: 2
    }
  };

  protected config = {};
  async run(): Promise<void> {}
}

// Host app registers Svelte components
import { AgentUIRegistry } from '@happyvertical/smrt-agents/ui';
AgentUIRegistry.register('Praeco', 'sources', SourcesPanel);

Internal Tables

agent_configs

Stores agent slot configurations:

ColumnTypeDescription
idTEXTUnique identifier
agentIdTEXTAgent instance ID
agentClassTEXTAgent class name
slotIdTEXTUI slot ID
configDataJSONConfiguration data
schemaVersionINTEGERSchema version

_smrt_agent_schedules

Stores agent schedule definitions:

ColumnTypeDescription
agentTypeTEXTAgent class name
cronTEXTCron expression
methodTEXTMethod to invoke (default: 'run')
maxConcurrentINTEGERMax concurrent executions
timeoutINTEGERExecution timeout (ms)

tenant_agents

Junction table binding agents to tenants:

ColumnTypeDescription
agentTypeTEXTAgent class name
tenantIdTEXTTenant ID
permissionsJSONPer-tenant permission overrides
statusTEXTBinding status

Background Execution & Safety Limits

Agents that run as background jobs go through the smrt-jobs runtime, which adds two opt-in guards around the dispatch surface. Both live in @happyvertical/smrt-jobs (re-exported from its package root) and apply to any SmrtObject method the runner can invoke from a persisted job row, agents included.

backgroundEligible() β€” method allowlist

The runner only invokes methods that already exist on the prototype (no eval, no dynamic import), but a class can tighten that further. The @backgroundEligible() decorator (a legacy / experimentalDecorators method decorator, which is the mode the SMRT monorepo compiles with) builds up a static backgroundEligibleMethods allowlist on the class. Once any method is marked, the runner refuses to dispatch a job whose method is not on the list β€” turning the dispatch surface from β€œany prototype method” into an explicit contract. In non-decorator code, markBackgroundEligible(ctor, ...methods) does the same thing.

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 β€” no allowlist entry

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

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

Per-tenant in-flight job cap

To stop one tenant from exhausting 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 cap is DEFAULT_TENANT_JOB_CAP (10,000) and is 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-tenant-context) jobs are exempt.
  • Override per enqueue with .tenantJobCap(max).
  • Pass 0 (or a negative value) to disable the cap for trusted internal callers.
  • A separate ceiling, MAX_JOB_RETRIES (25), clamps requested retry counts so a misconfigured .retries(n) can't pin a worker on a poison job forever.
import { bg } from '@happyvertical/smrt-jobs';

// Enqueue a background run of 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();

Best Practices

1. Always Call super Methods

async initialize(): Promise<this> {
  await super.initialize(); // Registers signal handlers IF manageProcessSignals: true
  // Your initialization...
  return this;
}

async shutdown(): Promise<void> {
  // Your cleanup...
  await super.shutdown(); // Deregisters any handlers that were registered
}

2. Persist State Regularly

async run(): Promise<void> {
  for (const item of items) {
    await this.process(item);
    this.itemsProcessed += 1;
    await this.save(); // Persist after each item
  }
}

3. Handle Errors Gracefully

async run(): Promise<void> {
  try {
    await this.doWork();
  } catch (error) {
    this.errors.push({ message: error.message, at: new Date() });
    await this.save();
    throw error; // Re-throw to set status to 'error'
  }
}

4. Use getModuleConfig() for Configuration

import { getModuleConfig } from '@happyvertical/smrt-config';

@smrt()
class MyAgent extends Agent {
  // Loads from smrt.config.ts modules.my-agent section
  protected config = getModuleConfig('my-agent', {
    cronSchedule: '0 2 * * *',
    maxRetries: 3
  });
}