Architecture
The library is mechanism — the loop, tool registry, RBAC, quota, cost, audit, HITL, streaming; your domain is policy you supply through a fixed set of SPIs.
@dudousxd/nestjs-agent draws one hard line: mechanism lives in the library, policy lives in
your app. The agentic loop, the tool registry, per-tool RBAC, daily quota, cost accounting, audit,
human approval, and resumable streaming are all mechanism — you never rewrite them. Which model,
which store, which roles, which tenant column — that's policy, and it's supplied entirely through a
small set of SPIs (service provider interfaces). Swap an SPI's implementation and nothing else in
the loop changes.
The SPIs are the seams
Every place the library needs something app-specific, it asks for an interface instead of assuming an answer:
| SPI | What it abstracts | Shipped implementation(s) |
|---|---|---|
ModelProvider | Calling the LLM for one turn | aiSdkModel(model) (-ai-sdk, any Vercel AI SDK v7 LanguageModel); FakeModelProvider (-testing) |
AgentStore | Threads, messages, tool calls (the write path) | MikroORM or Drizzle (-store-mikro-orm, -store-drizzle); InMemoryAgentStore (-testing) |
AgentGovernanceQueries | Spend/usage read-model (the analytics path) | Same MikroORM/Drizzle store packages; InMemoryGovernanceQueries (-testing) |
ActorResolver | Who's calling | HeaderActorResolver (-nestjs, gateway-only) — required, no default; you must supply one |
RolesPolicy | May this actor call this tool | DefaultRolesPolicy (role set-intersection, -core); AuthzRolesPolicy (-authz, delegates to a nestjs-authz Gate) |
TokenStreamSink | Live token transport (the data plane) | InProcessTokenStreamSink (-nestjs, the module default); for multi-pod fleets — required under durable: true, whose steps dispatch across the fleet by default — RedisTokenStreamSink (-transport-redis) via forRoot({ sink }) |
QuotaStore | Per-actor daily token budget | LedgerQuotaStore (-core, ledger-backed — turns on with quotaLimitTokens, no shared state needed); InMemoryQuotaStore (-testing); omit both to skip quota checking |
AgentRunner | Executing a turn | InlineAgentRunner (-nestjs, the module default); the durable runner from AgentDurableModule |
AgentStore and AgentGovernanceQueries are separate interfaces on purpose — one owns writes
(threads/messages/tool calls), the other owns read/analytics (spend, trends, activity) — but a
single store adapter binds both, so you configure persistence once. See
Persistence and Cost & Governance.
No SPI has an unsafe default
Most SPIs ship a working default (DefaultRolesPolicy, InProcessTokenStreamSink,
InlineAgentRunner). ActorResolver is the deliberate exception: there is no default at all — it's
a required field on AgentModuleOptions, so the agent never fabricates a caller. See
Identity & Authorization.
Control plane vs. data plane
The loop's checkpointed state (model calls, tool calls, approvals) and its live token stream travel on two different planes, and they don't fight each other:
- Control plane — the model turn and every tool call run as checkpointed steps under
whichever
AgentRunneris active. Under the inline runner that's just an awaited call. Under the durable runner, bookkeeping steps (persisting a message, bumping quota, stream start/finish markers) arectx.localSteps — checkpointed, but pinned to the workflow's own worker, since dispatching a few-millisecond DB write through a queue buys nothing. This is what makes anactiontool's approval pause safe to suspend either way. - Data plane — tokens the model streams mid-turn flow through the
TokenStreamSink(open/subscribe/close), independent of the control plane's checkpoints. A dropped SSE connection reconnects and replays the buffer; a durable suspend doesn't touch it at all.
This split is why streaming and durability compose instead of trading off — see The agent loop and Runners.
Dispatched steps: the turn's two long operations are routed, not pinned
The model call and each tool execution are the turn's two genuinely long-running operations — under
the durable runner they are dispatched as routed durable steps by default
(AgentRunSteps.llm / AgentRunSteps.tool), not run as ctx.localSteps on whichever pod happens to
be hosting the workflow:
AgentRunSteps.llm(@Step({ retries: 3 })) re-resolves the model, sink, and tool definitions from the serving worker's own DI and streams from wherever it actually runs — a live schema instance (a tool's Zod input) can't cross the wire, so the envelope carries only serializable data (LlmStepEnvelope) and the handler re-derives the tool set from the actor.AgentRunSteps.tool(no retries — tool idempotency is the app's own concern) rebuilds the toolctxhandler-side and applies the tool timeout there too.- Both worker groups are always registered by
AgentDurableModule, regardless of configuration — only whether the workflow dispatches to them is controlled by thedispatchedStepsoption (AgentModuleOptions.dispatchedSteps, defaulttrueunderdurable: true; setfalseto keep the turn's steps asctx.localSteps instead, the pre-dispatch behavior).
The practical consequence: a dispatched turn is not pinned to the pod that started it. The
model call or a tool execution can land on any worker in the fleet, which is exactly why a
multi-pod deployment needs a cross-process TokenStreamSink — the worker that ends up running
AgentRunSteps.llm streams tokens through the sink, and that sink has to reach whichever pod is
still holding the client's SSE connection. InProcessTokenStreamSink (the module default) cannot
do this; wire RedisTokenStreamSink (-transport-redis) instead. A boot warning fires when
dispatchedSteps is effectively on with the default in-process sink still bound.
This is orthogonal to durable: true itself
Even with dispatchedSteps: false, a turn under durable: true already runs on whichever worker
picks up the agent.run workflow — which may not be the pod that received the original HTTP
request. The cross-process-sink requirement is a property of durable: true, not specifically of
dispatched steps; dispatching just widens which steps can move mid-turn, from "the whole run" to
"the model call and every tool call, individually."
Run reliability: every run's outcome is durably recorded
Run outcomes are recorded independently of thread/message history, on an optional AgentStore SPI
surface (recordRunStart / recordRunEnd / bumpRunRetries — absent on a store implementation
degrades to a graceful no-op, not an error):
- The loop records start and completed (with duration) as checkpointed steps; the durable workflow and the inline runner each record a failure with its error code/message on their own catch path.
- Both bundled store adapters (MikroORM, Drizzle) ship the recording as an
agent_runtable (autoSchema-managed), including apromptHash— the sha256 of the run's resolved, pre-RAG system prompt, so you can correlate an error-rate shift with a prompt change across runs. AgentGovernanceQueriesexposes the aggregated view (runMetrics,runsByAgent,runErrors,runTrend,recentRuns, plus a paginatedrunsPage) — see Governance for the read-model's shape and Cost & Governance for using it.
Observability: tracing a turn as spans
Every run also emits diagnostics spans — not just the point events on aviary:agent:* — so a
single turn can be reconstructed as a nested waterfall instead of a flat event list:
- Four span events:
llm.turn,tool.execution,retrieval,follow-ups— each correlated bytraceId = runId, emitted from inside the checkpointed step body that does the real work (including fromAgentRunSteps.llm/.toolwhen a step is dispatched, so a span comes from whichever worker actually executed it). A replayed (cached) step never re-emits its span. - Payloads are metadata-only — model id, token counts, tool name/type, step index — never prompt or output text, matching the point events' redaction posture.
@dudousxd/nestjs-agent-telescoperenders the waterfall in Telescope's TRACES tab (requires a span-aware diagnostics bridge and aRecordInput.traceId-aware Telescope build); without those, the spans are emitted but simply unobserved — zero cost either way, since the phase envelopes are gated on subscriber presence.