@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
395 lines • 17.9 kB
JavaScript
import { utcDay } from './agent-deps.js';
import { normalizeSentAt } from './message-timestamps.js';
import { isChatQueueStore, queuedMessageView, } from './spi/chat-queue.js';
/** How many times a drain retries when the head it picked is removed under it. */
const MAX_DRAIN_ATTEMPTS = 8;
/**
* The thread-level message queue: messages a person sends while a turn is running wait here, and
* the next one starts as soon as the running turn settles. A port of `@dudousxd/nestjs-agent`'s
* `ChatQueueService`; the wire is its `docs/stream-protocol.md`, *Message queue*.
*
* Everything that decides WHICH run holds a thread goes through the store's compare-and-set
* admission (`claimActiveStream` / `releaseActiveStream`), so two processes that both try to start
* the next turn — a settling run on a worker and a send on a web pod — cannot both win. The next
* run's id is the queued message's own id, which makes every step here idempotent: a retried claim
* for the same message succeeds again, and a durable start under the same id is a no-op.
*
* Policy, per outcome of the run that held the thread:
* - `completed` → the head starts.
* - `failed` → the queue pauses (`run_failed`), keeping every message.
* - `cancelled` → the queue pauses (`cancelled`), unless the head was queued by an interrupt — the
* cancel was made for it, so it starts.
* A paused queue starts nothing until it is resumed. A head whose actor is over quota pauses it
* (`quota_exceeded`) instead of starting.
*/
export class ChatQueueService {
store;
sink;
options;
/**
* Runs this process claimed a thread for and has not handed to a runner yet. A holder in here is
* alive whatever the runner says — it simply has not registered the run yet.
*/
starting = new Set();
constructor(store, sink, options = {}) {
this.store = store;
this.sink = sink;
this.options = options;
}
/** Whether the bound store can hold a queue. Without one, a busy thread is not queued. */
get supported() {
return isChatQueueStore(this.store);
}
/** The bound store as a queue store; throws when it is not one (check {@link supported} first). */
queueStore() {
if (!isChatQueueStore(this.store)) {
throw new Error('Queueing messages requires an AgentStore that implements ChatQueueStore (enqueueMessage, ' +
'listQueue, …, claimActiveStream, releaseActiveStream); the bound store does not.');
}
return this.store;
}
async state(threadId) {
const store = this.queueStore();
const [items, paused] = await Promise.all([
store.listQueue(threadId),
store.queuePause(threadId),
]);
return { items: items.map(queuedMessageView), paused };
}
/**
* Whether `holder` is still running. A run this process is about to start is; otherwise the
* runner decides, and a runner that cannot tell says yes.
*/
async isAlive(holder, runner) {
if (this.starting.has(holder)) {
return true;
}
if (typeof runner.isRunActive !== 'function') {
return true;
}
try {
return await runner.isRunActive(holder);
}
catch {
return true;
}
}
/**
* The run holding `threadId`, or `null` when it is free — a holder the runner reports dead counts
* as free, and is returned as `stale` so the caller can claim over it.
*/
async holder(threadId, runner) {
const holder = await this.queueStore().activeRunForThread(threadId);
if (holder === null) {
return { live: null, stale: null };
}
return (await this.isAlive(holder, runner))
? { live: holder, stale: null }
: { live: null, stale: holder };
}
/**
* Decide what follows a TOP-LEVEL run that is settling on `threadId`, and move the thread there:
* hand it to the next queued message (claimed and popped here, started by the caller), or pause
* the queue, or release the thread. Call it BEFORE the run's terminal frame, so a client reading
* the stream learns what comes next from the `queue` frame, and so the thread is already free
* when the client sees the run end.
*
* A no-op returning `{}` on a store without a queue.
*/
async plan(args) {
if (!isChatQueueStore(this.store)) {
return {};
}
const store = this.store;
const { threadId, outcome } = args;
// Only the run holding the thread moves it on. One that already handed it over (a durable
// cancel settles from outside the body, and the body may still settle after it) has nothing
// left to decide — deciding again would pause a queue the handover just started.
if ((await store.activeRunForThread(threadId)) !== args.runId) {
return {};
}
// The id holding the thread right now, as far as this drain knows: the settling run, until it
// is released, then nobody.
let holding = args.runId;
for (let attempt = 0; attempt < MAX_DRAIN_ATTEMPTS; attempt += 1) {
const items = await store.listQueue(threadId);
const head = items[0];
if (head === undefined) {
if (holding !== null) {
await store.releaseActiveStream(threadId, holding);
holding = null;
// An enqueue that landed between the read above and this release saw the thread held and
// left its message for this drain — read once more now that the thread is free.
continue;
}
return {};
}
let pause = await store.queuePause(threadId);
if (pause === null) {
const reason = pauseReasonFor(outcome, head);
if (reason !== null) {
pause = {
reason,
...(outcome === 'failed' && args.error !== undefined ? { message: args.error } : {}),
at: new Date().toISOString(),
};
await store.setQueuePause(threadId, pause);
}
}
if (pause === null) {
const blocked = await this.quotaBlock(head.actor);
if (blocked !== null) {
pause = { reason: 'quota_exceeded', message: blocked, at: new Date().toISOString() };
await store.setQueuePause(threadId, pause);
}
}
if (pause !== null) {
if (holding !== null) {
await store.releaseActiveStream(threadId, holding);
}
return { frame: queueFrame(items, pause) };
}
const claimed = await store.claimActiveStream(threadId, head.id, holding !== null ? { replacing: holding } : {});
if (!claimed) {
// Someone else holds the thread now (a send that got in first); its own settle drains.
return {};
}
holding = head.id;
if (!(await store.removeQueuedMessage(head.id))) {
// Removed (or started) under us between the read and the claim: give the claim back and
// look at the new head.
await store.releaseActiveStream(threadId, head.id);
holding = null;
continue;
}
const next = {
runId: head.id,
messageId: head.id,
input: await this.inputFor(head),
};
return {
next,
frame: queueFrame(items.slice(1), null, { messageId: head.id, runId: head.id }),
};
}
// Pathological churn (every head removed under us): leave the thread free rather than spin.
if (holding !== null) {
await store.releaseActiveStream(threadId, holding);
}
return {};
}
/**
* {@link plan}, then start what it picked with `start`. What an in-process settle (the inline
* runner, a durable cancel issued outside a workflow) calls; answers the frame to write.
*/
async handoff(args, start) {
const plan = await this.plan(args);
if (plan.next === undefined) {
return plan.frame;
}
return (await this.launch(args.threadId, plan.next, start))
? plan.frame
: this.pausedFrame(args.threadId);
}
/**
* Start the head of an IDLE thread's queue — after a send queued behind a run that turned out to
* be gone, after a resume, after a stale holder. Does nothing while a live run holds the thread
* (its settle drains) or while the queue is paused. Answers the started run's id, if any.
*/
async kick(threadId, runner) {
if (!isChatQueueStore(this.store)) {
return undefined;
}
const { live, stale } = await this.holder(threadId, runner);
if (live !== null) {
return undefined;
}
if (stale !== null) {
console.warn(`[-agora/agent] thread ${threadId} was held by run ${stale}, which is no longer running`);
}
// Planned as if the stale holder completed: nothing it did pauses the queue.
const plan = await this.plan({ threadId, runId: stale, outcome: 'completed' });
if (plan.next === undefined) {
return undefined;
}
const started = await this.launch(threadId, plan.next, (input, runId) => runner.start(input, { runId }));
if (started && plan.frame !== undefined) {
// The new run's own stream is where a client attached to nothing yet learns about it.
await this.write(plan.next.runId, plan.frame);
}
return started ? plan.next.runId : undefined;
}
/**
* Start a turn the thread is already claimed for. On failure the message goes back to the head of
* the queue, the queue pauses (`start_failed`), and the claim is released — nothing is lost and
* nothing is left holding the thread.
*/
async launch(threadId, next, start) {
this.starting.add(next.runId);
try {
await start(next.input, next.runId);
return true;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[-agora/agent] queued message ${next.messageId} could not start: ${message}`);
await this.restore(threadId, next, 'start_failed', message);
return false;
}
finally {
this.starting.delete(next.runId);
}
}
/** Put a turn that never started back at the head of its queue, paused, and free the thread. */
async restore(threadId, next, reason, message) {
const store = this.queueStore();
const { input } = next;
await store.enqueueMessage({
threadId,
actor: input.actor,
content: input.userText,
at: 'head',
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(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 } : {}),
});
await store.setQueuePause(threadId, { reason, message, at: new Date().toISOString() });
await store.releaseActiveStream(threadId, next.runId);
}
/** A `queue` frame with the thread's current state. */
async pausedFrame(threadId) {
return { kind: 'queue', queue: await this.state(threadId) };
}
/**
* Tell whoever is watching the thread that its queue changed: a `queue` frame into the stream of
* the run holding it. Best-effort — a thread nobody is streaming has no one to tell, and the next
* read of the thread carries the queue anyway.
*/
async publish(threadId) {
const state = await this.state(threadId);
const holder = await this.queueStore().activeRunForThread(threadId);
if (holder !== null) {
await this.write(holder, { kind: 'queue', queue: state }).catch((error) => {
console.warn(`[-agora/agent] could not publish the queue of thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
});
}
return state;
}
/** Write one agent-protocol event into `runId`'s stream. */
async write(runId, frame) {
const writer = await this.sink.open(runId);
await writer.write({ t: 'event', event: frame });
}
/** Why `actor` may not start a turn now (the quota message), or `null`. */
async quotaBlock(actor) {
const quota = this.options.quota;
if (quota === undefined) {
return null;
}
try {
const { blocked } = await quota.report({ actor });
if (blocked === undefined) {
return null;
}
return (blocked.reason ?? `The ${blocked.period === 'day' ? 'daily' : 'monthly'} quota is used up`);
}
catch {
// Failing to ask is not a verdict; the send path's own quota gate still runs.
return null;
}
}
/**
* The turn a queued message starts: everything it was queued with, today's date, and its
* attachments' urls minted afresh — the ones stored at enqueue time may have expired while it
* waited. An attachment the store no longer resolves keeps its stored url.
*/
async inputFor(message) {
const attachments = await this.freshAttachments(message.actor, message.attachments ?? []);
const { agentName, persona } = await this.targetFor(message);
const sentAt = queuedSentAt(message);
return {
threadId: message.threadId,
actor: message.actor,
userText: message.content,
day: utcDay(),
// It was sent when it was queued (or at the time the send named), not when it starts.
...(sentAt !== undefined ? { sentAt } : {}),
...(agentName !== undefined ? { agentName } : {}),
// An id, never the definition: the run journals the definition itself (`persona:resolve`).
...(persona !== undefined ? { persona } : {}),
...(attachments.length > 0 ? { attachments } : {}),
...(message.pageContext !== undefined ? { pageContext: message.pageContext } : {}),
...(message.uiCapabilities !== undefined ? { uiCapabilities: message.uiCapabilities } : {}),
...(message.hostContext !== undefined ? { hostContext: message.hostContext } : {}),
...(message.model !== undefined ? { model: message.model } : {}),
};
}
/** The agent and persona a queued message starts as — see {@link ChatQueueServiceOptions.resolveTarget}. */
async targetFor(message) {
const queued = {
...(message.agentName !== undefined ? { agentName: message.agentName } : {}),
...(message.persona !== undefined ? { persona: message.persona } : {}),
};
if (message.persona !== undefined) {
return queued;
}
if (this.options.resolveTarget !== undefined) {
try {
return await this.options.resolveTarget(message);
}
catch {
// A pin or a default that no longer resolves is not a reason to drop the message.
return queued;
}
}
const legacy = this.options.personaFor?.(message.agentName)?.id;
return { ...queued, ...(legacy !== undefined ? { persona: legacy } : {}) };
}
async freshAttachments(actor, attachments) {
const staging = this.options.attachments;
if (attachments.length === 0 || staging === undefined) {
return attachments;
}
return Promise.all(attachments.map(async (attachment) => {
try {
return (await staging.resolve({ mediaId: attachment.mediaId, actor })) ?? attachment;
}
catch {
return attachment;
}
}));
}
}
/** The pause a settling run's outcome imposes on a non-empty queue, or `null` to keep draining. */
function pauseReasonFor(outcome, head) {
if (outcome === 'failed') {
return 'run_failed';
}
if (outcome === 'cancelled' && head.interrupt !== true) {
return 'cancelled';
}
return null;
}
function queueFrame(items, paused, started) {
return {
kind: 'queue',
queue: { items: items.map(queuedMessageView), paused },
...(started !== undefined ? { started } : {}),
};
}
/**
* A thread's own turn — not a sub-agent's run on a subthread, which neither holds its thread's
* queue nor answers to it.
*/
export function isThreadTurn(input) {
return input.sinkRunId === undefined && input.parentRunId === undefined;
}
/** When a queued message was sent: the time its send named, else when it was queued. */
function queuedSentAt(message) {
return normalizeSentAt(message.sentAt ?? message.createdAt);
}
//# sourceMappingURL=chat-queue-service.js.map