@happyvertical/smrt-agents
Build autonomous actors with persistent state, inter-agent communication via DispatchBus, and comprehensive lifecycle management.
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:$envsentinels +static configResolversunfreeze env-derived values at task pickup (#1161) - Tenant Alignment:
TenantAgentwalks 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: truefor 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)
pnpm add @happyvertical/smrt-agentsUsing npm
npm install @happyvertical/smrt-agentsPeer 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
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
const agent = new DataProcessorAgent({
name: 'data-processor-1',
});
await agent.execute();
// initialize() → validate() → run() → idle3. Query Agent State
// 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)
| Status | Description |
|---|---|
idle | Agent created, not running |
initializing | initialize() in progress |
running | run() executing |
error | Exception occurred during execution |
shutdown | Graceful shutdown in progress |
3. Configuration Management
Three-layer configuration with priority order:
- Database-persisted config (highest): user-modified via admin panels (
AgentConfigrows) - File-based config:
getModuleConfig('agent-name', defaults)fromsmrt.config.ts+ env - Agent class defaults: hardcoded defaults in constructor
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:
// 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
agentstable 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
// 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
| Pattern | Matches |
|---|---|
campaign.* | campaign.started, campaign.completed |
agent.*.completed | agent.suasor.completed, agent.fiscus.completed |
* | All single-segment events |
*.*.completed | Multi-level events with 'completed' suffix |
CLI Commands
smrt dispatch:list --status pending --source Suasor
smrt dispatch:process --subscriber Fiscus
smrt dispatch:retry --max-attempts 3
smrt dispatch:cleanup --completed-older-than 30Interest-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.
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
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.
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.
@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.
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.
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.
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 Key | Variables (PII-conscious) |
|---|---|
smrtAgents.praeco.summary | title, sourceUrl, bodyExcerpt, language |
smrtAgents.interests.qualify | type, 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
- Always call
superfrom overridden lifecycle methods - Persist state regularly with
await this.save()— long-running agents lose progress on crash otherwise - Use
manageProcessSignalsonly on single-agent entry points; let TaskRunner handle multi-agent shutdown - Prefer
static configResolversover$envsentinels when the resolver is class-specific (it's discoverable) - For tenant-scoped agents, always check the effective
TenantAgentbinding before running privileged actions - Register prompts via
definePrompt()so tenants can override without code changes