# @happyvertical/smrt-agents

Agent framework for autonomous actors with inter-agent messaging, interest-based object discovery, scheduling, and multi-tenant bindings.

## Agent Lifecycle

`initialize()` → `validate()` → `run()` → `shutdown()`

- Extend `Agent` (which extends `SmrtObject`) and implement `run()`
- Status tracking: `idle → initializing → running → error/shutdown`
- `execute()` runs the full lifecycle automatically
- Process signal handling is opt-in via `manageProcessSignals: true` and is intended for single-agent processes

## DispatchBus — Inter-Agent Communication

Agents communicate via persistent async messaging through core's DispatchBus:

```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);
}
async run() { await this.processDispatches(); } // processes via handleDispatch()
```

CLI: `smrt dispatch:list`, `dispatch:process --subscriber Fiscus`, `dispatch:retry`, `dispatch:cleanup`

## Interests — Object Discovery

Agents query objects they care about via declarative filters:

```typescript
constructor(options) {
  super({ ...options, interests: {
    objects: { Meeting: { filter: { status: 'upcoming' }, handler: async (m) => ({ action: 'recap' }) } },
    qualify: async (items) => items.filter(/* AI-based post-filter */),
  }});
}
async run() { for (const { type, data } of await this.interesting()) { ... } }
```

## Configuration

- **File-based**: `getModuleConfig('agent-name', defaults)` from `smrt.config.ts`
- **DB-persisted**: `saveSlotConfig(slotId, data)` for UI overrides. Persona-backed
  agents use `AgentOptions.personaId` as the durable config owner; legacy agents
  use the saved Agent row id.
- **Merged**: `getMergedConfig('slotId')` — DB overrides file config
- **UI slots**: `static uiSlots` declares admin panels (id, label, icon, order,
  `scope`, optional versioned `settingsSchema`). Without a registered custom
  component, `AgentSettingsForm` renders that schema.

## TenantAgent — Multi-Tenant Bindings

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 tenant hierarchy (source: 'inherited')
- Permissions: manifest defaults merged with per-tenant overrides

## AgentSchedule

Cron-based scheduling stored in `_smrt_agent_schedules`. Fields: `agentType`, `cron`, `method` (default: 'run'), `maxConcurrent`, `timeout`. Executed by ScheduleRunner from smrt-jobs.

## Lazy agent_config Resolution (issue #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:

1. **`$env` sentinels in persisted config** — register a global resolver and reference it from the JSON:

   ```ts
   import { registerConfigResolver } from '@happyvertical/smrt-agents';
   registerConfigResolver('sharedAssetStorage', () => resolveSharedAssetStorage());
   // persisted: { "assetStorage": { "$env": "sharedAssetStorage" } }
   ```

2. **`static configResolvers` on the agent class** — declarative, discoverable via the class itself:

   ```ts
   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. Re-exported from `@happyvertical/smrt-core` (`resolveLazyConfig`, `registerConfigResolver`, `getClassConfigResolvers`, …) for cases where agents isn't on the import path.

## Learning Trait (issue #1886) — opt-in

Any agent can opt into a confidence-scored **recall-before / capture-after** loop backed by core's `LearningMemory` (over `_smrt_contexts` + `_smrt_embeddings`). **Off by default** — a non-opted agent behaves byte-for-byte as today; the lifecycle's learning branches are never entered.

```typescript
@smrt()
class InvoiceAgent extends Agent {
  static override learning = true; // or { minConfidence: 0.8, scope: 'invoices', ... }
  protected config = {};

