Aviary
Packages

@dudousxd/nestjs-agent-core

The framework-agnostic agent loop, tool registry, and every SPI a provider, store, or dashboard implements against.

pnpm add @dudousxd/nestjs-agent-core
npm install @dudousxd/nestjs-agent-core

-core has no NestJS dependency. It owns runAgentLoop — the model → tools → model cycle — the ToolRegistry, and the SPIs every other package implements or is bound to: a model provider, a store, a governance read-model, an actor resolver, a roles policy, a token sink, and a quota store.

You rarely import this directly

Most apps only ever import @dudousxd/nestjs-agent (the umbrella) and a store package. -core matters when you're writing a ModelProvider, an AgentStore adapter, or a custom RolesPolicy — or reading the shapes below to understand what the loop guarantees. See Concepts → Architecture for how the pieces fit together.

Implementing an SPI directly

Most consumers only ever type against -core's interfaces — for example a custom RolesPolicy that replaces the default role-intersection check:

import type { Actor, RolesPolicy, ToolSpec } from '@dudousxd/nestjs-agent-core';

export class BusinessHoursPolicy implements RolesPolicy {
  can(actor: Actor, tool: ToolSpec): boolean {
    const withinHours = new Date().getUTCHours() < 18;
    const hasRole = (tool.roles ?? []).some((role) => actor.roles?.includes(role));
    return tool.kind === 'action' ? hasRole && withinHours : hasRole;
  }
}

runAgentLoop is the one place tool execution happens — a ModelProvider.runTurn performs exactly one model turn and must not execute tools itself, which is what keeps a turn replay-safe under the durable runner. See Concepts → The agent loop for the full turn-by-turn walkthrough.

The SPIs

Every contract a provider, store, or dashboard is built against. Shapes are trimmed to their key members — see the source for full JSDoc.

