@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
311 lines • 13.5 kB
JavaScript
import { attachActionProposalOutcome } from './action-proposal-outcome.js';
import { validateUiCapabilities } from './ui-capabilities.js';
function checkTime(value) {
if (!Number.isSafeInteger(value))
throw new RangeError('Action proposal timestamps must be safe integer milliseconds');
}
function checkString(value) {
if (typeof value !== 'string' || value.length === 0)
throw new TypeError('Action proposal identifiers must be nonempty strings');
}
export function validateActionProposalCreation(input) {
const allowed = [
'id',
'tenantRef',
'actorRef',
'threadId',
'originRunId',
'originMessageId',
'originToolCallId',
'toolName',
'input',
'preparationInput',
'executionContext',
'confirmation',
'approver',
'expiresAt',
'idempotencyKey',
'replacementKey',
];
if (Object.keys(input).some((key) => !allowed.includes(key)))
throw new TypeError('Unknown action proposal creation field');
for (const key of [
'id',
'actorRef',
'threadId',
'originRunId',
'originMessageId',
'originToolCallId',
'toolName',
'approver',
'idempotencyKey',
])
checkString(input[key]);
if (input.tenantRef !== null && typeof input.tenantRef !== 'string')
throw new TypeError('tenantRef must be an explicit string or null');
for (const value of [input.id, input.actorRef, input.threadId, input.tenantRef]) {
if (value !== null && value.length > 255)
throw new RangeError('Action proposal id and scope must fit 255 UTF-16 code units');
}
if (input.replacementKey !== undefined) {
checkString(input.replacementKey);
if (input.replacementKey.length > 255)
throw new RangeError('replacementKey must fit 255 UTF-16 code units');
}
if (Object.hasOwn(input, 'executionContext')) {
const context = input.executionContext;
if (context === null || typeof context !== 'object' || Array.isArray(context))
throw new TypeError('executionContext must be a JSON object');
const allowedContext = ['agentName', 'persona', 'requestId', 'pageContext', 'uiCapabilities'];
if (Object.keys(context).some((key) => !allowedContext.includes(key)))
throw new TypeError('Unknown execution context field');
checkString(context.requestId);
if (Object.hasOwn(context, 'uiCapabilities'))
validateUiCapabilities(context.uiCapabilities);
if (Object.hasOwn(context, 'agentName'))
checkString(context.agentName);
if (Object.hasOwn(context, 'persona'))
checkString(context.persona);
if (Object.hasOwn(context, 'pageContext')) {
const page = context.pageContext;
if (page === null || typeof page !== 'object' || Array.isArray(page))
throw new TypeError('pageContext must be a JSON object');
if (Object.hasOwn(page, 'kind') && typeof page.kind !== 'string')
throw new TypeError('pageContext.kind must be a string');
}
}
if (input.expiresAt !== null)
checkTime(input.expiresAt);
checkString(input.confirmation?.title);
checkString(input.confirmation?.verb);
canonicalActionProposalJson(input);
}
export function validateActionProposalListQuery(query = {}) {
const limit = query.limit === undefined ? 100 : query.limit;
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
throw new RangeError('limit must be an integer from 1 to 1000');
if (query.decision !== undefined &&
!['pending', 'approved', 'rejected', 'expired', 'superseded'].includes(query.decision))
throw new TypeError('Unsupported action proposal list decision');
if (query.after !== undefined) {
const after = query.after;
if (typeof after !== 'object' ||
after === null ||
Array.isArray(after) ||
Object.keys(after).some((key) => key !== 'createdAt' && key !== 'id'))
throw new TypeError('Invalid action proposal cursor');
checkTime(after.createdAt);
checkString(after.id);
if (after.id.length > 255)
throw new TypeError('Action proposal cursor id is too long');
}
return limit;
}
/** Canonical JSON snapshots keep replay equality independent of object key insertion order. */
export function canonicalActionProposalJson(value) {
const ancestors = new Set();
const sort = (item) => {
if (item === null || typeof item === 'string' || typeof item === 'boolean')
return item;
if (typeof item === 'number' && Number.isFinite(item))
return item;
if (typeof item !== 'object' || item === null || ancestors.has(item))
throw new TypeError('Action proposals require finite, acyclic JSON values');
const prototype = Object.getPrototypeOf(item);
if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null)
throw new TypeError('Action proposals require plain JSON objects');
ancestors.add(item);
const sorted = Array.isArray(item)
? Array.from(item, sort)
: Object.fromEntries(Object.keys(item)
.sort()
.map((key) => [key, sort(item[key])]));
ancestors.delete(item);
return sorted;
};
return JSON.stringify(sort(value));
}
export function snapshotActionProposal(value) {
return JSON.parse(canonicalActionProposalJson(value));
}
export function actionProposalScopeMatches(row, scope) {
return (row.tenantRef === scope.tenantRef &&
row.actorRef === scope.actorRef &&
row.threadId === scope.threadId);
}
export function initialActionProposal(input, now) {
validateActionProposalCreation(input);
checkTime(now);
return snapshotActionProposal({
...input,
decision: 'pending',
decisionAudit: null,
execution: null,
createdAt: now,
updatedAt: now,
});
}
export function actionProposalCreationMatches(row, input) {
validateActionProposalCreation(input);
const { decision: _decision, decisionAudit: _audit, execution: _execution, createdAt: _created, updatedAt: _updated, supersededBy: _supersededBy, outcome: _outcome, outcomeDelivery: _delivery, ...original } = row;
return canonicalActionProposalJson(original) === canonicalActionProposalJson(input);
}
function result(status, proposal) {
return { status, proposal: snapshotActionProposal(proposal) };
}
export function transitionActionProposalDecision(row, command, now) {
checkTime(now);
if (!['approved', 'rejected', 'expired'].includes(command.decision))
throw new TypeError('Unsupported action proposal decision');
checkString(command.actorRef);
checkString(command.via);
if (command.reason !== undefined && typeof command.reason !== 'string')
throw new TypeError('reason must be a string');
if (command.remember !== undefined && typeof command.remember !== 'boolean')
throw new TypeError('remember must be a boolean');
if (row.decision !== 'pending') {
return result(row.decision === command.decision
? 'unchanged'
: row.decision === 'expired'
? 'expired'
: 'conflict', row);
}
const next = snapshotActionProposal(row);
if (row.expiresAt !== null && now >= row.expiresAt) {
next.decision = 'expired';
next.decisionAudit = { actorRef: 'system', via: 'expiry', at: now };
next.updatedAt = now;
return result(command.decision === 'expired' ? 'applied' : 'expired', attachActionProposalOutcome(next, now));
}
if (command.decision === 'expired')
return result('conflict', row);
const { decision, ...audit } = command;
next.decision = decision;
next.decisionAudit = {
actorRef: audit.actorRef,
via: audit.via,
at: now,
...(audit.reason === undefined ? {} : { reason: audit.reason }),
...(audit.remember === undefined ? {} : { remember: audit.remember }),
};
next.updatedAt = now;
if (decision === 'approved')
next.execution = { status: 'queued', generation: 0, lease: null };
return result('applied', decision === 'approved' ? next : attachActionProposalOutcome(next, now));
}
function checkLeaseMs(leaseMs, now) {
checkTime(now);
if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || !Number.isSafeInteger(now + leaseMs))
throw new RangeError('leaseMs must be finite and positive');
}
export function transitionActionProposalClaim(row, command, now, token) {
checkLeaseMs(command.leaseMs, now);
checkString(command.workerId);
checkString(token);
const execution = row.execution;
if (row.decision !== 'approved' ||
!execution ||
(execution.status !== 'queued' &&
!(execution.status === 'executing' && execution.lease && now >= execution.lease.expiresAt)))
return result('conflict', row);
const next = snapshotActionProposal(row);
const generation = execution.generation + 1;
next.execution = {
status: 'executing',
generation,
lease: { token, generation, workerId: command.workerId, expiresAt: now + command.leaseMs },
};
next.updatedAt = now;
return result('applied', next);
}
function leaseStatus(row, command, now) {
checkTime(now);
checkString(command.token);
if (!Number.isSafeInteger(command.generation) || command.generation < 1)
throw new RangeError('Invalid lease generation');
const work = row.execution;
if (row.decision !== 'approved' ||
work?.status !== 'executing' ||
!work.lease ||
work.generation !== command.generation ||
work.lease.generation !== command.generation ||
work.lease.token !== command.token)
return 'conflict';
return now >= work.lease.expiresAt ? 'expired' : 'valid';
}
export function transitionActionProposalLease(row, command, now) {
checkLeaseMs(command.leaseMs, now);
const status = leaseStatus(row, command, now);
if (status !== 'valid')
return result(status, row);
const next = snapshotActionProposal(row);
if (next.execution?.lease)
next.execution.lease.expiresAt = Math.max(next.execution.lease.expiresAt, now + command.leaseMs);
next.updatedAt = now;
return result('applied', next);
}
export function transitionActionProposalSettlement(row, command, now) {
checkTime(now);
if (command.status !== 'succeeded' && command.status !== 'failed')
throw new TypeError('Unsupported settlement status');
if ((command.status === 'succeeded' && command.error !== undefined) ||
(command.status === 'failed' &&
(typeof command.error !== 'string' || command.result !== undefined)))
throw new TypeError('Ambiguous action proposal settlement');
const status = leaseStatus(row, command, now);
if (status !== 'valid')
return result(status, row);
const next = snapshotActionProposal(row);
next.execution =
command.status === 'succeeded'
? {
status: 'succeeded',
generation: command.generation,
lease: null,
...(command.result === undefined ? {} : { result: command.result }),
}
: { status: 'failed', generation: command.generation, lease: null, error: command.error };
if (command.ui !== undefined)
canonicalActionProposalJson(command.ui);
if (command.text !== undefined && typeof command.text !== 'string')
throw new TypeError('Outcome text must be a string');
next.updatedAt = now;
return result('applied', attachActionProposalOutcome(next, now, command.ui, command.text));
}
/** Caller must obtain the replacement through the same full scope and persistence authority. */
export function transitionActionProposalSupersession(row, replacement, command, now) {
checkTime(now);
checkString(command.replacementProposalId);
checkString(command.actorRef);
checkString(command.via);
if (replacement.id !== command.replacementProposalId ||
row.id === replacement.id ||
!actionProposalScopeMatches(row, replacement) ||
row.toolName !== replacement.toolName ||
!row.replacementKey ||
row.replacementKey !== replacement.replacementKey)
return result('conflict', row);
if (row.decision === 'superseded')
return result(row.supersededBy === replacement.id ? 'unchanged' : 'conflict', row);
if (row.decision !== 'pending')
return result('conflict', row);
if (row.expiresAt !== null && now >= row.expiresAt)
return {
...transitionActionProposalDecision(row, { decision: 'expired', actorRef: 'system', via: 'expiry' }, now),
status: 'expired',
};
const next = snapshotActionProposal({
...row,
decision: 'superseded',
supersededBy: replacement.id,
decisionAudit: {
actorRef: command.actorRef,
via: command.via,
replacementProposalId: replacement.id,
at: now,
},
updatedAt: now,
});
return result('applied', attachActionProposalOutcome(next, now));
}
//# sourceMappingURL=action-proposal-transitions.js.map