@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-corenpm 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.
| SPI | Purpose | Key shape |
|---|---|---|
ModelProvider | Wraps 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. |
AgentStore | ORM-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. |
AgentPricingStore | Write 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[]>. |
AgentGovernanceQueries | The 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). |
AgentApprovalPort | Console-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? }). |
ActorResolver | Resolves 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>. |
RolesPolicy | Decides whether an actor may invoke a tool. | can(actor: Actor, tool: ToolSpec): boolean | Promise<boolean>. |
TokenStreamSink + SinkWriter | The data plane — live token transport decoupled from durability, keyed by runId. | Sink: open(runId) / subscribe(runId): AsyncIterable<Uint8Array> / close(runId). Writer: write(chunk) / end(). |
QuotaStore | Per-actor/day token budget. | check(actorRef, day): Promise<QuotaState> / bump(actorRef, day, tokens): Promise<void>. |
AgentRunner | Runs a turn — the inline or durable strategy. | start(input: AgentRunInput): Promise<{ runId }> / signal(runId, toolCallId, decision) / cancel(runId). |
Retriever | RAG retrieval seam — vector/keyword search behind the agentic tool or inject mode. | retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>. |
EmbeddingProvider | Text → vectors, for retrieval + ingestion. | embed(texts: string[]): Promise<number[][]>. |
Reranker | Second-stage RAG precision — re-scores retrieved passages against the query. | rerank(query: string, passages: Passage[], options?: RerankOptions): Promise<Passage[]>. |
ActorDirectory | Optional 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>>. |
AttachmentStagingStore | Optional 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
| Type | What 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.
Packages
The full nestjs-agent package set — core, the NestJS module, the model adapter, stores, the React frontend, governed SQL, the dashboard, and the ecosystem glue points.
@dudousxd/nestjs-agent
The umbrella NestJS module — AgentModule, @AiTool discovery, provideAgentTool, the inline runner, HeaderActorResolver, and the /durable subpath.