@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
1,183 lines • 53 kB
JavaScript
import { createHash } from 'node:crypto';
import { canonicalActionProposalJson, snapshotActionProposal, } from '../action-proposal-transitions.js';
import { sumUsage, } from '../spi/agent-store.js';
import { toolCallApprovalFromRow, } from '../spi/approval-policy.js';
import { LucidActionProposalStore } from './lucid-action-proposal-store.js';
import { AGENT_TABLES, ensureAgentTables, hasSentAtColumns, hasToolCallIdColumn, } from './lucid-schema.js';
import { withStoreTransaction } from './sqlite-transactions.js';
/**
* `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 function inAppendOrder(query, direction) {
return query.orderBy('seq', direction).orderBy('created_at', direction).orderBy('id', direction);
}
/**
* 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 function toolCallRowId(toolCallId, messageId) {
const key = `${messageId}:${toolCallId}`;
return key.length <= 255 ? key : createHash('sha256').update(key).digest('hex');
}
/** The id the model gave the call on `row`: `call_id`, or the PK on a row from before it existed. */
export function toolCallIdOfRow(row) {
return row.call_id !== null && row.call_id !== undefined ? String(row.call_id) : String(row.id);
}
function toInt(value) {
if (typeof value === 'number')
return value;
if (typeof value === 'bigint')
return Number(value);
if (typeof value === 'string')
return Number.parseInt(value, 10) || 0;
return 0;
}
function msToIso(value) {
return new Date(toInt(value)).toISOString();
}
function safeJson(value) {
if (value === undefined || value === null)
return null;
try {
return JSON.stringify(value);
}
catch {
return null;
}
}
/**
* How many rows an `update`/`delete` touched. Knex answers a bare count on SQLite/MySQL and Postgres,
* Lucid may wrap it in an array, and a raw driver result carries it as `rowCount`/`changes`/
* `affectedRows` — read whichever is there. Unknown → `0` (a compare-and-set that cannot prove it
* won must say it lost).
*/
function affectedRows(result) {
if (typeof result === 'number')
return result;
if (typeof result === 'bigint')
return Number(result);
if (Array.isArray(result))
return result.length === 0 ? 0 : affectedRows(result[0]);
if (typeof result === 'object' && result !== null) {
const record = result;
for (const key of ['rowCount', 'changes', 'affectedRows']) {
if (typeof record[key] === 'number')
return record[key];
}
}
return 0;
}
function queuedMessageFromRow(row) {
const attachments = parseJson(row.attachments);
const pageContext = parseJson(row.page_context);
const uiCapabilities = parseJson(row.ui_capabilities);
const hostContext = parseJson(row.host_context);
return {
id: String(row.id),
threadId: String(row.thread_id),
actor: parseJson(row.actor) ?? { id: '' },
content: String(row.content ?? ''),
...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}),
...(typeof row.agent_name === 'string' ? { agentName: row.agent_name } : {}),
...(typeof row.persona === 'string' && row.persona.length > 0 ? { persona: row.persona } : {}),
...(typeof row.model === 'string' ? { model: row.model } : {}),
...(pageContext !== undefined ? { pageContext } : {}),
...(uiCapabilities !== undefined ? { uiCapabilities } : {}),
...(hostContext !== undefined ? { hostContext } : {}),
...(toInt(row.interrupt) === 1 ? { interrupt: true } : {}),
...(row.sent_at !== null && row.sent_at !== undefined ? { sentAt: msToIso(row.sent_at) } : {}),
createdAt: msToIso(row.created_at),
updatedAt: msToIso(row.updated_at),
};
}
function parseJson(text) {
if (typeof text !== 'string' || text.length === 0)
return undefined;
try {
return JSON.parse(text);
}
catch {
return undefined;
}
}
/**
* The message columns a model turn reads. `usage`, `follow_ups` and `run_id` stay in the table: they
* are the thread reader's bookkeeping, not part of the prompt.
*/
const TURN_MESSAGE_COLUMNS = [
'id',
'role',
'content',
'persona',
'tool_calls',
'tool_results',
'attachments',
'action_proposal_outcome',
'created_at',
];
/**
* 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 class LucidAgentStore extends LucidActionProposalStore {
db;
autoCreateTables;
ready = null;
/**
* 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.
*/
toolCallsScoped = true;
/**
* 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.
*/
sentAtColumns = true;
constructor(db, options = {}) {
super(db, options, () => this.init());
this.db = db;
this.autoCreateTables = options.autoCreateTables ?? true;
}
/** The database this store writes to — what other Lucid-backed state (a channel store) shares. */
get database() {
return this.db;
}
init() {
if (this.ready === null) {
const ready = this.autoCreateTables ? ensureAgentTables(this.db) : this.checkToolCallSchema();
// A failed provisioning is not remembered: the next call tries again.
ready.catch(() => {
if (this.ready === ready)
this.ready = null;
});
this.ready = ready;
}
return this.ready;
}
/**
* 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.
*/
async checkToolCallSchema() {
this.sentAtColumns = await hasSentAtColumns(this.db);
if (!this.sentAtColumns) {
console.warn('[@adonis-agora/agent] agent_message has no sent_at column. With autoCreateTables off the ' +
'schema is yours: add a migration that calls createAgentTables(). Until it runs, send times ' +
'(sentAt) are not stored, and messageTimestamps labels messages with their created_at.');
}
this.toolCallsScoped = await hasToolCallIdColumn(this.db);
if (!this.toolCallsScoped) {
console.warn('[@adonis-agora/agent] agent_tool_call has no call_id column. With autoCreateTables off the ' +
'schema is yours: add a migration that calls createAgentTables(). Until it runs, tool calls ' +
'are stored as before, and a tool call id a provider repeats across runs can still collide.');
}
}
/**
* 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() {
return this.init();
}
async createThread(input) {
await this.init();
const id = input.id ?? crypto.randomUUID();
const now = Date.now();
await this.db.table(AGENT_TABLES.threads).insert({
id,
actor_ref: input.actor.id,
tenant_ref: input.actor.tenantRef ?? null,
title: input.title ?? 'New chat',
// NOT NULL since the first schema: an empty string is "none pinned" (see `pinnedPersona`).
persona: input.persona ?? '',
transient: input.transient ? 1 : 0,
pinned_at: null,
summary: null,
summary_message_count: 0,
active_stream_id: null,
created_at: now,
updated_at: now,
deleted_at: null,
});
return {
id,
title: input.title ?? 'New chat',
persona: pinnedPersona(input.persona),
transient: input.transient ?? false,
createdAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
};
}
async getThread(threadId) {
await this.init();
const row = await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.whereNull('deleted_at')
.first();
if (row === null || row === undefined)
return null;
const messageRows = await inAppendOrder(this.db.from(AGENT_TABLES.messages).where('thread_id', threadId), 'asc').select('*');
const messages = await this.withApprovals(messageRows.map(rowToMessage));
const last = messages[messages.length - 1];
return {
...threadRowToSummary(row, last?.content),
messages,
};
}
async loadThreadForTurn(query) {
await this.init();
const row = await this.db
.from(AGENT_TABLES.threads)
.where('id', query.threadId)
.whereNull('deleted_at')
.first();
if (row === null || row === undefined)
return null;
const answered = await this.db
.from(AGENT_TABLES.messages)
.where('thread_id', query.threadId)
.where('role', 'assistant')
.limit(1)
.select('id');
return {
title: String(row.title),
hasAssistantMessage: answered.length > 0,
messages: await this.loadTurnWindow(query.threadId, query.messageLimit),
};
}
/**
* 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.
*/
async loadTurnWindow(threadId, limit) {
if (limit !== undefined && limit <= 0)
return [];
const query = inAppendOrder(this.db.from(AGENT_TABLES.messages).where('thread_id', threadId), 'desc');
if (limit !== undefined)
query.limit(limit);
const rows = await query.select(...TURN_MESSAGE_COLUMNS, ...(this.sentAtColumns ? ['sent_at'] : []));
return rows.reverse().map(rowToMessage);
}
async getThreadActorRef(threadId) {
await this.init();
const row = await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.whereNull('deleted_at')
.first();
if (row === null || row === undefined)
return null;
return String(row.actor_ref);
}
async listThreads(actorRef, limit = 50) {
await this.init();
const rows = await this.db
.from(AGENT_TABLES.threads)
.where('actor_ref', actorRef)
.where('transient', 0)
.whereNull('deleted_at')
.orderBy('updated_at', 'desc')
.limit(limit)
.select('*');
return rows.map((row) => threadRowToSummary(row));
}
async promoteThread(threadId) {
await this.init();
await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.where('transient', 1)
.update({ transient: 0, updated_at: Date.now() });
}
async softDeleteThread(threadId) {
await this.init();
await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.update({ deleted_at: Date.now() });
}
async forkThread(threadId, fromMessageId) {
await this.init();
return withStoreTransaction(this.db, async (trx) => {
const source = await trx.from(AGENT_TABLES.threads).where('id', threadId).first();
if (source === null || source === undefined) {
throw new Error(`thread ${threadId} not found`);
}
const messageRows = await inAppendOrder(trx.from(AGENT_TABLES.messages).where('thread_id', threadId), 'asc').select('*');
const cutoff = messageRows.findIndex((m) => String(m.id) === fromMessageId);
const kept = cutoff >= 0 ? messageRows.slice(0, cutoff + 1) : messageRows;
const id = crypto.randomUUID();
const now = Date.now();
const title = String(source.title);
const persona = pinnedPersona(source.persona);
await trx.table(AGENT_TABLES.threads).insert({
id,
actor_ref: source.actor_ref,
tenant_ref: source.tenant_ref ?? null,
title,
persona: persona ?? '',
transient: 0,
pinned_at: null,
summary: null,
summary_message_count: 0,
active_stream_id: null,
model: source.model ?? null,
default_agent: source.default_agent ?? null,
created_at: now,
updated_at: now,
deleted_at: null,
});
for (const [index, m] of kept.entries()) {
// New message id: the message PK is unique, so a fork copies content under fresh ids.
await trx.table(AGENT_TABLES.messages).insert({
id: crypto.randomUUID(),
// Numbered afresh in the order just read, so the copy reads back in the original's order.
seq: index + 1,
thread_id: id,
role: m.role,
content: m.content,
tool_calls: m.tool_calls ?? null,
tool_results: m.tool_results ?? null,
attachments: m.attachments ?? null,
follow_ups: m.follow_ups ?? null,
usage: m.usage ?? null,
persona: m.persona ?? null,
reasoning: m.reasoning ?? null,
reasoning_ms: m.reasoning_ms ?? null,
ui: m.ui ?? null,
action_proposal_outcome: m.action_proposal_outcome ?? null,
...(typeof m.agent_name === 'string' ? { agent_name: m.agent_name } : {}),
...(m.sent_at !== null && m.sent_at !== undefined ? { sent_at: toInt(m.sent_at) } : {}),
created_at: toInt(m.created_at),
});
}
return {
id,
title,
persona,
transient: false,
createdAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
};
});
}
async setTitle(threadId, title) {
await this.init();
await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.update({ title, updated_at: Date.now() });
}
async updateThread(threadId, patch) {
await this.init();
const update = { updated_at: Date.now() };
if (patch.title !== undefined)
update.title = patch.title;
if (patch.model !== undefined)
update.model = patch.model;
if (patch.defaultAgent !== undefined)
update.default_agent = patch.defaultAgent;
if (patch.persona !== undefined)
update.persona = patch.persona ?? '';
await this.db.from(AGENT_TABLES.threads).where('id', threadId).update(update);
}
async defaultAgentForThread(threadId) {
await this.init();
const row = await this.db.from(AGENT_TABLES.threads).where('id', threadId).first();
return typeof row?.default_agent === 'string' ? row.default_agent : null;
}
async personaForThread(threadId) {
await this.init();
const row = await this.db.from(AGENT_TABLES.threads).where('id', threadId).first();
return pinnedPersona(row?.persona);
}
async clearActiveStream(threadId, runId) {
await this.init();
await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.where('active_stream_id', runId)
.update({ active_stream_id: null });
}
async setActiveStream(threadId, runId) {
await this.init();
await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.update({ active_stream_id: runId });
}
// ── Chat queue (ChatQueueStore) ────────────────────────────────────────────
async activeRunForThread(threadId) {
await this.init();
const row = await this.db.from(AGENT_TABLES.threads).where('id', threadId).first();
return typeof row?.active_stream_id === 'string' ? row.active_stream_id : null;
}
async threadHeldByRun(runId) {
await this.init();
const row = await this.db
.from(AGENT_TABLES.threads)
.where('active_stream_id', runId)
.whereNull('deleted_at')
.first();
return row === null || row === undefined ? null : String(row.id);
}
/**
* 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.
*/
async claimActiveStream(threadId, runId, options = {}) {
await this.init();
const free = await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.whereNull('active_stream_id')
.update({ active_stream_id: runId });
if (affectedRows(free) > 0) {
return true;
}
const holders = options.replacing !== undefined ? [runId, options.replacing] : [runId];
const handed = await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.whereIn('active_stream_id', holders)
.update({ active_stream_id: runId });
if (affectedRows(handed) > 0) {
return true;
}
// Zero can still be a win: a MySQL connection without FOUND_ROWS reports CHANGED rows, and
// re-claiming a thread this run already holds changes nothing. The row says who holds it now.
const held = await this.db.from(AGENT_TABLES.threads).where('id', threadId).first();
return held !== null && held !== undefined && held.active_stream_id === runId;
}
async releaseActiveStream(threadId, runId) {
await this.init();
const released = await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.where('active_stream_id', runId)
.update({ active_stream_id: null });
return affectedRows(released) > 0;
}
async enqueueMessage(input) {
await this.init();
const id = crypto.randomUUID();
const now = Date.now();
await withStoreTransaction(this.db, async (trx) => {
const rows = await trx
.from(AGENT_TABLES.queuedMessages)
.where('thread_id', input.threadId)
.select('position');
const positions = rows.map((row) => toInt(row.position));
// The head takes `min - 1` and the tail `max + 1`, so nothing already waiting is rewritten.
const position = positions.length === 0
? 0
: input.at === 'head'
? Math.min(...positions) - 1
: Math.max(...positions) + 1;
await trx.table(AGENT_TABLES.queuedMessages).insert({
id,
thread_id: input.threadId,
actor: safeJson(input.actor) ?? '{}',
content: input.content,
attachments: input.attachments !== undefined && input.attachments.length > 0
? safeJson(input.attachments)
: null,
agent_name: input.agentName ?? null,
persona: input.persona ?? null,
model: input.model ?? null,
page_context: input.pageContext !== undefined ? safeJson(input.pageContext) : null,
ui_capabilities: input.uiCapabilities !== undefined ? safeJson(input.uiCapabilities) : null,
host_context: input.hostContext !== undefined ? safeJson(input.hostContext) : null,
...this.sentAtColumn(input.sentAt),
interrupt: input.interrupt === true ? 1 : 0,
position,
created_at: now,
updated_at: now,
});
});
const stored = await this.getQueuedMessage(id);
if (stored === null) {
throw new Error(`queued message ${id} vanished after insert`);
}
return stored;
}
async listQueue(threadId) {
await this.init();
const rows = await this.db
.from(AGENT_TABLES.queuedMessages)
.where('thread_id', threadId)
.orderBy('position', 'asc')
.select('*');
return rows.map(queuedMessageFromRow);
}
async getQueuedMessage(id) {
await this.init();
const row = await this.db.from(AGENT_TABLES.queuedMessages).where('id', id).first();
return row === null || row === undefined ? null : queuedMessageFromRow(row);
}
async updateQueuedMessage(id, patch) {
await this.init();
const update = { updated_at: Date.now() };
if (patch.content !== undefined)
update.content = patch.content;
if (patch.attachments !== undefined) {
update.attachments =
patch.attachments === null || patch.attachments.length === 0
? null
: safeJson(patch.attachments);
}
if (patch.interrupt !== undefined)
update.interrupt = patch.interrupt ? 1 : 0;
const updated = await this.db.from(AGENT_TABLES.queuedMessages).where('id', id).update(update);
return affectedRows(updated) > 0 ? this.getQueuedMessage(id) : null;
}
async moveQueuedMessage(id, index) {
await this.init();
return withStoreTransaction(this.db, async (trx) => {
const target = await trx.from(AGENT_TABLES.queuedMessages).where('id', id).first();
if (target === null || target === undefined) {
return false;
}
const rows = await trx
.from(AGENT_TABLES.queuedMessages)
.where('thread_id', String(target.thread_id))
.orderBy('position', 'asc')
.select('id');
const order = rows.map((row) => String(row.id)).filter((rowId) => rowId !== id);
order.splice(Math.max(0, Math.min(order.length, Math.trunc(index))), 0, id);
// A move rewrites the whole run order as `0..n-1`.
for (const [position, rowId] of order.entries()) {
await trx.from(AGENT_TABLES.queuedMessages).where('id', rowId).update({ position });
}
return true;
});
}
/** A conditional delete: `false` when the row was already gone (a drain lost its head). */
async removeQueuedMessage(id) {
await this.init();
const removed = await this.db.from(AGENT_TABLES.queuedMessages).where('id', id).delete();
return affectedRows(removed) > 0;
}
async clearQueue(threadId) {
await this.init();
const removed = await this.db
.from(AGENT_TABLES.queuedMessages)
.where('thread_id', threadId)
.delete();
return affectedRows(removed);
}
async queuePause(threadId) {
await this.init();
const row = await this.db.from(AGENT_TABLES.threads).where('id', threadId).first();
return parseJson(row?.queue_pause) ?? null;
}
async setQueuePause(threadId, pause) {
await this.init();
await this.db
.from(AGENT_TABLES.threads)
.where('id', threadId)
.update({ queue_pause: pause === null ? null : safeJson(pause) });
}
async appendMessage(input) {
await this.init();
const id = crypto.randomUUID();
const now = Date.now();
const seq = await this.nextMessageSeq(input.threadId);
await this.db.table(AGENT_TABLES.messages).insert({
id,
seq,
thread_id: input.threadId,
role: input.role,
content: input.content,
tool_calls: safeJson(input.toolCalls),
tool_results: safeJson(input.toolResults),
attachments: safeJson(input.attachments),
follow_ups: safeJson(input.followUps),
usage: safeJson(input.usage),
persona: input.persona ?? null,
run_id: input.runId ?? null,
// Only when set: a detached sub-agent's answer is the one message that carries it, so a
// database whose `agent_name` column has not been added yet keeps every other write working.
...(input.agentName !== undefined ? { agent_name: input.agentName } : {}),
reasoning: input.reasoning ?? null,
reasoning_ms: input.reasoningMs ?? null,
ui: safeJson(input.ui),
action_proposal_outcome: input.actionProposalOutcome !== undefined
? canonicalActionProposalJson(input.actionProposalOutcome)
: null,
...this.sentAtColumn(input.sentAt),
created_at: now,
});
// Keep the thread's `updated_at` in step so list ordering reflects the latest activity.
await this.db
.from(AGENT_TABLES.threads)
.where('id', input.threadId)
.update({ updated_at: now });
return {
id,
role: input.role,
content: input.content,
createdAt: new Date(now).toISOString(),
...(input.toolCalls !== undefined ? { toolCalls: input.toolCalls } : {}),
...(input.toolResults !== undefined ? { toolResults: input.toolResults } : {}),
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
...(input.followUps !== undefined ? { followUps: input.followUps } : {}),
...(input.usage !== undefined ? { usage: input.usage } : {}),
...(input.persona !== undefined ? { persona: input.persona } : {}),
...(input.runId !== undefined ? { runId: input.runId } : {}),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(input.reasoning !== undefined ? { reasoning: input.reasoning } : {}),
...(input.reasoningMs !== undefined ? { reasoningMs: input.reasoningMs } : {}),
...(input.ui !== undefined ? { ui: input.ui } : {}),
...(input.actionProposalOutcome !== undefined
? { actionProposalOutcome: snapshotActionProposal(input.actionProposalOutcome) }
: {}),
...(input.sentAt !== undefined && this.sentAtColumns ? { sentAt: input.sentAt } : {}),
};
}
/**
* `{ 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.
*/
sentAtColumn(sentAt) {
if (sentAt === undefined || !this.sentAtColumns)
return {};
const at = Date.parse(sentAt);
return Number.isFinite(at) ? { sent_at: at } : {};
}
/**
* 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`.
*/
async nextMessageSeq(threadId) {
const [last] = await this.db
.from(AGENT_TABLES.messages)
.where('thread_id', threadId)
.orderBy('seq', 'desc')
.limit(1)
.select('seq');
return toInt(last?.seq) + 1;
}
async setMessageUi(messageId, ui) {
await this.init();
await this.db
.from(AGENT_TABLES.messages)
.where('id', messageId)
.update({ ui: safeJson(ui) });
}
async setMessageToolResults(messageId, results) {
await this.init();
await this.db
.from(AGENT_TABLES.messages)
.where('id', messageId)
.update({ tool_results: safeJson(results) });
}
async truncateFrom(threadId, messageId) {
await this.init();
await withStoreTransaction(this.db, async (trx) => {
const rows = await inAppendOrder(trx.from(AGENT_TABLES.messages).where('thread_id', threadId), 'asc').select('id');
const cutoff = rows.findIndex((m) => String(m.id) === messageId);
if (cutoff < 0)
return;
const doomed = rows.slice(cutoff).map((m) => String(m.id));
for (const mid of doomed) {
// Delete tool calls explicitly (works on SQLite without FK cascade), then the message.
await trx.from(AGENT_TABLES.toolCalls).where('message_id', mid).delete();
await trx.from(AGENT_TABLES.messages).where('id', mid).delete();
}
});
}
/**
* 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.
*/
async referencedMediaIds(actorRef, mediaIds) {
if (mediaIds.length === 0) {
return [];
}
await this.init();
const threads = await this.db
.from(AGENT_TABLES.threads)
.where('actor_ref', actorRef)
.select('id');
if (threads.length === 0) {
return [];
}
const rows = await this.db
.from(AGENT_TABLES.messages)
.whereIn('thread_id', threads.map((thread) => String(thread.id)))
.where('attachments', 'is not', null)
.select('attachments');
// A message waiting in a thread's queue has been sent and not yet run: what it carries is in use.
const queued = await this.db
.from(AGENT_TABLES.queuedMessages)
.whereIn('thread_id', threads.map((thread) => String(thread.id)))
.where('attachments', 'is not', null)
.select('attachments');
const wanted = new Set(mediaIds);
const found = new Set();
for (const row of [...rows, ...queued]) {
for (const attachment of parseJson(row.attachments) ?? []) {
if (wanted.has(attachment.mediaId)) {
found.add(attachment.mediaId);
}
}
}
return [...wanted].filter((mediaId) => found.has(mediaId));
}
async recordToolCall(input) {
await this.init();
await this.db.table(AGENT_TABLES.toolCalls).insert({
...(this.toolCallsScoped
? { id: toolCallRowId(input.toolCallId, input.messageId), call_id: input.toolCallId }
: { id: input.toolCallId }),
message_id: input.messageId,
proposal_id: input.proposalId ?? null,
tool_name: input.toolName,
tool_type: input.toolType,
input: safeJson(input.input),
output: null,
status: input.status,
executed_by_ref: null,
execution_ms: null,
error: null,
run_id: input.runId ?? null,
created_at: Date.now(),
executed_at: null,
approver: input.approver ?? null,
confirmation: input.confirmation !== undefined ? JSON.stringify(input.confirmation) : null,
expires_at: input.expiresAt !== undefined ? Date.parse(input.expiresAt) : null,
remember: null,
decided_via: null,
});
}
async updateToolCall(input) {
await this.init();
const patch = { status: input.status };
if (input.output !== undefined)
patch.output = safeJson(input.output);
if (input.error !== undefined)
patch.error = input.error;
if (input.executionMs !== undefined)
patch.execution_ms = input.executionMs;
if (input.executedByRef !== undefined)
patch.executed_by_ref = input.executedByRef;
if (input.remember !== undefined)
patch.remember = input.remember ? 1 : 0;
if (input.decidedVia !== undefined)
patch.decided_via = input.decidedVia;
if (input.status === 'executed' || input.status === 'failed')
patch.executed_at = Date.now();
const row = await this.findToolCall(input.toolCallId, input.runId);
if (row === null)
return;
await this.db.from(AGENT_TABLES.toolCalls).where('id', String(row.id)).update(patch);
}
/**
* 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.
*/
async findToolCall(toolCallId, runId) {
const table = AGENT_TABLES.toolCalls;
if (this.toolCallsScoped) {
const query = this.db.from(table).where('call_id', toolCallId);
if (runId !== undefined)
query.where('run_id', runId);
const [newest] = await query.orderBy('created_at', 'desc').limit(1).select('*');
if (newest !== undefined)
return newest;
}
const bare = this.db.from(table).where('id', toolCallId);
if (this.toolCallsScoped)
bare.whereNull('call_id');
const row = await bare.first();
if (row === null || row === undefined)
return null;
return runId === undefined || row.run_id == null || String(row.run_id) === runId ? row : null;
}
/** Attach, per message, the approval record of every call on it a policy put to a person. */
async withApprovals(messages) {
if (messages.length === 0)
return messages;
const rows = await this.db
.from(AGENT_TABLES.toolCalls)
.whereIn('message_id', messages.map((message) => message.id))
.orderBy('created_at', 'asc')
.orderBy('id', 'asc')
.select('*');
const byMessage = new Map();
for (const call of rows) {
const approval = toolCallApprovalFromRow(approvalColumns(call));
if (approval === null)
continue;
const list = byMessage.get(String(call.message_id)) ?? [];
list.push(approval);
byMessage.set(String(call.message_id), list);
}
return messages.map((message) => {
const approvals = byMessage.get(message.id);
return approvals === undefined ? message : { ...message, approvals };
});
}
async rememberedApprovals(threadId) {
await this.init();
const scope = await this.getThreadActionProposalScope(threadId);
if (!scope)
return [];
const names = new Set(await this.rememberedActionProposalTools(scope));
const messageIds = (await this.db.from(AGENT_TABLES.messages).where('thread_id', threadId).select('id')).map((row) => String(row.id));
if (messageIds.length) {
const rows = await this.db
.from(AGENT_TABLES.toolCalls)
.whereIn('message_id', messageIds)
.where('remember', 1)
.select('tool_name');
for (const row of rows)
names.add(String(row.tool_name));
}
return [...names];
}
async toolCallApproval(toolCallId, runId) {
await this.init();
const row = await this.findToolCall(toolCallId, runId);
if (row === null)
return null;
return {
status: String(row.status),
approver: typeof row.approver === 'string' ? row.approver : null,
expiresAt: row.expires_at !== null && row.expires_at !== undefined
? new Date(toInt(row.expires_at)).toISOString()
: null,
};
}
async threadOfMessage(messageId) {
await this.init();
const row = await this.db.from(AGENT_TABLES.messages).where('id', messageId).first();
return row === null || row === undefined ? null : String(row.thread_id);
}
async setMessageFeedback(messageId, feedback) {
await this.init();
await this.db
.from(AGENT_TABLES.messages)
.where('id', messageId)
.update({ feedback: safeJson(feedback) });
}
async toolCallInput(toolCallId, runId) {
await this.init();
const row = await this.findToolCall(toolCallId, runId);
if (row === null)
return null;
return parseJson(row.input) ?? null;
}
async getToolCallRunId(toolCallId) {
await this.init();
const row = await this.findToolCall(toolCallId);
if (row === null || row.run_id == null)
return null;
return String(row.run_id);
}
async toolCallOutcomes(toolCallIds) {
if (toolCallIds.length === 0)
return [];
await this.init();
const table = AGENT_TABLES.toolCalls;
const columns = ['id', 'status', 'output', 'error', 'created_at'];
const rows = this.toolCallsScoped
? [
// A row from before `call_id` existed, whose PK is the model's id — older than any other.
...(await this.db
.from(table)
.whereIn('id', toolCallIds)
.whereNull('call_id')
.select(...columns, 'call_id')),
...(await this.db
.from(table)
.whereIn('call_id', toolCallIds)
.orderBy('created_at', 'asc')
.select(...columns, 'call_id')),
]
: // The old layout (see `checkToolCallSchema`): the PK is the model's id.
await this.db
.from(table)
.whereIn('id', toolCallIds)
.select(...columns);
// One outcome per id: when two responses were handed the same id, the newest call's.
const newest = new Map();
for (const row of rows)
newest.set(toolCallIdOfRow(row), row);
return [...newest.entries()].map(([id, row]) => {
const output = parseJson(row.output);
return {
id,
status: String(row.status),
...(output !== undefined && output !== null ? { output } : {}),
...(typeof row.error === 'string' ? { error: row.error } : {}),
};
});
}
async failUnsettledToolCalls(runId, error) {
await this.init();
const updated = await this.db
.from(AGENT_TABLES.toolCalls)
.where('run_id', runId)
.where('status', 'pending_approval')
.update({ status: 'failed', error });
const count = Array.isArray(updated) ? updated[0] : updated;
return typeof count === 'number' ? count : Number(count ?? 0);
}
async recordUsage(input) {
await this.init();
await this.db.table(AGENT_TABLES.tokenUsage).insert({
id: crypto.randomUUID(),
thread_id: input.threadId,
actor_ref: input.actorRef,
message_id: input.messageId ?? null,
model_id: input.modelId,
purpose: input.purpose,
input_tokens: input.usage.inputTokens,
output_tokens: input.usage.outputTokens,
cache_write_tokens: input.usage.cacheWriteTokens ?? null,
cache_read_tokens: input.usage.cacheReadTokens ?? null,
cost_usd: input.costUsd ?? null,
cost_source: input.costUsd !== undefined ? (input.costSource ?? 'provider') : null,
run_id: input.runId ?? null,
created_at: Date.now(),
});
}
async recordGenuiCalls(rows) {
if (rows.length === 0)
return;
await this.init();
await withStoreTransaction(this.db, async (trx) => {
const ids = rows.map((row) => row.toolCallId);
// A call recorded again (a replayed step) replaces its row.
await trx.from(AGENT_TABLES.genuiCalls).whereIn('id', ids).delete();
for (const row of rows) {
await trx.table(AGENT_TABLES.genuiCalls).insert({
id: row.toolCallId,
run_id: row.runId,
thread_id: row.threadId,
actor_ref: row.actorRef,
agent_name: row.agentName ?? null,
step: row.step,
tool_name: row.toolName,
channel: row.channel,
mode: row.mode,
model_id: row.modelId,
attempt: row.attempt,
outcome: row.outcome,
validity: row.validity,
error_category: row.errorCategory ?? null,
error_message: row.errorMessage ?? null,
input_tokens: row.inputTokens,
output_tokens: row.outputTokens,
cost_usd: row.costUsd ?? null,
cost_source: row.costUsd !== undefined ? (row.costSource ?? null) : null,
first_frame_ms: row.firstFrameMs ?? null,
complete_frame_ms: row.completeFrameMs ?? null,
payload_bytes: row.payloadBytes,
payload: row.payload ?? null,
created_at: Date.now(),
});
}
});
}
async recordRunStart(input) {
await this.init();
await this.db.table(AGENT_TABLES.runs).insert({
id: input.runId,
thread_id: input.threadId,
agent_name: input.agentName ?? null,
parent_run_id: input.parentRunId ?? null,
actor_ref: input.actor.id,
tenant_ref: input.actor.tenantRef ?? null,
status: 'running',
started_at: Date.now(),
finished_at: null,
step_count: 0,
input_tokens: 0,
output_tokens: 0,
cost_usd: null,
error: null,
durable: input.durable ? 1 : 0,
});
}
async getRunActorRef(runId) {
await this.init();
const row = await this.db.from(AGENT_TABLES.runs).where('id', runId).first();
if (row === null || row === undefined)
return null;
return String(row.actor_ref);
}
async recordRunEnd(input) {
await this.init();
const patch = {
status: input.status,
finished_at: input.finishedAt ?? Date.now(),
};
if (input.stepCount !== undefined)
patch.step_count = input.stepCount;
if (input.inputTokens !== undefined)
patch.input_tokens = input.inputTokens;
if (input.outputTokens !== undefined)
patch.output_tokens = input.outputTokens;
if (input.costUsd !== undefined)
patch.cost_usd = input.costUsd;
if (input.error !== undefined)
patch.error = input.error;
if (input.ttftMs !== undefined)
patch.ttft_ms = input.ttftMs;
if (input.errorCode !== undefined)
patch.error_code = input.errorCode;
if (input.modelId !== undefined)
patch.model_id = input.modelId;
// First terminal wins: only settle a run still `running`, so a late `completed` from the loop can
// never overwrite a `failed`/`cancelled` the runner already recorded (idempotent under replay too).
await this.db
.from(AGENT_TABLES.runs)
.where('id', input.runId)
.where('status', 'running')
.update(patch);
}
async usageBetween(actorRef, fromDay, toDay) {
await this.init();
const rows = await this.db
.from(AGENT_TABLES.tokenUsage)
.where('actor_ref', actorRef)
.where('created_at', '>=', Date.parse(`${fromDay}T00:00:00.000Z`))
.where('created_at', '<=', Date.parse(`${toDay}T23:59:59.999Z`))
.select('input_tokens', 'output_tokens', 'cost_usd', 'cost_source');
return sumUsage(rows.map((row) => ({
inputTokens: toInt(row.input_tokens),
outputTokens: toInt(row.output_tokens),
costUsd: row.cost_usd === null || row.cost_usd === undefined ? null : Number(row.cost_usd),
costSource: typeof row.cost_source === 'string' ? row.cost_source : null,
})));
}
async quotaToday(actorRef, day) {
await this.init();
// Inclusive UTC day window over epoch-ms `created_at`. Cache tokens are subsets of input/output
// and are NEVER re-added, so summing input+output is the whole-day token spend.
const start = Date.parse(`${day}T00:00:00.000Z`);
const end = Date.parse(`${day}T23:59:59.999Z`);
const rows = await this.db
.from(AGENT_TABLES.tokenUsage)
.where('actor_ref', actorRef)
.where('created_at', '>=', start)
.where('created_at', '<=', end)
.select('input_tokens', 'output_tokens');
const usedTokens = rows.reduce((sum, row) => sum + toInt(row.input_tokens) + toInt(row.output_tokens), 0);
return { usedTokens };
}
}
/**
* A thread's `persona` column as the pin it stands for. The column is `NOT NULL` (an empty string is
* "none pinned"), so `null` is never stored; a store written before pins existed holds whatever its
* thread was created with, which the service reads as a pin only where the agent declares it.
*/
function pinnedPersona(value) {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function threadRowToSummary(row, lastPreview) {
const pinnedAt = row.pinned_at;
return {
id: String(row.id),
title: String(row.title),
persona: pinnedPersona(row.persona),
transient: toInt(row.transient) !== 0,
createdAt: msToIso(row.created_at),
updatedAt: msToIso(row.updated_at),
...(pinnedAt !== null && pinnedAt !== undefined ? { pinnedAt: msToIso(pinnedAt) } : {}),
...(lastPreview !== undefined ? { lastMessagePreview: lastPreview.slice(0, 120) } : {}),
model: typeof row.model === 'string' ? row.model : null,
defaultAgent: typeof row.default_agent === 'string' ? row.default_agent : null,
activeRunId: typeof row.active_stream_id === 'string' ? row.active_stream_id : null,
};
}
/** A tool-call row's approval columns, in the shape the shared mapping reads. */
function approvalColumns(row) {
return {
...(typeof row.proposal_id === 'string' ? { proposalId: row.proposal_id } : {}),
toolCallId: toolCallIdOfRow(row),
...(row.confirmation != null
? { confirmation: parseJson(row.confirmation) }
: {}),
status: String(row.status),
approver: typeof row.approver === 'string' ? row.approver : null,
expiresAt: row.expires_at !== null && row.expires_at !== undefined
? new Date(toInt(row.expires_at)).toISOString()
: null,
remember: row.remember === null || row.remember === undefined ? null : toInt(row.remember) === 1,
executedByRef: typeof row.executed_by_ref === 'string' ? row.executed_by_ref : null,
decidedVia: typeof row.decided_via === 'string' ? row.decided_via : null,
error: typeof row.error === 'string' ? row.error : null,
};
}
function rowToMessage(row) {
const toolCalls = parseJson(row.tool_calls);
const toolResults = parseJson(row.tool_results);
const attachments = parseJson(row.attachments);
const followUps = parseJson(row.follow_ups);
const usage = parseJson(row.usage);
const outcome = parseJson(row.action_proposal_outcome);
const ui = outcome?.ui ?? parseJson(row.ui);
const feedback = parseJson(row.feedback);
return {
id: String(row.id),
role: String(row.role),
content: String(row.content),
createdAt: msToIso(row.created_at),
...(toolCalls !== undefined ? { toolCalls } : {}),
...(toolResults !== undefined ? { toolResults } : {}),
...(attachments !== undefined ? { attachments } : {}),
...(followUps !== undefined ? { followUps } : {}),
...(usage !== undefined ? { usage } : {}),
...(typeof row.persona === 'string' ? { persona: row.persona } : {}),
...(typeof row.run_id === 'string' ? { runId: row.run_id } : {}),
...(typeof row.agent_name === 'string' ? { agentName: row.agent_name } : {}),
...(typeof row.reasoning === 'string' ? { reasoning: row.reasoning } : {}),
...(row.reasoning_ms !== null && row.reasoning_ms !== undefined
? { reasoningMs: toInt(row.reasoning_ms) }
: {}),
...(ui !== undefined ? { ui } : {}),
...(outcome !== undefined ? { actionProposalOutcome: outcome } : {}),
...(feedback !== undefined && feedback !== null ? { feedback } : {}),
...(row.sent_at !== null && row.sent_at !== undefined ? { sentAt: msToIso(row.sent_at) } : {}),
};
}
//# sourceMappingURL=lucid.js.map