SPIPurposeKey shape
ModelProviderWraps the actual LLM call.runTurn(args: ModelTurnArgs): Promise<ModelTurnResult>args carries system, messages, tools, a sink: SinkWriter for live deltas, abortSignal; the result carries text, toolCalls, usage, optional modelId and costUsd.
AgentStoreORM-agnostic persistence for threads, messages, tool calls, and usage.createThread / getThread / listThreads / forkThread / appendMessage / truncateFrom / recordToolCall / updateToolCall / recordUsage / quotaToday / ownerOfThread(threadId): Promise<string | null> / ownerOfToolCall(toolCallId): Promise<string | null> / runForToolCall(toolCallId): Promise<string | null> / ownerOfActiveStream(runId): Promise<string | null> / optional updateThread / activeRunForThread / optional recordRunStart(input: RecordRunStartInput) / recordRunEnd(input: RecordRunEndInput) / bumpRunRetries(runId) — run reliability recording, absent = graceful no-op.
AgentPricingStoreWrite side of model pricing — the SPI that seeds the pricing table the cost estimate reads.upsertModelPrice(input: ModelPriceInput): Promise<void> (atomic supersede — retires the model's current row, inserts a new current one) / listCurrentPrices(): Promise<CurrentModelPrice[]>.
AgentGovernanceQueriesThe read/analytics half of the store — the dashboard and Telescope tab both consume this one interface.Usage/spend: spendByModel(range) / spendByActor(range) / spendByThread(range, limit) / usageTrend(range) / recentToolCalls(limit) / recentThreads(limit). Run reliability: runMetrics(range) / runsByAgent(range) / runErrors(range) / runTrend(range) / recentRuns(limit) — all REQUIRED; an adapter over a store that never records runs returns zeros/empty. Governance: pendingApprovals(limit) (the approvals inbox) / toolStats(range). Paged, filterable variants for the dashboard's list tables: toolCallsPage / threadsPage / runsPage (each takes a GovernancePageQuery<TWhere>page, pageSize, typed where — and returns a GovernancePage<TRow> with total for prev/next paging).
AgentApprovalPortConsole-side HITL decisions — the approvals inbox routes through this instead of re-running chat's own approve/reject authorization. Optional: absent = the dashboard's approvals inbox 501s and renders read-only.approve(toolCallId, opts?: { executedByRef? }) / reject(toolCallId, opts?: { executedByRef?, reason? }).
ActorResolverResolves the caller for an inbound request — the identity seam; no default fabricates one, and it's a required option — there's no throwing placeholder to fall back to.resolve(req: unknown): Actor | Promise<Actor>.
RolesPolicyDecides whether an actor may invoke a tool.can(actor: Actor, tool: ToolSpec): boolean | Promise<boolean>.
TokenStreamSink + SinkWriterThe data plane — live token transport decoupled from durability, keyed by runId.Sink: open(runId) / subscribe(runId): AsyncIterable<Uint8Array> / close(runId). Writer: write(chunk) / end().
QuotaStorePer-actor/day token budget.check(actorRef, day): Promise<QuotaState> / bump(actorRef, day, tokens): Promise<void>.
AgentRunnerRuns a turn — the inline or durable strategy.start(input: AgentRunInput): Promise<{ runId }> / signal(runId, toolCallId, decision) / cancel(runId).
RetrieverRAG retrieval seam — vector/keyword search behind the agentic tool or inject mode.retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>.
EmbeddingProviderText → vectors, for retrieval + ingestion.embed(texts: string[]): Promise<number[][]>.
RerankerSecond-stage RAG precision — re-scores retrieved passages against the query.rerank(query: string, passages: Passage[], options?: RerankOptions): Promise<Passage[]>.
ActorDirectoryOptional read-side lookup from opaque store actorRefs to human display labels — governance/dashboard surfaces resolve refs to names instead of rendering u_8f21…. Unbound → surfaces show the raw ref. Injected via AGENT_ACTOR_DIRECTORY.resolveDisplay(refs: readonly string[]): Promise<Record<string, string>>.
AttachmentStagingStoreOptional upload seam for message attachments (an image/PDF a user attaches before the model sees it) — turns an uploaded file into a model-reachable MessageAttachment URL (the lib never fetches bytes itself). Unbound → the POST /agent/attachments controller is never mounted. Injected via AGENT_ATTACHMENT_STAGING.stage(input: StageAttachmentInput): Promise<MessageAttachment>.

Run reliability recording

Every run is durably recorded: the loop calls AgentStore.recordRunStart (with a promptHash — the sha256 of the run's resolved, pre-RAG system prompt, so a governance read can correlate an error-rate shift with a prompt change) as a checkpointed step, then recordRunEnd on completion or failure (duration, error code/message). Both bundled store adapters (MikroORM, Drizzle) ship an agent_run table (autoSchema-managed) backing this; a custom AgentStore that omits these three methods just gets zeros/empty back from the runMetrics/runsByAgent/runErrors/runTrend/recentRuns/runsPage reads instead of a crash.

A ModelProvider never executes tools

runTurn streams text and returns requested tool calls — it must not run them. The loop runs every tool call as its own step so a turn stays replay-safe when it's executing as a durable workflow. See Concepts → Runners for inline vs durable.

Core types worth knowing

TypeWhat it holds
Actor{ id, roles?, tenantRef? } — the caller, resolved by an ActorResolver.
ToolSpec{ name, kind: 'read' | 'action' | 'agent', description, inputSchema, roles?, ability?, targetAgent? } — the declared shape of a tool.
AiToolCtx{ actor, threadId, runId, requestId, agentName?, pageContext?, host? } — handed to every ToolHandler.execute. agentName is the agent running this turn (provenance a tool can scope on).
MessageUsage{ inputTokens, outputTokens, cacheWriteTokens?, cacheReadTokens?, reasoningTokens? } — the cache-aware usage shape behind cost accounting.
AgentDefinition{ name, description?, systemPrompt?, tools?, delegatesTo?, modelId?, maxSteps? } — one named agent, authored as an @Agent-decorated class and populated into the AgentRegistry by discovery.
ModelPriceInput{ modelId, inputPricePer1m, outputPricePer1m, cacheWritePricePer1m?, cacheReadPricePer1m? } — the shape AgentPricingStore.upsertModelPrice accepts.
Decision{ approved: boolean, reason?: string, executedByRef?: string } — a human decision on a pending action tool call. executedByRef is who decided; absent means the run's own actor decided (the chat approve/reject flow). The dashboard's approvals inbox stamps this from its approvalActorRef option (or the configured ActorResolver by default) when routing through AgentApprovalPort.
RecordRunStartInput / RecordRunEndInput{ runId, threadId, actorRef, agentName?, promptHash? } / { runId, status: 'completed' | 'failed', durationMs?, errorCode?, errorMessage? } — what AgentStore.recordRunStart/recordRunEnd accept.

Also exported

ToolRegistry, DefaultRolesPolicy, AgentRegistry, the error classes (ToolForbiddenError, ToolNotFoundError, ToolInputInvalidError, QuotaExceededError), the seedModelPrices(store, prices[]) helper (loops upsertModelPrice over an array — the quick way to seed a pricing table at boot), and the aviary:agent:* diagnostics helpers — re-exported by the umbrella package too, so an app importing @dudousxd/nestjs-agent never needs a direct dependency on -core.

Dispatched-step plumbing (advanced)

Most apps never touch this — it exists so @dudousxd/nestjs-agent's durable module can route a turn's model/tool step to any worker in a fleet instead of running it in-process. -core exports the serializable LlmStepEnvelope/ToolStepEnvelope shapes those steps carry over the wire, the optional AgentLoopHooks.dispatchLlm/dispatchTool hooks the loop calls when dispatching is enabled (absent = identical in-process behavior), AgentLoopHooks.isControlFlowError (so a durable suspend/continue-as-new signal escapes the loop's tool-failure catch instead of being mispersisted as a real failure), traceLlmTurn/traceToolExecution (the diagnostics spans, emitted from wherever the step actually executes), and withToolTimeout. See @dudousxd/nestjs-agent — the "Durable subpath" section — for how the nestjs package wires these into AgentRunSteps.llm/.tool.

See Getting Started to wire the loop into a NestJS app, or Concepts → Architecture for how the SPIs above compose into a running turn.

On this page