UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

434 lines • 26.2 kB
import { BaseWorkflow, ContinueAsNew, WorkflowSuspended, } from '@adonis-agora/durable'; import { utcDay } from '../agent-deps.js'; import { RunCancelledError, runAgentLoop, settleAll, streamErrorCode, streamErrorFrame, } from '../agent-loop.js'; import { RUN_ENDED_BEFORE_TOOL_CALL } from '../dangling-tool-calls.js'; import { settleDeadRun } from '../dead-run.js'; import { settleUnsettledDelegation } from '../delegation.js'; import { PACKAGE_ORIGIN } from '../package-origin.js'; import { isReplayIntegrityError } from '../replay-integrity.js'; import { isChatQueueStore, releaseThreadRun } from '../spi/chat-queue.js'; import { childSinkWriter } from '../spi/token-stream-sink.js'; import { agentEngine, getDurableAgentContext } from './agent-run-context.js'; import { ambientWorkflowCtx, outsideWorkflowCtx } from './outside-workflow-ctx.js'; /** * A control-flow signal (suspend / continue-as-new) the durable engine throws THROUGH the workflow * body to pause it — NOT a failure. `runAgentLoop` suspends by letting `ctx.waitForSignal` (HITL) or * `ctx.child` (delegation) throw here; the workflow's catch must re-throw it so the engine handles * it. Checked by `instanceof` AND by `name` so a duplicated copy of the durable module (whose class * identity differs) is still recognised. */ /** * The runtime's `SignalTimeoutError`, recognized by NAME: the class can differ between a bundled and * a hoisted copy of `@adonis-agora/durable`, so `instanceof` against one import would miss the other. */ function isSignalTimeout(error) { return error instanceof Error && error.name === 'SignalTimeoutError'; } function isControlFlowSignal(error) { if (error instanceof WorkflowSuspended || error instanceof ContinueAsNew) { return true; } const name = error?.name; return name === 'WorkflowSuspended' || name === 'ContinueAsNew'; } /** * The agent turn AS a durable workflow — the replay-safe counterpart of `InlineAgentRunner`. The * shared `runAgentLoop` body drives model→tools→model exactly as inline; the durable hooks make it * suspend-and-resume: * - `step(name, fn)` → `ctx.localStep` — every LLM turn, tool execution, and persist/quota write is a * checkpoint, so a replay returns the cached result instead of re-running it (stable ids, no * double-write, no re-streamed tokens). The run-tracing SPANS (`agora:agent:llm.turn` / * `tool.execution` / `retrieval`) are emitted from INSIDE these step bodies, so a replay — which * skips the bodies — never re-emits them. Unlike the inline runner, the durable workflow does NOT * emit a body-level root `turn` span (the body replays, which would duplicate it); the trace is * rooted implicitly by `traceId = runId`, which every child span already carries. * - `awaitApproval(call)` / `awaitAnswers(request)` → `ctx.waitForSignal('tool:<runId>:<callId>')` — * an action tool or a question set suspends the run with zero compute until an approve/reject or * answers signal arrives (namespaced by run, so one run's reply can never satisfy another's). * A DELEGATED run suspends the same way, on its own runId — which is what `sinkRunId` exists for: * its frames land in the stream the human is already watching and carry that runId, so a * sub-agent's own HITL wait can be seen, and therefore answered. * - `runAgent(name, task)` → `ctx.child(AgentRunWorkflow, …)` — sub-agent delegation is a tracked, * replay-safe CHILD run (a node in the durable dashboard) that streams into the top-level sink. * - `startAgent(…)` → a run of its own, started from a journaled `detach:<toolCallId>` step — a * DETACHED delegation: no suspend, so the turn ends with a receipt; the child owns its own stream * and posts its answer into the delegating thread (`deliverTo`), and a Stop on this turn does not * reach it. A run that journaled a `spawn:` (`ctx.startChild`) there before replays that. * - `openSink()` → the run's own sink writer (top-level) or a {@link childSinkWriter} (a child). * * Instantiated by the engine with no arguments; its deps come from {@link getDurableAgentContext}. */ export class AgentRunWorkflow extends BaseWorkflow { static workflow = { name: 'agora.agent.run', version: '1', origin: PACKAGE_ORIGIN }; async run(ctx, input) { const context = getDurableAgentContext(); const { factory, store, queue } = context; const engine = agentEngine(context); const day = input.day ?? utcDay(); const deps = factory.forAgent(input.agentName); // Three shapes of run, told apart by their input alone (so every replay and every pod agrees): // - a thread's own turn: owns its stream, its thread and the thread's queue; // - an awaited delegate (`sinkRunId`): forwards into its ancestor's stream, owns none of it; // - a DETACHED delegate (`deliverTo`): owns its stream (nobody is watching the turn that started // it any more), but no thread turn of anyone's — it runs on a scratch thread and posts its // answer into the delegating one (`deliver:detached`, in the loop). const isChild = input.sinkRunId !== undefined; const detached = !isChild && input.deliverTo !== undefined; const ownsThread = !isChild && !detached; const sinkRunId = input.sinkRunId ?? ctx.runId; // Every checkpoint this workflow writes. The body runs outside the ambient workflow ctx, so // nothing the application does in there — a tool dispatching a workflow of its own, a store, a // quota provider — can take a position in this run's journal (see {@link outsideWorkflowCtx}). const step = (name, fn) => ctx.localStep(name, () => outsideWorkflowCtx(fn)); /** * Move the thread past this settling turn — to the next queued message, started here as a * fire-and-forget `ctx.startChild` under the message's own id, or to a paused/empty queue — and * answer the `queue` frame to write before the turn's terminal. * * Journaled: the decision (which message, claimed and popped) is one `localStep`, so a replay * reads it back instead of popping a second message, and the start is the runtime's own * replay-safe spawn. Gated by `ctx.patched`, so a run recorded before the queue existed replays * against the history it has. A no-op for a sub-agent's run and on a store without a queue. */ const advanceQueue = async (outcome, error) => { if (queue === undefined || !queue.supported || !ownsThread) { return undefined; } if (!(await ctx.patched('agent:chat-queue'))) { return undefined; } const plan = await step(`queue:${outcome}`, async () => { try { return await queue.plan({ threadId: input.threadId, runId: ctx.runId, outcome, ...(error !== undefined ? { error } : {}), }); } catch { // Not worth failing a settling turn over: the queue is picked up by the next send or // resume on the thread. return {}; } }); const next = plan.next; if (next === undefined) { return plan.frame; } try { await ctx.startChild(AgentRunWorkflow, next.input, next.runId); return plan.frame; } catch (failure) { if (isControlFlowSignal(failure)) { throw failure; } const reason = failure instanceof Error ? failure.message : String(failure); return step('queue:restore', async () => { await queue.restore(input.threadId, next, 'start_failed', reason); return queue.pausedFrame(input.threadId); }); } }; // 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. Derived from the workflow's // input, so it is the same on every replay and on every pod. const chainBelow = [ ...(input.delegationPath ?? []), ...(input.agentName !== undefined ? [input.agentName] : []), ]; /** * Start a detached delegation as a TOP-LEVEL run, under an id derived from this run and the call. * * Not `ctx.startChild`: the engine treats a `spawn:` child as part of its parent, and a cancel on * the parent cascades to it. A detached run is exactly the work the person did NOT ask to stop — * they stopped the turn they were watching. Cascaded, it died parked on its approval, where its * body never runs again, so its card said "started" for ever. The agent-level edge stays on the * input (`parentRunId`). * * One `detach:<toolCallId>` localStep, its body outside the ambient ctx: the start writes no * position in this run, the recorded result keeps a replay from starting it again, and a body * re-run after a crash between the start and the checkpoint asks for the same id, which the * engine answers with the run it already has. Stamped with this run's namespace, as * `ctx.startChild` would have stamped it. */ const startDetached = (toolCallId, childInput) => step(`detach:${toolCallId}`, async () => { const childRunId = `${ctx.runId}.detached.${toolCallId}`; const namespace = engine ? await engine .getRun(ctx.runId) .then((run) => run?.namespace) .catch(() => undefined) : undefined; const options = namespace !== undefined ? { namespace } : {}; try { if (engine !== undefined) { await engine.start(AgentRunWorkflow, childInput, childRunId, options); } else if (ambientWorkflowCtx !== undefined) { await AgentRunWorkflow.dispatch(childInput, { runId: childRunId, ...options }); } else { throw new Error('[@adonis-agora/agent] a detached delegation needs the engine in the durable agent context (setDurableAgentContext({ engine })).'); } } catch (error) { // A driving dispatcher can surface the new run's own suspend here — control flow of THAT // run, not a failed start (see `DurableAgentRunner.start`). if (!isControlFlowSignal(error)) { throw error; } } return childRunId; }); const hooks = { runId: ctx.runId, durable: true, // HITL: suspend until the run-namespaced signal arrives. This throw escapes the loop cleanly — // it happens BEFORE the loop's tool try/catch, so a suspend is never seen as a tool failure. // A DELEGATED run suspends on its OWN runId, and that wait is answerable: the pending row it // writes carries that runId, and so does the `elicitation` frame it forwards into the stream // the human is already watching. // A policy's time to live becomes the signal wait's own timeout: the runtime journals the // deadline on the first call (reached only for a call the journal says has a ttl) and wakes // the run when it passes. The lapse comes back as a Decision rather than a throw, so the loop // settles the call `expired` on its ordinary rejection checkpoint. awaitApproval: (call, _toolCtx, opts) => opts?.timeoutMs === undefined ? ctx.waitForSignal(`tool:${ctx.runId}:${call.id}`) : ctx .waitForSignal(`tool:${ctx.runId}:${call.id}`, { timeoutMs: opts.timeoutMs, }) .catch((error) => { if (isSignalTimeout(error)) { return { approved: false, expired: true }; } throw error; }), // A question set parks on the SAME signal an approval does, under the tool call's own id — so // `POST /agent/tool-call/answer` and `/approve` are one delivery path, and a deployment that // only ever wired approval still settles an elicitation. awaitAnswers: (request) => ctx.waitForSignal(`tool:${ctx.runId}:${request.id}`), // A child forwards into the top-level sink (so the human watching the parent sees it) but must // not end it; a top-level run opens and owns its own sink keyed by its runId. // A thread's own turn hands the thread to its queue just before the loop ends the stream, so // the reader learns what runs next from the `queue` frame, before the end. openSink: async () => { if (isChild) { return childSinkWriter(await deps.sink.open(sinkRunId)); } const writer = await deps.sink.open(ctx.runId); return { write: (frame) => writer.write(frame), end: async () => { const frame = await advanceQueue('completed'); if (frame !== undefined) { await writer.write({ t: 'event', event: frame }); } await writer.end(); }, }; }, // Every side effect + control-flow read is a durable local step (memoized on replay). step, // A thread's own turn that no longer holds its thread was stopped from outside its body (a // Stop, or an interrupt that already started the next message): it must not write an answer // after whatever took its place. Asked live, only where the queue's compare-and-set admission // is in force — there a run holds its thread from its claim to its own settle, on every replay. ...(queue?.supported === true && ownsThread && isChatQueueStore(store) ? { cancelled: async () => (await store.activeRunForThread(input.threadId)) !== ctx.runId, } : {}), // Lets the tool transient-retry loop tell a real suspend/continue-as-new apart from a // retryable tool error, so a control-flow signal is never swallowed by a retry. isControlFlowError: isControlFlowSignal, // `ctx.localStep` takes its position on the CALL, before its first await, so a batch of tool // invocations launched in one tick occupies the positions in call order however they settle. // That is the whole precondition the loop asks for before overlapping anything. parallel: settleAll, // The version gate for in-place loop-shape changes, so a run that suspended under an older // shape replays against the shape its own history holds. patched: (id) => ctx.patched(id), // Delegation: a fresh transient subthread (checkpointed so its id is replay-stable), then a // tracked child run that streams into this run's own top-level sink. runAgent: async (agentName, task) => { const subThreadId = await step(`subthread:${agentName}`, async () => { const thread = await store.createThread({ actor: input.actor, persona: 'default', transient: true, }); return thread.id; }); return ctx.child(AgentRunWorkflow, { agentName, threadId: subThreadId, actor: input.actor, userText: task, day, delegationDepth: (input.delegationDepth ?? 0) + 1, delegationPath: chainBelow, parentRunId: ctx.runId, sinkRunId, }); }, // The same delegation, not awaited: `ctx.startChild` records `spawn:<childRunId>` at this // position and returns, so THIS turn ends while the child is still working. It is reached from // the loop BODY (never inside a `step`), only for a call whose `persist:toolcall` journaled // `detached` — which no run recorded before this existed — so a parked run's positions are the // ones it always had. Two things the awaited form does are left off on purpose: // - no `sinkRunId`: a detached run owns its own stream. Forwarding into the turn that started it // would write tokens (and an approval card) into a stream whose reader already saw it end. // Its approvals reach the inbox anyway: the call is persisted `pending_approval` under the // child's OWN runId, which is what `tool-call/approve` resolves a decision to. // - `deliverTo` instead: the parent's tool result is a receipt, so the answer needs an address // of its own, and by the time it exists nobody else is holding one. // - no runtime parent: the engine cascades a cancel to every `spawn:` child, and a Stop on // this turn is not a Stop on the work it handed off. So the run is started on its own from // a `detach:<toolCallId>` step (see `startDetached`), behind `ctx.patched` — a run that // journaled the `spawn:` before this release gets `false` at that position and replays it. startAgent: async ({ agentName, task, toolCallId }) => { const subThreadId = await step(`subthread:${agentName}`, async () => { const thread = await store.createThread({ actor: input.actor, persona: 'default', transient: true, }); return thread.id; }); const childInput = { agentName, threadId: subThreadId, actor: input.actor, userText: task, day, delegationDepth: (input.delegationDepth ?? 0) + 1, delegationPath: chainBelow, parentRunId: ctx.runId, deliverTo: { threadId: input.threadId, toolCallId }, }; if (!(await ctx.patched('agent:detached-unlinked'))) { return { runId: await ctx.startChild(AgentRunWorkflow, childInput) }; } return { runId: await startDetached(toolCallId, childInput) }; }, }; /** * Settle the delegation a DETACHED run was started for, when it ends without an answer. The loop * delivers its own success (`deliver:detached`) but cannot catch its own crash; left alone, the * card in the delegating conversation says "started" for ever. A checkpoint of its own, reached * only on a detached run's failure path — a position no other run has. */ const settleDetached = async (error) => { const delivery = input.deliverTo; if (!detached || delivery === undefined) { return; } await step('deliver:detached:unsettled', () => settleUnsettledDelegation({ store, delivery, agent: input.agentName ?? 'default', runId: ctx.runId, ...(input.parentRunId !== undefined ? { parentRunId: input.parentRunId } : {}), status: 'failed', error, })); }; try { const result = await runAgentLoop({ ...deps, day }, input, hooks); // The top-level run owns the thread's active-run pointer (a child runs on a scratch thread). if (ownsThread) { // Conditional: a turn that handed the thread to the next queued message must not clear it. await step('deactivate', () => releaseThreadRun(store, input.threadId, ctx.runId)); } return result; } catch (error) { // A suspend / continue-as-new is control flow, not a failure — let the engine handle it. if (isControlFlowSignal(error)) { throw error; } // Stopped from outside (see `cancelled` above): whoever stopped it already settled the run's // row, its thread and its stream. Nothing to record, and nothing of this turn to keep. if (error instanceof RunCancelledError) { return { text: '' }; } const message = error instanceof Error ? error.message : String(error); // A replay-integrity failure gets the stream half of this path but NOT the checkpoint half. // The journal has already diverged, so `persist:run:fail` below would ask for a position the // history cannot supply and raise its own refusal — burying the one that names the checkpoints // that actually disagreed. The stream still has to be settled, or the subscriber hangs on a // run the engine is about to fail. if (isReplayIntegrityError(error)) { // What the checkpoints below would have settled, written straight to the store instead: the // run's row, the calls it left awaiting a decision, and — for a thread's own turn — the // thread, so the next message starts a turn instead of queueing behind a run that is gone. await settleDeadRun(store, { runId: ctx.runId, error: message, errorCode: streamErrorCode(error), ...(ownsThread ? { threadId: input.threadId } : {}), }); if (detached && input.deliverTo !== undefined) { // Straight to the store, like the rest of this branch: the journal has no room left. // Idempotent by itself (a thread already holding this run's message is left alone). await settleUnsettledDelegation({ store, delivery: input.deliverTo, agent: input.agentName ?? 'default', runId: ctx.runId, ...(input.parentRunId !== undefined ? { parentRunId: input.parentRunId } : {}), status: 'failed', error: message, }).catch(() => undefined); } if (!isChild) { const writer = await deps.sink.open(ctx.runId); await writer.write(streamErrorFrame(error, ctx.runId)); await writer.end(); } throw error; } // A real failure (e.g. quota exceeded, which throws before the sink is opened) would otherwise // leave an HTTP subscriber hanging on a stream that never ends. Surface it on the stream and // close it, then rethrow so the engine still records the run as failed. Only a top-level run // owns the stream — a child defers the surfaced error to its ancestor, which also unwinds. // Settle the run's persisted outcome (the loop only records completions — it can't catch its // own crash). A checkpointed step so a replay re-settles the ONE row idempotently; first- // terminal, so it can't clobber a completion. Each run (parent AND child) owns its own row. await step('persist:run:fail', async () => { await store.recordRunEnd({ runId: ctx.runId, status: 'failed', error: message, errorCode: streamErrorCode(error), }); // A call this run had put to a person is not waiting for anything any more. Inside the same // checkpoint, so it adds no position to a failing run's journal. await store.failUnsettledToolCalls?.(ctx.runId, RUN_ENDED_BEFORE_TOOL_CALL).catch(() => 0); }); if (ownsThread) { // The queue behind a failed turn pauses — its next message would likely fail the same way. const queueFrame = await advanceQueue('failed', message); await step('deactivate', () => releaseThreadRun(store, input.threadId, ctx.runId)); const writer = await deps.sink.open(ctx.runId); if (queueFrame !== undefined) { await writer.write({ t: 'event', event: queueFrame }); } await writer.write(streamErrorFrame(error, ctx.runId)); await writer.end(); } else if (detached) { // Tell the delegating thread, then end the run's own stream like a thread turn would. await settleDetached(message); const writer = await deps.sink.open(ctx.runId); await writer.write(streamErrorFrame(error, ctx.runId)); await writer.end(); } throw error; } } } //# sourceMappingURL=agent-run-workflow.js.map