  async run() {
    // recall-before-run already populated `recalledMemories` (confidence >= floor)
    const cached = this.recalledMemories.find((m) => m.key === this.docUrl);
    const strategy = cached?.value ?? (await this.generateStrategy());

    // stage the episode; the lifecycle reinforces it after run()
    this.stageLearning({ scope: this.learningScope(), key: this.docUrl, value: strategy });

    // a validated failure decays the memory without throwing
    if (!ok) this.reportLearningOutcome({ success: false, error: 'no match' });
  }
}
```

- **`capture` semantics** (`LearningMemory`): success strengthens `confidence` toward 1.0 and increments `success_count`; failure decays toward `failureConfidence` (0.3) and increments `failure_count`. A single failure drops a confident memory below the reuse floor (0.7), so recall stops returning it. Refreshes `last_used_at`; honours `expires_at` and optional time-decay.
- **Memory isolation**: bound to `(agentType, agentInstanceId)` as `(owner_class, owner_id)`, so two tenants on the same agent class never share memory. `tenantId` is threaded into the optional semantic-search `where`.
- **Seams to override**: `learningScope()`, `recallForRun(memory)`, `captureForRun(memory, outcome)`, `getLearningSemanticSearch()`. Helpers for `run()`: `stageLearning(episode)`, `reportLearningOutcome(outcome)`, `getLearningMemory()`, and the `recalledMemories` field.
- **Config**: `static learning: boolean | AgentLearningConfig` — `{ enabled?, scope?, minConfidence?, successConfidence?, failureConfidence?, reinforcement?, decayHalfLifeMs? }`. `LearningMemory` and its types are re-exported from `@happyvertical/smrt-core`.

## Multi-Instance Agents (issue #1890) — opt-in

`static multiInstance = false` by default: a class is a **singleton** (the N=1 case) and is byte-for-byte unchanged — one dispatch subscriber keyed by the agent type, one memory scope, class-wide interests. Set `static multiInstance = true` to run N durable instances (personas, from `@happyvertical/smrt-personas`) of one class per tenant, each independent.

The framework provides only the per-instance **identity**; a package scopes its own dispatch/interests to the instance's config by overriding the seams.

- **`AgentOptions.personaId`** — durable persona/settings identity, including
  the default persona. Independent from execution instance identity.
- **`AgentOptions.instanceKey`** — the durable execution-instance key (typically
  the persona id, but null for the default persona). Honored **only** when
  `multiInstance` is true, so passing it to a non-opted agent is a no-op.
- **`getInstanceKey()`** → the key, or `null` for a singleton (opt-in off, or no key).
- **`getDispatchSubscriber()`** → `` `${agentType}#${key}` `` for a multi-instance agent, the bare `agentType` for a singleton. Used everywhere the agent subscribes/seeds/processes, so each instance has its own subscription rows and pending-dispatch queue — two instances never compete for or double-process each other's dispatches. Composed by the exported `instanceScopedSubscriber(agentType, key)`.
- **`learningScope()`** — suffixed with `#<key>` for a multi-instance agent, so instances learn independently (singleton scope unchanged).
- **Seams to override** (both default to singleton behavior):
  - `resolveSignalSubscriptions()` — derive **instance-scoped** signal types from the instance config so an emit meant for one instance only matches its subscription.
  - `instanceInterestFilter()` — an `ObjectFilter` AND-merged (as the base layer) into every `interesting()` query so instances partition the objects they process.

The **`default` persona reuses the singleton identity** (a `null` key), which is what makes the singleton→multi upgrade non-destructive — see `@happyvertical/smrt-personas` (`personaInstanceKey`, `upgradeSingletonToDefaultPersona`).

## Principal Execution (issue #1888)

`executeAsPrincipal(options, fn)` runs agent work **AS a persona's bound user**, reusing the existing RBAC cascade with no snapshotting. It publishes `(user_id, tenant_id, permissions[])` onto the DB session (Postgres RLS then bounds every query per-`(table, action)` and per-tenant) and hands `fn` a `PrincipalRun` whose `assertToolAllowed()` / `assertOperation()` enforce the persona tool ceiling and the RLS-off catalog gate. Effective authority = **bound-user RBAC ∩ agent-class ceiling ∩ persona `allowedTools`**. Actions audit as on-behalf-of the originating user via a `PrincipalAuditSink`.

## Data Surface Read Tools (issue #2447)

`createDataSurfaceTools()` produces the `data.discover`, `data.inspect`, and
`data.query` `PrincipalTool`s consumed through chat's `extraTools` seam. The
caller supplies a server-owned surface catalog and executor; the tools copy
`userId`, `tenantId`, database, and permissions only from the live
`PrincipalRun`. Discovery and inspection first assert the collection's read
catalog permission and omit denied surfaces/fields. Query requests and results
are normalized with the core bounded data-query protocol, including projection,
cursor/page, row/byte, fingerprint, freshness, total, and truncation rules.
Sensitive/read-permission fields are removed from descriptors, and
`DataSurfaceField` policy metadata is stripped before the core schema validator.
Executor-provided paginated rows retain their order and are validated with a
stable identity tie-breaker (including type-aware numeric/date comparisons),
while internal sort keys are stripped when they were not requested in the
projection. Execution has a bounded deadline; public executor/result failures
use stable generic errors while optional `onFailure` telemetry receives the
authenticated/delegated principal and detailed server-side error. Hidden field
request failures also use a stable public error. Tool arguments never contain
principal or tenant authority.

## Report Data-Surface Tools (issue #2462)

