UNPKG

@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
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