@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
187 lines • 9.67 kB
JavaScript
import { utcDay } from '../agent-deps.js';
import { isThreadTurn } from '../chat-queue-service.js';
import { settleUnsettledDelegation } from '../delegation.js';
import { releaseThreadRun } from '../spi/chat-queue.js';
import { AgentRunWorkflow } from './agent-run-workflow.js';
import { drivePendingRuns } from './drive-pending-runs.js';
/**
* A control-flow signal surfaced out of `engine.start`. With the default in-process dispatcher the
* body runs on a microtask AFTER `start` returns, so this never fires; but an app that configures a
* DRIVING dispatcher (a durable worker/tenant) can surface the engine's internal suspend synchronously
* here — expected control flow, NOT a start failure. Recognised by `name` so it survives a duplicated
* durable module copy (whose class identity would differ).
*/
function isControlFlowSignal(error) {
const name = error?.name;
return name === 'WorkflowSuspended' || name === 'ContinueAsNew';
}
/**
* Runs the agent turn as an `@adonis-agora/durable` workflow ({@link AgentRunWorkflow}) — the opt-in
* runner (`durable: true`). `start` enqueues the run and returns its id immediately (a worker runs the
* body and streams to the sink meanwhile); HITL approval is delivered as a durable signal namespaced
* by run, so it can never cross-resolve another run. Mirrors the {@link InlineAgentRunner} interface.
*/
export class DurableAgentRunner {
engine;
store;
queue;
sink;
/**
* `store` is optional so a bare `new DurableAgentRunner(engine)` still works; when passed (the
* provider does), {@link cancel} settles the `agent_run` row `cancelled` for governance.
*/
constructor(engine, store,
/** The thread message queue — what a cancel hands the thread to. */
queue,
/** Where a cancelled thread turn's stream is ended (`cancelled` frame, then the end). */
sink) {
this.engine = engine;
this.store = store;
this.queue = queue;
this.sink = sink;
}
async start(input, options = {}) {
const stamped = { ...input, day: input.day ?? utcDay() };
// Own the run id so we can still return it when the run suspends synchronously on start (below).
// A caller-chosen id (a queued message's) makes the start idempotent: the engine answers a start
// under an id it already has with that run, instead of starting a second one.
const runId = options.runId ?? crypto.randomUUID();
// Before the engine starts it: a run the engine drives synchronously could otherwise end (and
// clear it) before it was set. The workflow clears it when the run ends.
await this.store?.setActiveStream(input.threadId, runId);
try {
await this.engine.start(AgentRunWorkflow, stamped, runId);
}
catch (error) {
// A run that suspends on its first step under a driving dispatcher surfaces a control-flow
// signal here — the run is already persisted and a worker resumes it, so swallow it and return
// the id; any other error is a real start failure and propagates.
if (!isControlFlowSignal(error)) {
throw error;
}
}
return { runId };
}
async signal(runId, toolCallId, reply) {
await this.engine.signal(`tool:${runId}:${toolCallId}`, reply);
}
/** The runs {@link drive} set going that have not ended yet. */
driving = new Set();
/** Lease and execute here what of `runId` is still pending — see {@link drivePendingRuns}. */
async drive(runId) {
await drivePendingRuns(this.engine, runId, this.driving);
}
/**
* From the engine's own run row. A run it does not know yet counts as running — a queued turn is
* claimed before its start lands — and so does one it cannot be asked about: only a run the engine
* reports settled (`completed`/`failed`/`cancelled`/`dead`) is stale.
*/
async isRunActive(runId) {
try {
const status = (await this.engine.getRun(runId))?.status;
return !(status === 'completed' ||
status === 'failed' ||
status === 'cancelled' ||
status === 'dead');
}
catch {
return true;
}
}
async cancel(runId) {
// What the run was started with, read before the cancel lands: which thread it holds.
const run = await this.engine.getRun(runId).catch(() => null);
const input = run?.input;
const settled = run?.status === 'completed' ||
run?.status === 'failed' ||
run?.status === 'cancelled' ||
run?.status === 'dead';
// The runs it spawned, read before the cancel cascades to them. A detached delegation is no
// longer started that way, but a turn that journaled its `spawn:` before this release still owns
// that child in the engine's eyes, and the cascade stops it from outside, where nothing of ours runs.
const children = settled ? [] : await this.engine.getRunChildren(runId).catch(() => []);
// Best-effort: cascade cancellation to children and broadcast to the owning worker. A run that
// already settled is a no-op. Errors are swallowed — cancel is advisory, not a guarantee.
await this.engine.cancel(runId).catch(() => undefined);
for (const childId of children) {
await this.settleCascadedDetached(childId);
}
// Settle the persisted run `cancelled` (first-terminal: a run that already completed stays put).
await this.store?.recordRunEnd({ runId, status: 'cancelled' }).catch(() => undefined);
// A thread's own turn, cancelled from outside its body, never reaches its own settle: hand the
// thread on here — an interrupt's message starts now, anything else queued pauses behind the
// Stop — and free it. A sub-agent's run holds no thread of its own.
const threadId = typeof input?.threadId === 'string' ? input.threadId : undefined;
const delivery = input?.deliverTo;
if (delivery !== undefined && input?.sinkRunId === undefined) {
if (settled || this.store === undefined) {
// It already ended — and told the thread how, on its own way out.
return;
}
// A DETACHED delegate, stopped on its own id (the receipt carries it). Its body never runs
// again, so the delegating conversation is told here — once: a thread already holding this
// run's message is left alone — and the run's own stream is ended for whoever is attached.
await this.settleStoppedDelegation(runId, input ?? {});
return;
}
if (this.store === undefined || threadId === undefined || !isThreadTurn(input ?? {})) {
return;
}
const writer = this.sink !== undefined ? await this.sink.open(runId) : undefined;
if (this.queue?.supported === true) {
try {
const frame = await this.queue.handoff({ threadId, runId, outcome: 'cancelled' }, (next, nextRunId) => this.start(next, { runId: nextRunId }));
if (frame !== undefined) {
await writer?.write({ t: 'event', event: frame });
}
}
catch (error) {
console.error(`[-agora/agent] could not advance the queue of thread ${threadId} after cancelling ${runId}: ${error instanceof Error ? error.message : String(error)}`);
}
}
await releaseThreadRun(this.store, threadId, runId).catch(() => undefined);
if (writer !== undefined) {
// The last frame before the end: a reader can tell a stopped answer from a complete one.
await writer.write({ t: 'event', event: { kind: 'cancelled' } });
await writer.end();
}
}
/** Tell the delegating thread a detached run was stopped, and end that run's own stream. */
async settleStoppedDelegation(runId, input) {
const delivery = input.deliverTo;
if (delivery === undefined || this.store === undefined) {
return;
}
await settleUnsettledDelegation({
store: this.store,
delivery,
agent: typeof input.agentName === 'string' ? input.agentName : 'default',
runId,
...(typeof input.parentRunId === 'string' ? { parentRunId: input.parentRunId } : {}),
status: 'cancelled',
}).catch(() => undefined);
if (this.sink !== undefined) {
const writer = await this.sink.open(runId);
await writer.write({ t: 'event', event: { kind: 'cancelled' } });
await writer.end();
}
}
/**
* A detached child the engine cancelled along with its parent (a `spawn:` journaled before detached
* runs were started on their own): end it the way a Stop on its own id would have.
*/
async settleCascadedDetached(childId) {
const child = await this.engine.getRun(childId).catch(() => null);
const input = child?.input;
if (child === null ||
child === undefined ||
input?.deliverTo === undefined ||
input.sinkRunId !== undefined ||
child.status !== 'cancelled') {
return;
}
await this.store?.recordRunEnd({ runId: childId, status: 'cancelled' }).catch(() => undefined);
await this.settleStoppedDelegation(childId, input);
}
}
//# sourceMappingURL=durable-agent-runner.js.map