@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
470 lines • 28 kB
TypeScript
export { DEFAULT_REFUSAL_REASON } from './refusal.js';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { type ActionApprovalMode } from './action-proposal-receipt.js';
import { type AgentIntake, type ElicitationRequest, type HumanReply } from './elicitation.js';
import { type MemoryConfig } from './memory.js';
import { type MessageTimestampsConfig } from './message-timestamps.js';
import { type ResolveToolUiCatalog, type ToolUiChannelGuide } from './negotiated-tool-ui.js';
import { type SkillsConfig } from './skills.js';
import type { AgentStore } from './spi/agent-store.js';
import { type ApprovalPolicy } from './spi/approval-policy.js';
import type { HistoryWindow } from './spi/history-window.js';
import type { ModelProvider } from './spi/model-provider.js';
import { type AgentPricingStore } from './spi/pricing-store.js';
import { type InputProcessor, type OutputProcessor } from './spi/processors.js';
import type { QuotaStore } from './spi/quota-store.js';
import type { Retriever } from './spi/retriever.js';
import type { RolesPolicy } from './spi/roles-policy.js';
import type { SinkWriter, StreamFrame } from './spi/token-stream-sink.js';
import type { AiToolCtx, PresentationErrorHandler } from './spi/tool.js';
import type { AgentStreamErrorCode, AgentStreamEvent } from './stream-events.js';
import { type ToolRegistry } from './tool-registry.js';
import { type ToolTransientRetrySetting } from './tool-retry.js';
import type { Actor, AgentRunInput, Decision, Persona, PromptBuilder, ToolCallRequest, ToolDefinition, ToolResult } from './types.js';
export interface AgentLoopDeps<TOutput = unknown> {
actionApprovalMode?: ActionApprovalMode;
resolveUiCatalog?: ResolveToolUiCatalog;
/**
* genui `channels`: on a channel the server draws for (WhatsApp, Telegram, email), a prompt block
* saying how each component reaches the user there, and — on each tool result that pushed UI —
* `delivered`, what the user actually received. Undefined → neither.
*/
uiChannelGuide?: ToolUiChannelGuide;
/** Where a tool's failed `present` is reported, with the call's ids. Undefined → a console warning. */
onPresentationError?: PresentationErrorHandler;
model: ModelProvider;
store: AgentStore;
registry: ToolRegistry;
rolesPolicy: RolesPolicy;
/**
* Who has to approve an `action` call, and for how long the request stays open. Undefined →
* {@link DefaultApprovalPolicy}: the requester, with no expiry — the behaviour before the SPI.
*/
approvalPolicy?: ApprovalPolicy;
quota?: QuotaStore;
/**
* Prices each step's token usage into `usage.costUsd` on the persisted assistant message. The
* current price list is fetched ONCE per run (not per step) and reused for every step's estimate.
* Undefined → `costUsd` is always `null` (never a fabricated `0`). A provider-reported cost always
* wins over the estimate.
*/
pricingStore?: AgentPricingStore;
/**
* Fallback accounting label when the provider's turn result doesn't report a `modelId`.
* Optional — a provider that reports its own model makes this unnecessary.
*/
modelId?: string;
/** Pre-computed (YYYY-MM-DD) so the loop body stays deterministic under durable replay. */
day: string;
/** The agent's base prompt. A flat string, or a {@link PromptBuilder} resolved per turn. */
systemPrompt: string | PromptBuilder;
maxSteps?: number;
/** Optional host handle threaded to tool ctx (e.g. an ORM EntityManager). */
host?: unknown;
/** Agent-level tool allow-list (intersected with the persona's). Undefined → all tools. */
toolAllowList?: string[];
/**
* The agent's personas, by id. Read ONLY when the run names one by id (`AgentRunInput.persona`),
* and then only once, inside the `persona:resolve` checkpoint — so a replay runs under the persona
* the turn started with even after this changed. Undefined → no personas.
*/
personas?: ReadonlyMap<string, Persona>;
/**
* How many agent→agent delegations deep a chain may go. Defaults to {@link MAX_DELEGATION_DEPTH}.
*
* The backstop for a chain that is merely LONG; a chain going in circles is caught by
* {@link maxAgentAppearances} instead, and named as such.
*/
maxDelegationDepth?: number;
/**
* How many times one agent may appear on a single delegation chain. Defaults to
* {@link DEFAULT_MAX_AGENT_APPEARANCES}. It counts APPEARANCES, so 2 admits exactly one
* deliberate return to an earlier agent.
*/
maxAgentAppearances?: number;
/**
* Enables always-on ("inject") RAG: before the turn, retrieve passages for the user message and fold
* them into the system prompt. Its presence IS inject mode (a retriever wired as a `read` tool for
* agentic retrieval sets nothing here). Retrieval runs inside `hooks.step` so durable replay reuses
* the same passages deterministically. Undefined → no injection (unchanged behavior).
*/
retriever?: Retriever;
/** How many passages inject-mode retrieval requests. Undefined → 5. */
retrievalTopK?: number;
/**
* Derives the metadata filter applied to inject-mode RAG retrieval, from the run's actor.
* Without it, retrieval is UNSCOPED: every passage in the corpus is eligible for every actor's
* system prompt. Any deployment sharing one corpus across tenants must set this. The returned
* object is passed verbatim as `RetrieveOptions.filter` and is interpreted by the store (see the
* `audience` ACL pattern in docs/retrieval/rag.mdx). A throwing hook fails the turn rather than
* falling back to unfiltered retrieval — a filter that fails open is worse than no filter.
*/
retrievalFilter?: (actor: Actor) => Record<string, unknown>;
/**
* Retries a tool's own invocation, in place, when it throws a classified-transient error (a DB
* deadlock, a lock-wait timeout, a serialization failure — see {@link isTransientToolError}) —
* never a new durable step/checkpoint, just repeated attempts inside the same `tool:<call.id>` step
* body (so a durable replay reuses the memoized successful result and side effects run once).
* Default ON (`{ attempts: 2, backoffMs: 150 }` with the default classifier) when undefined; set
* `{ classify }` to widen/narrow which errors count as transient, or `false` to disable entirely. A
* tool's other (non-transient) failures are unaffected — they remain a one-shot business outcome.
*/
toolTransientRetry?: ToolTransientRetrySetting;
/**
* Bounds how much of the persisted thread rides into the model call each turn. Undefined → the
* WHOLE thread, every message the store holds, which is unbounded: a long-lived thread eventually
* exceeds the provider's context limit, and pays for the full transcript on every turn until it
* does. Applied once per run. See {@link SlidingWindowHistory} for the built-in.
*/
historyWindow?: HistoryWindow;
/**
* Show the model when the person sent each message — a label in front of every user message of
* the history it reads (`[sent 2026-10-23T11:17-03:00] …`), from `StoredMessage.sentAt`, else the
* message's `createdAt`. Only the model's view: stored content and thread reads are untouched.
* Undefined → messages reach the model as stored, and `load:thread` records byte-identical
* payloads. See {@link MessageTimestampsConfig}.
*/
messageTimestamps?: MessageTimestampsConfig;
/**
* Rewrites `{ system, messages }` before EVERY model call of the turn — masking identifiers,
* stamping a policy preamble, collapsing an oversized tool result. Every step, not once per run,
* because the transcript grows between steps. Adds one `process:input:<step>` checkpoint per step;
* empty/undefined adds none, so a deployment with no processors records byte-identical
* checkpoints.
*
* Not a second {@link HistoryWindow}: that owns WHICH messages ride into the turn (pure, outside
* any checkpoint), this owns what they SAY (may call a model, journaled). The loop's canonical
* `modelMessages` is untouched — a redaction is what leaves the process, never the thread's own
* memory of what was said.
*/
inputProcessors?: InputProcessor[];
/**
* Rules on each step's answer before anything downstream sees it, and may redact or refuse it.
* Adds one `process:output:<step>` checkpoint per step.
*
* **Registering any of these takes the turn's model call off the run's live sink**, because a gate
* that must read the answer cannot run after the answer has reached the reader. What that costs is
* proportional to what the chain declares: a chain whose every member declares
* {@link import('./spi/processors.js').OutputProcessor.incremental} keeps streaming a
* lookback-bounded prefix; one undeclared member holds the whole answer and releases it as a
* single `text` frame once the chain has passed.
*/
outputProcessors?: OutputProcessor[];
/**
* Constrain the turn's answer to a schema — any [Standard Schema](https://standardschema.dev) —
* returned validated as `object` on the loop's result and recorded on the assistant message as a
* synthetic auto-executed `structured_output` tool call, the same device inject-mode retrieval
* uses, so no store gains a column for it.
*
* HOW IT COMPOSES WITH TOOL CALLING: as a separate formatting pass, ALWAYS. The turn runs its
* model→tools iteration exactly as it would without a schema; once a step comes back with no tool
* calls, one extra non-streamed call (`structured:<step>:<attempt>`, `tools: []`, `outputSchema`
* set) restates that answer as the schema. Most providers cannot serve a response format and a
* tool set in one request, and skipping the pass for an agent that happens to have no tools would
* make the checkpoint sequence depend on a tool-registry lookup — the registry of whichever
* process is replaying — which is exactly how a run ends up asking for a position its history has
* no room for. So the pass is unconditional, and it costs one model call per turn, billed as its
* own `structured_output` usage row.
*/
outputSchema?: StandardSchemaV1<unknown, TOutput>;
/** Overrides the formatting pass's system prompt. Undefined → {@link DEFAULT_STRUCTURED_OUTPUT_INSTRUCTION}. */
outputInstruction?: string;
/**
* How many extra model calls may try to fix an answer that failed `outputSchema`, each shown the
* previous attempt's validation issues. Undefined → 1; `0` → fail on the first invalid reply.
* Bounded because a model that cannot satisfy a schema usually cannot satisfy it on the fourth try
* either, and every attempt is billed.
*/
outputRepairAttempts?: number;
/**
* Show the formatting pass the turn's whole transcript instead of just the question and the
* answer. For an agent whose answer cannot be restated from its own words — one that reports on
* rows a tool returned and names only their total in the prose, say.
*
* OFF by default because the pass is a translation, and a translation needs the thing being
* translated. The transcript it would otherwise carry is the turn's entire prompt a second time,
* at no discount: the pass swaps the system block for the schema instruction, and the system block
* is the prompt cache's prefix, so nothing of the first call's cache survives into it.
*/
outputFromTranscript?: boolean;
/**
* A question set the agent puts to the user BEFORE it starts working — collecting the scope, as
* against `awaitApproval`, which sanctions work already proposed. The questions are AUTHORED, so
* the turn spends no model call producing them and a client knows the total ("Question 1 of 3")
* the moment the form appears.
*
* The turn parks on the answers exactly as it parks on an approval, and the request persists as
* the same tool-call row the model's `ask` writes — see {@link AgentLoopDeps.ask}. Undefined → no
* intake, and a turn's checkpoint sequence is byte-identical to one that never had the option.
*/
intake?: AgentIntake;
/**
* Offer the model the built-in `ask` tool, so it can put its own question set to the user when it
* judges the scope is missing — the same surface as {@link AgentLoopDeps.intake}, minus the "known
* in advance".
*
* `ask` is NOT a registered tool: it has no handler (the loop settles it against a human), and
* keeping it out of the `ToolRegistry` is what keeps its kind out of a process-local lookup. The
* loop appends its definition to the turn's tool list from THIS flag, which is module config and
* therefore uniform across a deployment. Undefined/false → the model never sees it.
*/
ask?: boolean;
/**
* Authored procedures the model can pull in mid-turn, scoped to the actor's own scope tokens — see
* `skills.ts`. Undefined → no catalog block, no `skill` tool, and a turn's checkpoint sequence is
* byte-identical to one that never had the option.
*
* WHAT IT COSTS THE PROMPT. Four things write the system block — the agent's own prompt, the
* persona's, injected retrieval, and this — and the catalog is the cheapest of them by
* construction: one line per skill, carrying a name, a scope and a description. A skill's BODY
* never enters the system block at all; it arrives as a tool result, on the transcript, where
* {@link AgentLoopDeps.historyWindow} already governs it. So a deployment with fifty skills pays
* for fifty lines and for whichever bodies a turn actually asked to read.
*/
skills?: SkillsConfig;
/**
* What the assistant has concluded about this actor and their organisation, carried into every
* turn against the same scope tokens skills use — see `memory.ts`. Undefined → no memory block, no
* `remember` tool, and a turn's checkpoint sequence is byte-identical to one that never had the
* option.
*
* WHY IT IS IN THE SYSTEM BLOCK AT ALL, when a skill's body deliberately is not. A body is read
* when the model decides a task needs it; a memory is worthless unless it is already in front of
* the model on the turn nobody thought to look for it. That is the whole capability, and it costs
* one line per memory, bounded by `maxMemories` here and by `maxFactChars` at write time.
*/
memory?: MemoryConfig;
}
/** One task's outcome under {@link AgentLoopHooks.parallel}, reported instead of thrown. */
export type SettledTask<T> = {
ok: true;
value: T;
} | {
ok: false;
error: unknown;
};
/**
* The {@link AgentLoopHooks.parallel} implementation for a runner whose checkpoint positions are
* handed out on the CALL — `@adonis-agora/durable`'s `ctx.localStep` is: it takes `pos.next()`
* before its first `await`. Every task is invoked here, synchronously and in list order, before any
* of them is awaited, which is what fixes the block of positions they occupy whatever order they
* then settle in. Nothing rejects: the caller decides what an individual failure means.
*/
export declare function settleAll<T>(tasks: readonly (() => Promise<T>)[]): Promise<SettledTask<T>[]>;
export interface AgentLoopHooks {
runId: string;
/**
* True when this run executes as a replay-safe durable workflow, false/undefined for the inline
* runner. Recorded on the `agent_run` row so governance can tell durable runs apart.
*/
durable?: boolean;
/** A writer for this run's live token stream (data plane). */
openSink(): SinkWriter | Promise<SinkWriter>;
/**
* HITL gate for an action tool. Inline resolves a pending promise; durable awaits a signal.
*
* `opts.timeoutMs` is the request's time to live, from the turn's `ApprovalPolicy`. When it
* elapses with no decision the runner resolves `{ approved: false, expired: true }` — it must not
* throw — and the loop settles the call `expired`. The value comes out of the call's
* `persist:toolcall` checkpoint, so it is the same on every replay; a durable runner hands it to
* its own timed wait (whose deadline the runtime journals), an inline one arms a timer.
*/
awaitApproval(call: ToolCallRequest, ctx: AiToolCtx, opts?: {
timeoutMs?: number;
}): Promise<Decision>;
/**
* The wait an elicitation parks on. Both shipped runners map it to the SAME channel an approval
* uses — `tool:<runId>:<callId>` — so an answer and an approval reach a parked run by one path.
*
* Optional, and its absence changes no checkpoint: the loop falls back to `awaitApproval` and
* reads the decision as "the user confirmed the pre-picked answers" (approved) or "the user
* skipped" (rejected). That is the honest reduction of a yes/no channel, and it means a host that
* only ever implemented approval still runs an elicitation to completion instead of hanging.
*
* Declared as {@link HumanReply} rather than `ElicitationReply` because that is what the channel
* really carries: a question set is parked as a `pending_approval` action, so a `Decision` can
* arrive on this wait from the approvals inbox even where the host implements it. The loop reduces
* one to the other, so an implementer never has to.
*/
awaitAnswers?(request: ElicitationRequest, ctx: AiToolCtx): Promise<HumanReply>;
/**
* Run another named agent and return its answer. Provided only when the host wired multi-agent
* support (durable → child workflow, inline → nested loop). Exposed to tools as `ctx.runAgent`.
*/
runAgent?(agentName: string, task: string): Promise<{
text: string;
}>;
/**
* Start another named agent and return its run id WITHOUT waiting for it, so the calling turn can
* finish while the delegate is still working. The durable runner maps this to `ctx.startChild` (a
* `spawn:<id>` position, no suspend — unlike the `ctx.child` behind {@link runAgent}); the inline
* runner to a nested loop nobody awaits.
*
* `toolCallId` is the delegation's own call, which the started run carries as its delivery address
* (`AgentRunInput.deliverTo`): it posts its answer back into the calling thread against that row.
*
* Called from the loop BODY, never from inside a {@link step}: it takes a position of its own.
*
* Absent → a delegation the journal declares detached is AWAITED instead. The loop writes the same
* checkpoint names either way, so a runner that cannot detach still answers the user; only the
* runner's own positions differ, and a given runner always makes the same choice for the same call.
*/
startAgent?(args: {
agentName: string;
task: string;
toolCallId: string;
}): Promise<{
runId: string;
}>;
/**
* Checkpoint wrapper. Inline = call fn directly; durable = ctx.step(name, fn).
* EVERY side-effect and control-flow read goes through this so durable replay returns
* cached results (stable ids, no double-write, no re-streaming).
*/
step<T>(name: string, fn: () => Promise<T>): Promise<T>;
/**
* Recognizes the runner's control-flow signals (durable suspend / continue-as-new) so the tool
* transient-retry loop never mistakes one for a retryable tool error. Provided only by the durable
* runner; the inline runner has no such notion and leaves it unset.
*/
isControlFlowError?(error: unknown): boolean;
/**
* Run `tasks` concurrently, resolving once EVERY one has settled — one outcome per task, in INPUT
* order, never rejecting. Supplying it is a statement about the runner's checkpointing: each task
* MUST be invoked synchronously, in list order, before any is awaited, so a runner that hands out
* positions on the call assigns them in that order regardless of which task finishes first.
* {@link settleAll} is exactly that, and is what both bundled runners pass.
*
* Waiting for ALL of them is the other half of the contract. A durable runner unwinds a turn by
* THROWING, and a sibling abandoned part-way through its own step is a tool nobody ever runs.
*
* Absent → the loop runs a turn's tool calls one at a time, which is the honest answer for a
* runner whose positions are assigned anywhere other than the call.
*/
parallel?<T>(tasks: readonly (() => Promise<T>)[]): Promise<SettledTask<T>[]>;
/**
* Does this run take the loop shape guarded by `id`? A runner replaying against recorded
* checkpoints answers `false` for a run that started before the shape changed, so that run keeps
* replaying the shape its history holds (`@adonis-agora/durable`'s `ctx.patched`, which consumes a
* position for a new run and gives it back to an old one). Absent → `true`: a runner that records
* no positions has no older shape to preserve.
*/
patched?(id: string): Promise<boolean>;
/**
* Has someone asked this run to stop? Asked between steps and again once a step's model call has
* answered (before anything of it is persisted); `true` unwinds the turn with
* {@link RunCancelledError}. To also cut short the call the run is in, give it
* {@link AgentLoopHooks.abortSignal}.
*
* For a runner that records NO positions (the inline runner): the question is asked live, outside
* any checkpoint, so a runner that replays a journal must leave it undefined — its runtime's own
* cancel stops the run.
*/
cancelled?(): Promise<boolean>;
/**
* Aborted when someone stops this run. Handed to the model call (`ModelTurnArgs.abortSignal`, the
* AI SDK's `abortSignal`) and to each tool (`AiToolCtx.abortSignal`), so a Stop cuts short what the
* run is waiting on instead of letting it run to the end of the step. A model call that fails
* because of it unwinds the turn with {@link RunCancelledError}, like {@link cancelled}; a tool
* that honours it settles failed, and the turn stops at its next safe point.
*
* For a runner that records no positions (the inline runner), like {@link cancelled}.
*/
abortSignal?: AbortSignal;
}
/**
* Someone asked this run to stop, and it did. NOT a failure: the runner that set
* {@link AgentLoopHooks.cancelled} settles the run `cancelled` and ends its stream with a
* `cancelled` frame rather than an error.
*/
export declare class RunCancelledError extends Error {
constructor();
}
export declare class QuotaExceededError extends Error {
constructor();
}
/**
* How many agent→agent delegations deep a chain may go when the host names no ceiling.
*
* A backstop for a chain that is merely LONG. The cycle it would otherwise stand in for is detected
* directly — see {@link DEFAULT_MAX_AGENT_APPEARANCES}.
*
* WHEN IT ACTUALLY BINDS. At one appearance per agent a chain cannot be longer than the number of
* registered agents, so a deployment with fewer agents than this number never reaches it: the cycle
* guard always fires first. It starts mattering with a larger fleet than the ceiling, or once a host
* raises {@link AgentLoopDeps.maxAgentAppearances}, which is what lets a chain revisit an agent and
* therefore grow past the fleet's size.
*/
export declare const MAX_DELEGATION_DEPTH = 5;
/**
* How many times one agent may appear on a single delegation chain, when the host names no other
* number. Once: a chain that reaches an agent it has already passed through is going in circles.
*
* Override with {@link AgentLoopDeps.maxAgentAppearances} — a supervisor that genuinely hands work
* back to an earlier agent needs a larger number, and it is the count of APPEARANCES, so 2 admits
* exactly one return.
*/
export declare const DEFAULT_MAX_AGENT_APPEARANCES = 1;
/**
* The turn's tool list plus the built-ins the module offers — `ask`, `skill`. Appended from CONFIG
* rather than the registry, because neither is ever registered: see {@link AgentLoopDeps.ask} and
* {@link AgentLoopDeps.skills}.
*/
export declare function withBuiltInTools(args: {
tools: ToolDefinition[];
ask: boolean | undefined;
skills: boolean;
memory: boolean;
}): ToolDefinition[];
/** The reason an expired call is denied with — on the row, the stream frame and the result. */
export declare const APPROVAL_EXPIRED_REASON = "approval expired";
/** What a turn answered with: the assistant text, plus the validated `outputSchema` value if any. */
/**
* The frame a settled call is streamed on. A refusal is its own kind, ahead of the error branch:
* `denied` and `error` are both set on a declined call — the first for every consumer, the second
* because `error` is the channel a model reads an outcome on — and a client that saw the error frame
* would draw a person's "no" as a malfunction.
*/
export declare function outcomeFrame(result: ToolResult): AgentStreamEvent;
/**
* What a person is told when a run fails for a reason that is ours rather than theirs. The error's
* own text is for whoever operates the deployment: it names run ids, checkpoint positions and
* provider internals ("non-determinism at <uuid>#41 …", "No output generated. Check the stream for
* errors."), which mean nothing to the person reading the chat and are not theirs to see.
*/
export declare const RUN_FAILED_MESSAGE = "The assistant could not finish this answer. Please try again.";
/**
* Whether an error frame carries the error's own message. Unset → only outside production
* (`NODE_ENV !== 'production'`), where the person reading the stream is the one debugging it.
* `true`/`false` decides it outright; `undefined` returns to the default.
*/
export declare function exposeStreamErrorDetails(expose: boolean | undefined): void;
/**
* The stable `code` of a failed run — what a client branches on, and translates. `quota_exceeded`,
* `output_rejected` and `structured_output_invalid` are outcomes the library words itself;
* `replay_diverged` is the durable runtime refusing a checkpoint position, `model_no_output` a model
* call that produced nothing, and `run_failed` everything else.
*/
export declare function streamErrorCode(error: unknown): AgentStreamErrorCode;
/**
* The `event: error` frame a runner closes a failed run's stream with, coded the way the NestJS
* library codes it so a shared client can tell a spent budget from a crash.
*
* The frame is what the PERSON sees. For a crash it carries a stable `code` and
* {@link RUN_FAILED_MESSAGE}; the error itself goes to the log with the run it belongs to. Outside
* production the raw message rides the frame as it always did (see
* {@link exposeStreamErrorDetails}).
*/
export declare function streamErrorFrame(error: unknown, runId?: string): StreamFrame;
export interface AgentLoopResult<TOutput = unknown> {
text: string;
/** Present only when {@link AgentLoopDeps.outputSchema} was set — the validated structured answer. */
object?: TOutput;
}
/**
* The provider-agnostic agent turn, reused by both the inline and durable runners.
* It drives the model→tools→model iteration; the runner supplies the `step`/`awaitApproval`
* hooks that make the same loop body either in-process or a replay-safe durable workflow.
*/
export declare function runAgentLoop<TOutput = unknown>(boundDeps: AgentLoopDeps<TOutput>, requested: AgentRunInput, hooks: AgentLoopHooks): Promise<AgentLoopResult<TOutput>>;
//# sourceMappingURL=agent-loop.d.ts.map