@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
489 lines • 23.7 kB
JavaScript
import { utcDay } from '../agent-deps.js';
import { RunCancelledError, runAgentLoop, settleAll, streamErrorCode, streamErrorFrame, } from '../agent-loop.js';
import { isThreadTurn, } from '../chat-queue-service.js';
import { RUN_ENDED_BEFORE_TOOL_CALL } from '../dangling-tool-calls.js';
import { settleUnsettledDelegation } from '../delegation.js';
import { spannedAgent } from '../diagnostics.js';
import { HumanReplyMismatchError, isHumanDecision, } from '../elicitation.js';
import { releaseThreadRun } from '../spi/chat-queue.js';
import { childSinkWriter } from '../spi/token-stream-sink.js';
/**
* Runs the agent turn in-process — the default runner (`durable: false`). A HITL wait resolves a
* pending promise keyed run-namespaced by `${runId}:${toolCallId}`, so one run's reply can never
* satisfy another's pending action.
*
* Sub-agent delegation runs a nested loop on a transient sub-thread, and that nested run parks on a
* human exactly as a top-level one does — under its OWN runId, which is what makes it answerable:
* the pending row it writes carries that runId, and its frames are forwarded into the top-level
* ancestor's stream (the only stream anyone subscribed to) carrying it too. The mirror of the
* durable runner's `sinkRunId`.
*
* Single-replica only — durable is the scaled path (deferred).
*/
export class InlineAgentRunner {
factory;
store;
queue;
pending = new Map();
/**
* The input of each top-level run this process is running — what {@link isRunActive} answers
* from, and what a cancel settles the thread (and its queue) of.
*/
live = new Map();
/** Runs someone asked to stop; the loop observes it at its next safe point. */
cancelled = new Set();
/**
* Aborted by {@link cancel}: what cuts short the model call or tool a stopped run is in (the
* loop's `abortSignal` hook). Keyed like {@link cancelled}, and dropped with it.
*/
aborts = new Map();
/** The detached delegates this process is running, by runId — what a Stop on one settles. */
detached = new Map();
constructor(factory, store,
/** The thread message queue this runner drains when a thread's own turn settles. */
queue) {
this.factory = factory;
this.store = store;
this.queue = queue;
}
/**
* In-process, so exact: a run this process is not running is not running anywhere — the process
* that was running it is gone (a restart), and a thread it still holds is a stale claim.
*/
async isRunActive(runId) {
if (this.live.has(runId) || this.detached.has(runId)) {
return true;
}
// A delegated run is not in `live` (it holds no thread), but one parked on a person is as alive
// as its parent: its decision has somewhere to go.
for (const key of this.pending.keys()) {
if (key.startsWith(`${runId}:`)) {
return true;
}
}
return false;
}
async start(input, options = {}) {
const runId = options.runId ?? crypto.randomUUID();
const day = input.day ?? utcDay();
const deps = this.factory.forAgent(input.agentName);
const hooks = this.topLevelHooks({
runId,
input,
deps,
actor: input.actor,
day,
// The chain this run sits on with its OWN agent appended — what lets a child recognise a
// delegation back to an agent the chain has already passed through.
chainBelow: [
...(input.delegationPath ?? []),
...(input.agentName !== undefined ? [input.agentName] : []),
],
depth: input.delegationDepth ?? 0,
});
// The root turn span — the trace's root, correlated to every child step span by traceId = runId.
// Emitted by the runner (not the shared loop) because the inline runner executes the loop exactly
// once; the durable runner's body replays, so it roots its trace by traceId instead. Zero-cost
// when unobserved.
// The thread's active run is set BEFORE the loop starts — a fast turn could otherwise end (and
// clear it) before it was ever set — and cleared when the turn ends, however it ends.
// (A run the queue claimed the thread for already holds it under this id; setting it again is a
// no-op.)
await this.store.setActiveStream(input.threadId, runId);
this.live.set(runId, input);
void spannedAgent('turn', runId, { runId }, () => runAgentLoop({ ...deps, day }, input, hooks), (result) => ({ textLength: result.text.length }))
// The drain already moved the thread on (or released it) before the stream ended; this only
// catches a turn that ended without one. Conditional, so it never clears a successor's claim.
.then(() => releaseThreadRun(this.store, input.threadId, runId))
.catch(async (error) => {
if (error instanceof RunCancelledError) {
// `cancel` already settled the run, its thread and its stream; the loop only had to stop.
return;
}
const message = error instanceof Error ? error.message : String(error);
// Settle the run's persisted outcome — the loop only records completions (it can't catch its
// own crash). First-terminal, so this can't clobber a completion that already landed.
await this.store.recordRunEnd({
runId,
status: 'failed',
error: message,
errorCode: streamErrorCode(error),
});
await this.store.failUnsettledToolCalls?.(runId, RUN_ENDED_BEFORE_TOOL_CALL).catch(() => 0);
const writer = await deps.sink.open(runId);
// The queue behind it pauses (a failed turn's next message would likely fail the same way),
// told to the reader before the error frame.
await this.settleQueue(writer, input, runId, 'failed', message);
await releaseThreadRun(this.store, input.threadId, runId);
// Surface the failure on the live stream and close it, so a subscriber isn't left hanging.
await writer.write(streamErrorFrame(error, runId));
await writer.end();
})
.catch((error) => {
console.error(`[-agora/agent] run ${runId} could not be settled: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => {
this.live.delete(runId);
this.forgetCancel(runId);
});
return { runId };
}
/**
* Move the thread past a settling TOP-LEVEL run — to the next queued message, which starts here,
* or to a paused/empty queue — and write the resulting `queue` frame into this run's stream. A
* no-op without a queue-capable store, and for a run that is not a thread's own turn.
*
* Never throws: the run is settling either way, and a queue that could not be advanced is picked
* up by the next send or resume on the thread.
*/
async settleQueue(writer, input, runId, outcome, error) {
const queue = this.queue;
if (queue === undefined || !queue.supported || !isThreadTurn(input)) {
return;
}
// A Stop that arrived too late to interrupt anything (the turn's last model call was already
// answering) still means stop: the queue behind it pauses as it would after a cancel.
const settled = outcome === 'completed' && this.cancelled.has(runId) ? 'cancelled' : outcome;
try {
const frame = await queue.handoff({
threadId: input.threadId,
runId,
outcome: settled,
...(error !== undefined ? { error } : {}),
}, (next, nextRunId) => this.start(next, { runId: nextRunId }));
if (frame !== undefined) {
await writer.write({ t: 'event', event: frame });
}
}
catch (failure) {
console.error(`[-agora/agent] could not advance the queue of thread ${input.threadId} after run ${runId}: ${failure instanceof Error ? failure.message : String(failure)}`);
}
}
/**
* Deliver a human's reply to whichever wait this run is parked on.
*
* An approval wait takes a {@link Decision} only. Answers addressed there are refused rather than
* delivered: `approved` is absent on them, and the tool-call row would record a rejection the
* person never made. The refusal leaves the wait intact, so the approval is still there to make.
*
* The other direction IS delivered — a question set parks as a `pending_approval` action, so an
* operator pressing Approve on it is expected, and the loop reads that as "confirmed the
* pre-picked answers".
*/
async signal(runId, toolCallId, reply) {
const key = `${runId}:${toolCallId}`;
const parked = this.pending.get(key);
if (parked === undefined) {
return;
}
if (parked.on === 'approval' && !isHumanDecision(reply)) {
throw new HumanReplyMismatchError(runId, toolCallId);
}
this.pending.delete(key);
parked.resolve(reply);
}
/** The signal {@link cancel} aborts for `runId` — one per run, shared by its awaited delegates. */
abortSignalFor(runId) {
let controller = this.aborts.get(runId);
if (controller === undefined) {
controller = new AbortController();
this.aborts.set(runId, controller);
}
return controller.signal;
}
/** Drop a settled run's Stop: its flag and its abort controller. */
forgetCancel(runId) {
this.cancelled.delete(runId);
this.aborts.delete(runId);
}
async cancel(runId) {
// The loop stops at its next safe point (between steps, or once the model call it is in has
// answered, which the abort below makes now); a turn parked on a human is unwound now.
this.cancelled.add(runId);
// The model call or tool the run is in, cut short now rather than at the end of its step.
this.aborts.get(runId)?.abort(new RunCancelledError());
for (const [key, parked] of this.pending) {
if (key.startsWith(`${runId}:`)) {
this.pending.delete(key);
parked.reject(new RunCancelledError());
}
}
// Best-effort: settle the run `cancelled` (first-terminal, so a completed run stays completed),
// then close the live stream so a subscriber isn't left hanging.
await this.store.recordRunEnd({ runId, status: 'cancelled' });
const deps = this.factory.forAgent();
const writer = await deps.sink.open(runId);
const detached = this.detached.get(runId);
if (detached?.deliverTo !== undefined) {
// Told now rather than when the loop unwinds: a run parked on a person unwinds at once, but one
// mid model call only at its next safe point, and the thread should not wait for that.
await settleUnsettledDelegation({
store: this.store,
delivery: detached.deliverTo,
agent: detached.agentName ?? 'default',
runId,
...(detached.parentRunId !== undefined ? { parentRunId: detached.parentRunId } : {}),
status: 'cancelled',
}).catch(() => undefined);
}
const input = this.live.get(runId);
if (input !== undefined) {
// An interrupt's message starts now; anything else queued pauses behind the Stop.
await this.settleQueue(writer, input, runId, 'cancelled');
await releaseThreadRun(this.store, input.threadId, runId);
}
else if (detached === undefined) {
// Nothing of this process's is running under it; a detached run keeps the flag until it
// unwinds (its own `finally` clears it), or a Stop mid model call would be forgotten.
this.forgetCancel(runId);
}
// The last frame before a normal end: without it a reader cannot tell a truncated answer from
// a complete one. Not an error — a client that retries a failed stream must not retry this.
await writer.write({ t: 'event', event: { kind: 'cancelled' } });
await writer.end();
}
topLevelHooks(args) {
const { runId, input, deps, actor, day, chainBelow, depth } = args;
return {
runId,
durable: false,
// The loop ends the stream when the turn completes. Just before, the thread passes to the next
// queued message (or is released), and the reader is told which — so by the time a client sees
// the run end, the thread is already free or already running what it queued.
openSink: async () => {
const writer = await deps.sink.open(runId);
return this.queue === undefined
? writer
: {
write: (frame) => writer.write(frame),
end: async () => {
await this.settleQueue(writer, input, runId, 'completed');
await writer.end();
},
};
},
cancelled: async () => this.cancelled.has(runId),
abortSignal: this.abortSignalFor(runId),
...this.humanHooks(runId),
step: (_name, fn) => fn(),
// Nothing here records a position, so a turn's read tools can simply overlap.
parallel: settleAll,
runAgent: (agentName, task) => this.runNested({
agentName,
task,
actor,
day,
depth: depth + 1,
path: chainBelow,
parentRunId: runId,
// This run owns the stream a human subscribed to, so every delegation below it writes here.
sinkRunId: runId,
}),
startAgent: ({ agentName, task, toolCallId }) => this.startDetached({
agentName,
task,
actor,
day,
depth: depth + 1,
path: chainBelow,
parentRunId: runId,
deliverTo: { threadId: input.threadId, toolCallId },
}),
};
}
/**
* The two waits a run parks on, keyed by the run that is parked. Identical for a top-level run and
* for a delegated one: `signal(runId, …)` finds either, because the pending map belongs to the
* runner rather than to a run.
*/
humanHooks(runId) {
return {
awaitApproval: (call, _ctx, opts) =>
// Run-namespaced key: `${runId}:${toolCallId}` — one run can't approve another's tool call.
this.parkApproval(`${runId}:${call.id}`, opts?.timeoutMs),
// An answer and an approval reach a parked run by the same channel, because a question set is
// itself a `pending_approval` row: `POST /agent/tool-call/answer` and `/approve` both land here.
awaitAnswers: (request) => this.park(`${runId}:${request.id}`, 'answers'),
};
}
/** Hold a run at `key` until a human's reply arrives through {@link InlineAgentRunner.signal}. */
/**
* {@link park} for an approval, bounded by the policy's time to live when it has one: the timer
* settles the wait as `expired` — the same Decision the durable runner builds from its signal
* timeout — and a decision that arrives first disarms it.
*/
parkApproval(key, timeoutMs) {
const waiting = this.park(key, 'approval');
if (timeoutMs === undefined) {
return waiting;
}
let timer;
const lapse = new Promise((resolve) => {
timer = setTimeout(() => {
if (this.pending.delete(key)) {
resolve({ approved: false, expired: true });
}
}, timeoutMs);
// A parked request must not keep the process alive on its own.
timer.unref?.();
});
return Promise.race([waiting, lapse]).finally(() => clearTimeout(timer));
}
park(key, on) {
return new Promise((resolve, reject) => {
this.pending.set(key, { on, resolve, reject });
});
}
/**
* Delegate WITHOUT waiting: a nested loop nobody awaits, on its own scratch thread and its OWN
* stream, which posts its answer back into the delegating thread when it lands (`deliverTo`,
* delivered by the loop). The calling turn gets the run id straight back and finishes.
*
* No `sinkRunId`, unlike {@link runNested}: forwarding into the stream the human is watching would
* write into a turn that has already ended. It parks on a human under its own runId, which is what
* `tool-call/approve` resolves the pending row to; and it is stoppable on that id (the receipt
* carries it), which settles the delegation as `cancelled` in the thread that asked.
*/
async startDetached(args) {
const { agentName, task, actor, day, depth, path, parentRunId, deliverTo } = args;
const subThread = await this.store.createThread({ actor, persona: 'default', transient: true });
const runId = crypto.randomUUID();
const deps = this.factory.forAgent(agentName);
const input = {
threadId: subThread.id,
actor,
userText: task,
agentName,
day,
parentRunId,
delegationDepth: depth,
delegationPath: path,
deliverTo,
};
const hooks = {
runId,
durable: false,
openSink: () => deps.sink.open(runId),
...this.humanHooks(runId),
cancelled: async () => this.cancelled.has(runId),
abortSignal: this.abortSignalFor(runId),
step: (_name, fn) => fn(),
parallel: settleAll,
runAgent: (childName, childTask) => this.runNested({
agentName: childName,
task: childTask,
actor,
day,
depth: depth + 1,
path: [...path, agentName],
parentRunId: runId,
// A detached run owns a stream of its own, so ITS awaited delegates forward into that one.
sinkRunId: runId,
}),
startAgent: ({ agentName: childName, task: childTask, toolCallId }) => this.startDetached({
agentName: childName,
task: childTask,
actor,
day,
depth: depth + 1,
path: [...path, agentName],
parentRunId: runId,
deliverTo: { threadId: subThread.id, toolCallId },
}),
};
this.detached.set(runId, input);
void spannedAgent('turn', runId, { runId }, () => runAgentLoop({ ...deps, day }, input, hooks), (result) => ({ textLength: result.text.length }))
.catch(async (error) => {
const cancelled = error instanceof RunCancelledError;
const message = error instanceof Error ? error.message : String(error);
if (!cancelled) {
await this.store.recordRunEnd({
runId,
status: 'failed',
error: message,
errorCode: streamErrorCode(error),
});
await this.store
.failUnsettledToolCalls?.(runId, RUN_ENDED_BEFORE_TOOL_CALL)
.catch(() => 0);
const writer = await deps.sink.open(runId);
await writer.write(streamErrorFrame(error, runId));
await writer.end();
}
// The loop delivers its own success; a run that never produced an answer settles the
// delegation here, or the calling conversation shows "started" for ever. (A Stop already
// settled it in `cancel`; this is then a no-op.)
await settleUnsettledDelegation({
store: this.store,
delivery: deliverTo,
agent: agentName,
runId,
parentRunId,
status: cancelled ? 'cancelled' : 'failed',
...(cancelled ? {} : { error: message }),
});
})
.catch((error) => {
console.error(`[-agora/agent] detached run ${runId} could not be settled: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => {
this.detached.delete(runId);
this.forgetCancel(runId);
});
return { runId };
}
/** Delegate to another agent as a nested in-process run (a transient sub-thread). */
async runNested(args) {
const { agentName, task, actor, day, depth, path, parentRunId, sinkRunId } = args;
const subThread = await this.store.createThread({ actor, persona: 'default', transient: true });
const runId = crypto.randomUUID();
const deps = this.factory.forAgent(agentName);
const hooks = {
runId,
durable: false,
// Forward into the ancestor's stream but never end it: the top-level run owns that lifecycle
// across however many delegations it spans.
openSink: async () => childSinkWriter(await deps.sink.open(sinkRunId)),
// A delegated run parks on a human like any other, keyed by ITS OWN runId — which is the id
// its pending row and its forwarded frames both carry, so the wait can be answered.
...this.humanHooks(runId),
// A child stops when the run a human is actually watching is stopped.
cancelled: async () => this.cancelled.has(sinkRunId),
abortSignal: this.abortSignalFor(sinkRunId),
step: (_name, fn) => fn(),
parallel: settleAll,
runAgent: (childName, childTask) => this.runNested({
agentName: childName,
task: childTask,
actor,
day,
depth: depth + 1,
path: [...path, agentName],
parentRunId: runId,
sinkRunId,
}),
startAgent: ({ agentName: childName, task: childTask, toolCallId }) => this.startDetached({
agentName: childName,
task: childTask,
actor,
day,
depth: depth + 1,
path: [...path, agentName],
parentRunId: runId,
deliverTo: { threadId: subThread.id, toolCallId },
}),
};
// A nested sub-agent run is its own trace (its own runId), rooted by the same turn span.
return spannedAgent('turn', runId, { runId }, () => runAgentLoop({ ...deps, day }, {
threadId: subThread.id,
actor,
userText: task,
agentName,
day,
parentRunId,
delegationDepth: depth,
delegationPath: path,
}, hooks), (result) => ({ textLength: result.text.length }));
}
}
//# sourceMappingURL=inline-agent-runner.js.map