UNPKG

@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
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(`[@adonis-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