@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
237 lines • 14.9 kB
TypeScript
import type { ToolCallOutcome } from '../dangling-tool-calls.js';
import type { RecordGenuiCallInput } from '../genui-telemetry.js';
import type { ActionProposalStoreOptions } from '../spi/action-proposal-store.js';
import { type AgentStore, type AppendMessageInput, type CreateThreadInput, type RecordRunEndInput, type RecordRunStartInput, type RecordToolCallInput, type RecordUsageInput, type ThreadTurnPage, type ThreadTurnQuery, type ThreadTurnReader, type UpdateThreadInput, type UpdateToolCallInput, type UsageTotals } from '../spi/agent-store.js';
import { type ToolCallApprovalState } from '../spi/approval-policy.js';
import type { ChatQueueStore, EnqueueMessageInput, QueuedMessage, QueuedMessagePatch, QueuePause } from '../spi/chat-queue.js';
import type { AgentUiComponent } from '../stream-events.js';
import type { MessageFeedback, StoredMessage, ThreadDetail, ThreadSummary, ToolResult } from '../types.js';
import { LucidActionProposalStore } from './lucid-action-proposal-store.js';
/** The chainable query-builder surface the store leans on (Knex-shaped). */
export interface LucidQueryBuilderLike {
where(column: string, value: unknown): this;
where(column: string, operator: string, value: unknown): this;
whereNull(column: string): this;
whereIn(column: string, values: readonly unknown[]): this;
orderBy(column: string, direction: 'asc' | 'desc'): this;
limit(value: number): this;
offset(value: number): this;
first(): Promise<Record<string, unknown> | null>;
select(...columns: string[]): Promise<Record<string, unknown>[]>;
update(row: Record<string, unknown>): Promise<unknown>;
delete(): Promise<unknown>;
}
export interface LucidInsertBuilderLike {
insert(row: Record<string, unknown>): Promise<unknown>;
}
/** A query client — the base connection or a transaction client (both expose `from`/`table`). */
export interface LucidClientLike {
from(table: string): LucidQueryBuilderLike;
table(table: string): LucidInsertBuilderLike;
}
/**
* Bindings a raw query accepts: positional (an array) or named (an object). Deliberately a SUPERTYPE
* of Lucid's own `RawQueryBindings` (`StrictValues[] | { [key: string]: StrictValues }`) rather than a
* narrower guess.
*
* It used to be `unknown[]`, and that silently excluded the very client the migration stub passes in.
* A method parameter is checked bivariantly, so a candidate satisfies this interface if EITHER
* direction assigns — but `unknown[]` assigned in neither: not to `StrictValues[]` (an `unknown`
* element is not a `StrictValue`), and `RawQueryBindings` not to `unknown[]` (the named-bindings
* object is not an array). So `QueryClientContract` — what `db.connection(name)` returns — failed to
* match, only the `Database` manager did (its `bindings?: any` matches anything), and the published
* migration did not compile in a consumer app.
*
* Widening to `readonly unknown[] | Record<string, unknown>` makes `RawQueryBindings` assignable in
* the outward direction, so every real Lucid client matches. It stays a structural mirror: nothing
* here imports `@adonisjs/lucid`, which is what keeps it an optional peer.
*/
export type LucidRawBindings = readonly unknown[] | Record<string, unknown>;
/**
* A raw SQL runner — the ONLY capability the schema helpers need. Kept separate from
* {@link LucidDatabaseLike} so `createAgentTables` and friends ask for exactly what they use: both the
* `Database` manager and a per-connection `QueryClientContract` satisfy it, which is what lets the
* published migration scope its DDL with `db.connection(this.db.connectionName)` and honour
* `migration:run --connection=x`.
*/
export interface LucidRawRunner {
rawQuery(sql: string, bindings?: LucidRawBindings): Promise<unknown>;
}
/** The `Database` facade slice: a client plus raw SQL (DDL) and transactions. */
export interface LucidDatabaseLike extends LucidClientLike, LucidRawRunner {
transaction<T>(callback: (trx: LucidClientLike) => Promise<T>): Promise<T>;
}
/**
* `query` in the order its messages were appended: `seq`, which append assigns, then `created_at`
* and `id` for the rows from before `seq` existed (they all hold 0, and so sort first). `created_at`
* alone could not order a transcript: two messages of one turn routinely share a millisecond, and
* the database then returned them in either order.
*/
export declare function inAppendOrder<Q extends LucidQueryBuilderLike>(query: Q, direction: 'asc' | 'desc'): Q;
export interface LucidAgentStoreOptions extends ActionProposalStoreOptions {
/**
* Provision the agent tables (via {@link ensureAgentTables}) — as the app starts when the agent
* provider built the store, else on first use — so the lib manages its own schema, the ecosystem
* convention (mirrors `@adonis-agora/durable` and `@adonis-agora/authz`).
* Default `true`. Set `false` to opt out and run the published migration (`createAgentTables` /
* `node ace configure @adonis-agora/agent`) instead — e.g. when you want the schema versioned.
* The same flag governs the pricing store and the governance read-model, which share these tables.
*/
autoCreateTables?: boolean;
}
/**
* The PK of a tool call's row: the model's id scoped to the assistant message that carries it. A
* provider only promises a call id is unique within one response — one assistant message, since the
* loop writes one per step — and some repeat one across runs (the short `call_<digits>` ids of some
* providers behind OpenRouter), so the bare id as PK let one insert fail on another's row. Scoping
* by message rather than run also covers a provider that numbers calls per response (`call_0` again
* on the next step of the same run), and a call recorded without a run. Deterministic, so a durable
* replay derives the same key. The model's id itself is kept in `call_id`, and is still what every
* caller addresses the call by. Hashed in the rare case the pair outgrows the column.
*/
export declare function toolCallRowId(toolCallId: string, messageId: string): string;
/** The id the model gave the call on `row`: `call_id`, or the PK on a row from before it existed. */
export declare function toolCallIdOfRow(row: Record<string, unknown>): string;
/**
* A production-grade, persistent {@link AgentStore} backed by AdonisJS **Lucid** (Knex) over the five
* agent tables (threads, messages, tool calls, token usage, model pricing). JSON payloads are stored
* as TEXT and timestamps as epoch-ms integers, so it is portable across SQLite / Postgres / MySQL and
* is the behavioral twin of the in-memory store.
*
* `forkThread` / `truncateFrom` run in a transaction (cheap safety); `truncateFrom` deletes the
* doomed messages' tool calls explicitly (not only via FK cascade) so it works on SQLite even without
* `PRAGMA foreign_keys=ON`. The tool-call PK is always the model-supplied `toolCallId`.
*
* Usually you don't construct this directly: `config/agent.ts` selects it via `stores.lucid({ ... })`
* and the provider builds it, lazily importing `@adonisjs/lucid` only when the `lucid` store is chosen.
*/
export declare class LucidAgentStore extends LucidActionProposalStore implements AgentStore, ThreadTurnReader, ChatQueueStore {
private readonly db;
private readonly autoCreateTables;
private ready;
/**
* Whether `agent_tool_call` has `call_id`, i.e. rows are keyed per message (#183). Always so when
* the store provisions the schema itself; see {@link checkToolCallSchema} for when it is not.
*/
private toolCallsScoped;
/**
* Whether `agent_message` / `agent_queued_message` have `sent_at`. Always so when the store
* provisions the schema; see {@link checkToolCallSchema} for when it is not.
*/
private sentAtColumns;
constructor(db: LucidDatabaseLike, options?: LucidAgentStoreOptions);
/** The database this store writes to — what other Lucid-backed state (a channel store) shares. */
get database(): LucidDatabaseLike;
private init;
/**
* Under `autoCreateTables: false` the app owns the schema, so it can boot this version before its
* migration has added `agent_tool_call.call_id`. Rather than fail every tool call insert until then,
* the store keeps the old layout — PK = the model's id, so the #183 collision is still possible —
* and says so once, at startup.
*/
private checkToolCallSchema;
/**
* Provision (or repair) the agent tables now. The agent provider calls this once as the app starts,
* so no request — and no transaction a caller happens to have open — ever pays for DDL. Idempotent;
* a no-op under `autoCreateTables: false`. Every other method still awaits it, which is what keeps
* a store built by hand (no provider) working.
*/
ensureSchema(): Promise<void>;
createThread(input: CreateThreadInput): Promise<ThreadSummary>;
getThread(threadId: string): Promise<ThreadDetail | null>;
loadThreadForTurn(query: ThreadTurnQuery): Promise<ThreadTurnPage | null>;
/**
* The newest `limit` messages, oldest-first, projected to the columns a model turn reads. The
* bound is the database's: a `limit` on rows ordered newest-first, so a long thread's older rows
* are never materialized at all.
*/
private loadTurnWindow;
getThreadActorRef(threadId: string): Promise<string | null>;
listThreads(actorRef: string, limit?: number): Promise<ThreadSummary[]>;
promoteThread(threadId: string): Promise<void>;
softDeleteThread(threadId: string): Promise<void>;
forkThread(threadId: string, fromMessageId: string): Promise<ThreadSummary>;
setTitle(threadId: string, title: string): Promise<void>;
updateThread(threadId: string, patch: UpdateThreadInput): Promise<void>;
defaultAgentForThread(threadId: string): Promise<string | null>;
personaForThread(threadId: string): Promise<string | null>;
clearActiveStream(threadId: string, runId: string): Promise<void>;
setActiveStream(threadId: string, runId: string | null): Promise<void>;
activeRunForThread(threadId: string): Promise<string | null>;
threadHeldByRun(runId: string): Promise<string | null>;
/**
* Compare-and-set, as two conditional updates rather than one `OR` (the structural query builder
* has no `orWhere`): free → `runId`; else held by `runId` or `replacing` → `runId`. Each statement
* is atomic and re-checks its own predicate under the row lock, so of two racing claims exactly
* one matches.
*/
claimActiveStream(threadId: string, runId: string, options?: {
replacing?: string;
}): Promise<boolean>;
releaseActiveStream(threadId: string, runId: string): Promise<boolean>;
enqueueMessage(input: EnqueueMessageInput): Promise<QueuedMessage>;
listQueue(threadId: string): Promise<QueuedMessage[]>;
getQueuedMessage(id: string): Promise<QueuedMessage | null>;
updateQueuedMessage(id: string, patch: QueuedMessagePatch): Promise<QueuedMessage | null>;
moveQueuedMessage(id: string, index: number): Promise<boolean>;
/** A conditional delete: `false` when the row was already gone (a drain lost its head). */
removeQueuedMessage(id: string): Promise<boolean>;
clearQueue(threadId: string): Promise<number>;
queuePause(threadId: string): Promise<QueuePause | null>;
setQueuePause(threadId: string, pause: QueuePause | null): Promise<void>;
appendMessage(input: AppendMessageInput): Promise<StoredMessage>;
/**
* `{ sent_at }` for an insert, only when there is one to write and the column exists — a database
* whose `sent_at` has not been added yet (`autoCreateTables: false`) keeps every write working.
*/
private sentAtColumn;
/**
* The next `seq` in a thread: one past its highest. Two appends racing on ONE thread can draw the
* same number — the turn loop never does that, and the tie then falls back to `created_at`, `id`.
*/
private nextMessageSeq;
setMessageUi(messageId: string, ui: AgentUiComponent[]): Promise<void>;
setMessageToolResults(messageId: string, results: ToolResult[]): Promise<void>;
truncateFrom(threadId: string, messageId: string): Promise<void>;
/**
* Of `mediaIds`, the ones a surviving message — or one waiting in a thread's queue — in one of this
* actor's threads still carries.
*
* The match on the attachment's `mediaId` runs here rather than in SQL: the column is JSON text,
* and every dialect this store targets spells "an array element with this field" differently
* while none of them could use an index for it. The scan is bounded by ONE actor's
* attachment-bearing messages.
* Soft-deleted threads are included: their messages survive, so what they point at is still
* reachable from stored state.
*/
referencedMediaIds(actorRef: string, mediaIds: readonly string[]): Promise<string[]>;
recordToolCall(input: RecordToolCallInput): Promise<void>;
updateToolCall(input: UpdateToolCallInput): Promise<void>;
/**
* The row of the call the model named `toolCallId`: the newest one `runId` recorded, or without a
* run the newest one overall. Newest is the live one: a run's steps are sequential, so when a step
* settles its call, a repeat of the id in an earlier step has already settled. Falls back to a row
* from before `call_id` existed, whose PK is the bare model id, unless it belongs to another run.
*/
private findToolCall;
/** Attach, per message, the approval record of every call on it a policy put to a person. */
private withApprovals;
rememberedApprovals(threadId: string): Promise<string[]>;
toolCallApproval(toolCallId: string, runId?: string): Promise<ToolCallApprovalState | null>;
threadOfMessage(messageId: string): Promise<string | null>;
setMessageFeedback(messageId: string, feedback: MessageFeedback | null): Promise<void>;
toolCallInput(toolCallId: string, runId?: string): Promise<unknown>;
getToolCallRunId(toolCallId: string): Promise<string | null>;
toolCallOutcomes(toolCallIds: string[]): Promise<ToolCallOutcome[]>;
failUnsettledToolCalls(runId: string, error: string): Promise<number>;
recordUsage(input: RecordUsageInput): Promise<void>;
recordGenuiCalls(rows: readonly RecordGenuiCallInput[]): Promise<void>;
recordRunStart(input: RecordRunStartInput): Promise<void>;
getRunActorRef(runId: string): Promise<string | null>;
recordRunEnd(input: RecordRunEndInput): Promise<void>;
usageBetween(actorRef: string, fromDay: string, toDay: string): Promise<UsageTotals>;
quotaToday(actorRef: string, day: string): Promise<{
usedTokens: number;
}>;
}
//# sourceMappingURL=lucid.d.ts.map