UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

470 lines • 28 kB
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