UNPKG

@adonis-agora/agent

Version:

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

137 lines • 6.79 kB
import { DEFAULT_TEXT_ACTION_PROPOSAL_REPLIES, DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY, parseTextActionProposalCommand, resolveTextActionProposalDecision, textActionProposalReply, } from './action-proposal-text.js'; import { validateActionProposalListQuery } from './action-proposal-transitions.js'; import { mayDecideApproval } from './spi/approval-policy.js'; export class ActionProposalServiceError extends Error { status; constructor(status, message) { super(message); this.status = status; } } /** Authenticated decisions live independently of the origin runner and its signals. */ export class ActionProposalService { store; policy; /** The words a text decision is made of — `actionProposalText.vocabulary` over the defaults. */ vocabulary; replies; constructor(store, policy, text = {}) { this.store = store; this.policy = policy; this.vocabulary = { ...DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY, ...text.vocabulary }; this.replies = { ...DEFAULT_TEXT_ACTION_PROPOSAL_REPLIES, ...text.replies }; } async scope(actor, threadId) { const scope = await this.store.getThreadActionProposalScope(threadId); if (!scope) throw new ActionProposalServiceError(404, 'Unknown thread'); if ((actor.tenantRef ?? null) !== scope.tenantRef) throw new ActionProposalServiceError(403, 'Forbidden tenant'); return scope; } async list(actor, threadId, after) { return (await this.listPage(actor, threadId, after)).items; } async listPage(actor, threadId, after) { const scope = await this.scope(actor, threadId); const query = { limit: 1000, ...(after !== undefined ? { after } : {}) }; validateActionProposalListQuery(query); const raw = await this.store.listActionProposals(scope, query); const last = raw.at(-1); const next = raw.length === 1000 && last ? { createdAt: last.createdAt, id: last.id } : undefined; const permitted = actor.id === scope.actorRef ? raw : (await Promise.all(raw.map(async (proposal) => (await this.canDecide(actor, proposal)) ? proposal : null))).filter((proposal) => proposal !== null); return { items: permitted, ...(next !== undefined ? { next } : {}) }; } async handleTextDecision(actor, threadId, text) { const command = parseTextActionProposalCommand(text, this.vocabulary); if (command.status === 'unmatched') return command; if (command.proposalId !== undefined) { // An `#ID` naming no proposal of this thread is an ordinary message, not a failed decision — // the same answer the candidate resolver gives. const scope = await this.scope(actor, threadId); if (!(await this.store.getActionProposal(scope, command.proposalId))) return { status: 'unmatched' }; const result = await this.decide(actor, threadId, command.proposalId, command.decision, { remember: command.remember }, 'text'); return { threadId, proposalDecision: result, text: this.reply(result, command.decision), }; } const scope = await this.scope(actor, threadId); const visible = await this.store.listActionProposals(scope, { decision: 'pending', limit: 1000, }); const permitted = await Promise.all(visible.map(async (proposal) => ((await this.canDecide(actor, proposal)) ? proposal : null))); const resolution = resolveTextActionProposalDecision(text, permitted.filter((proposal) => proposal !== null), this.vocabulary); if (resolution.status === 'unmatched') return resolution; if (visible.length === 1000 && resolution.status === 'decision' && !text.includes('#')) return { threadId, proposalDecision: { status: 'ambiguous', proposalIds: permitted .filter((proposal) => proposal !== null) .map((proposal) => proposal.id), }, text: this.replies.tooMany, }; if (resolution.status === 'ambiguous') return { threadId, proposalDecision: resolution, text: this.replies.ambiguous(resolution.proposalIds), }; const result = await this.decide(actor, threadId, resolution.proposalId, resolution.decision, { remember: resolution.remember }, 'text'); return { threadId, proposalDecision: result, text: this.reply(result, resolution.decision), }; } /** The configured reply to a decision the store answered with `result`. */ reply(result, decision) { return textActionProposalReply(result, decision, this.replies); } canDecide(actor, proposal) { return mayDecideApproval(this.policy, actor, { toolCallId: proposal.originToolCallId, approver: proposal.approver, requesterRef: proposal.actorRef, }); } async decide(actor, threadId, proposalId, decision, body, via = 'web') { if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ActionProposalServiceError(400, 'Invalid decision body'); const fields = Object.keys(body); if (fields.some((field) => !['remember', 'reason'].includes(field))) throw new ActionProposalServiceError(400, 'Invalid decision fields'); const { remember, reason } = body; if ((remember !== undefined && typeof remember !== 'boolean') || (reason !== undefined && typeof reason !== 'string')) throw new ActionProposalServiceError(400, 'Invalid decision values'); const scope = await this.scope(actor, threadId); const proposal = await this.store.getActionProposal(scope, proposalId); if (!proposal) throw new ActionProposalServiceError(404, 'Unknown proposal'); if (!(await mayDecideApproval(this.policy, actor, { toolCallId: proposal.originToolCallId, approver: proposal.approver, requesterRef: proposal.actorRef, }))) throw new ActionProposalServiceError(403, 'Forbidden decision'); return this.store.decideActionProposal(scope, proposalId, { decision, actorRef: actor.id, via, ...(typeof remember === 'boolean' ? { remember } : {}), ...(typeof reason === 'string' ? { reason } : {}), }); } } //# sourceMappingURL=action-proposal-service.js.map