`createReportDataSurfaceTools()` maps server-owned report definitions into the
generic `data.discover`, `data.inspect`, and `data.query` catalog, and adds
principal-bound `reports.query`, `reports.refresh`, `reports.drilldown`, and
`reports.export` tools. It reuses the reports package's materialized-query,
lifecycle, drilldown, and snapshot contracts rather than reimplementing them.
Report ids, collections, query seams, browser delivery, background queues, and
action hosts come only from the configured server catalog; tool arguments never
carry principal, tenant, or report-constructor authority.

- Generic discovery excludes sensitive and permission-gated report columns.
- Discovered report fields retain safe `kind`, `filterScope`, and capability
  metadata, while the surface reports its available actions and lifecycle
  freshness support. Actions are filtered by the current tool allow-list and
  effective permissions. This lets agents distinguish dimensions/buckets
  (`where`) from measures (`having`) without exposing hidden columns or
  authority.
- Silent reads do not change browser state; visible reads require a host-bound
  acknowledgement whose post-command revision is not older than the requested
  compare-and-swap revision; background requests contain no principal or tenant
  payload.
- Read results request the tenant-safe report lifecycle whenever an
  authenticated database is available, so `freshness` and the redacted
  lifecycle state cover stale and lock-skipped materializations.
- Refresh and export use app-owned action hosts for live authorization and
  auditing. Export captures an opaque immutable snapshot and preserves the
  reports package's preview/confirmation protocol.
- Drilldown re-reads a single accessible materialized row before it builds the
  inherited source query, so callers cannot inject row values from another
  tenant.

## Agent Orchestration (issue #1892) — invoke-agent + principal delegation

A conversational (orchestrator) agent can invoke worker agents with **principal delegation**. This is *not* a new engine — it is a standard `invoke-agent` tool plus a completion-dispatch convention on top of `executeAsPrincipal` + the DispatchBus.

```typescript
import { createInvokeAgentTool, rootDelegationEnvelope } from '@happyvertical/smrt-agents';

const tool = createInvokeAgentTool({
  db,
  parentEnvelope: rootDelegationEnvelope({ runAsUserId, tenantId, onBehalfOfUserId }),
  worker: async ({ run, agentClass, task }) => runWorker(run, agentClass, task),
});
// Offered through the chat tool loop as an `extraTools` entry, gated by the
// persona's allowedTools like any other tool (slug: 'agents.invoke').
```

- **`DelegationEnvelope`** carries the **immutable principal** (`runAsUserId` + `tenantId` + originating `onBehalfOfUserId`) and a bounded `depth`. `deriveDelegationEnvelope()` copies the principal verbatim and asserts `depth <= MAX_DELEGATION_DEPTH` (3) — a worker cannot invoke a further worker under a broader principal (`PrincipalWideningError` / `DelegationDepthExceededError`).
- **`createInvokeAgentTool()`** → a `PrincipalTool` whose handler derives the child envelope with the principal taken **from the live run context, never the tool args**, so the invoke-agent tool is structurally immune to principal widening.
- **`executeDelegatedInvocation()`** runs the worker via `executeAsPrincipal` under that same principal and emits a correlated `agent.completed` dispatch; **`surfaceAgentCompletions(bus, correlationId)`** reads it back into the conversation.
- **Transports** (pluggable): the default `inlineInvokeAgentTransport` runs the worker in-process (completion surfaces in the same turn); `createDispatchInvokeTransport(bus)` emits an `agent.invoke` signal a worker processes via `processAgentInvocations()` (async). A job-queue transport (enqueue on the `agents` queue) is a consumer-supplied `InvokeAgentTransport` — orchestration never hard-depends on `@happyvertical/smrt-jobs`, which sits *below* agents in the dependency graph.

## Key Files

| File | Purpose |
|------|---------|
| `src/agent.ts` | Base Agent class — lifecycle, dispatch, interests, config, opt-in learning trait, multi-instance identity |
| `src/execute-as-principal.ts` | `executeAsPrincipal` / `PrincipalRun` — run agent work as a persona's bound user (#1888) |
| `src/report-data-surface.ts` | Principal-bound report discovery, query, lifecycle, drilldown, and export tools (#2462) |
| `src/delegation.ts` | `DelegationEnvelope` — immutable principal + bounded delegation depth (#1892) |
| `src/invoke-agent.ts` | `invoke-agent` tool, worker executor, completion-dispatch convention, transports (#1892) |
| `src/learning.ts` | `AgentLearningConfig` + `resolveAgentLearning()` declaration normalisation |
| `src/schedule.ts` | AgentSchedule model — cron, execution tracking |
| `src/tenant-agent.ts` | TenantAgent — junction table, hierarchical resolution |
| `src/interests.ts` | Interest filter types and configuration |
| `src/config.ts` | File + DB config management, UI slots |
