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
configproperty must be defined by subclasses - All properties auto-persist to database via SmrtObject
- Uses Single Table Inheritance (STI) - all agents share the
agentstable - Must apply
@smrt()decorator on subclasses - Opt-in SIGTERM/SIGINT signal handling for graceful shutdown β pass
manageProcessSignals: truein agent options (off by default)
Agent Lifecycle
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β execute() β
β β
β initialize() βββΊ validate() βββΊ run() βββΊ [idle] β
β β β β
β β βΌ β
β β [error] β
β β
β shutdown() ββββ SIGTERM/SIGINT (only when β
β manageProcessSignals: true) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | Method | Purpose |
|---|---|
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 runninginitializing- initialize() in progressrunning- run() executingerror- Exception occurredshutdown- 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);
}
}
} | Method | Purpose |
|---|---|
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
| Option | Type | Purpose |
|---|---|---|
filter | Record<string, any> | SQL filter with operators |
query | (tableName) => [sql, params] | Custom SQL for complex patterns |
sort | string | string[] | ORDER BY clause |
limit | number | Max results |
qualify | (items) => Promise<items> | Post-filter async processing |
handler | (item, agent) => any | Action 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:
| Column | Type | Description |
|---|---|---|
id | TEXT | Unique identifier |
agentId | TEXT | Agent instance ID |
agentClass | TEXT | Agent class name |
slotId | TEXT | UI slot ID |
configData | JSON | Configuration data |
schemaVersion | INTEGER | Schema version |
_smrt_agent_schedules
Stores agent schedule definitions:
| Column | Type | Description |
|---|---|---|
agentType | TEXT | Agent class name |
cron | TEXT | Cron expression |
method | TEXT | Method to invoke (default: 'run') |
maxConcurrent | INTEGER | Max concurrent executions |
timeout | INTEGER | Execution timeout (ms) |
tenant_agents
Junction table binding agents to tenants:
| Column | Type | Description |
|---|---|---|
agentType | TEXT | Agent class name |
tenantId | TEXT | Tenant ID |
permissions | JSON | Per-tenant permission overrides |
status | TEXT | Binding 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
});
}