@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
JavaScript
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