UNPKG

@adonis-agora/agent

Version:

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

790 lines • 31.4 kB
import { actionProposalOutcomeFenceValid, actionProposalOutcomeText, } from '../action-proposal-outcome.js'; import { snapshotActionProposal } from '../action-proposal-transitions.js'; import { sumUsage } from '../spi/agent-store.js'; import { toolCallApprovalFromRow } from '../spi/approval-policy.js'; import { InMemoryActionProposalStore } from './in-memory-action-proposal-store.js'; /** A fully in-memory `AgentStore` for tests and the offline demo. */ export class InMemoryAgentStore extends InMemoryActionProposalStore { threads = new Map(); /** Each thread's waiting messages, in run order. */ queues = new Map(); pauses = new Map(); /** Keyed by message and model-supplied id: two responses can be handed the same id (see `toolCallKey`). */ toolCalls = new Map(); usage = []; genuiCalls = new Map(); runs = new Map(); now() { return new Date().toISOString(); } async createThread(input) { const id = input.id ?? crypto.randomUUID(); if (this.threads.has(id)) { throw new Error(`thread ${id} already exists`); } const ts = this.now(); const row = { id, actorRef: input.actor.id, tenantRef: input.actor.tenantRef ?? null, title: input.title ?? 'New chat', persona: input.persona !== undefined && input.persona.length > 0 ? input.persona : null, transient: input.transient ?? false, createdAt: ts, updatedAt: ts, messages: [], }; this.threads.set(id, row); return this.toSummary(row); } async getThread(threadId) { const row = this.threads.get(threadId); if (row === undefined) { return null; } return { ...this.toSummary(row), messages: row.messages.map((message) => this.withApprovals(message)), }; } async getThreadActionProposalScope(threadId) { const row = this.threads.get(threadId); return row ? { threadId, actorRef: row.actorRef, tenantRef: row.tenantRef } : null; } async getThreadActorRef(threadId) { return this.threads.get(threadId)?.actorRef ?? null; } async listThreads(actorRef, limit = 50) { return [...this.threads.values()] .filter((row) => row.actorRef === actorRef && !row.transient) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .slice(0, limit) .map((row) => this.toSummary(row)); } async promoteThread(threadId) { const row = this.threads.get(threadId); if (row?.transient === true) { row.transient = false; row.updatedAt = this.now(); } } async softDeleteThread(threadId) { this.threads.delete(threadId); } async forkThread(threadId, fromMessageId) { const source = this.threads.get(threadId); if (source === undefined) { throw new Error(`thread ${threadId} not found`); } const cutoff = source.messages.findIndex((message) => message.id === fromMessageId); const kept = cutoff >= 0 ? source.messages.slice(0, cutoff + 1) : [...source.messages]; const id = crypto.randomUUID(); const ts = this.now(); const row = { id, actorRef: source.actorRef, tenantRef: source.tenantRef, title: source.title, persona: source.persona, transient: false, createdAt: ts, updatedAt: ts, // Feedback rates a message in ITS thread; a fork starts unrated. messages: kept.map(({ feedback: _feedback, ...message }) => ({ ...message })), ...(source.model != null ? { model: source.model } : {}), ...(source.defaultAgent != null ? { defaultAgent: source.defaultAgent } : {}), }; this.threads.set(id, row); return this.toSummary(row); } async setTitle(threadId, title) { const row = this.threads.get(threadId); if (row !== undefined) { row.title = title; row.updatedAt = this.now(); } } async updateThread(threadId, patch) { const row = this.threads.get(threadId); if (row === undefined) { return; } if (patch.title !== undefined) { row.title = patch.title; } if (patch.model !== undefined) { row.model = patch.model; } if (patch.defaultAgent !== undefined) { row.defaultAgent = patch.defaultAgent; } if (patch.persona !== undefined) { row.persona = patch.persona; } row.updatedAt = this.now(); } async defaultAgentForThread(threadId) { return this.threads.get(threadId)?.defaultAgent ?? null; } async personaForThread(threadId) { return this.threads.get(threadId)?.persona ?? null; } async clearActiveStream(threadId, runId) { const row = this.threads.get(threadId); if (row !== undefined && row.activeStreamId === runId) { delete row.activeStreamId; } } async activeRunForThread(threadId) { return this.threads.get(threadId)?.activeStreamId ?? null; } async threadHeldByRun(runId) { for (const [threadId, row] of this.threads) { if (row.activeStreamId === runId) { return threadId; } } return null; } async claimActiveStream(threadId, runId, options = {}) { const row = this.threads.get(threadId); if (row === undefined) { return false; } const holder = row.activeStreamId; if (holder !== undefined && holder !== runId && holder !== options.replacing) { return false; } row.activeStreamId = runId; return true; } async releaseActiveStream(threadId, runId) { const row = this.threads.get(threadId); if (row?.activeStreamId !== runId) { return false; } delete row.activeStreamId; return true; } async enqueueMessage(input) { const ts = this.now(); const message = { id: crypto.randomUUID(), threadId: input.threadId, actor: input.actor, content: input.content, ...(input.attachments !== undefined && input.attachments.length > 0 ? { attachments: input.attachments } : {}), ...(input.agentName !== undefined ? { agentName: input.agentName } : {}), ...(input.persona !== undefined ? { persona: input.persona } : {}), ...(input.model !== undefined ? { model: input.model } : {}), ...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}), ...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}), ...(input.hostContext !== undefined ? { hostContext: input.hostContext } : {}), ...(input.sentAt !== undefined ? { sentAt: input.sentAt } : {}), ...(input.interrupt === true ? { interrupt: true } : {}), createdAt: ts, updatedAt: ts, }; const queue = this.queues.get(input.threadId) ?? []; if (input.at === 'head') { queue.unshift(message); } else { queue.push(message); } this.queues.set(input.threadId, queue); return { ...message }; } async listQueue(threadId) { return (this.queues.get(threadId) ?? []).map((message) => ({ ...message })); } async getQueuedMessage(id) { const found = this.findQueued(id); return found === undefined ? null : { ...found.message }; } async updateQueuedMessage(id, patch) { const found = this.findQueued(id); if (found === undefined) { return null; } const { message } = found; if (patch.content !== undefined) { message.content = patch.content; } if (patch.attachments !== undefined) { if (patch.attachments === null || patch.attachments.length === 0) { delete message.attachments; } else { message.attachments = patch.attachments; } } if (patch.interrupt === true) { message.interrupt = true; } else if (patch.interrupt === false) { delete message.interrupt; } message.updatedAt = this.now(); return { ...message }; } async moveQueuedMessage(id, index) { const found = this.findQueued(id); if (found === undefined) { return false; } const { queue, position, message } = found; queue.splice(position, 1); const target = Math.max(0, Math.min(queue.length, Math.trunc(index))); queue.splice(target, 0, message); return true; } async removeQueuedMessage(id) { const found = this.findQueued(id); if (found === undefined) { return false; } found.queue.splice(found.position, 1); return true; } async clearQueue(threadId) { const count = this.queues.get(threadId)?.length ?? 0; this.queues.delete(threadId); return count; } async queuePause(threadId) { return this.pauses.get(threadId) ?? null; } async setQueuePause(threadId, pause) { if (pause === null) { this.pauses.delete(threadId); } else { this.pauses.set(threadId, pause); } } findQueued(id) { for (const queue of this.queues.values()) { const position = queue.findIndex((message) => message.id === id); const message = queue[position]; if (message !== undefined) { return { queue, position, message }; } } return undefined; } async setActiveStream(threadId, runId) { const row = this.threads.get(threadId); if (row !== undefined) { if (runId === null) { delete row.activeStreamId; } else { row.activeStreamId = runId; } } } async admitActionProposalOutcome(command) { const row = [...this.rows.values()].find((row) => row.outcome?.id === command.outcomeId); if (!row?.outcome || !row.outcomeDelivery) return { status: 'not_found' }; if (row.outcomeDelivery.status === 'admitted') return { status: 'unchanged', ...(row.outcomeDelivery.messageId !== undefined ? { messageId: row.outcomeDelivery.messageId } : {}), }; if (row.outcomeDelivery.status === 'discarded') return { status: 'discarded' }; if (!actionProposalOutcomeFenceValid(row, command, this.clock())) return { status: 'conflict' }; const thread = this.threads.get(row.threadId); if (!thread) { this.rows.set(row.id, snapshotActionProposal({ ...row, outcomeDelivery: { ...row.outcomeDelivery, status: 'discarded', lease: null }, })); return { status: 'discarded' }; } if (thread.actorRef !== row.actorRef || thread.tenantRef !== row.tenantRef) return { status: 'conflict' }; if (thread.activeStreamId) return { status: 'busy' }; // No await between checking the thread holder and committing both in-memory mutations. const message = { id: crypto.randomUUID(), role: 'assistant', content: actionProposalOutcomeText(row.outcome), createdAt: this.now(), actionProposalOutcome: snapshotActionProposal(row.outcome), ui: snapshotActionProposal(row.outcome.ui), }; const next = snapshotActionProposal({ ...row, outcomeDelivery: { ...row.outcomeDelivery, status: 'admitted', lease: null, messageId: message.id, }, }); thread.messages.push(message); thread.updatedAt = message.createdAt; this.rows.set(row.id, next); return { status: 'applied', messageId: message.id }; } async appendMessage(input) { const row = this.threads.get(input.threadId); if (row === undefined) { throw new Error(`thread ${input.threadId} not found`); } const message = { id: crypto.randomUUID(), role: input.role, content: input.content, createdAt: this.now(), ...(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 ? { sentAt: input.sentAt } : {}), }; row.messages.push(message); row.updatedAt = message.createdAt; return message; } async setMessageUi(messageId, ui) { for (const row of this.threads.values()) { const index = row.messages.findIndex((candidate) => candidate.id === messageId); const message = row.messages[index]; if (message !== undefined) { row.messages[index] = { ...message, ui }; return; } } } async setMessageToolResults(messageId, results) { for (const row of this.threads.values()) { const message = row.messages.find((candidate) => candidate.id === messageId); if (message !== undefined) { message.toolResults = results; return; } } } async truncateFrom(threadId, messageId) { const row = this.threads.get(threadId); if (row === undefined) { return; } const cutoff = row.messages.findIndex((message) => message.id === messageId); if (cutoff >= 0) { row.messages = row.messages.slice(0, cutoff); } } /** * Of `mediaIds`, the ones a surviving (or queued) message in one of this actor's threads still * carries. Re-derived from the messages each call, so a media whose message was truncated away reads as * unreferenced again. */ async referencedMediaIds(actorRef, mediaIds) { if (mediaIds.length === 0) { return []; } const wanted = new Set(mediaIds); const found = new Set(); for (const thread of this.threads.values()) { if (thread.actorRef !== actorRef) { continue; } // A message waiting in the thread's queue carries its attachments too: it has been sent, it // just has not run yet — collecting its media would fail the turn it is waiting to start. const queued = this.queues.get(thread.id) ?? []; for (const message of [...thread.messages, ...queued]) { for (const attachment of message.attachments ?? []) { if (wanted.has(attachment.mediaId)) { found.add(attachment.mediaId); } } } } return [...wanted].filter((mediaId) => found.has(mediaId)); } async threadOfMessage(messageId) { return this.threadIdForMessage(messageId) ?? null; } async setMessageFeedback(messageId, feedback) { for (const row of this.threads.values()) { const index = row.messages.findIndex((candidate) => candidate.id === messageId); const message = row.messages[index]; if (message !== undefined) { const { feedback: _previous, ...rest } = message; row.messages[index] = feedback !== null ? { ...rest, feedback } : rest; return; } } } /** * A provider only promises a call id is unique within one response (one assistant message), so a * call is keyed by its message too — as `LucidAgentStore` keys its rows. */ toolCallKey(toolCallId, messageId) { return `${messageId}\u0000${toolCallId}`; } /** * The newest call `runId` recorded under `toolCallId`, or without a run the newest overall — the * same rule as `LucidAgentStore`. Insertion order is recording order. */ findToolCall(toolCallId, runId) { let newest; for (const row of this.toolCalls.values()) { if (row.toolCallId === toolCallId && (runId === undefined || row.runId === runId)) { newest = row; } } return newest; } async toolCallInput(toolCallId, runId) { return this.findToolCall(toolCallId, runId)?.input ?? null; } async getToolCallRunId(toolCallId) { return this.findToolCall(toolCallId)?.runId ?? null; } async toolCallOutcomes(toolCallIds) { const outcomes = []; for (const id of toolCallIds) { const row = this.findToolCall(id); if (row !== undefined) { outcomes.push({ id, status: row.status, ...(row.output !== undefined ? { output: row.output } : {}), ...(row.error !== undefined ? { error: row.error } : {}), }); } } return outcomes; } async failUnsettledToolCalls(runId, error) { let settled = 0; for (const row of this.toolCalls.values()) { if (row.runId === runId && row.status === 'pending_approval') { row.status = 'failed'; row.error = error; settled += 1; } } return settled; } async recordToolCall(input) { this.toolCalls.set(this.toolCallKey(input.toolCallId, input.messageId), { toolCallId: input.toolCallId, messageId: input.messageId, threadId: this.threadIdForMessage(input.messageId) ?? '', toolName: input.toolName, toolType: input.toolType, input: input.input, status: input.status, ...(input.proposalId !== undefined ? { proposalId: input.proposalId } : {}), createdAt: this.now(), ...(input.runId !== undefined ? { runId: input.runId } : {}), ...(input.confirmation !== undefined ? { confirmation: { ...input.confirmation } } : {}), ...(input.approver !== undefined ? { approver: input.approver } : {}), ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}), }); } async updateToolCall(input) { const row = this.findToolCall(input.toolCallId, input.runId); if (row === undefined) { return; } row.status = input.status; if (input.output !== undefined) { row.output = input.output; } if (input.error !== undefined) { row.error = input.error; } if (input.executionMs !== undefined) { row.executionMs = input.executionMs; } if (input.executedByRef !== undefined) { row.executedByRef = input.executedByRef; } if (input.remember !== undefined) { row.remember = input.remember; } if (input.decidedVia !== undefined) { row.decidedVia = input.decidedVia; } } async rememberedApprovals(threadId) { const names = new Set(); const scope = await this.getThreadActionProposalScope(threadId); if (!scope) return []; for (const proposal of this.rows.values()) if (proposal.threadId === threadId && proposal.actorRef === scope.actorRef && proposal.tenantRef === scope.tenantRef && proposal.decision === 'approved' && (proposal.execution?.status === 'succeeded' || proposal.execution?.status === 'failed') && proposal.decisionAudit?.remember === true) names.add(proposal.toolName); for (const call of this.toolCalls.values()) { if (call.threadId === threadId && call.remember === true) { names.add(call.toolName); } } return [...names]; } async toolCallApproval(toolCallId, runId) { const call = this.findToolCall(toolCallId, runId); if (call === undefined) { return null; } return { status: call.status, approver: call.approver ?? null, expiresAt: call.expiresAt ?? null, }; } /** A message with the approval record of every call on it that was put to a person. */ withApprovals(message) { const approvals = []; for (const call of this.toolCalls.values()) { if (call.messageId !== message.id) { continue; } const approval = toolCallApprovalFromRow({ toolCallId: call.toolCallId, proposalId: call.proposalId, status: call.status, approver: call.approver, confirmation: call.confirmation, expiresAt: call.expiresAt, remember: call.remember, executedByRef: call.executedByRef, decidedVia: call.decidedVia, error: call.error, }); if (approval !== null) { approvals.push(approval); } } return approvals.length > 0 ? { ...message, approvals } : message; } async recordGenuiCalls(rows) { for (const row of rows) { const existing = this.genuiCalls.get(row.toolCallId); this.genuiCalls.set(row.toolCallId, { ...row, createdAt: existing?.createdAt ?? new Date(this.now()), }); } } /** Generative-UI telemetry rows, oldest first (test helper; the governance feed). */ genuiCallRows() { return [...this.genuiCalls.values()].map((row) => ({ ...row })); } async recordUsage(input) { const createdAt = this.now(); this.usage.push({ actorRef: input.actorRef, threadId: input.threadId, modelId: input.modelId, purpose: input.purpose, inputTokens: input.usage.inputTokens, outputTokens: input.usage.outputTokens, day: createdAt.slice(0, 10), createdAt, ...(input.runId !== undefined ? { runId: input.runId } : {}), ...(input.usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: input.usage.cacheWriteTokens } : {}), ...(input.usage.cacheReadTokens !== undefined ? { cacheReadTokens: input.usage.cacheReadTokens } : {}), ...(input.costUsd !== undefined ? { costUsd: input.costUsd } : {}), ...(input.costUsd !== undefined && input.costSource !== undefined ? { costSource: input.costSource } : {}), }); } async getRunActorRef(runId) { return this.runs.get(runId)?.actorRef ?? null; } async recordRunStart(input) { this.runs.set(input.runId, { runId: input.runId, threadId: input.threadId, actorRef: input.actor.id, status: 'running', startedAt: this.now(), stepCount: 0, inputTokens: 0, outputTokens: 0, durable: input.durable ?? false, ...(input.actor.tenantRef !== undefined ? { tenantRef: input.actor.tenantRef } : {}), ...(input.agentName !== undefined ? { agentName: input.agentName } : {}), ...(input.parentRunId !== undefined ? { parentRunId: input.parentRunId } : {}), }); } async recordRunEnd(input) { const row = this.runs.get(input.runId); // First terminal wins: a no-op when the run is unknown or already settled (mirrors the Lucid twin). if (row === undefined || row.status !== 'running') { return; } row.status = input.status; row.finishedAt = input.finishedAt !== undefined ? new Date(input.finishedAt).toISOString() : this.now(); if (input.stepCount !== undefined) row.stepCount = input.stepCount; if (input.inputTokens !== undefined) row.inputTokens = input.inputTokens; if (input.outputTokens !== undefined) row.outputTokens = input.outputTokens; if (input.costUsd !== undefined) row.costUsd = input.costUsd; if (input.error !== undefined) row.error = input.error; if (input.ttftMs !== undefined) row.ttftMs = input.ttftMs; if (input.errorCode !== undefined) row.errorCode = input.errorCode; if (input.modelId !== undefined) row.modelId = input.modelId; } async usageBetween(actorRef, fromDay, toDay) { return sumUsage(this.usage.filter((row) => row.actorRef === actorRef && row.day >= fromDay && row.day <= toDay)); } async quotaToday(actorRef, day) { const usedTokens = this.usage .filter((row) => row.actorRef === actorRef && row.day === day) .reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0); return { usedTokens }; } /** Test helper: read the recorded usage rows (modelId + purpose + token totals). */ usageRows() { return this.usage.map((row) => ({ actorRef: row.actorRef, tokens: row.inputTokens + row.outputTokens, modelId: row.modelId, purpose: row.purpose, })); } /** Governance read-model feed: recorded usage rows with the input/output split + thread/day. */ governanceUsage() { return this.usage.map((row) => ({ actorRef: row.actorRef, threadId: row.threadId, modelId: row.modelId, purpose: row.purpose, inputTokens: row.inputTokens, outputTokens: row.outputTokens, day: row.day, createdAt: row.createdAt, ...(row.runId !== undefined ? { runId: row.runId } : {}), ...(row.cacheWriteTokens !== undefined ? { cacheWriteTokens: row.cacheWriteTokens } : {}), ...(row.cacheReadTokens !== undefined ? { cacheReadTokens: row.cacheReadTokens } : {}), ...(row.costUsd !== undefined ? { costUsd: row.costUsd } : {}), ...(row.costSource !== undefined ? { costSource: row.costSource } : {}), })); } /** Governance read-model feed: recorded tool calls with the resolved thread + timestamp. */ governanceToolCalls() { return [...this.toolCalls.values()].map((row) => ({ toolCallId: row.toolCallId, toolName: row.toolName, toolType: row.toolType, status: row.status, threadId: row.threadId, input: row.input, createdAt: row.createdAt, ...(row.runId !== undefined ? { runId: row.runId } : {}), ...(row.output !== undefined ? { output: row.output } : {}), ...(row.error !== undefined ? { error: row.error } : {}), ...(row.executionMs !== undefined ? { executionMs: row.executionMs } : {}), })); } /** Governance read-model feed: recorded runs (full lifecycle + rollups). */ governanceRuns() { return [...this.runs.values()].map((row) => ({ ...row })); } /** Governance read-model feed: recorded messages with their thread + run correlation. */ governanceMessages() { const rows = []; for (const thread of this.threads.values()) { for (const message of thread.messages) { rows.push({ id: message.id, threadId: thread.id, role: message.role, content: message.content, createdAt: message.createdAt, ...(message.runId !== undefined ? { runId: message.runId } : {}), }); } } return rows; } /** Governance read-model feed: thread metadata (title/actor/message count/last activity). */ governanceThreads() { return [...this.threads.values()].map((row) => ({ threadId: row.id, title: row.title, actorRef: row.actorRef, messageCount: row.messages.length, updatedAt: row.updatedAt, })); } threadIdForMessage(messageId) { for (const [threadId, row] of this.threads) { if (row.messages.some((message) => message.id === messageId)) { return threadId; } } return undefined; } /** Test helper: read the recorded tool-call rows. */ toolCallRows() { return [...this.toolCalls.values()].map((row) => ({ toolCallId: row.toolCallId, ...(row.runId !== undefined ? { runId: row.runId } : {}), toolName: row.toolName, status: row.status, ...(row.output !== undefined ? { output: row.output } : {}), })); } toSummary(row) { const last = row.messages[row.messages.length - 1]; return { id: row.id, title: row.title, persona: row.persona, transient: row.transient, createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.pinnedAt !== undefined ? { pinnedAt: row.pinnedAt } : {}), ...(last !== undefined ? { lastMessagePreview: last.content.slice(0, 120) } : {}), model: row.model ?? null, defaultAgent: row.defaultAgent ?? null, activeRunId: row.activeStreamId ?? null, }; } } //# sourceMappingURL=in-memory-store.js.map