UNPKG

@adonis-agora/agent

Version:

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

465 lines • 22.3 kB
import { createHash, randomUUID } from 'node:crypto'; import { validateActionProposalDiscoveryIndexBatch, validateActionProposalExpiryBatch, validateActionProposalWorkerClaim, } from '../action-proposal-discovery.js'; import { actionProposalOutcomeFenceValid, actionProposalOutcomeText, claimActionProposalOutcome, } from '../action-proposal-outcome.js'; import { actionProposalCreationMatches, actionProposalScopeMatches, canonicalActionProposalJson, initialActionProposal, transitionActionProposalClaim, transitionActionProposalDecision, transitionActionProposalLease, transitionActionProposalSettlement, transitionActionProposalSupersession, validateActionProposalListQuery, } from '../action-proposal-transitions.js'; import { AGENT_TABLES, forDialect, rowsOf } from './lucid-schema.js'; import { isMySql } from './sql-dialect.js'; import { withStoreTransaction } from './sqlite-transactions.js'; function key(value) { return createHash('sha256').update(canonicalActionProposalJson(value)).digest('hex'); } function proposalKey(id) { return key(id); } function scopeKey(scope) { return key([scope.tenantRef, scope.actorRef, scope.threadId]); } function logicalSort(id) { let value = ''; for (let index = 0; index < id.length; index++) value += id.charCodeAt(index).toString(16).padStart(4, '0'); return value; } function affected(result) { if (typeof result === 'number') return result; if (Array.isArray(result)) return affected(result[0]); if (result && typeof result === 'object') { for (const field of ['rowCount', 'changes', 'affectedRows']) { const value = Reflect.get(result, field); if (typeof value === 'number') return value; } } return 0; } function read(row) { return JSON.parse(String(row.payload)); } function discoveryMetadata(proposal) { return { replacement_group_key: proposal.replacementKey === undefined ? null : key([proposal.toolName, proposal.replacementKey]), decision: proposal.decision, execution_status: proposal.execution?.status ?? null, lease_expires_at: proposal.execution?.lease?.expiresAt ?? null, proposal_expires_at: proposal.expiresAt, discovery_index_version: 1, outcome_key: proposal.outcome ? key(proposal.outcome.id) : null, delivery_status: proposal.outcomeDelivery?.status ?? null, delivery_lease_expires_at: proposal.outcomeDelivery?.lease?.expiresAt ?? null, }; } /** * Durable work is embedded in the proposal snapshot. One version-fenced UPDATE commits both * approval and queued work, so there is no dispatch gap requiring a second transactional table. * Physical keys hash exact JSON identities to avoid MySQL PAD SPACE/collation equivalence. */ export class LucidActionProposalStore { proposalDb; ensureProposalSchema; proposalClock; constructor(proposalDb, options = {}, ensureProposalSchema = async () => { }) { this.proposalDb = proposalDb; this.ensureProposalSchema = ensureProposalSchema; this.proposalClock = options.clock ?? Date.now; } async createActionProposal(input) { await this.ensureProposalSchema(); const proposal = initialActionProposal(input, this.proposalClock()); const token = randomUUID(); const mysql = isMySql(this.proposalDb); const columns = [ 'id', 'scope_key', 'logical_sort', 'insert_token', 'decision', 'execution_status', 'lease_expires_at', 'proposal_expires_at', 'discovery_index_version', 'replacement_group_key', 'outcome_key', 'delivery_status', 'delivery_lease_expires_at', 'payload', 'version', 'created_at', 'updated_at', ]; const insertion = `INSERT INTO "${AGENT_TABLES.actionProposals}" (${columns.map((column) => `"${column}"`).join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`; const conflict = mysql ? ' ON DUPLICATE KEY UPDATE "id" = "id"' : ' ON CONFLICT ("id") DO NOTHING'; // Conflict-ignore does not poison a PostgreSQL caller transaction. A per-insertion marker // identifies the creator independently of MySQL FOUND_ROWS or identical timestamps. await this.proposalDb.rawQuery(forDialect(insertion + conflict, mysql), [ proposalKey(input.id), scopeKey(input), logicalSort(input.id), token, 'pending', null, null, proposal.expiresAt, 1, proposal.replacementKey === undefined ? null : key([proposal.toolName, proposal.replacementKey]), null, null, null, canonicalActionProposalJson(proposal), 0, proposal.createdAt, proposal.updatedAt, ]); const row = await this.proposalDb .from(AGENT_TABLES.actionProposals) .where('id', proposalKey(input.id)) .first(); if (!row) throw new Error('Proposal insert did not persist a row'); const existing = read(row); if (existing.id !== input.id || !actionProposalScopeMatches(existing, input)) return { status: 'conflict' }; if (row.insert_token === token) return { status: 'created', proposal: existing }; return { status: actionProposalCreationMatches(existing, input) ? 'unchanged' : 'conflict', proposal: existing, }; } async createReplacingActionProposal(input) { initialActionProposal(input, this.proposalClock()); await this.ensureProposalSchema(); if (input.replacementKey === undefined) return this.createActionProposal(input); return withStoreTransaction(this.proposalDb, async (client) => { const tx = client; await tx.rawQuery(forDialect(`UPDATE "${AGENT_TABLES.threads}" SET "updated_at" = "updated_at" WHERE "id" = ?`, isMySql(this.proposalDb)), [input.threadId]); const scope = await tx .from(AGENT_TABLES.threads) .where('id', input.threadId) .whereNull('deleted_at') .first(); if (!scope || scope.id !== input.threadId || scope.actor_ref !== input.actorRef || (scope.tenant_ref ?? null) !== input.tenantRef) return { status: 'conflict' }; const adapter = new LucidActionProposalStore(tx, { clock: this.proposalClock }); const created = await adapter.createActionProposal(input); if (created.status !== 'created') return created; const siblings = await tx .from(AGENT_TABLES.actionProposals) .where('scope_key', scopeKey(input)) .where('decision', 'pending') .where('replacement_group_key', key([input.toolName, input.replacementKey])) .select('*'); for (const row of siblings) { const sibling = read(row); if (sibling.id === input.id || sibling.toolName !== input.toolName || sibling.replacementKey !== input.replacementKey) continue; await adapter.supersedeActionProposal(input, sibling.id, { replacementProposalId: input.id, actorRef: input.actorRef, via: 'system/replacement', }); } return created; }); } async supersedeActionProposal(scope, id, command) { const replacement = await this.getActionProposal(scope, command.replacementProposalId); if (!replacement) return { status: 'not_found' }; return this.mutate(scope, id, (proposal, now) => transitionActionProposalSupersession(proposal, replacement, command, now)); } async rememberedActionProposalTools(scope) { await this.ensureProposalSchema(); const rows = await this.proposalDb .from(AGENT_TABLES.actionProposals) .where('scope_key', scopeKey(scope)) .where('decision', 'approved') .whereIn('execution_status', ['succeeded', 'failed']) .select('payload'); return rows .map(read) .filter((proposal) => actionProposalScopeMatches(proposal, scope) && proposal.decision === 'approved' && (proposal.execution?.status === 'succeeded' || proposal.execution?.status === 'failed') && proposal.decisionAudit?.remember === true) .map((proposal) => proposal.toolName); } async getActionProposal(scope, id) { await this.ensureProposalSchema(); const row = await this.proposalDb .from(AGENT_TABLES.actionProposals) .where('id', proposalKey(id)) .where('scope_key', scopeKey(scope)) .first(); if (!row) return null; const proposal = read(row); return proposal.id === id && actionProposalScopeMatches(proposal, scope) ? proposal : null; } async listActionProposals(scope, query = {}) { await this.ensureProposalSchema(); const limit = validateActionProposalListQuery(query); const filters = ['"scope_key" = ?']; const bindings = [scopeKey(scope)]; if (query.decision !== undefined) { filters.push('"decision" = ?'); bindings.push(query.decision); } if (query.after !== undefined) { filters.push('("created_at" > ? OR ("created_at" = ? AND "logical_sort" > ?))'); bindings.push(query.after.createdAt, query.after.createdAt, logicalSort(query.after.id)); } bindings.push(limit); const rows = rowsOf(await this.proposalDb.rawQuery(forDialect(`SELECT * FROM "${AGENT_TABLES.actionProposals}" WHERE ${filters.join(' AND ')} ORDER BY "created_at" ASC, "logical_sort" ASC LIMIT ?`, isMySql(this.proposalDb)), bindings)); return rows.map(read).filter((p) => actionProposalScopeMatches(p, scope)); } async mutate(scope, id, transition) { await this.ensureProposalSchema(); for (let attempt = 0; attempt < 32; attempt++) { const row = await this.proposalDb .from(AGENT_TABLES.actionProposals) .where('id', proposalKey(id)) .where('scope_key', scopeKey(scope)) .first(); if (!row) return { status: 'not_found' }; const previous = read(row); if (previous.id !== id || !actionProposalScopeMatches(previous, scope)) return { status: 'not_found' }; // Recompute the trusted time after every lost CAS, never carry a stale expiry check. const result = transition(previous, this.proposalClock()); if (!result.proposal || canonicalActionProposalJson(previous) === canonicalActionProposalJson(result.proposal)) return result; const changed = await this.proposalDb .from(AGENT_TABLES.actionProposals) .where('id', proposalKey(id)) .where('scope_key', scopeKey(scope)) .where('version', row.version) .update({ payload: canonicalActionProposalJson(result.proposal), ...discoveryMetadata(result.proposal), version: Number(row.version) + 1, updated_at: result.proposal.updatedAt, }); if (affected(changed) > 0) return result; } const proposal = await this.getActionProposal(scope, id); return proposal ? { status: 'conflict', proposal } : { status: 'not_found' }; } async getThreadActionProposalScope(threadId) { await this.ensureProposalSchema(); const row = await this.proposalDb .from(AGENT_TABLES.threads) .where('id', threadId) .whereNull('deleted_at') .first(); return row && row.id === threadId ? { threadId, actorRef: String(row.actor_ref), tenantRef: row.tenant_ref == null ? null : String(row.tenant_ref), } : null; } async claimNextActionProposalOutcome(command) { const now = this.proposalClock(); validateActionProposalWorkerClaim(command, now); await this.ensureProposalSchema(); const rows = rowsOf(await this.proposalDb.rawQuery(forDialect(`SELECT * FROM "${AGENT_TABLES.actionProposals}" WHERE "delivery_status" = 'pending' AND ("delivery_lease_expires_at" IS NULL OR "delivery_lease_expires_at" <= ?) ORDER BY "created_at" ASC, "logical_sort" ASC LIMIT 32`, isMySql(this.proposalDb)), [now])); for (const row of rows) { const proposal = read(row); const result = await this.mutate(proposal, proposal.id, (current, time) => { const next = claimActionProposalOutcome(current, command, time, randomUUID()); return next ? { status: 'applied', proposal: next } : { status: 'conflict', proposal: current }; }); const next = result.proposal; if (result.status === 'applied' && next?.outcome && next.outcomeDelivery?.lease) return { outcome: next.outcome, lease: { outcomeId: next.outcome.id, token: next.outcomeDelivery.lease.token, generation: next.outcomeDelivery.generation, }, }; } return null; } async admitActionProposalOutcome(command) { // Validate malformed privileged commands even when the outcome does not exist. actionProposalOutcomeFenceValid({}, command, this.proposalClock()); await this.ensureProposalSchema(); const observed = await this.proposalDb .from(AGENT_TABLES.actionProposals) .where('outcome_key', key(command.outcomeId)) .first(); if (!observed) return { status: 'not_found' }; const observedProposal = read(observed); return withStoreTransaction(this.proposalDb, async (client) => { const tx = client; const sql = (statement) => forDialect(statement, isMySql(this.proposalDb)); // This write starts SQLite's reserved writer lock before reading a transaction snapshot; // on PostgreSQL/MySQL the same update locks the real admission row used by user sends. await tx.rawQuery(sql(`UPDATE "${AGENT_TABLES.threads}" SET "updated_at" = "updated_at" WHERE "id" = ?`), [observedProposal.threadId]); const row = await tx .from(AGENT_TABLES.actionProposals) .where('id', observed.id) .where('scope_key', observed.scope_key) .first(); if (!row) return { status: 'not_found' }; const proposal = read(row); if (proposal.outcome?.id !== command.outcomeId) return { status: 'not_found' }; if (proposal.outcomeDelivery?.status === 'admitted') return { status: 'unchanged', ...(proposal.outcomeDelivery.messageId !== undefined ? { messageId: proposal.outcomeDelivery.messageId } : {}), }; if (proposal.outcomeDelivery?.status === 'discarded') return { status: 'discarded' }; const now = this.proposalClock(); if (!actionProposalOutcomeFenceValid(proposal, command, now)) return { status: 'conflict' }; const thread = await tx.from(AGENT_TABLES.threads).where('id', proposal.threadId).first(); const discarded = !thread || thread.id !== proposal.threadId || thread.deleted_at != null || thread.actor_ref !== proposal.actorRef || (thread.tenant_ref ?? null) !== proposal.tenantRef; if (!discarded && thread.active_stream_id != null) return { status: 'busy' }; const messageId = key(['action-proposal-outcome', command.outcomeId]); const next = { ...proposal, outcomeDelivery: { ...proposal.outcomeDelivery, status: discarded ? 'discarded' : 'admitted', lease: null, ...(discarded ? {} : { messageId }), }, }; if (!discarded) { const sequence = rowsOf(await tx.rawQuery(sql(`SELECT MAX("seq") AS "maximum" FROM "${AGENT_TABLES.messages}" WHERE "thread_id" = ?`), [proposal.threadId]))[0]; // Store JSON metadata in escaped TEXT; native UI columns remain empty on these facts. await tx.table(AGENT_TABLES.messages).insert({ id: messageId, thread_id: proposal.threadId, seq: Number(sequence?.maximum ?? 0) + 1, role: 'assistant', content: actionProposalOutcomeText(proposal.outcome), action_proposal_outcome: canonicalActionProposalJson(proposal.outcome), created_at: now, }); } const changed = await tx .from(AGENT_TABLES.actionProposals) .where('id', row.id) .where('scope_key', row.scope_key) .where('version', row.version) .update({ payload: canonicalActionProposalJson(next), ...discoveryMetadata(next), version: Number(row.version) + 1, }); if (affected(changed) !== 1) throw new Error('Outcome admission lost its version fence'); if (!discarded) await tx .from(AGENT_TABLES.threads) .where('id', proposal.threadId) .update({ updated_at: now }); return discarded ? { status: 'discarded' } : { status: 'applied', messageId }; }); } async claimNextActionProposal(command) { const now = this.proposalClock(); validateActionProposalWorkerClaim(command, now); await this.ensureProposalSchema(); const rows = rowsOf(await this.proposalDb.rawQuery(forDialect(`SELECT * FROM "${AGENT_TABLES.actionProposals}" WHERE "discovery_index_version" = 1 AND "decision" = 'approved' AND ("execution_status" = 'queued' OR ("execution_status" = 'executing' AND "lease_expires_at" <= ?)) ORDER BY "created_at" ASC, "logical_sort" ASC LIMIT 32`, isMySql(this.proposalDb)), [now])); for (const row of rows) { const proposal = read(row); const result = await this.claimActionProposal(proposal, proposal.id, command); if (result.status === 'applied') return result.proposal ?? null; } return null; } async expireActionProposals(command) { const now = this.proposalClock(); validateActionProposalExpiryBatch(command, now); await this.ensureProposalSchema(); const rows = rowsOf(await this.proposalDb.rawQuery(forDialect(`SELECT * FROM "${AGENT_TABLES.actionProposals}" WHERE "discovery_index_version" = 1 AND "decision" = 'pending' AND "proposal_expires_at" <= ? ORDER BY "created_at" ASC, "logical_sort" ASC LIMIT ?`, isMySql(this.proposalDb)), [now, command.limit])); let changed = 0; for (const row of rows) { const proposal = read(row); const result = await this.decideActionProposal(proposal, proposal.id, { decision: 'expired', actorRef: 'system', via: 'expiry', }); if (result.status === 'applied') changed++; } return changed; } async backfillActionProposalDiscoveryIndex(command) { validateActionProposalDiscoveryIndexBatch(command); await this.ensureProposalSchema(); const rows = await this.proposalDb .from(AGENT_TABLES.actionProposals) .where('discovery_index_version', 0) .orderBy('created_at', 'asc') .orderBy('logical_sort', 'asc') .limit(command.limit) .select('*'); let changed = 0; for (const row of rows) { const proposal = read(row); const result = await this.proposalDb .from(AGENT_TABLES.actionProposals) .where('id', row.id) .where('scope_key', row.scope_key) .where('version', row.version) .where('discovery_index_version', 0) .update({ ...discoveryMetadata(proposal), version: Number(row.version) + 1 }); if (affected(result) > 0) changed++; } return changed; } decideActionProposal(scope, id, command) { return this.mutate(scope, id, (p, now) => transitionActionProposalDecision(p, command, now)); } claimActionProposal(scope, id, command) { return this.mutate(scope, id, (p, now) => transitionActionProposalClaim(p, command, now, randomUUID())); } extendActionProposalLease(scope, id, command) { return this.mutate(scope, id, (p, now) => transitionActionProposalLease(p, command, now)); } settleActionProposal(scope, id, command) { return this.mutate(scope, id, (p, now) => transitionActionProposalSettlement(p, command, now)); } } //# sourceMappingURL=lucid-action-proposal-store.js.map