Aviary
Concepts

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:

SPIWhat it abstractsShipped implementation(s)
ModelProviderCalling the LLM for one turnaiSdkModel(model) (-ai-sdk, any Vercel AI SDK v7 LanguageModel); FakeModelProvider (-testing)
AgentStoreThreads, messages, tool calls (the write path)MikroORM or Drizzle (-store-mikro-orm, -store-drizzle); InMemoryAgentStore (-testing)
AgentGovernanceQueriesSpend/usage read-model (the analytics path)Same MikroORM/Drizzle store packages; InMemoryGovernanceQueries (-testing)
ActorResolverWho's callingHeaderActorResolver (-nestjs, gateway-only) — required, no default; you must supply one
RolesPolicyMay this actor call this toolDefaultRolesPolicy (role set-intersection, -core); AuthzRolesPolicy (-authz, delegates to a nestjs-authz Gate)
TokenStreamSinkLive 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 })
QuotaStorePer-actor daily token budgetLedgerQuotaStore (-core, ledger-backed — turns on with quotaLimitTokens, no shared state needed); InMemoryQuotaStore (-testing); omit both to skip quota checking
AgentRunnerExecuting a turnInlineAgentRunner (-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 AgentRunner is 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) are ctx.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 an action tool'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:

A dispatched agent turn: API pod starts agent.run, AgentRunSteps.llm/.tool execute on any durable worker pod, and a cross-process sink carries tokens back to the SSE connectionClientPOST /agent/chatreads SSE framesAPI podAgentControllerstarts workflow agent.runholds the SSE connectionsubscribes to the sinkTransportdurable queueAgentRunSteps.llmctx.step — model callANY pod running adurable workerAgentRunSteps.toolctx.step — tool execANY pod running adurable workerToken sinkRedis pub/subHTTP + SSEstart agent.rundispatchdispatchtokenstool resultsubscribe (cross-process)
The API pod that starts agent.run and holds the SSE connection is rarely the pod that runs AgentRunSteps.llm/.tool — a cross-process sink (Redis pub/sub) is what gets tokens and tool output back to the client.
  • 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 tool ctx handler-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 the dispatchedSteps option (AgentModuleOptions.dispatchedSteps, default true under durable: true; set false to keep the turn's steps as ctx.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_run table (autoSchema-managed), including a promptHash — 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.
  • AgentGovernanceQueries exposes the aggregated view (runMetrics, runsByAgent, runErrors, runTrend, recentRuns, plus a paginated runsPage) — 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 by traceId = runId, emitted from inside the checkpointed step body that does the real work (including from AgentRunSteps.llm/.tool when 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-telescope renders the waterfall in Telescope's TRACES tab (requires a span-aware diagnostics bridge and a RecordInput.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.

Where to go next

On this page