@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
1,153 lines • 58 kB
JavaScript
import { assertIndependentActionRuntime } from './action-proposal-runtime.js';
import { ActionProposalServiceError } from './action-proposal-service.js';
import { DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY, textActionProposalReply, } from './action-proposal-text.js';
import { utcDay } from './agent-deps.js';
import { DEFAULT_ALLOWED_ATTACHMENT_CONTENT_TYPES, DEFAULT_MAX_ATTACHMENT_BYTES, } from './attachment-limits.js';
import { RunNotActiveError, settleDeadRun } from './dead-run.js';
import { readElicitationQuestions, validateElicitationAnswer } from './elicitation-input.js';
import { normalizeSentAt } from './message-timestamps.js';
import { personaCatalogEntry } from './personas.js';
import { mayDecideApproval, } from './spi/approval-policy.js';
import { findCatalogModel, ModelNotAllowedError, } from './spi/model-catalog.js';
import { QuotaBlockedError } from './spi/quota-provider.js';
import { LucidAgentStore } from './stores/lucid.js';
import { threadPersona } from './thread-persona.js';
import { validateUiCapabilities } from './ui-capabilities.js';
/** A feedback comment is stored with the message — bounded like any other stored text. */
const MAX_FEEDBACK_COMMENT_LENGTH = 2000;
/**
* A queue request the service refuses — `status` and `code` are what the routes answer:
* `409 run_active` (a start-or-refuse {@link AgentService.chat}, or a regenerate, on a busy thread),
* `410` (a queued message that already started or was removed), `404` (no such queued message),
* `400` (a bad patch), `501` (the store cannot hold a queue).
*/
export class ChatQueueError extends Error {
status;
code;
constructor(status, message, code) {
super(message);
this.status = status;
this.code = code;
this.name = 'ChatQueueError';
}
}
/** `regenerate: true` on a send without a `threadId` — there is no answer to regenerate. */
export class RegenerateNeedsThreadError extends Error {
constructor() {
super('regenerate requires an existing threadId');
this.name = 'RegenerateNeedsThreadError';
}
}
/** A send's attachments were refused — `status` is what the chat route answers (`403`, `501`). */
/** A thread's `defaultAgent` named an agent this app does not register. */
export class UnknownAgentError extends Error {
agentName;
constructor(agentName) {
super(`unknown agent "${agentName}"`);
this.agentName = agentName;
this.name = 'UnknownAgentError';
}
}
export class AttachmentRefusedError extends Error {
status;
constructor(status, message) {
super(message);
this.status = status;
this.name = 'AttachmentRefusedError';
}
}
/**
* The staged-attachment inventory cannot be answered here — no attachment store, a store without
* `list`, or an agent store without `referencedMediaIds`. `status` is what `GET <path>/attachments`
* answers. Never answered with an empty list instead: "no inventory" and "no files" are opposite
* facts, and a sweep that read the first as the second would delete files that are in use.
*/
export class AttachmentInventoryError extends Error {
status = 501;
constructor(message) {
super(message);
this.name = 'AttachmentInventoryError';
}
}
export class AgentService {
runner;
store;
deps;
options;
constructor(runner, store, deps, options = {}) {
this.runner = runner;
this.store = store;
this.deps = deps;
this.options = options;
const mode = typeof deps.actionApprovalMode === 'function' ? deps.actionApprovalMode() : 'blocking';
assertIndependentActionRuntime(mode, store, options.backgroundActorResolver, options.queue?.supported === true);
if (mode === 'independent' && !options.actionProposals)
throw new Error('Independent action decision service is required');
}
/**
* The models `actor` may pick for `agent` (`GET <path>/models`). An empty catalog when none is
* configured, so a picker simply has nothing to offer.
*/
/** Is a model catalog configured (so `GET <path>/models` lists something)? */
hasModelCatalog() {
return this.options.models !== undefined;
}
async listModels(actor, agent) {
if (this.options.models === undefined) {
return { providers: [], default: null };
}
return this.options.models.list({ actor, ...(agent !== undefined ? { agent } : {}) });
}
/**
* `model` if the catalog offers it to this actor and agent right now, else a
* {@link ModelNotAllowedError} naming why. The one gate every model choice passes — a send's own,
* and a thread's pinned one when it is pinned and again at every turn that runs on it.
*/
async assertModelAllowed(actor, agent, model) {
if (this.options.models === undefined) {
throw new ModelNotAllowedError(`model "${model}" cannot be selected: no model catalog is configured (models in config/agent.ts)`);
}
const entry = findCatalogModel(await this.options.models.list({ actor, agent }), model);
if (entry === undefined) {
throw new ModelNotAllowedError(`model "${model}" is not offered`);
}
if (!entry.available) {
throw new ModelNotAllowedError(`model "${model}" is not available${entry.unavailableReason ? `: ${entry.unavailableReason}` : ''}`);
}
return entry.id;
}
/**
* The model a turn runs on: the send's own (that turn only — never stored on the thread), else the
* thread's pinned one, else none. An agent the catalog locks to one model runs on it whatever was
* sent or pinned, and a send naming another model is refused.
*/
async resolveModel(actor, agent, requested, threadId) {
const locked = this.options.models !== undefined
? (await this.options.models.list({ actor, agent })).locked
: undefined;
if (locked !== undefined) {
if (requested !== undefined && requested !== locked.model) {
throw new ModelNotAllowedError(`model "${requested}" cannot be selected: ${locked.reason ?? `this agent always uses "${locked.model}"`}`);
}
return locked.model;
}
const pinned = requested === undefined && threadId !== undefined
? ((await this.store.getThread(threadId))?.model ?? null)
: null;
const model = requested ?? pinned;
return model === null || model === undefined
? undefined
: this.assertModelAllowed(actor, agent, model);
}
/**
* Pin (or with `null`, unpin) a catalog model on a thread — checked against the catalog for the
* thread's default agent. `false` when the store cannot persist it (no `updateThread`).
*/
async setThreadModel(actor, threadId, model) {
return this.updateThreadSettings(actor, threadId, { model });
}
/**
* Set a thread's default agent and/or pin its model (`PATCH <path>/threads/:id`). `null` clears
* either. The model is checked against the catalog for the agent the thread's next turn runs as —
* the one this patch sets, else the thread's own default, else the configured one. A
* `defaultAgent` that names no registered agent is refused ({@link UnknownAgentError}). `false`
* when the store cannot persist it (no `updateThread`). Ownership is the caller's to check.
*/
async updateThreadSettings(actor, threadId, patch) {
if (patch.defaultAgent === undefined &&
patch.model === undefined &&
patch.persona === undefined) {
return true;
}
if (this.store.updateThread === undefined) {
return false;
}
if (typeof patch.defaultAgent === 'string' && !this.isKnownAgent(patch.defaultAgent)) {
throw new UnknownAgentError(patch.defaultAgent);
}
// The agent the thread's next turn runs as — the one this patch sets, else the thread's own,
// else the configured one; a name a persona took over counts as the agent that owns it.
const nextAgent = async () => this.deps.resolveAgent(patch.defaultAgent ?? (await this.resolveAgentName(undefined, threadId))).agentName;
const model = patch.model === undefined || patch.model === null
? patch.model
: await this.assertModelAllowed(actor, await nextAgent(), patch.model);
// Validated against that same agent, like the model.
if (typeof patch.persona === 'string') {
this.deps.resolvePersona({ agentName: await nextAgent(), requested: patch.persona });
}
await this.store.updateThread(threadId, {
...(patch.defaultAgent !== undefined ? { defaultAgent: patch.defaultAgent } : {}),
...(model !== undefined ? { model } : {}),
...(patch.persona !== undefined ? { persona: patch.persona } : {}),
});
return true;
}
/** A registered agent, or a name one of their personas took over (`Persona.aliases`). */
isKnownAgent(name) {
return this.deps.isAgent(name) || this.deps.resolveAgent(name).agentName !== name;
}
/**
* The persona this send runs under — see {@link AgentDepsFactory.resolvePersona}. The thread's
* pinned persona is read only when the send names none AND the agent has personas to pick from.
*/
async resolveSendPersona(agentName, requested, threadId) {
const needsThread = requested === undefined &&
threadId !== undefined &&
this.deps.personaCatalog(agentName).length > 0;
return this.deps.resolvePersona({
agentName,
...(requested !== undefined ? { requested } : {}),
...(needsThread ? { threadPersona: await threadPersona(this.store, threadId) } : {}),
});
}
/** Pin `persona` on the thread when it is not already — a no-op on a store that cannot. */
async pinThreadPersona(threadId, persona) {
if (this.store.updateThread === undefined) {
return;
}
if ((await threadPersona(this.store, threadId)) !== persona) {
await this.store.updateThread(threadId, { persona });
}
}
/**
* The agent a send runs as: the one it names, else the thread's own default agent (when the
* thread exists and has one), else the configured default.
*/
async resolveAgentName(agentName, threadId) {
if (agentName !== undefined) {
return agentName;
}
if (threadId !== undefined) {
const reader = this.store.defaultAgentForThread?.bind(this.store);
const threadDefault = reader !== undefined
? await reader(threadId)
: ((await this.store.getThread(threadId))?.defaultAgent ?? null);
if (threadDefault !== null && threadDefault !== undefined) {
return threadDefault;
}
}
return this.deps.defaultAgentName();
}
/**
* The registered agents, for a picker (`GET <path>/agents`). With a model catalog, an agent it
* locks to one model for this actor carries `lockedModel` — the same lock `GET <path>/models`
* reports as `locked`.
*/
async listAgents(actor) {
const defaultName = this.deps.defaultAgentName();
const listed = this.deps.agentDefinitions();
const entries = listed.map((definition) => {
const personas = this.deps.personaCatalog(definition.name);
const defaultPersona = this.deps.defaultPersona(definition.name);
return {
name: definition.name,
description: definition.description ?? '',
...(definition.name === defaultName ? { isDefault: true } : {}),
...(personas.length > 0 ? { personas } : {}),
...(defaultPersona !== undefined ? { defaultPersona } : {}),
};
});
const all = entries.some((entry) => entry.name === defaultName)
? entries
: [{ name: defaultName, description: '', isDefault: true }, ...entries];
const models = this.options.models;
if (models === undefined) {
return all;
}
return Promise.all(all.map(async (entry) => {
const locked = (await models.list({ actor, agent: entry.name })).locked;
return locked !== undefined ? { ...entry, lockedModel: locked.model } : entry;
}));
}
/** The actor's budget across windows (`GET <path>/quota`). */
async quotaReport(actor) {
if (this.options.quota !== undefined) {
return this.options.quota.provider.report({ actor });
}
const today = await this.quotaToday(actor.id);
return { windows: [{ period: 'day', usedTokens: today.usedTokens, usedUsd: 0 }] };
}
/**
* Refuse a turn the actor's budget no longer covers — only when a budget was configured
* (`quota: { limits }` or a `QuotaProvider`): the default report is informational.
*/
async assertWithinQuota(actor) {
if (this.options.quota?.gated !== true) {
return;
}
const { blocked } = await this.options.quota.provider.report({ actor });
if (blocked !== undefined) {
throw new QuotaBlockedError(blocked.period, blocked.reason ?? `The ${blocked.period === 'day' ? 'daily' : 'monthly'} quota is used up`);
}
}
/** The queue service when the bound store can hold a queue, else `undefined`. */
queueing() {
return this.options.queue?.supported === true ? this.options.queue : undefined;
}
/** The queue service, or a `501` naming what is missing. */
requireQueue() {
const queue = this.queueing();
if (queue === undefined) {
throw new ChatQueueError(501, 'Queueing messages requires an AgentStore that implements ChatQueueStore; the bound store does not.');
}
return queue;
}
/**
* Start a turn — for in-process callers that need a run id back. A send that would have to wait
* (its thread already has a turn running) is refused with a `409 run_active`
* {@link ChatQueueError} instead; {@link send} is the form that queues it.
*/
async chat(params) {
const result = await this.sendTurn(withValidCapabilities({ ...params, mode: 'auto' }));
if (result.queued !== true) {
return result;
}
if (result.runId !== undefined) {
// The thread freed up while it was being queued, and it started straight away.
return { runId: result.runId, threadId: result.threadId };
}
// Take it back out, so this call has no effect — unless it started in the meantime.
const queue = this.requireQueue();
if (!(await queue.queueStore().removeQueuedMessage(result.messageId))) {
return { runId: result.messageId, threadId: result.threadId };
}
await queue.publish(result.threadId);
throw new ChatQueueError(409, `thread ${result.threadId} already has a turn running`, 'run_active');
}
/**
* Send a message: start a turn, or — when its thread already has one running — queue the message
* to run after it (see {@link ChatSendMode}). What `POST <path>/chat` calls.
*/
async send(params, options = {}) {
params = withValidCapabilities(params);
if (options.textDecisions !== false &&
params.threadId !== undefined &&
params.regenerate !== true &&
this.options.actionProposals) {
const decision = await this.options.actionProposals.handleTextDecision(params.actor, params.threadId, params.message);
if ('proposalDecision' in decision)
return decision;
}
return this.sendTurn(params);
}
/** Start or queue the turn itself — what a send is once it is not a text decision. */
async sendTurn(params) {
if (params.regenerate === true && params.threadId === undefined) {
throw new RegenerateNeedsThreadError();
}
await this.assertWithinQuota(params.actor);
// The send's own agent, else the thread's default agent, else the configured default — and a name
// a persona took over (`Persona.aliases`) runs as the agent that owns it, under it.
const target = this.deps.resolveAgent(await this.resolveAgentName(params.agentName, params.threadId));
const agentName = target.agentName;
// Before the thread exists, so a refused persona, model or attachment leaves nothing behind.
// Resolved to an id HERE, so the run's own input names it: a durable replay, or a queued message
// started later, runs under the one this send resolved.
const persona = await this.resolveSendPersona(agentName, params.personaId ?? target.persona, params.threadId);
const model = await this.resolveModel(params.actor, agentName, params.model, params.threadId);
const attachments = await this.resolveAttachments(params.actor, params.attachments ?? []);
let threadId = params.threadId;
if (threadId === undefined) {
const created = await this.store.createThread({
actor: params.actor,
// A persona the send NAMES is the person's pick for this conversation: pinned. One it fell
// back to (the agent's default) is not, so a later change of default reaches the thread.
...(params.personaId !== undefined ? { persona: params.personaId } : {}),
...(params.transient === true ? { transient: true } : {}),
...(params.newThreadId !== undefined ? { id: params.newThreadId } : {}),
});
threadId = created.id;
}
else if (params.personaId !== undefined) {
await this.pinThreadPersona(threadId, params.personaId);
}
const sentAt = params.regenerate === true ? undefined : normalizeSentAt(params.sentAt);
const input = {
threadId,
actor: params.actor,
userText: params.message,
day: utcDay(),
agentName,
...(sentAt !== undefined ? { sentAt } : {}),
...(persona !== undefined ? { persona } : {}),
...(params.pageContext !== undefined ? { pageContext: params.pageContext } : {}),
...(params.uiCapabilities !== undefined ? { uiCapabilities: params.uiCapabilities } : {}),
...(attachments.length > 0 ? { attachments } : {}),
...(model !== undefined ? { model } : {}),
...(params.regenerate === true ? { regenerate: true } : {}),
...(params.hostContext !== undefined ? { hostContext: params.hostContext } : {}),
};
const queue = this.queueing();
if (queue === undefined) {
// No admission to take: a store that predates the queue starts every send at once. The
// runner marks the thread's active run itself, before the turn can end.
const { runId } = await this.runner.start(input);
return { runId, threadId };
}
const mode = params.mode ?? 'auto';
const { live, stale } = await queue.holder(threadId, this.runner);
if (stale !== null) {
// The thread is still pointed at a run that is gone: settle what it left — its row, and any
// call still showing an approval card — before another turn reads the thread.
await settleDeadRun(this.store, { runId: stale, error: 'the run is no longer running' });
}
// `queue` always answers as a queued send (202), so a client that asked for it handles one
// shape; an idle thread starts it straight away all the same (`enqueue` kicks the queue).
if (live === null && mode !== 'queue') {
const runId = this.runner.runIdFor?.(input) ?? crypto.randomUUID();
if (await queue
.queueStore()
.claimActiveStream(threadId, runId, stale !== null ? { replacing: stale } : {})) {
return { runId: await this.startClaimed(queue, input, runId), threadId };
}
// Lost the race for the thread to another send: fall through and queue behind it.
}
if (params.regenerate === true) {
throw new ChatQueueError(409, 'cannot regenerate while a turn is running on this thread', 'run_active');
}
return this.enqueue(queue, input, mode, live);
}
/**
* Start a turn the thread is already claimed for, under the claimed id. A runner that minted an
* id of its own anyway gets the thread re-pointed at it; one that fails to start frees the thread.
*/
async startClaimed(queue, input, runId) {
const store = queue.queueStore();
let started;
try {
started = (await this.runner.start(input, { runId })).runId;
}
catch (error) {
await store.releaseActiveStream(input.threadId, runId);
throw error;
}
if (started !== runId) {
await store.claimActiveStream(input.threadId, started, { replacing: runId });
}
return started;
}
/** Put a send in its thread's queue, then drain it if the thread turned out to be free. */
async enqueue(queue, input, mode, live) {
const store = queue.queueStore();
const threadId = input.threadId;
const interrupting = mode === 'interrupt' && live !== null ? live : undefined;
const queued = await store.enqueueMessage({
threadId,
actor: input.actor,
content: input.userText,
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(typeof input.persona === 'string' ? { 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 } : {}),
...(interrupting !== undefined ? { interrupt: true, at: 'head' } : {}),
});
let runId;
if (interrupting !== undefined) {
// An interrupt is a person choosing to run this now: whatever paused the queue, they are
// overriding it. The cancel settles into the queue, which starts this message.
await store.setQueuePause(threadId, null);
await queue.publish(threadId);
await this.runner.cancel(interrupting);
}
else {
// The run holding the thread may have settled between our look and our enqueue — its drain
// then found nothing. Starting the head here covers that; with a live holder it does nothing.
runId = await queue.kick(threadId, this.runner);
if (runId === undefined) {
await queue.publish(threadId);
}
}
const state = await queue.state(threadId);
const position = state.items.findIndex((item) => item.id === queued.id);
return {
threadId,
queued: true,
messageId: queued.id,
position: position === -1 ? 0 : position,
queue: state,
...(runId !== undefined && runId === queued.id ? { runId } : {}),
...(interrupting !== undefined ? { interrupting } : {}),
};
}
/** Can this deployment queue messages (the store is a `ChatQueueStore` and a queue is wired)? */
queueSupported() {
return this.queueing() !== undefined;
}
/** `GET <path>/threads/:id/queue` — the thread's waiting messages and whether it drains. */
getQueue(threadId) {
return this.requireQueue().state(threadId);
}
/** The thread a queued message waits on — `null` when there is no such message. */
async queuedMessageThread(messageId) {
const message = await this.requireQueue().queueStore().getQueuedMessage(messageId);
return message?.threadId ?? null;
}
/**
* `PATCH <path>/queue/:messageId` — change a waiting message's text and/or attachments, and/or
* move it to `position` in the queue. Answers the thread's queue.
*/
async updateQueuedMessage(actor, messageId, patch) {
const queue = this.requireQueue();
const store = queue.queueStore();
const current = await store.getQueuedMessage(messageId);
if (current === null) {
throw new ChatQueueError(404, `queued message ${messageId} not found`);
}
if (patch.message !== undefined && patch.message.trim().length === 0) {
throw new ChatQueueError(400, 'message must not be empty');
}
if (patch.position !== undefined && (!Number.isInteger(patch.position) || patch.position < 0)) {
throw new ChatQueueError(400, 'position must be a non-negative integer');
}
const attachments = patch.attachments === undefined || patch.attachments === null
? patch.attachments
: await this.resolveAttachments(actor, patch.attachments);
const gone = new ChatQueueError(410, `queued message ${messageId} already started or was removed`);
if (patch.message !== undefined || attachments !== undefined) {
const updated = await store.updateQueuedMessage(messageId, {
...(patch.message !== undefined ? { content: patch.message } : {}),
...(attachments !== undefined ? { attachments } : {}),
});
if (updated === null) {
throw gone;
}
}
if (patch.position !== undefined &&
!(await store.moveQueuedMessage(messageId, patch.position))) {
throw gone;
}
return queue.publish(current.threadId);
}
/**
* `POST <path>/queue/:messageId/interrupt` — run a waiting message NOW: it moves to the head of
* its queue as an interrupt, any pause is lifted, and the turn holding the thread is cancelled for
* it (the cancel settles into the queue, which starts it). With nothing running it starts at once.
*
* One request rather than "remove it, then send it again with `mode: 'interrupt'`", because a
* client cannot compose that safely: the message is lost if the second request fails, and runs
* twice if another tab's drain starts it in between. Here it never leaves the queue. Answers the
* queue, plus `interrupting` (the run that was cancelled) or `runId` (the run the message started
* under — its own id).
*/
async interruptQueuedMessage(messageId) {
const queue = this.requireQueue();
const store = queue.queueStore();
const current = await store.getQueuedMessage(messageId);
if (current === null) {
throw new ChatQueueError(404, `queued message ${messageId} not found`);
}
const threadId = current.threadId;
const gone = () => new ChatQueueError(410, `queued message ${messageId} already started or was removed`);
const marked = await store.updateQueuedMessage(messageId, { interrupt: true });
if (marked === null) {
throw gone();
}
if (marked.interrupt !== true) {
// Cancelling for a message the store did not mark would pause the queue behind the cancel
// instead of starting it — refuse before anything is cancelled.
throw new ChatQueueError(501, 'Interrupting a queued message requires a ChatQueueStore whose updateQueuedMessage stores `interrupt`; the bound store ignored it.');
}
if (!(await store.moveQueuedMessage(messageId, 0))) {
throw gone();
}
// A person choosing to run this now overrides whatever paused the queue.
await store.setQueuePause(threadId, null);
const { live } = await queue.holder(threadId, this.runner);
if (live !== null && live !== messageId && (await store.getQueuedMessage(messageId)) !== null) {
const state = await queue.publish(threadId);
await this.runner.cancel(live);
return { ...state, interrupting: live };
}
if (live !== null) {
// The drain started it while this request was on its way: it is the running turn already.
return { ...(await queue.state(threadId)), runId: messageId };
}
const runId = await queue.kick(threadId, this.runner);
const state = runId === undefined ? await queue.publish(threadId) : await queue.state(threadId);
return { ...state, ...(runId !== undefined ? { runId } : {}) };
}
/** `DELETE <path>/queue/:messageId` — drop a waiting message. Answers the thread's queue. */
async removeQueuedMessage(messageId) {
const queue = this.requireQueue();
const store = queue.queueStore();
const current = await store.getQueuedMessage(messageId);
if (current === null) {
throw new ChatQueueError(404, `queued message ${messageId} not found`);
}
if (!(await store.removeQueuedMessage(messageId))) {
throw new ChatQueueError(410, `queued message ${messageId} already started or was removed`);
}
return queue.publish(current.threadId);
}
/** `DELETE <path>/threads/:id/queue` — drop every waiting message (and any pause). */
async clearQueue(threadId) {
const queue = this.requireQueue();
await queue.queueStore().clearQueue(threadId);
await queue.queueStore().setQueuePause(threadId, null);
return queue.publish(threadId);
}
/**
* `POST <path>/threads/:id/queue/resume` — lift a pause and start the head when nothing is
* running. Answers the queue, and the run the head started under, if it did.
*/
async resumeQueue(threadId) {
const queue = this.requireQueue();
await queue.queueStore().setQueuePause(threadId, null);
const runId = await queue.kick(threadId, this.runner);
const state = runId === undefined ? await queue.publish(threadId) : await queue.state(threadId);
return { ...state, ...(runId !== undefined ? { runId } : {}) };
}
subscribe(runId) {
return this.deps.forAgent().sink.subscribe(runId);
}
/**
* Rate a message: `'up'`/`'down'` with an optional comment, or `null` to clear the rating. Checks
* nothing about WHO — the route resolves the message's thread (`threadOfMessage`) and its owner
* first. Returns the stored rating, or an error the route maps to a status.
*/
async setMessageFeedback(messageId, input) {
const { value } = input;
if (value !== null && value !== 'up' && value !== 'down') {
return { ok: false, status: 400, error: "value must be 'up', 'down' or null" };
}
if (input.comment !== undefined && typeof input.comment !== 'string') {
return { ok: false, status: 400, error: 'comment must be a string' };
}
const comment = input.comment?.trim();
if (comment !== undefined && comment.length > MAX_FEEDBACK_COMMENT_LENGTH) {
return {
ok: false,
status: 400,
error: `comment must be at most ${MAX_FEEDBACK_COMMENT_LENGTH} characters`,
};
}
if (this.store.setMessageFeedback === undefined) {
return {
ok: false,
status: 501,
error: 'Message feedback requires an AgentStore that implements threadOfMessage() and setMessageFeedback().',
};
}
const feedback = value === null
? null
: {
value,
...(comment !== undefined && comment.length > 0 ? { comment } : {}),
updatedAt: new Date().toISOString(),
};
await this.store.setMessageFeedback(messageId, feedback);
return { ok: true, feedback };
}
/** The thread a message belongs to (`AgentStore.threadOfMessage`); `undefined` when unsupported. */
async threadOfMessage(messageId) {
return this.store.threadOfMessage === undefined
? undefined
: this.store.threadOfMessage(messageId);
}
/**
* Is anything streaming (or buffered) under this run — or about to? `true` when the sink cannot
* say.
*
* "About to": a run that holds its thread and has written nothing yet. It was admitted (a send, or
* a queue drain handing the thread to the next message) and its body has not reached the sink — on
* a durable runner, because no worker has picked it up. A client told that run started attaches
* right then; answering "nothing to resume" would make it give up on a turn that is a moment from
* streaming. The runner decides whether the holder is alive, as it does for admission: a claim
* left by a process that died is not a stream.
*/
async hasStream(runId) {
const buffered = await this.deps.forAgent().sink.has?.(runId);
if (buffered !== false) {
return true;
}
const queue = this.queueing();
const threadId = await this.store.threadHeldByRun?.(runId);
if (queue === undefined || threadId === undefined || threadId === null) {
return false;
}
return (await queue.holder(threadId, this.runner)).live === runId;
}
/**
* The owning actor ref of a run (turn), or `null` if unknown — for per-actor route ownership
* checks. A run that holds a thread but has not started executing has no row of its own yet (see
* `AgentStore.threadHeldByRun`): its owner is the thread's.
*/
async runOwner(runId) {
const recorded = await this.store.getRunActorRef(runId);
if (recorded !== null) {
return recorded;
}
const threadId = await this.store.threadHeldByRun?.(runId);
return threadId === undefined || threadId === null
? null
: this.store.getThreadActorRef(threadId);
}
/** The owning actor ref of a thread, or `null` if unknown — for per-actor route ownership checks. */
/** The run a tool call belongs to (see `AgentStore.getToolCallRunId`); `null` when unknown. */
async toolCallRun(toolCallId) {
return (await this.store.getToolCallRunId?.(toolCallId)) ?? null;
}
threadOwner(threadId) {
return this.store.getThreadActorRef(threadId);
}
/**
* Refuse a decision addressed at a run that is over. The runtime would take the signal and buffer
* it for a run that never comes back — the person's "yes" is accepted, the card says so, and
* nothing runs. Settles what the dead run left behind on the way out. A runner that cannot say
* whether a run is alive is taken at its word that it is.
*/
async assertRunWaiting(runId) {
if (typeof this.runner.isRunActive !== 'function') {
return;
}
const active = await this.runner.isRunActive(runId).catch(() => true);
if (active) {
return;
}
await settleDeadRun(this.store, { runId, error: 'the run is no longer running' });
throw new RunNotActiveError(runId);
}
/**
* A thread's independent proposals. Without them (the default blocking approvals) there are none,
* so the read answers an empty list rather than refusing: a client that lists by default (React's
* `useAgentChat`) needs no flag. Deciding one still answers `501`.
*/
async listActionProposals(actor, threadId) {
if (!this.options.actionProposals)
return [];
return this.options.actionProposals.list(actor, threadId);
}
/** One page of {@link listActionProposals}: an empty, final page without independent proposals. */
async listActionProposalsPage(actor, threadId, after) {
if (!this.options.actionProposals)
return { items: [] };
return this.options.actionProposals.listPage(actor, threadId, after);
}
decideActionProposal(actor, threadId, proposalId, decision, body, via = 'web') {
if (!this.options.actionProposals)
throw new ActionProposalServiceError(501, 'Independent proposals are unavailable');
return this.options.actionProposals.decide(actor, threadId, proposalId, decision, body, via);
}
/** `actionApprovalMode` in `config/agent.ts` — `'blocking'` unless it says `'independent'`. */
actionApprovalMode() {
return typeof this.deps.actionApprovalMode === 'function'
? this.deps.actionApprovalMode()
: 'blocking';
}
/**
* The words a text decision is made of (`actionProposalText.vocabulary` over the English
* defaults) — what a surface tells a person to reply, or what it maps a button to.
*/
actionProposalVocabulary() {
return this.options.actionProposals?.vocabulary ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
}
/** The configured chat reply to a proposal decision (see `actionProposalText.replies`). */
actionProposalReply(result, decision) {
return this.options.actionProposals
? this.options.actionProposals.reply(result, decision)
: textActionProposalReply(result, decision);
}
handleTextDecision(actor, threadId, text) {
if (!this.options.actionProposals)
return Promise.resolve({ status: 'unmatched' });
return this.options.actionProposals.handleTextDecision(actor, threadId, text);
}
/**
* Approve a parked action call. `executedByRef` is who decided (the routes stamp the caller);
* `remember` approves later calls of the same tool in the same thread; `via` names the surface the
* decision came through (`'web'`, `'slack'`, …) — all persisted with the call.
*/
async approve(runId, toolCallId, opts = {}) {
if ((await this.toolCallApproval(toolCallId, runId))?.status === 'proposed')
throw new ActionProposalServiceError(400, 'Action proposals require a scoped proposal decision');
await this.assertRunWaiting(runId);
return this.runner.signal(runId, toolCallId, {
approved: true,
...(opts.executedByRef !== undefined ? { executedByRef: opts.executedByRef } : {}),
...(opts.remember === true ? { remember: true } : {}),
...(opts.via !== undefined ? { decidedVia: opts.via } : {}),
});
}
async reject(runId, toolCallId, reason, opts = {}) {
if ((await this.toolCallApproval(toolCallId, runId))?.status === 'proposed')
throw new ActionProposalServiceError(400, 'Action proposals require a scoped proposal decision');
await this.assertRunWaiting(runId);
return this.runner.signal(runId, toolCallId, {
approved: false,
...(reason !== undefined ? { reason } : {}),
...(opts.executedByRef !== undefined ? { executedByRef: opts.executedByRef } : {}),
...(opts.via !== undefined ? { decidedVia: opts.via } : {}),
});
}
/**
* A call's approval state (see `AgentStore.toolCallApproval`); `null` when unknown or unrecorded.
* Pass the call's `runId` when known: another run may have been handed the same call id.
*/
async toolCallApproval(toolCallId, runId) {
return (await this.store.toolCallApproval?.(toolCallId, runId)) ?? null;
}
/**
* May `actor` settle a call whose recorded approver is `approver` (never the requester — that is
* the ownership check)? The configured policy's `canDecide`, else "holds that role".
*/
mayDecide(actor, decision) {
return mayDecideApproval(this.deps.forAgent().approvalPolicy, actor, decision);
}
/**
* Deliver a human's answers to a parked question set. An id the reply omits takes that question's
* own pre-picked default, resolved by the loop against the request it already holds — so "just
* pressed enter" and "picked exactly the defaults" persist identically, and a client that never
* rendered the defaults cannot submit a blank.
*
* Addressed at a tool call that is in fact waiting for an approve/reject, this is refused: see
* {@link import('./elicitation.js').HumanReplyMismatchError}. Answers say nothing about whether
* proposed work should go ahead, and the alternative is a rejection nobody made.
*/
/**
* Check a reply against the questions it answers before it is signalled: every submitted value a
* question's own rules accept (a typed input's type, bounds and pattern; a pick from the offered
* options), and every `required` question answered — by the reply, or by the defaults an omitted
* question falls back to. Returns the first problem as `answers["<id>"] <reason>`, or `null`.
* Needs the store's `toolCallInput`; without it the loop's own filtering is the only check.
*/
async answerProblem(toolCallId, answers, runId) {
if (this.store.toolCallInput === undefined) {
return null;
}
const questions = readElicitationQuestions(await this.store.toolCallInput(toolCallId, runId));
for (const question of questions) {
const submitted = Object.hasOwn(answers, question.id) ? answers[question.id] : undefined;
const problem = validateElicitationAnswer(question, submitted ?? question.defaults ?? []);
if (problem !== null) {
return `answers["${question.id}"] ${problem}`;
}
}
return null;
}
async answer(args) {
await this.assertRunWaiting(args.runId);
return this.runner.signal(args.runId, args.toolCallId, {
answers: args.answers,
...(args.answeredByRef !== undefined ? { answeredByRef: args.answeredByRef } : {}),
...(args.answeredVia !== undefined ? { answeredVia: args.answeredVia } : {}),
});
}
/**
* The user declined to answer and told the agent to proceed on its own assumptions. It lands on
* the same values a confirmation would and persists as `rejected` rather than `executed`, because
* proceeding on an assumption someone declined to confirm is a different fact from proceeding on
* one they chose.
*/
async skip(args) {
await this.assertRunWaiting(args.runId);
return this.runner.signal(args.runId, args.toolCallId, {
answers: {},
skipped: true,
...(args.answeredByRef !== undefined ? { answeredByRef: args.answeredByRef } : {}),
...(args.answeredVia !== undefined ? { answeredVia: args.answeredVia } : {}),
});
}
cancel(runId) {
return this.runner.cancel(runId);
}
/**
* Execute in this process what of `runId` still waits for a worker (see
* {@link AgentRunner.drive}) — for a caller that waits on the run from inside that worker's own
* work. A no-op when the runner runs turns where it starts them.
*/
async drive(runId) {
await this.runner.drive?.(runId);
}
/**
* The tools THIS actor can reach through an agent, with how a chat surface talks about each —
* the same list the model is offered (`ToolRegistry.visibleSpecs` behind `definitionsFor`, against
* the agent's allow-list and the roles policy). Built-in tools the loop serves itself (`ask`,
* `skill`, `remember`) are not registry tools and are not listed.
*/
async toolCatalog(actor, agentName) {
const deps = this.deps.forAgent(agentName);
const specs = await deps.registry.visibleSpecs(actor, deps.rolesPolicy, deps.toolAllowList);
return specs.map((spec) => ({
name: spec.name,
kind: spec.kind,
...(spec.presentation !== undefined ? { presentation: spec.presentation } : {}),
}));
}
/**
* `GET <path>/tools?agent=*`: the union of {@link toolCatalog} across the default agent and every
* registered one — each tool this actor reaches through ANY of them, once (first agent wins its
* entry), under the same gates.
*/
async toolCatalogForAllAgents(actor) {
const names = [undefined, ...this.deps.agentDefinitions().map((definition) => definition.name)];
const entries = new Map();
for (const name of names) {
for (const entry of await this.toolCatalog(actor, name)) {
if (!entries.has(entry.name))
entries.set(entry.name, entry);
}
}
return [...entries.values()];
}
/**
* The persona `id` of `agentName` (default: the default agent), else its default persona — the
* definition, for a caller that wants to read it. Unlike a send, an unknown `id` is not refused.
*/
resolvePersona(agentName, id) {
const deps = this.deps.forAgent(agentName);
return deps.personas.get(id ?? this.deps.defaultPersona(agentName ?? this.deps.defaultAgentName()) ?? '');
}
/**
* The agent and persona a queued message that carries no persona starts as: an agent name a
* persona took over, then the thread's pin, then the agent's default — never refused. What the
* provider hands the queue as `resolveTarget`.
*/
async queuedTarget(message) {
const target = this.deps.resolveAgent(message.agentName ?? this.deps.defaultAgentName());
const persona = target.persona ??
(this.deps.personaCatalog(target.agentName).length > 0
? this.deps.resolvePersona({
agentName: target.agentName,
threadPersona: await threadPersona(this.store, message.threadId),
})
: undefined);
return {
...(message.agentName !== undefined ? { agentName: target.agentName } : {}),
...(persona !== undefined ? { persona } : {}),
};
}
/** `agentName`'s personas (default: the default agent's) as a picker reads them. */
personaCatalog(agentName) {
return [...this.deps.forAgent(agentName).personas.values()].map(personaCatalogEntry);
}
async listThreads(actorRef) {
return (await this.store.listThreads(actorRef)).map((thread) => this.withPinnedPersona(thread));
}
/**
* A thread as the routes report it: a persona pin that no registered agent declares — a thread
* created before pins existed holds whatever it was created with (`'default'`, most often), or the
* persona was removed since — reads as `null`, which is what it resolves to.
*/
withPinnedPersona(thread) {
const pinned = thread.persona ?? null;
if (pinned === null) {
return thread.persona === null ? thread : { ...thread, persona: null };
}
const declared = this.deps
.agentDefinitions()
.some((definition) => this.deps.hasPersona(definition.name, pinned));
return declared ? thread : { ...thread, persona: null };
}
/**
* A thread as `actor` reads it back. Each attachment's url is re-minted by `mediaId` through the
* attachment store: the one persisted with the message was minted for THAT turn (a signed url that
* expires), so an old turn would otherwise show a dead link. One the store will not resolve for
* this actor keeps the url it was stored with.
*/
async getThread(threadId, actor) {
const read = await this.store.getThread(threadId);
const stored = read === null ? null : this.withPinnedPersona(read);
const queue = this.queueing();
// The waiting messages ride along, so a reload shows them without a second request.
const thread = stored !== null && queue !== undefined
? { ...stored, queue: await queue.state(threadId) }
: stored;
const staging = this.options.attachments;
if (thread === null || actor === undefined || staging === undefined)
return thread;
if (!thread.messages.some((message) => (message.attachments?.length ?? 0) > 0))
return thread;
const messages = await Promise.all(thread.messages.map(async (message) => {
if (message.attachments === undefined || message.attachments.length === 0)
return message;
const attachments = await Promise.all(message.attachments.map(async (attachment) => {
try {
return (await staging.resolve({ mediaId: attachment.mediaId, actor })) ?? attachment;
}
catch {
return attachment;
}
}));
return { ...message, attachments };
}));
return { ...thread, messages };
}
/**
* The size cap and content types an attachment is held to — what the attachment store declares,
* else the defaults (20 MiB; images, PDF, plain text, CSV). `null` when no attachment store is
* configured, so nothing can be attached.
*/
attachmentLimits() {
const staging = this.options.attachments;
if (staging === undefined)
return null;
const declared = staging.describe?.() ?? {};
return {
maxBytes: declared.maxBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES,
allowedContentTypes: declared.allowedContentTypes ?? DEFAULT_ALLOWED_ATTACHMENT_CONTENT_TYPES,
};
}
/**
* Stage a file the server received itself (a channel's media message) for `actor`, held to the same
* limits as `POST <path>/attachments` — then name it in a send as `{ mediaId }`. Refused
* ({@link AttachmentRefusedError}) with `501` when no attachment store is configured, `415` for a
* content type outside the allowlist, `413` past the size cap.
*/
async stageAttachment(actor, file) {
const limits = this.attachmentLimits();
const staging = this.options.attachments;
if (limits === null || staging === undefined) {
throw new AttachmentRefusedError(501, 'Attachments are off: set `attachments` in config/agent.ts (e.g. attachmentStores.media()).');
}
const contentType = file.contentType.split(';')[0]?.trim().toLowerCase() ?? '';
if (!limits.allowedContentTypes.includes(contentType)) {
throw new AttachmentRefusedError(415, `content type "${contentType}" is not allowed`);
}
if (file.data.byteLength > limits.maxBytes) {
throw new AttachmentRefusedError(413, `file exceeds the ${limits.maxBytes}-byte limit`);
}
return staging.stage({
data: file.data,
filename: file.filename,
contentType,
sizeBytes: file.data.byteLength,
actor,
});
}
/**
* The Lucid database the agent's store writes to, or `null` when the store is not Lucid — what
* shared state that should live next to the threads (a text channel's store) is built on.
*/
lucidDatabase() {
return this.store instanceof LucidAgentStore ? this.store.database : null;
}
/**
* Turn a send's `{ mediaId }` refs into attachments through the store, for this actor. Refused
* ({@link AttachmentRefusedError}) when no store is configured or one ref is not the actor's to use.
*/
async resolveAttachments(actor, refs) {
if (refs.length === 0)
return [];
const staging = this.options.attachments;
if (staging === undefined) {
throw new AttachmentRefusedError(501, 'Attachments are off: set `attachments` in config/agent.ts (e.g. attachmentStores.media()).');
}
const resolved = [];
for (const ref of refs) {
const attachment = await staging.resolve({ mediaId: ref.mediaId, actor });
if (attachment === null) {
throw new AttachmentRefusedError(403, `attachment ${ref.mediaId} is not available to this actor`);
}
resolved.push(attachment);
}
return resolved;
}
/**
* Every file this actor has staged, newest first — across threads, including uploads that were
* never sent, which nothing else on this surface can see (`GET <path>/attachments`). Metadata only:
* a url is minted per request by `resolve`, so it can stay short-lived.
*
* Refused ({@link AttachmentInventoryError}) when the configured attachment store keeps no
* inventory (`AttachmentStagingStore.list`).
*/
async listAttachments(actor, options = {}) {
const list = this.attachmentInventory();
return list({ actor, ...(options.limit !== undefined ? { limit: options.limit } : {}) });
}
/**
* This actor's staged media that no live message — and no message waiting in a thread's queue —
* references, and that is old enough not to be an upload in flight: the candidate set for a sweep.
* Returns them; never deletes them. The bytes are the host's, and so is the decision:
*
* ```ts
* const service = await app.container.make(AgentService)
* for (const file of await service.collectableAttachments(actor, { olderThan: subDays(new Date(), 7) })) {
* await media.delete(file.mediaId)
* }
* ```
*
* `olderThan` is required and has no default: how long a composer may sit open with a file attached
* is the host's knowledge. The staging store is asked to apply it, and the result is filtered again
* here — a store that ignored the hint would otherwise hand back an upload someone is about to send.
*
* Both halves must be answerable or this refuses ({@link AttachmentInventoryError}): an
* unanswerable reference query means "cannot tell", and treating it as "nothing is referenced"
* would mark every attachment the actor ever sent as safe to delete.
*/
async collectableAttachments(actor, options) {
const list = this.attachmentInventory();
const referencedMediaIds = this.store.referencedMediaIds?.bind(this.store);
if (referencedMediaIds === undefined) {
throw new AttachmentInventoryError('Collecting attachments needs an AgentStore that implements referencedMediaIds(): the ' +
'bound store cannot say which media a message still references, and guessing would ' +
'delete files that are in use.');
}
const stagedBefore = options.olderThan.toISOString();
const inventory = await list({
actor,
stagedBefore,
...(options.limit !== undefined ? { limit: options.limit } : {}),
});
const aged = inventory.filter((entry) => entry.createdAt < stagedBefore);
if (aged.length === 0) {
return [];
}
const referenced = new Set(await referencedMediaIds(actor.id, aged.map((entry) => entry.mediaId)));
return aged.filter((entry) => !referenced.has(entry.mediaId));
}
/** The configured store's `list`, or a refusal — never an empty inventory in its place. */
attachmentInventory() {
const staging = this.options.attachments;
const list = staging?.list?.bind(staging);
if (list === undefined) {
throw new AttachmentInventoryError(staging === undefined
? 'Attachments are off: set `attachments` in config/agent.ts (e.g. attachmentStores.media()).'
: 'Listing attachments needs an attachment store that implements list(); the host owns ' +
'the staged bytes, so only its store can enumerate them.');
}
return list;
}
renameThread(threadId, title) {
return this.store.setTitle(threadId, title);
}
deleteThread(threadId) {
return this.store.softDeleteThread(threadId);
}
/**
* Make a transient thread a regular one (listed by {@link listThreads}). `false` when the store
* cannot — it has no `promoteThread`.
*/
async promoteThread(threadId) {
if (this.store.promoteThread === undefined) {
return false;
}
await this.store.promoteThread(threadId);
return true;
}
/**
* Drop a message and everything after it — the "edit and resend" primitive: the client then sends
* a fresh turn on the shortened thread.
*/
truncateThreadFrom(threadId, messageId) {
return this.store.truncateFrom(threadId, messageId);
}
forkThread(threadId, fromMessageId) {
return this.store.forkThread(threadId, fromMessageId);
}
async quotaToday(actorRef) {
return this.store.quotaToday(actorRef, utcDay());
}
}
/** `params` with its `uiCapabilities` validated (a `TypeError` when malformed). */
function withValidCapabilities(params) {
const { channel, ...rest } = params;
const stamped = channel === undefined ? params : { ...rest, pageContext: { ...params.pageContext, channel } };
return stamped.uiCapabilities === undefined
? stamped
: { ...stamped, uiCapabilities: validateUiCapabilities(stamped.uiCapabilities) };
}
//# sourceMappingURL=agent-service.js.map