@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
2,989 lines • 156 kB
JavaScript
import { turnChannel } from './genui/channels.js';
import { GenuiTurnTelemetry, observeGenuiFrames } from './genui-telemetry.js';
import { DEFAULT_REFUSAL_REASON } from './refusal.js';
import { observeFirstText } from './turn-timing.js';
export { DEFAULT_REFUSAL_REASON } from './refusal.js';
import { createHash } from 'node:crypto';
import { resolveActionProposalApproval } from './action-proposal-approval.js';
import { prepareActionProposal } from './action-proposal-preparation.js';
import { actionProposalReceipt } from './action-proposal-receipt.js';
import { danglingToolCallIds, settleDanglingToolCalls } from './dangling-tool-calls.js';
import { detachedDelivered, detachedStarted, } from './delegation.js';
import { publishAgentDelegated, publishAgentMessage, publishAgentRetrieved, publishAgentRunCancelled, publishAgentRunFailed, publishAgentRunFinished, publishAgentRunStarted, publishAgentToolCall, publishAgentToolRetry, spannedAgent, } from './diagnostics.js';
import { ASK_TOOL_NAME, askInputSchema, askToolDefinition, DEFAULT_INTAKE_PREAMBLE, isHumanDecision, normalizeElicitationReply, settleElicitation, } from './elicitation.js';
import { renderHistorySummary } from './history-window.js';
import { buildMemoryBlock, offerMemories, REMEMBER_TOOL_NAME, rememberInputSchema, rememberToolDefinition, writeMemory, } from './memory.js';
import { messageTimestampLabeller } from './message-timestamps.js';
import { channelChoiceLimits, createNegotiatedUiCollector, withDelivered, } from './negotiated-tool-ui.js';
import { personaFromTurn } from './personas.js';
import { resolveUsageCost } from './pricing/resolve.js';
import { createFrameBuffer, createIncrementalGate, gateTail, releaseGatedFrames, resolveGateLookback, resolveOutputGateMode, runInputProcessors, runOutputProcessors, } from './processors.js';
import { isReplayIntegrityError } from './replay-integrity.js';
import { buildSkillsBlock, loadSkill, offerSkills, SKILL_TOOL_NAME, skillInputSchema, skillToolDefinition, } from './skills.js';
import { DefaultApprovalPolicy } from './spi/approval-policy.js';
import { withSelectedModel } from './spi/model-catalog.js';
import { estimateCost, resolveModelPrice, } from './spi/pricing-store.js';
import { OutputRejectedError, } from './spi/processors.js';
import { DEFAULT_STRUCTURED_OUTPUT_INSTRUCTION, repairInstruction, StructuredOutputError, validateStructured, } from './structured-output.js';
import { previewToolInputs, withdrawPreviewFrame, } from './tool-input-preview.js';
import { fillToolConfirmation, resolveToolConfirmation } from './tool-presentation.js';
import { ToolForbiddenError, ToolInputInvalidError, ToolPreflightDeniedError, } from './tool-registry.js';
import { invokeWithTransientRetry } from './tool-retry.js';
import { createNoopEmitUi, escapeUnsafeToolUiText, mergeUi, unwrapToolStepOutput, wrapToolStepOutput, } from './tool-ui.js';
import { observeTurnFrames, withTurnFrames } from './turn-frames.js';
/** Renders retrieved passages as a numbered, citable context block appended to the system prompt. */
function buildContextBlock(passages) {
const items = passages
.map((passage, index) => {
const label = passage.source !== undefined ? ` (${passage.source})` : '';
return `[${index + 1}]${label} ${passage.text}`;
})
.join('\n\n');
return `<retrieved_context>\n${items}\n</retrieved_context>\nUse the retrieved context above to answer when relevant, and cite sources by their bracket number.`;
}
/** Intersect two allow-lists where `undefined` means "no restriction". */
function intersectAllow(a, b) {
if (a === undefined) {
return b;
}
if (b === undefined) {
return a;
}
const second = new Set(b);
return a.filter((name) => second.has(name));
}
/**
* The {@link AgentLoopHooks.parallel} implementation for a runner whose checkpoint positions are
* handed out on the CALL — `@adonis-agora/durable`'s `ctx.localStep` is: it takes `pos.next()`
* before its first `await`. Every task is invoked here, synchronously and in list order, before any
* of them is awaited, which is what fixes the block of positions they occupy whatever order they
* then settle in. Nothing rejects: the caller decides what an individual failure means.
*/
export function settleAll(tasks) {
const started = tasks.map((task) => task());
return Promise.all(started.map((work) => work.then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }))));
}
/**
* Someone asked this run to stop, and it did. NOT a failure: the runner that set
* {@link AgentLoopHooks.cancelled} settles the run `cancelled` and ends its stream with a
* `cancelled` frame rather than an error.
*/
export class RunCancelledError extends Error {
constructor() {
super('Run cancelled');
this.name = 'RunCancelledError';
}
}
/**
* Make a model call under the run's {@link AgentLoopHooks.abortSignal}. A call that fails once the
* run was stopped failed BECAUSE it was stopped (the AI SDK rejects with an `AbortError`, or with
* "no output" after an aborted stream): that is the run's cancel, not a failure.
*/
async function abortableModelCall(hooks, call) {
const signal = hooks.abortSignal;
try {
return await call(signal !== undefined ? { abortSignal: signal } : {});
}
catch (error) {
if (signal?.aborted === true)
throw new RunCancelledError();
throw error;
}
}
/** Unwind the turn if the runner says it was asked to stop (see {@link AgentLoopHooks.cancelled}). */
async function haltIfCancelled(hooks) {
if (hooks.cancelled !== undefined && (await hooks.cancelled())) {
throw new RunCancelledError();
}
}
export class QuotaExceededError extends Error {
constructor() {
super('Daily token quota exceeded');
this.name = 'QuotaExceededError';
}
}
/** Resolve a prompt that may be a flat string or a {@link PromptBuilder}. */
async function resolvePrompt(prompt, ctx) {
return typeof prompt === 'function' ? prompt(ctx) : prompt;
}
/**
* The effective system prompt for a turn. A persona that carries a prompt stands in for the base —
* already resolved, with the base it may wrap, in {@link resolveTurnPersona} (inside `persona:resolve`
* for a run that names its persona by id), so a replay reads back the exact text the turn started
* with. Otherwise the agent's base prompt, which sees the persona too.
*/
async function resolveSystemPrompt(deps, input, persona) {
if (persona?.prompt !== undefined) {
return persona.prompt;
}
return resolvePrompt(deps.systemPrompt, { ...promptContext(input, persona), basePrompt: '' });
}
/** What every prompt builder of a turn is handed, short of `basePrompt`. */
function promptContext(input, persona) {
return {
actor: input.actor,
...(persona !== undefined ? { persona: personaFromTurn(persona) } : {}),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}),
channel: turnChannel(input.pageContext),
};
}
/**
* Freeze `persona` for this turn: its id, label and allow-list, and its prompt resolved against the
* agent's base prompt. A flat prompt stands in for the base; a builder is handed the base as
* `ctx.basePrompt`; no prompt → the base, resolved per turn like any agent's.
*/
async function freezePersona(deps, input, persona) {
const ref = {
id: persona.id,
label: persona.label,
...(persona.allowedTools !== undefined ? { allowedTools: [...persona.allowedTools] } : {}),
};
if (persona.systemPrompt === undefined) {
return ref;
}
const ctx = promptContext(input, ref);
const basePrompt = typeof persona.systemPrompt === 'function'
? await resolvePrompt(deps.systemPrompt, { ...ctx, basePrompt: '' })
: '';
return { ...ref, prompt: await resolvePrompt(persona.systemPrompt, { ...ctx, basePrompt }) };
}
/**
* The `persona:resolve` checkpoint's body: the persona the run names, looked up in this process's
* config and FROZEN ({@link freezePersona}). `null` when the agent no longer declares it, which the
* turn runs as no persona at all.
*
* Reached only for a run whose input names a persona by id, and every such run was started by a
* release that has this checkpoint; a run started before it names none (or carries the whole persona
* in its input), spends no position here, and replays on the sequence it recorded.
*/
async function resolveTurnPersona(deps, input, id) {
const persona = deps.personas?.get(id);
return persona === undefined ? null : freezePersona(deps, input, persona);
}
/**
* A persona handed over WHOLE in the run's input — by a direct caller, or recorded so by a release
* before 0.60. Applied as given, with no checkpoint (its journal has none). Its input went through
* JSON on a durable run, which drops a builder prompt, so a prompt the input lost is looked up by id
* in this process's config.
*/
async function inlineTurnPersona(deps, input, persona) {
const prompt = persona.systemPrompt ?? deps.personas?.get(persona.id)?.systemPrompt;
return freezePersona(deps, input, {
...persona,
...(prompt !== undefined ? { systemPrompt: prompt } : {}),
});
}
/** `{ persona: id }` for a message the turn writes, when it runs under one. */
function personaProvenance(input) {
const persona = personaOf(input);
return persona === undefined ? {} : { persona: persona.id };
}
/** `{ persona }` for a tool's context, when the turn runs under one. */
function personaContext(input) {
const persona = personaOf(input);
return persona === undefined ? {} : { persona };
}
/** The turn's persona as a whole {@link Persona} — what the run input carries once it is resolved. */
function personaOf(input) {
return typeof input.persona === 'object' ? input.persona : undefined;
}
/** An `agent`-kind tool's input is `{ task }` by convention; fall back to a JSON dump. */
function extractTask(input) {
if (typeof input === 'object' && input !== null && 'task' in input) {
const task = input.task;
if (typeof task === 'string') {
return task;
}
}
return JSON.stringify(input);
}
/**
* How many agent→agent delegations deep a chain may go when the host names no ceiling.
*
* A backstop for a chain that is merely LONG. The cycle it would otherwise stand in for is detected
* directly — see {@link DEFAULT_MAX_AGENT_APPEARANCES}.
*
* WHEN IT ACTUALLY BINDS. At one appearance per agent a chain cannot be longer than the number of
* registered agents, so a deployment with fewer agents than this number never reaches it: the cycle
* guard always fires first. It starts mattering with a larger fleet than the ceiling, or once a host
* raises {@link AgentLoopDeps.maxAgentAppearances}, which is what lets a chain revisit an agent and
* therefore grow past the fleet's size.
*/
export const MAX_DELEGATION_DEPTH = 5;
/**
* How many times one agent may appear on a single delegation chain, when the host names no other
* number. Once: a chain that reaches an agent it has already passed through is going in circles.
*
* Override with {@link AgentLoopDeps.maxAgentAppearances} — a supervisor that genuinely hands work
* back to an earlier agent needs a larger number, and it is the count of APPEARANCES, so 2 admits
* exactly one return.
*/
export const DEFAULT_MAX_AGENT_APPEARANCES = 1;
/**
* Why this delegation must not happen, or `null` to let it through.
*
* Two different refusals, and the order matters: a CYCLE is named before a depth, because they
* describe different faults and the depth is the vaguer of the two. `alpha → beta → alpha` points at
* the wiring; "depth limit of 5 reached" leaves a reader to work out whether the chain was looping or
* merely long, which is exactly the question a count cannot answer.
*
* Decided from the run's own input and the agent's configured ceilings, inside the checkpoint that
* settles the call's kind — so the verdict is what a replay reads back rather than something each
* process re-derives.
*/
function delegationRefusal(args) {
const { deps, input, targetAgent } = args;
// `delegationPath` is the chain that REACHED this run, so this run's own agent is the last link of
// the chain a delegation from here would extend. Naming it is what makes the refusal describe the
// wiring: a mutual handoff reads `alpha → beta → alpha` rather than an `alpha → alpha` that no
// edge in the deployment declares. It also makes an agent delegating to ITSELF a cycle on the spot
// rather than one hop later.
const ancestry = [
...(input.delegationPath ?? []),
...(input.agentName !== undefined ? [input.agentName] : []),
];
const appearances = ancestry.filter((name) => name === targetAgent).length;
const maxAppearances = deps.maxAgentAppearances ?? DEFAULT_MAX_AGENT_APPEARANCES;
if (appearances >= maxAppearances) {
const chain = [...ancestry, targetAgent].join(' → ');
const times = appearances + 1;
return `delegation cycle: ${chain} — ${targetAgent} ${times} times on one chain`;
}
const maxDepth = deps.maxDelegationDepth ?? MAX_DELEGATION_DEPTH;
if ((input.delegationDepth ?? 0) >= maxDepth) {
return `delegation depth limit of ${maxDepth} reached`;
}
return null;
}
/**
* The gates a delegation must clear. Delegation bypasses `ToolRegistry.invoke` (it is a ctx-level
* suspend point), so the three checks `invoke` applies have to be re-applied here in the same order
* — authorization first, then shape:
* - the offered-tools (persona/agent allow-list) filter: a tool the model was not offered must not
* run, even if the model names it anyway;
* - the role/ability re-check. A synthesized delegate spec (`registerDelegateTools` in
* agent-deps-factory.ts) carries whatever `roles`/`ability` its `delegatesTo` edge declared, and
* nothing at all when the edge is a bare string — under `DefaultRolesPolicy` that is ADMIN-only,
* and under an authz posture a tool with no `ability` is always denied;
* - input validation, so a malformed input is rejected rather than silently coerced.
*
* Then the two nesting guards, once the target is known: {@link delegationRefusal} refuses a chain
* that is going in circles, or one that is merely too deep.
*
* Returns the verdict rather than throwing it: this runs inside the call's checkpoint, whose output
* is what a replay reads back, and it has to run BEFORE any event publication so a refusal never
* emits `agent.delegated`.
*/
async function resolveDelegation(deps, input, call, spec) {
try {
if (spec === undefined) {
// Unreachable while the kind is read off this same spec, but an `agent`-kind call with no
// resolvable spec must fail closed rather than fall through.
throw new ToolForbiddenError(call.name);
}
const offeredAllow = intersectAllow(personaOf(input)?.allowedTools, deps.toolAllowList);
if (offeredAllow !== undefined && !offeredAllow.includes(call.name)) {
throw new ToolForbiddenError(call.name);
}
if (!(await deps.rolesPolicy.can(input.actor, spec))) {
throw new ToolForbiddenError(call.name);
}
const validation = await spec.inputSchema['~standard'].validate(call.input);
if (validation.issues !== undefined) {
throw new ToolInputInvalidError(call.name, validation.issues);
}
const targetAgent = spec.targetAgent ?? call.name;
const refusal = delegationRefusal({ deps, input, targetAgent });
if (refusal !== null) {
return { error: refusal };
}
// Settled HERE, inside the call's `persist:toolcall` checkpoint, so a replay reads the branch
// back instead of asking a registry that may have changed while the run was parked. Absent unless
// declared: a deployment with no detached edge writes the bytes this checkpoint always held, and
// a call journaled before the flag existed replays as the awaited delegation it was.
return {
targetAgent,
task: extractTask(validation.value),
...(spec.detached === true ? { detached: true } : {}),
};
}
catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
function deriveTitle(userText) {
const trimmed = userText.trim().replace(/\s+/g, ' ');
return trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed || 'New chat';
}
/**
* A step's cost: the provider's own reported figure when it has one (a gateway), else an estimate
* from `price` when the model has a current price row, else `null` — never a fabricated `0` for
* "we don't know". Mirrors the reference resolution order exactly.
*/
function resolveCostUsd(usage, reportedCostUsd, price) {
if (reportedCostUsd !== undefined) {
return reportedCostUsd;
}
return price === undefined ? null : estimateCost(usage, price);
}
/** The cost a usage row persists — see {@link resolveUsageCost}, the rule every engine records by. */
const usageCost = resolveUsageCost;
/**
* Split a windowed history into the `system` messages it leads with — the folded summary — and the
* conversation after them. The summary reaches the model through the system prompt, never as a
* message: AI SDK 7 refuses `system` messages in `messages` (`AI_InvalidPromptError`), and a
* provider that tolerates them still reads them as part of the conversation rather than as standing
* instructions.
*
* The window's result keeps its old shape (the summary as a leading `system` message) on purpose: a
* run that recorded the single `history:window` checkpoint journaled exactly that array, and its
* replay is lifted here the same way a fresh run is.
*/
function liftLeadingSystem(messages) {
let index = 0;
while (messages[index]?.role === 'system') {
index += 1;
}
return {
system: messages.slice(0, index).map((message) => message.content),
messages: messages.slice(index),
};
}
/**
* Identifies the split shape to {@link AgentLoopHooks.patched}: a run whose journal holds one
* `history:window` checkpoint has to keep replaying against that, not against a pure selection that
* spends no position at all. Reached only when a window is configured, so a deployment with none
* never spends the marker's position and records byte-identical checkpoints.
*/
const HISTORY_SELECT_PATCH = 'agent:history-select';
/** The three answers, normalized out of whatever shape `load:thread` recorded them in. */
function threadForTurn(recorded) {
// Settled once more on the way out of the journal: a payload recorded before the read settled
// dangling calls itself, or by a store that cannot read a call's row, still has to reach the model
// as a prompt a provider accepts. A no-op on a payload that is already whole.
const messages = settleDanglingToolCalls((recorded?.messages ?? []).map(toModelMessage));
return {
// Blank assistant messages are left out here, on every path a turn's history takes (recorded,
// replayed, windowed after this), so a thread that stored one before the loop stopped writing
// them heals on its next turn.
messages: messages.filter((message) => !isBlankAssistantMessage(message)),
actionApprovalMode: recorded?.actionApprovalMode ?? 'blocking',
title: recorded?.title ?? null,
hasAssistantMessage: recorded?.hasAssistantMessage ?? messages.some((message) => message.role === 'assistant'),
};
}
/**
* An assistant message that says nothing to the model: blank text, no tool call, no tool result.
*
* A step can end that way — the model stops right after a tool whose result IS the answer — and
* Anthropic and Bedrock refuse a request that replays one ("content … is empty"), so a single such
* message fails every later turn on its thread. A tool-call-only message is NOT blank: it is the
* valid assistant half of a tool exchange and has to stay.
*/
function isBlankAssistantMessage(message) {
return (message.role === 'assistant' &&
message.content.trim().length === 0 &&
(message.toolCalls?.length ?? 0) === 0 &&
(message.toolResults?.length ?? 0) === 0);
}
/**
* A message as the model sees it — the store's own bookkeeping (`id`, `createdAt`, `usage`,
* `followUps`, `runId`, `persona`) left behind, so what `load:thread` records is the prompt's rows
* rather than the table's.
*/
function toModelMessage(message) {
return {
role: message.role,
content: message.content,
...(message.toolCalls !== undefined ? { toolCalls: message.toolCalls } : {}),
...(message.toolResults !== undefined ? { toolResults: message.toolResults } : {}),
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
};
}
/**
* How a stored message reaches the model this turn: {@link toModelMessage}, and with
* `messageTimestamps` configured, each user message labelled with when it was sent. Applied inside
* `load:thread`, so the labels are part of the recorded payload (a replay reads them back rather than
* labelling again) and a `historyWindow` selects over labelled messages. Without the option this is
* exactly `toModelMessage`, and `load:thread` records what it always did.
*/
function modelViewOf(deps, input) {
const config = deps.messageTimestamps;
if (config === undefined)
return toModelMessage;
const label = messageTimestampLabeller(config, input);
return (message) => label(message, toModelMessage(message));
}
/**
* How many of the thread's newest rows to ask for — the window's own row ceiling, or nothing at all,
* which asks for the transcript.
*
* A window that SUMMARIZES gets no limit. `summarize` is handed what `select` dropped, and a read
* bounded to what `select` keeps drops nothing: the turn would fold an empty summary into a prompt
* that is missing the messages it stands in for, with no error anywhere.
*
* A ceiling expressed only in TOKENS gets no limit either, and none can be derived from it: one
* message can be four tokens or forty thousand, so no row count follows from a token budget. Naming
* one too low reads fewer rows than `select` would have kept, which changes the prompt itself;
* leaving it out only costs the read.
*/
function turnMessageLimit(window) {
if (window === undefined || window.summarize !== undefined) {
return undefined;
}
return window.maxMessages;
}
/**
* Read the thread: the store's bounded WINDOW where it offers one, else its whole transcript cut to
* the same bound in process.
*
* The probe is structural — the window is an optimization a store either offers or does not, and one
* that offers none still answers correctly. Which branch ran is invisible to the journal on purpose:
* both produce the same three answers over the same rows, so the payload `load:thread` records is
* identical either way and a deployment's choice of store can never decide a run's checkpoints. That
* is also why the probe lives INSIDE `load:thread` — it adds no position, and a replaying process
* reads the recorded payload back without calling the store at all.
*
* `hasAssistantMessage` is answered over the whole thread, never over the window. It decides a
* `thread-start` intake, and a window that happens to hold only the user's last questions belongs to
* a conversation that has still been answered — scanned off the window, such a thread re-introduces
* itself every turn.
*/
async function readThreadForTurn(deps, input, messageLimit) {
const { threadId } = input;
const toModel = modelViewOf(deps, input);
const windowing = deps.store;
if (typeof windowing.loadThreadForTurn === 'function') {
const page = await windowing.loadThreadForTurn({
threadId,
...(messageLimit !== undefined ? { messageLimit } : {}),
});
return page === null
? { messages: [], title: null, hasAssistantMessage: false }
: {
messages: await settleHistory(deps, page.messages.map(toModel)),
title: page.title,
hasAssistantMessage: page.hasAssistantMessage,
};
}
const thread = await deps.store.getThread(threadId);
const stored = thread?.messages ?? [];
const window = messageLimit === undefined
? stored
: stored.slice(Math.max(0, stored.length - Math.max(0, messageLimit)));
return {
messages: await settleHistory(deps, window.map(toModel)),
title: thread?.title ?? null,
hasAssistantMessage: stored.some((message) => message.role === 'assistant'),
};
}
/**
* Settle the history's dangling tool calls (see {@link settleDanglingToolCalls}) from the calls' own
* rows where the store can read them. Runs INSIDE `load:thread`, so what the rows said is part of
* the recorded payload and a replay composes the same prompt without asking again. A store that
* cannot answer, or fails to, leaves the calls to be put to the model as never completed.
*/
async function settleHistory(deps, messages) {
const dangling = danglingToolCallIds(messages);
if (dangling.length === 0) {
return messages;
}
const outcomes = await (deps.store.toolCallOutcomes?.(dangling) ?? Promise.resolve([])).catch(() => []);
return settleDanglingToolCalls(messages, outcomes);
}
/**
* Apply the configured ceiling to the thread's messages. `select` runs OUTSIDE any checkpoint — it
* is contractually pure and its input is already `load:thread`'s cached result, so a replay reaches
* the same split without a checkpoint of its own.
*
* Summarizing is the opposite: it calls a model. Journaled under `history:summarize`, so a resumed
* run reads back the summary the suspended attempt produced (and does not pay for it twice) instead
* of prompting with a different one. Both that checkpoint and the usage row below are reachable
* only through a window that actually summarizes.
*/
async function applyHistoryWindow(window, deps, input, hooks, messages, step) {
const ctx = { actor: input.actor, threadId: input.threadId };
const { keep, drop } = window.select(messages, ctx);
const summarize = window.summarize?.bind(window);
if (drop.length === 0 || summarize === undefined) {
return keep;
}
const summary = await step('history:summarize', () => summarize(drop, ctx));
if (summary.usage !== undefined) {
const usage = summary.usage;
await step('persist:usage:history', () => deps.store.recordUsage({
threadId: input.threadId,
actorRef: input.actor.id,
runId: hooks.runId,
modelId: summary.modelId ?? deps.modelId ?? 'unknown',
purpose: 'summary',
usage,
}));
}
const render = window.renderSummary?.bind(window) ?? renderHistorySummary;
return [{ role: 'system', content: render(summary.text) }, ...keep];
}
/**
* The turn's tool list plus the built-ins the module offers — `ask`, `skill`. Appended from CONFIG
* rather than the registry, because neither is ever registered: see {@link AgentLoopDeps.ask} and
* {@link AgentLoopDeps.skills}.
*/
export function withBuiltInTools(args) {
return [
...args.tools,
...(args.ask === true ? [askToolDefinition()] : []),
...(args.skills ? [skillToolDefinition()] : []),
...(args.memory ? [rememberToolDefinition()] : []),
];
}
/**
* Does this deployment offer `remember` at all? A provider that serves memory read-only omits
* `write`, and then the tool is never offered — so a turn's tool list is the same on every process,
* decided by module config rather than by what a given pod happens to hold.
*/
function memoryIsWritable(deps) {
return deps.memory?.provider.write !== undefined;
}
/**
* Park on a question set, through whichever wait the host implemented, and read back whatever that
* wait delivered as answers.
*
* The reduction of a yes/no channel — approve is "confirmed the pre-picked answers", reject is
* "skipped" — applies to BOTH branches, because the shape of the reply is decided by whoever settled
* the tool call, not by which hook the host wired. The question set sits in the approvals inbox as a
* `pending_approval` action, so an operator pressing Approve there sends a `Decision` into a run
* whose host implements `awaitAnswers` perfectly well.
*
* Neither branch moves a checkpoint: the wait sits at the same position either way, so the choice of
* hook cannot change the sequence a replay lines up with.
*/
async function awaitElicitation(args) {
const { hooks, request, ctx } = args;
if (hooks.awaitAnswers !== undefined) {
return normalizeElicitationReply(await hooks.awaitAnswers(request, ctx));
}
return normalizeElicitationReply(await hooks.awaitApproval({ id: request.id, name: ASK_TOOL_NAME, input: request }, ctx));
}
/** How many wrong-shaped replies one approval wait absorbs before {@link awaitDecision} gives up. */
const MAX_DISCARDED_APPROVAL_REPLIES = 100;
/**
* Park on an `action`'s approval until a human's yes/no actually arrives.
*
* The wait carries both reply shapes, because a question set parks as a `pending_approval` action
* too — so a client can address `POST /agent/tool-call/answer` at a call that is in fact waiting on
* an approve/reject. A set of answers is not a verdict on proposed work; reading its absent
* `approved` as `false` would persist a rejection nobody made, against a tool whose operator only
* ever submitted a form. The reply is discarded and the approval stays pending, which is what it is.
*
* Waiting again spends another position, and a replay lines up with it: the reply that was discarded
* is itself journaled at the position it arrived on, so every process replaying the turn discards
* the same replies in the same order and reaches the same wait.
*/
async function awaitDecision(args) {
const { hooks, call, ctx, timeoutMs } = args;
for (let discarded = 0; discarded < MAX_DISCARDED_APPROVAL_REPLIES; discarded += 1) {
const reply = timeoutMs !== undefined
? await hooks.awaitApproval(call, ctx, { timeoutMs })
: await hooks.awaitApproval(call, ctx);
if (isHumanDecision(reply)) {
return reply;
}
}
// Both shipped runners wait here, so the ceiling is out of reach: a person would have to misdirect
// the whole budget at one tool call. What it rules out is a HOST whose `awaitApproval` resolves
// WITHOUT waiting — which would otherwise spin this loop on the only thread the process has.
throw new Error(`Tool call "${call.id}" was sent ${MAX_DISCARDED_APPROVAL_REPLIES} replies that carried no approve/reject`);
}
/**
* Does the configured intake run this turn? Both inputs are facts the journal already holds — the
* config, and `load:thread`'s record of whether the thread had an assistant message when the turn
* began. Neither is re-read from the store here, and that is the point: by the time a replay reaches
* this line the first attempt has already appended the intake's own assistant message to the thread,
* so a fresh read would answer differently on the resume than it did on the way in — and the answer
* decides how many checkpoints follow it.
*/
function intakeApplies(args) {
return args.intake.when === 'every-turn' || !args.threadHasAssistant;
}
/**
* The configured intake: post the authored question set, park until a human settles it, and leave
* the exchange on the transcript as an ordinary tool round-trip so the model reads the answers the
* same way it reads its own `ask`'s.
*
* COSTS NO MODEL CALL. The questions, their options and their pre-picked defaults are all authored
* on the agent, so nothing here is generated and nothing here is billed — which is also why the
* total is knowable before the first question is shown.
*
* One checkpoint whenever an `intake` is configured, plus two more on the turns it actually asks —
* all reachable only through that config, so a run that predates the option cannot land on any of
* them and no {@link AgentLoopHooks.patched} marker is spent keeping the sequence stable for it.
*/
async function runIntake(args) {
const { intake, deps, input, hooks, writer, threadHasAssistant } = args;
const preamble = intake.preamble ?? DEFAULT_INTAKE_PREAMBLE;
// Derived from the run, so a replay rebuilds the same id without minting one — the id is a
// checkpoint name's suffix and the signal's own key, so it cannot come from a random source.
const request = {
id: `intake-${hooks.runId}`,
source: 'intake',
preamble,
questions: intake.questions,
};
const call = {
id: request.id,
name: ASK_TOOL_NAME,
input: { preamble, questions: intake.questions },
};
const asked = (await hooks.step('intake:ask', async () => {
if (!intakeApplies({ intake, threadHasAssistant })) {
return null;
}
const message = await deps.store.appendMessage({
threadId: input.threadId,
role: 'assistant',
content: preamble,
runId: hooks.runId,
toolCalls: [call],
...personaProvenance(input),
});
await deps.store.recordToolCall({
toolCallId: request.id,
messageId: message.id,
toolName: ASK_TOOL_NAME,
// The store knows read/action only. An unanswered question is work waiting on a human, which
// is what `action` + `pending_approval` already mean — so it surfaces in an approvals inbox
// rather than needing one of its own.
toolType: 'action',
input: call.input,
status: 'pending_approval',
runId: hooks.runId,
});
await writer.write({ t: 'elicitation', runId: hooks.runId, id: request.id, request });
return message.id;
}));
if (asked === null || asked === false) {
return null;
}
// A checkpoint that recorded only WHETHER the intake ran carries no message id, so such a run
// settles the tool-call row alone.
const messageId = typeof asked === 'string' ? asked : null;
const reply = await awaitElicitation({
hooks,
request,
ctx: elicitationContext(deps, input, hooks),
});
const result = settleElicitation({ request, reply });
await hooks.step('intake:answers', async () => {
await deps.store.updateToolCall({
toolCallId: request.id,
runId: hooks.runId,
// A skip is not an answer. Both leave the agent holding the same values, but only one of them
// is evidence the user chose them, and a reader auditing what the agent was told has to be
// able to tell those apart.
status: result.skipped ? 'rejected' : 'executed',
output: result,
...(result.skipped ? { error: 'skipped by the user' } : {}),
...(reply.answeredByRef !== undefined ? { executedByRef: reply.answeredByRef } : {}),
});
if (messageId !== null) {
await deps.store.setMessageToolResults(messageId, [
{ id: request.id, name: ASK_TOOL_NAME, output: result },
]);
}
});
return {
role: 'assistant',
content: preamble,
toolCalls: [call],
toolResults: [{ id: request.id, name: ASK_TOOL_NAME, output: result }],
};
}
/** The context a wait is handed. The intake has no tool ctx of its own — it is not a tool call. */
function elicitationContext(deps, input, hooks) {
return {
actor: input.actor,
threadId: input.threadId,
runId: hooks.runId,
requestId: hooks.runId,
emitUi: createNoopEmitUi(hooks.runId),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...personaContext(input),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}),
channel: turnChannel(input.pageContext),
...(deps.host !== undefined ? { host: deps.host } : {}),
};
}
/**
* Identifies the batched tool-call shape to {@link AgentLoopHooks.patched}: batching hoists a turn's
* `persist:toolcall` checkpoints ahead of its first tool execution, so a run whose journal
* interleaves them one call at a time has to keep replaying against THAT.
*/
const PARALLEL_TOOLS_PATCH = 'agent:parallel-tools';
/**
* Identifies the message-borne tool results to {@link AgentLoopHooks.patched}: attaching them adds a
* checkpoint after a turn's last tool, a position a run recorded under the shape without it cannot
* supply.
*/
const MESSAGE_TOOL_RESULTS_PATCH = 'agent:message-tool-results';
/**
* Record the call and settle its KIND, which decides this call's control flow: an `action` suspends
* the run on an approval signal (`tool:<runId>:<callId>`), an `agent` delegates to a child run,
* anything else records a plain step. Resolving it from `deps.registry` in the loop body would tie
* that branch to the registry of WHICHEVER PROCESS runs the body — and a process whose registry
* lacks the tool reads `undefined`, falls back to 'read', and then asks for a `tool:` checkpoint
* where the history holds the approval signal. That is a non-determinism refusal on resume, and it
* is an approval-gated action about to run with nobody's approval.
*
* So the lookup — and the delegation gates that hang off it — happen INSIDE the `persist:toolcall`
* step, and the verdict is RETURNED from it. The first process to reach this call writes the kind
* into the journal; every later replay reads it back instead of asking its own registry.
*/
async function claimToolCall(turn, call) {
const { deps, input, hooks, messageId, writer } = turn;
const persona = personaOf(input);
const ctx = {
actor: input.actor,
threadId: input.threadId,
runId: hooks.runId,
requestId: hooks.runId,
// Replaced by the call's own collector inside its `tool:` step (see invokeClaimedTool); a
// kind that never reaches a handler keeps this no-op.
emitUi: createNoopEmitUi(call.id),
...(hooks.abortSignal !== undefined ? { abortSignal: hooks.abortSignal } : {}),
...(deps.onPresentationError !== undefined
? {
onPresentationError: (error, details) => deps.onPresentationError(error, {
...details,
toolCallId: call.id,
runId: hooks.runId,
threadId: input.threadId,
}),
}
: {}),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(persona !== undefined ? { persona } : {}),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}),
channel: turnChannel(input.pageContext),
...(deps.host !== undefined ? { host: deps.host } : {}),
};
const persisted = (await hooks.step(`persist:toolcall:${call.id}`, async () => {
// The kind the llm checkpoint stamped WINS: it came from the process that offered the tool,
// whereas this one is only the process claiming the call — and the two disagree exactly when
// that matters. The fallback is for a journal written before kinds travelled.
const kind = call.kind ?? declaredKind(deps, call.name);
if (kind === 'agent') {
const delegation = await resolveDelegation(deps, input, call, deps.registry.spec(call.name));
await deps.store.recordToolCall({
toolCallId: call.id,
messageId,
toolName: call.name,
// The store knows read/action only; a delegation is neither approved nor rejected by a
// human, so it persists as a read. A refused one is recorded `failed` directly — never
// `auto_executed` even transiently.
toolType: 'read',
input: call.input,
status: delegation.error !== undefined ? 'failed' : 'auto_executed',
runId: hooks.runId,
});
return { kind, delegation };
}
// An `ask` persists exactly as an `action` does: the store knows read/action only, and an
// unanswered question is work waiting on a human — which is what `pending_approval` already
// means, so it lands in the approvals inbox a deployment already has.
const awaitsHuman = kind === 'action' || kind === 'ask';
// Decided HERE, inside the checkpoint, and returned from it: the policy and the remembered
// set can both change while a run is parked, and the branch below (park or run) has to be the
// one the journal recorded. A run claimed before this existed reads `approval` back as
// undefined and waits on the requester, exactly as it did.
let actionApproval = call.actionApproval;
if (kind === 'action' &&
turn.actionApprovalMode === 'independent' &&
actionApproval === undefined)
actionApproval = await resolveActionProposalApproval({
actor: input.actor,
thread: {
threadId: input.threadId,
runId: hooks.runId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
},
tool: {
name: call.name,
kind: 'action',
...(deps.registry.spec(call.name) !== undefined
? { spec: deps.registry.spec(call.name) }
: {}),
},
store: deps.store,
...(deps.approvalPolicy !== undefined ? { policy: deps.approvalPolicy } : {}),
});
let preflight = call.preflight;
let prepared = call.prepared;
if (kind === 'action' && preflight === undefined && deps.registry.has(call.name)) {
try {
const allowedTools = intersectAllow(persona?.allowedTools, deps.toolAllowList);
if (turn.actionApprovalMode === 'independent' && actionApproval?.mode === 'ask') {
prepared = await prepareActionProposal(deps.registry, call.name, call.input, { ...ctx, toolCallId: call.id, idempotencyKey: `${hooks.runId}:${call.id}` }, deps.rolesPolicy, allowedTools === undefined ? {} : { allowedTools });
preflight = prepared.preflight;
}
else
preflight = await deps.registry.prepare(call.name, call.input, {
...ctx,
toolCallId: call.id,
idempotencyKey: `${hooks.runId}:${call.id}`,
}, deps.rolesPolicy, allowedTools === undefined ? {} : { allowedTools });
}
catch (error) {
if (isReplayIntegrityError(error) || hooks.isControlFlowError?.(error) === true)
throw error;
preflight = {
status: 'failed',
error: error instanceof Error ? error.message : String(error),
};
}
}
const early = preflight !== undefined && preflight.status !== 'ready';
let confirmation = preflight?.status === 'ready' ? preflight.confirmation : undefined;
const approval = kind === 'action' && !early
? (actionApproval ?? (await claimApproval(turn, call)))
: undefined;
let proposalId;
if (turn.actionApprovalMode === 'independent' && approval?.mode === 'ask') {
if (!prepared)
throw new Error('Independent action lacks trusted prepared input');
const store = deps.store;
if (typeof store.createActionProposal !== 'function')
throw new Error('Independent proposal store unavailable');
proposalId = `proposal-${createHash('sha256')
.update(JSON.stringify([hooks.runId, call.id]))
.digest('hex')}`;
const create = prepared.replacementKey === undefined
? store.createActionProposal.bind(store)
: store.createReplacingActionProposal.bind(store);
const created = await create({
...(prepared.replacementKey !== undefined
? { replacementKey: prepared.replacementKey }
: {}),
id: proposalId,
tenantRef: input.actor.tenantRef ?? null,
actorRef: input.actor.id,
threadId: input.threadId,
originRunId: hooks.runId,
originMessageId: messageId,
originToolCallId: call.id,
toolName: call.name,
input: prepared.input,
preparationInput: prepared.preparationInput,
executionContext: {
requestId: hooks.runId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(persona !== undefined ? { persona: persona.id } : {}),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}),
},
// The preflight's own words, else the tool's `confirm` filled by the shared filler, else
// its label / name in words — the card a channel or the proposal inbox shows.
confirmation: confirmation ??
resolveToolConfirmation(call.name, prepared.input, deps.registry.spec(call.name)?.presentation),
approver: approval.approver,
expiresAt: approval.expiresAt === undefined ? null : Date.parse(approval.expiresAt),
idempotencyKey: `${hooks.runId}:${call.id}`,
});
if (created.status === 'conflict')
throw new Error('Action proposal replay payload conflict');
confirmation = created.proposal?.confirmation ?? confirmation;
}
// An ordinary approval carries the tool's own prompt ("Refund order #1002?") filled from the
// call's input on the server, so a transport whose client has no tool catalog (AG-UI, A2UI,
// A2A) words it as the native page does instead of naming the tool.
if (approval?.mode === 'ask' && proposalId === undefined && confirmation === undefined) {
const declared = deps.registry.spec(call.name)?.presentation?.confirm;
if (declared !== undefined)
confirmation = fillToolConfirmation(declared, call.input);
}
const parks = kind === 'ask' || (approval?.mode === 'ask' && proposalId === undefined);
await deps.store.recordToolCall({
toolCallId: call.id,
messageId,
toolName: call.name,
toolType: awaitsHuman ? 'action' : 'read',
input: call.input,
...(proposalId !== undefined ? { proposalId } : {}),
status: proposalId !== undefined
? 'proposed'
: early
? preflight?.status === 'denied'
? 'rejected'
: preflight?.status === 'failed'
? 'failed'
: 'executed'
: parks
? 'pending_approval'
: 'auto_executed',
...(confirmation !== undefined ? { confirmation } : {}),
runId: hooks.runId,
...(approval !== undefined && approval.mode !== 'auto'
? { approver: approval.approver }
: {}),
...(approval?.mode === 'ask' && approval.expiresAt !== undefined
? { expiresAt: approval.expiresAt }
: {}),
});
if (early) {
await deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: preflight?.status === 'denied'
? 'rejected'
: preflight?.status === 'failed'
? 'failed'
: 'executed',
...(preflight?.status === 'completed' ? { output: preflight.output } : {}),
...(preflight?.status === 'denied' ? { error: preflight.reason } : {}),
...(preflight?.status === 'failed' ? { error: preflight.error } : {}),
});
}
// Written from INSIDE this checkpoint, so the frame is streamed once and a replay — which
// returns the memoized result without re-running the body — never re-posts a form for a
// decision already made. That is also why it spends no position of its own.
//
// An `ask` is left out: `elicitToolCall` posts its `elicitation` frame, which carries the
// whole question set. Two frames for one parked call would be two forms.
if (approval?.mode === 'ask') {
await writer.write({
t: 'approval',
...(proposalId !== undefined
? { target: { kind: 'proposal', proposalId } }
: {}),
runId: hooks.runId,
id: call.id,
toolName: call.name,
input: call.input,
approver: approval.approver,
...(confirmation !== undefined ? { confirmation } : {}),
...(approval.expiresAt !== undefined ? { expiresAt: approval.expiresAt } : {}),
});
}
// Absent unless declared, so a deployment with no terminal tool writes the bytes it always
// has, and a replay reads the branch back instead of re-deciding it.
const terminal = deps.registry.spec(call.name)?.terminal === true;
return {
kind,
...(proposalId !== undefined ? { proposalId } : {}),
...(preflight !== undefined ? { preflight } : {}),
...(approval !== undefined ? { approval } : {}),
...(terminal ? { terminal: true } : {}),
...(preflight?.status === 'completed' && deps.registry.hasPresentation(call.name)
? { presentCompletedPreflight: true }
: {}),
};
}));
// A checkpoint written before the kind was journaled carries no output. Such a run is already
// committed to whatever its first process resolved, so the local registry is the only thing left
// to consult — reached by those runs alone.
const toolType = persisted?.kind ?? declaredKind(deps, call.name);
return {
call,
toolType,
...(persisted?.preflight !== undefined ? { preflight: persisted.preflight } : {}),
...(persisted?.terminal === true ? { terminal: true } : {}),
...(persisted?.presentCompletedPreflight === true ? { presentCompletedPreflight: true } : {}),
...(persisted?.proposalId !== undefined ? { proposalId: persisted.proposalId } : {}),
...(persisted?.delegation !== undefined ? { delegation: persisted.delegation } : {}),
...(toolType === 'action' && persisted?.approval !== undefined
? { approval: persisted.approval }
: {}),
ctx,
};
}
/**
* Execute one claimed call. Asks for its checkpoint SYNCHRONOUSLY — nothing is awaited before
* `hooks.step` — which is what lets a batch of these be launched together and still occupy
* positions in call order (see {@link AgentLoopHooks.parallel}).
*
* A tool's own failure is an OUTCOME, not a throw: the model is handed it as a result and adapts.
* Only the runner's control flow (a suspend / continue-as-new) and a replay-integrity refusal
* escape — answering either of those with a `persist:toolfail` checkpoint would ask for a position
* a diverged history has no room for, so the operator reads that second refusal instead of the
* disagreement that caused it.
*
* The retry loop runs INSIDE the `tool:<call.id>` step so it stays replay-safe: a durable step
* memoizes only its successful result, so on replay the whole step returns cached and the retries
* never re-run (side effects happen exactly once). The span sits inside the step body for the same
* reason, and covers all in-place attempts; the tool's output never rides it.
*/
/**
* How an `action` call gets approved: ask the turn's policy, then — when it wants a person — check
* whether someone in this thread already approved this tool for good. Runs INSIDE the call's
* `persist:toolcall` checkpoint; see {@link claimToolCall}.
*/
async function claimApproval(turn, call) {
const { deps, input, hooks } = turn;
const policy = deps.approvalPolicy ?? new DefaultApprovalPolicy();
const spec = deps.registry.spec(call.name);
const requirement = await policy.requirementFor({ name: call.name, kind: 'action', ...(spec !== undefined ? { spec } : {}) }, input.actor, {
threadId: input.threadId,
runId: hooks.runId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
});
if (!requirement.required) {
return { mode: 'auto' };
}
const remembered = (await deps.store.rememberedApprovals?.(input.threadId)) ?? [];
if (remembered.includes(call.name)) {
return { mode: 'remembered', approver: requirement.approver };
}
// The tool's own lifetime (a sign-in link's) over the policy's.
const wanted = spec?.approvalTtlMs ?? requirement.ttlMs;
const ttlMs = wanted !== undefined && Number.isFinite(wanted) && wanted > 0 ? Math.floor(wanted) : undefined;
return {
mode: 'ask',
approver: requirement.approver,
...(ttlMs !== undefined
? { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() }
: {}),
};
}
/**
* The span's narrower tool vocabulary. An `ask` is settled against a human and a `skill` is read out
* of the journal, so neither reaches a registry invocation; each maps to the kind whose control flow
* it shares, for a reader of a trace who only ever sees the three.
*/
function spanToolType(kind) {
if (kind === 'ask') {
return 'action';
}
return kind === 'skill' || kind === 'memory' ? 'read' : kind;
}
async function invokeClaimedTool(turn, claimed, presentationOnly) {
const { deps, hooks } = turn;
const { call, ctx, toolType } = claimed;
// Execution retains every narrowing the offer and preparation had, including an agent-only list.
const allowedTools = intersectAllow(personaOf(turn.input)?.allowedTools, deps.toolAllowList);
const invokeOptions = allowedTools === undefined ? {} : { allowedTools };
// Set only when the step's body runs here: a checkpoint read back from the journal has no timing.
let ranAt;
const timing = () => ranAt === undefined ? { replayed: true } : { durationMs: Date.now() - ranAt };
try {
const raw = await hooks.step(`tool:${call.id}`, async () => {
ranAt = Date.now();
// Pushed components stream as they happen and ride this step's RESULT into the journal (see
// `wrapToolStepOutput`), so a replay neither re-streams nor re-persists them.
const ui = createNegotiatedUiCollector(call.id, {
actor: ctx.actor,
threadId: ctx.threadId,
...(ctx.agentName !== undefined ? { agentName: ctx.agentName } : {}),
...(ctx.uiCapabilities !== undefined ? { uiCapabilities: ctx.uiCapabilities } : {}),
channel: ctx.channel ?? turnChannel(ctx.pageContext),
}, deps.resolveUiCatalog, turn.writer);
const toolCtx = {
...ctx,
// The same for every execution of THIS call (a replay after a crash, a transient retry).
toolCallId: call.id,
idempotencyKey: `${hooks.runId}:${call.id}`,
emitUi: ui.emit,
// The older push, kept: it now goes through the same collector, so it is persisted too.
emitComponent: async (name, data) => {
await ui.emit(name, typeof data === 'object' && data !== null && !Array.isArray(data)
? data
: { value: data });
},
};
let output;
try {
if (presentationOnly !== undefined) {
output = presentationOnly.output;
await deps.registry.presentResult(call.name, output, toolCtx);
}
else {
output = await spannedAgent('tool.execution', hooks.runId, {
runId: hooks.runId,
toolCallId: call.id,
toolName: call.name,
toolType: spanToolType(toolType),
}, () => invokeWithTransientRetry(() => {
ui.restart();
return deps.registry.invoke(call.name, call.input, toolCtx, deps.rolesPolicy, invokeOptions);
}, deps.toolTransientRetry ?? {}, {
...(hooks.isControlFlowError !== undefined
? { isControlFlowError: hooks.isControlFlowError }
: {}),
onRetry: (attempt, retryError) => {
publishAgentToolRetry({
runId: hooks.runId,
toolName: call.name,
toolCallId: call.id,
attempt,
message: retryError instanceof Error ? retryError.message : String(retryError),
});
},
}), () => ({}));
}
}
catch (error) {
// A domain refusal is a settled result, so a durable replay must not re-run the check.
if (!(error instanceof ToolPreflightDeniedError))
throw error;
return { '@@adonis-agent/tool-preflight-denied': 1, reason: error.reason };
}
// On a channel the server draws for, the model reads what the user actually got there (a
// pick-list, an image, text) — not the web view the tool's description talks about. Inside
// the step: a replay reads back the same words.
const pushed = ui.components();
if (deps.uiChannelGuide !== undefined && pushed.length > 0) {
const delivered = await deps.uiChannelGuide
.delivered({
actor: ctx.actor,
threadId: ctx.threadId,
...(ctx.agentName !== undefined ? { agentName: ctx.agentName } : {}),
...(ctx.uiCapabilities !== undefined ? { uiCapabilities: ctx.uiCapabilities } : {}),
channel: ctx.channel ?? turnChannel(ctx.pageContext),
}, pushed, channelChoiceLimits(ctx.pageContext))
.catch(() => undefined);
if (delivered !== undefined)
output = withDelivered(output, delivered);
}
return wrapToolStepOutput(output, pushed, ui.text());
});
if (typeof raw === 'object' &&
raw !== null &&
'@@adonis-agent/tool-preflight-denied' in raw &&
raw['@@adonis-agent/tool-preflight-denied'] === 1 &&
'reason' in raw &&
typeof raw.reason === 'string') {
return { status: 'failed', error: raw.reason, denied: true, ...timing() };
}
const { output, ui, text } = unwrapToolStepOutput(raw);
if (text)
turn.toolText.set(call.id, text);
if (ui.length > 0) {
turn.toolUi.set(call.id, ui);
}
if (claimed.terminal === true) {
turn.halt.terminal = true;
}
return { status: 'executed', output, ...timing() };
}
catch (error) {
if (isReplayIntegrityError(error) || hooks.isControlFlowError?.(error) === true) {
throw error;
}
return {
status: 'failed',
error: error instanceof Error ? error.message : String(error),
...(error instanceof ToolPreflightDeniedError ? { denied: true } : {}),
...timing(),
};
}
}
/** The timing a settled invocation carries onto its `tool-call` diagnostic. */
function toolTiming(outcome) {
return {
...(outcome.durationMs !== undefined ? { durationMs: outcome.durationMs } : {}),
...(outcome.replayed === true ? { replayed: true } : {}),
};
}
/**
* The row's `execution_ms`: measured inside the `tool:<id>` checkpoint, so it is how long the tool ran
* rather than how long a replay took to read it back. A replayed checkpoint has none and writes none.
*/
function executionMs(outcome) {
return outcome.durationMs !== undefined ? { executionMs: outcome.durationMs } : {};
}
/** Persist one settled invocation and shape the result the model is fed. */
async function recordToolOutcome(turn, claimed, outcome, deciderRef = turn.input.actor.id, meta) {
const { deps, hooks } = turn;
const { call } = claimed;
// An `agent` call branches to delegation before reaching here, so anything that isn't an `action`
// is a read — the same posture the kind fallback itself takes.
const toolType = claimed.toolType === 'action' ? 'action' : 'read';
// What a decided call adds to its row: how it was decided. Written with the outcome rather than
// at a checkpoint of its own, so an approval adds no position to the turn.
const decided = meta === undefined
? {}
: {
executedByRef: meta.decidedBy,
...(meta.decidedVia !== undefined ? { decidedVia: meta.decidedVia } : {}),
...(meta.remember === true ? { remember: true } : {}),
};
const settle = async () => {
if (meta === undefined || !meta.streamed) {
return;
}
await turn.writer.write({
t: 'event',
event: {
kind: 'approval-settled',
id: call.id,
status: 'approved',
...(meta.approver !== undefined ? { approver: meta.approver } : {}),
decidedBy: meta.decidedBy,
...(meta.decidedVia !== undefined ? { decidedVia: meta.decidedVia } : {}),
...(meta.remember === true ? { remember: true } : {}),
},
});
};
if (outcome.status === 'failed') {
await hooks.step(`persist:toolfail:${call.id}`, async () => {
await deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: 'failed',
error: outcome.error,
...executionMs(outcome),
...decided,
});
await settle();
});
publishAgentToolCall({
runId: hooks.runId,
toolCallId: call.id,
toolName: call.name,
toolType,
status: 'failed',
...toolTiming(outcome),
});
return {
id: call.id,
name: call.name,
output: outcome.denied ? { reason: outcome.error } : null,
error: outcome.error,
...(outcome.denied ? { denied: true } : {}),
};
}
await hooks.step(`persist:toolexec:${call.id}`, async () => {
await deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: 'executed',
output: outcome.output,
...(toolType === 'action' ? { executedByRef: deciderRef } : {}),
...executionMs(outcome),
...decided,
});
await settle();
});
publishAgentToolCall({
runId: hooks.runId,
toolCallId: call.id,
toolName: call.name,
toolType,
status: 'executed',
...toolTiming(outcome),
});
return { id: call.id, name: call.name, output: outcome.output };
}
/**
* Delegate to another agent (an `agent`-kind call). Dispatched at the LOOP level (not in a step)
* because the durable runner maps it to `ctx.child`, a ctx-level suspend point.
*/
async function delegateToolCall(turn, claimed) {
const { deps, input, hooks } = turn;
const { call } = claimed;
const delegation = claimed.delegation ??
(await resolveDelegation(deps, input, call, deps.registry.spec(call.name)));
if (delegation.error !== undefined) {
return { id: call.id, name: call.name, output: null, error: delegation.error };
}
const { targetAgent, task } = delegation;
// The journal decides (see `resolveDelegation`); the runner only says whether it CAN detach.
const start = delegation.detached === true ? hooks.startAgent : undefined;
publishAgentDelegated({
runId: hooks.runId,
toAgent: targetAgent,
...(input.agentName !== undefined ? { fromAgent: input.agentName } : {}),
...(start !== undefined ? { detached: true } : {}),
});
// Both branches write the same checkpoint, under the same name, at the same position — what differs
// is the RUNNER's own position before it (`spawn:` for a started child, `signal:child:` for an
// awaited one). Called from the loop body, never inside a step, so the start's position is the
// workflow's own and a replay asks for it in the same place.
let sub;
if (start !== undefined) {
const started = await start({ agentName: targetAgent, task, toolCallId: call.id });
sub = detachedStarted({ agent: targetAgent, runId: started.runId });
}
else if (hooks.runAgent) {
sub = await hooks.runAgent(targetAgent, task);
}
else {
sub = { text: `(no multi-agent support wired; cannot reach "${targetAgent}")` };
}
await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: 'executed',
output: sub,
}));
return { id: call.id, name: call.name, output: sub };
}
/**
* Stamp each of a model turn's calls with the kind declared WHERE THE TOOL WAS OFFERED.
*
* A call exists only because some process put that tool's definition in front of the model, so that
* process is the one that certainly knows the tool. Another process might not: a deployment that
* splits its pods by role has one that serves HTTP and replays run bodies without registering the
* tool classes, and its registry answers `undefined`. Stamping here, INSIDE the llm checkpoint, is
* what puts the kind in the journal — so the branch a call takes is a fact about the turn rather
* than about whichever process happened to replay it (see {@link claimToolCall}).
*
* Provider-supplied preparation is discarded. The trusted result rides this same model checkpoint
* so a process that first claims the call without its handler still follows the offering process's verdict.
*/
async function stampToolKinds(result, deps, input, hooks) {
const persona = personaOf(input);
const allowedTools = intersectAllow(persona?.allowedTools, deps.toolAllowList);
const toolCalls = [];
for (const incoming of result.toolCalls) {
// The provider cannot supply trusted preparation. Resolve it only where the tool was offered.
const { preflight: _untrusted, prepared: _untrustedPrepared, actionApproval: _untrustedApproval, ...call } = incoming;
const kind = declaredKind(deps, call.name);
let preflight;
let prepared;
let actionApproval;
if (kind === 'action') {
try {
if (deps.actionApprovalMode === 'independent')
actionApproval = await resolveActionProposalApproval({
actor: input.actor,
thread: {
threadId: input.threadId,
runId: hooks.runId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
},
tool: {
name: call.name,
kind: 'action',
...(deps.registry.spec(call.name) !== undefined
? { spec: deps.registry.spec(call.name) }
: {}),
},
store: deps.store,
...(deps.approvalPolicy !== undefined ? { policy: deps.approvalPolicy } : {}),
});
if (deps.actionApprovalMode === 'independent' && actionApproval?.mode === 'ask') {
prepared = await prepareActionProposal(deps.registry, call.name, call.input, {
actor: input.actor,
threadId: input.threadId,
runId: hooks.runId,
requestId: hooks.runId,
toolCallId: call.id,
idempotencyKey: `${hooks.runId}:${call.id}`,
emitUi: createNoopEmitUi(call.id),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(persona !== undefined ? { persona } : {}),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
...(input.uiCapabilities !== undefined
? { uiCapabilities: input.uiCapabilities }
: {}),
channel: turnChannel(input.pageContext),
...(deps.host !== undefined ? { host: deps.host } : {}),
}, deps.rolesPolicy, allowedTools === undefined ? {} : { allowedTools });
preflight = prepared.preflight;
}
else
preflight = await deps.registry.prepare(call.name, call.input, {
actor: input.actor,
threadId: input.threadId,
runId: hooks.runId,
requestId: hooks.runId,
toolCallId: call.id,
idempotencyKey: `${hooks.runId}:${call.id}`,
emitUi: createNoopEmitUi(call.id),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(persona !== undefined ? { persona } : {}),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
...(input.uiCapabilities !== undefined
? { uiCapabilities: input.uiCapabilities }
: {}),
channel: turnChannel(input.pageContext),
...(deps.host !== undefined ? { host: deps.host } : {}),
}, deps.rolesPolicy, allowedTools === undefined ? {} : { allowedTools });
}
catch (error) {
if (isReplayIntegrityError(error) || hooks.isControlFlowError?.(error) === true)
throw error;
preflight = {
status: 'failed',
error: error instanceof Error ? error.message : String(error),
};
}
}
toolCalls.push({
...call,
...(actionApproval !== undefined ? { actionApproval } : {}),
kind,
...(prepared !== undefined ? { prepared } : {}),
...(preflight !== undefined ? { preflight } : {}),
});
}
return { ...result, toolCalls };
}
/**
* A call's declared kind, resolved from this process's own configuration and registry.
*
* Read by {@link stampToolKinds} where the tools were offered, which is the answer that then rides
* the journal; a claim falls back to it only for a call that arrives unstamped.
*
* The reserved `ask` name resolves from module CONFIG, never from the registry: `ask` has no handler
* to register, and grounding its branch in config is what keeps a process with a partial registry
* from disagreeing about it. Every other name is the registry's `ToolSpec`, as before.
*/
function declaredKind(deps, name) {
if (deps.ask === true && name === ASK_TOOL_NAME) {
return 'ask';
}
if (deps.skills !== undefined && name === SKILL_TOOL_NAME) {
return 'skill';
}
if (memoryIsWritable(deps) && name === REMEMBER_TOOL_NAME) {
return 'memory';
}
return deps.registry.spec(name)?.kind ?? 'read';
}
/**
* Settle an `ask` call against a human. The model authored the questions, so unlike the configured
* intake there is nothing to persist first — `persist:toolcall` already wrote the row, under the same
* name and at the same position any other kind would have used.
*
* Costs nothing beyond the step that produced it: the question set arrived as the ARGUMENTS of a tool
* call the model was already making, in a step already billed as `chat`. There is no second model
* call and therefore no usage row of its own — the opposite of the structured-output pass.
*
* The two new positions here (`stream:elicitation:<id>`, and the wait) are reachable only when this
* call's journaled kind is `ask`, which no run recorded before the kind existed. Every replay reads
* that kind back rather than re-deciding it, so no run can disagree about whether they are there.
*/
async function elicitToolCall(turn, claimed) {
const { deps, hooks } = turn;
const { call, ctx } = claimed;
// Validated HERE rather than by the registry, which never sees this tool. Deterministic on the
// same footing as `validateStructured`: a module-constant schema over an input the `llm:<i>`
// checkpoint already holds, so every replay reaches the same verdict — and the same branch.
const parsed = await askInputSchema['~standard'].validate(call.input);
if (parsed.issues !== undefined) {
const error = `invalid ask input: ${parsed.issues
.map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
.join('; ')}`;
await hooks.step(`persist:toolfail:${call.id}`, () => deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: 'failed',
error,
}));
// A malformed question set is the model's mistake to fix, so it comes back as a tool failure —
// the vocabulary the model already knows how to answer — rather than failing the run.
return { id: call.id, name: call.name, output: null, error };
}
const request = {
id: call.id,
source: 'ask',
questions: parsed.value.questions,
...(parsed.value.preamble !== undefined ? { preamble: parsed.value.preamble } : {}),
};
await hooks.step(`stream:elicitation:${call.id}`, () => Promise.resolve(turn.writer.write({ t: 'elicitation', runId: hooks.runId, id: call.id, request })));
const reply = await awaitElicitation({ hooks, request, ctx });
const result = settleElicitation({ request, reply });
await hooks.step(result.skipped ? `persist:toolreject:${call.id}` : `persist:toolexec:${call.id}`, () => deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: result.skipped ? 'rejected' : 'executed',
output: result,
...(result.skipped ? { error: 'skipped by the user' } : {}),
...(reply.answeredByRef !== undefined ? { executedByRef: reply.answeredByRef } : {}),
}));
publishAgentToolCall({
runId: hooks.runId,
toolCallId: call.id,
toolName: call.name,
toolType: 'action',
status: result.skipped ? 'rejected' : 'executed',
});
return { id: call.id, name: call.name, output: result };
}
/** The turn's identity as the skills seam sees it — the same inputs the prompt is resolved from. */
function skillContext(input) {
return {
actor: input.actor,
threadId: input.threadId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}),
};
}
/**
* Serve one `skill` call: read the body and hand it to the model as an ordinary tool result.
*
* WHY THE BODY IS RETURNED FROM THE CHECKPOINT. Which instructions entered a turn's prompt is a
* decision about the turn, and a decision about a turn has to be readable from its journal — the
* same property `persist:toolcall` gives the read/action branch. A replay that re-read the provider
* would compose a DIFFERENT prompt from a skill edited in between, at a transcript position the
* history already holds: the model would then be answering something nobody can reconstruct from the
* record. Inside `tool:<callId>` the first attempt's text is the only text there ever was.
*
* The positions are a read tool's, exactly (`persist:toolcall` before, `tool:<id>` here,
* `persist:toolexec:<id>` / `persist:toolfail:<id>` after), so nothing about the shape of a turn
* depends on whether a call was a skill — only on the kind the journal recorded.
*/
async function loadSkillIntoTurn(turn, claimed) {
const { deps, input, hooks } = turn;
const { call } = claimed;
const config = deps.skills;
const offer = turn.skills;
const outcome = await hooks.step(`tool:${call.id}`, async () => {
if (config === undefined || offer === undefined) {
// Reachable where a call's journaled kind is `skill` but this process has no skills
// configured. A tool failure, because it is the one vocabulary the model can act on, and
// because failing the run would strand a turn over a lookup.
return { ok: false, error: 'Skills are not available in this deployment.' };
}
const parsed = await skillInputSchema['~standard'].validate(call.input);
if (parsed.issues !== undefined) {
return {
ok: false,
error: `invalid skill input: ${parsed.issues
.map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
.join('; ')}`,
};
}
const loaded = await loadSkill({
config,
offer,
name: parsed.value.name,
ctx: skillContext(input),
});
if (!loaded.ok) {
return { ok: false, error: loaded.error };
}
return {
ok: true,
output: {
name: loaded.skill.name,
scope: loaded.skill.scope,
body: loaded.skill.body,
...(loaded.shadows !== undefined ? { shadows: loaded.shadows } : {}),
},
};
});
return outcome.ok
? { status: 'executed', output: outcome.output }
: { status: 'failed', error: outcome.error };
}
/**
* Serve one `remember` call: write the fact and hand the stored record back as an ordinary tool
* result.
*
* WHY THE WRITE HAPPENS INSIDE THE CHECKPOINT. Two reasons, and the second is the one that is easy
* to miss. It makes the write idempotent under replay — a resumed run reads the record back from
* `tool:<callId>` instead of storing a second copy of a fact the model only decided once. And it
* makes what the model was TOLD about the write part of the journal, so the transcript a later step
* reads is the one the first attempt built rather than whatever a second write would have returned.
*
* The positions are a read tool's, exactly, so nothing about the shape of a turn depends on whether
* a call wrote a memory — only on the kind the journal recorded.
*/
async function rememberIntoTurn(turn, claimed) {
const { deps, input, hooks } = turn;
const { call } = claimed;
const config = deps.memory;
const digest = turn.memory;
const outcome = await hooks.step(`tool:${call.id}`, async () => {
if (config === undefined || digest === undefined) {
// Reachable where a call's journaled kind is `memory` but this process has none configured.
// A tool failure, because it is the one vocabulary the model can act on, and because failing
// the run would strand a turn over a lookup.
return { ok: false, error: 'Memory is not available in this deployment.' };
}
const parsed = await rememberInputSchema['~standard'].validate(call.input);
if (parsed.issues !== undefined) {
return {
ok: false,
error: `invalid remember input: ${parsed.issues
.map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
.join('; ')}`,
};
}
return writeMemory({
config,
digest,
call: parsed.value,
ctx: skillContext(input),
runId: hooks.runId,
});
});
return outcome.ok
? { status: 'executed', output: outcome.record }
: { status: 'failed', error: outcome.error };
}
/**
* Run one claimed call's INVOCATION, whatever kind it is. A `skill` reads the catalog the journal
* holds instead of the registry; everything that reaches here spends the same `tool:<id>` position
* either way, which is what lets the two be batched together.
*/
function invokeClaimed(turn, claimed) {
if (claimed.toolType === 'skill') {
return loadSkillIntoTurn(turn, claimed);
}
return claimed.toolType === 'memory'
? rememberIntoTurn(turn, claimed)
: invokeClaimedTool(turn, claimed);
}
/** Everything a claimed call still needs, on its own: delegation or approval, then execute. */
async function runClaimedToolCall(turn, claimed) {
const { deps, hooks } = turn;
const { call, toolType, ctx } = claimed;
if (claimed.proposalId !== undefined) {
turn.halt.independentProposal = true;
return { id: call.id, name: call.name, output: actionProposalReceipt(claimed.proposalId) };
}
if (claimed.preflight !== undefined && claimed.preflight.status !== 'ready') {
const result = claimed.preflight;
if (result.status === 'completed') {
// The claim freezes this branch: adding/removing a hook in a rolling deployment must
// not change existing checkpoint positions. Old claims have no opt-in and skip UI.
if (claimed.presentCompletedPreflight === true) {
await invokeClaimedTool(turn, claimed, { output: result.output });
}
if (claimed.terminal === true)
turn.halt.terminal = true;
return { id: call.id, name: call.name, output: result.output };
}
const reason = result.status === 'denied' ? result.reason : result.error;
return {
id: call.id,
name: call.name,
output: result.status === 'denied' ? { reason } : null,
error: reason,
...(result.status === 'denied' ? { denied: true } : {}),
};
}
if (toolType === 'agent') {
return delegateToolCall(turn, claimed);
}
if (toolType === 'ask') {
return elicitToolCall(turn, claimed);
}
// WHO settled an action tool: the Decision's ref when it carries one (the route stamps the
// caller), else the run's own actor. Stamped on both the executed and rejected persists.
let deciderRef = turn.input.actor.id;
let meta;
if (toolType === 'action' && claimed.approval?.mode === 'remembered') {
meta = {
approver: claimed.approval.approver,
decidedBy: turn.input.actor.id,
decidedVia: 'remembered',
remember: true,
streamed: true,
};
}
else if (toolType === 'action' && claimed.approval?.mode !== 'auto') {
const ttlMs = claimed.approval?.mode === 'ask' ? claimed.approval.ttlMs : undefined;
const decision = await awaitDecision({
hooks,
call,
ctx,
...(ttlMs !== undefined ? { timeoutMs: ttlMs } : {}),
});
deciderRef = decision.executedByRef ?? turn.input.actor.id;
// Only a call claimed under a policy streams how it settled — a run from before that keeps
// writing exactly the frames it always wrote.
const streamsSettlement = claimed.approval !== undefined;
if (decision.expired === true) {
return expireToolCall(turn, claimed);
}
if (!decision.approved) {
await hooks.step(`persist:toolreject:${call.id}`, async () => {
await deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: 'rejected',
executedByRef: deciderRef,
...(decision.reason !== undefined ? { error: decision.reason } : {}),
...(decision.decidedVia !== undefined ? { decidedVia: decision.decidedVia } : {}),
});
if (streamsSettlement) {
await turn.writer.write({
t: 'event',
event: {
kind: 'approval-settled',
id: call.id,
status: 'rejected',
decidedBy: deciderRef,
...(decision.decidedVia !== undefined ? { decidedVia: decision.decidedVia } : {}),
...(decision.reason !== undefined ? { reason: decision.reason } : {}),
},
});
}
});
publishAgentToolCall({
runId: hooks.runId,
toolCallId: call.id,
toolName: call.name,
toolType,
status: 'rejected',
});
return {
id: call.id,
name: call.name,
output: { rejected: true, reason: decision.reason ?? DEFAULT_REFUSAL_REASON },
denied: true,
// What the MODEL is told. The bare word `rejected` names no actor and reads exactly like a
// tool that blew up, so the answer that follows speculates about causes ("the key may not
// exist", "there may be permission restrictions") and offers to run it again. A person's
// decision is not a fault to diagnose, so this says who decided, that nothing ran, and what
// not to do next.
error: refusalNarrative(decision.reason),
};
}
meta = {
decidedBy: deciderRef,
...(decision.decidedVia !== undefined ? { decidedVia: decision.decidedVia } : {}),
...(decision.remember === true ? { remember: true } : {}),
streamed: streamsSettlement,
};
}
return recordToolOutcome(turn, claimed, await invokeClaimed(turn, claimed), deciderRef, meta);
}
/**
* The approval request lapsed with nobody deciding. Settled on the rejection's own checkpoint name
* — an expiry is a refusal as far as the sequence is concerned, and one fewer name to keep stable —
* but persisted as `expired`, streamed as `approval-settled { status: 'expired' }`, and put to the
* model as "nobody approved this in time" rather than as someone's "no".
*/
async function expireToolCall(turn, claimed) {
const { deps, hooks } = turn;
const { call } = claimed;
await hooks.step(`persist:toolreject:${call.id}`, async () => {
await deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: 'expired',
error: APPROVAL_EXPIRED_REASON,
});
await turn.writer.write({
t: 'event',
event: { kind: 'approval-settled', id: call.id, status: 'expired' },
});
});
publishAgentToolCall({
runId: hooks.runId,
toolCallId: call.id,
toolName: call.name,
toolType: 'action',
status: 'rejected',
});
return {
id: call.id,
name: call.name,
output: { rejected: true, expired: true, reason: APPROVAL_EXPIRED_REASON },
denied: true,
expired: true,
error: expiryNarrative(),
};
}
/** The reason an expired call is denied with — on the row, the stream frame and the result. */
export const APPROVAL_EXPIRED_REASON = 'approval expired';
/**
* How an expiry is put to the model. Nobody said no, so it must not read like a refusal — but it
* must stop the model from simply trying again, which would park the turn on the same unanswered
* request.
*/
function expiryNarrative() {
return ('This action needed approval, and the approval request expired before anyone decided. ' +
'Nothing ran and nothing changed. Do not run it again on your own; tell the person it was not ' +
'approved in time and ask whether they still want it.');
}
/**
* How a refusal is put to the model. Written as instructions rather than as a status because the
* model's next move is the whole problem: told only that something was "rejected", it treats the
* refusal as a fault, lists possible causes it cannot check, and offers to retry the same action —
* which asks the person to say no twice.
*/
function refusalNarrative(reason) {
const base = 'The person was asked to approve this action and declined it. Nothing ran and nothing changed. ' +
'This is their decision, not an error, a missing record or a permissions problem — do not ' +
'explain it as one, do not guess at causes, and do not run this action again or reach for ' +
'another way to do the same thing. Acknowledge the decision and ask what they would like instead.';
return reason === undefined ? base : `${base} They said: ${reason}`;
}
/**
* Overlap the INVOCATIONS of a batch of claimed calls, and only those. The persist checkpoints on
* either side stay strictly sequential, in call order — they are asked for after their neighbours'
* positions are already spent, so nothing about them depends on which tool finished first.
*
* A rejection here is the runner unwinding the turn, never a tool's own failure
* ({@link invokeClaimedTool} reports those). Rethrow the first one in call order and persist
* NOTHING: the resume replays this whole block, and a `persist:toolexec` written now would sit at
* the position the replay computes for an earlier call's.
*/
async function invokeClaimedToolsTogether(turn, parallel, claimed) {
const settled = await parallel(claimed.map((entry) => () => invokeClaimed(turn, entry)));
for (const outcome of settled) {
if (!outcome.ok) {
throw outcome.error;
}
}
const results = [];
for (const [index, entry] of claimed.entries()) {
const outcome = settled[index];
if (outcome?.ok === true) {
results.push(await recordToolOutcome(turn, entry, outcome.value));
}
}
return results;
}
/**
* What the formatting pass is shown. By default the QUESTION and the ANSWER — the pass is a
* translation of the answer, and its own instruction tells the model to use only what the
* conversation already contains, so handing it the whole transcript again would roughly double what
* a turn with an `outputSchema` costs for nothing. See {@link AgentLoopDeps.outputFromTranscript}
* for the agent that genuinely needs more, and why it is off by default.
*
* The question comes off the PROCESSED prompt rather than `AgentRunInput.userText`: this pass is a
* second route out of the model and has to stand behind the same input chain the streamed turn did,
* so a question a processor masked cannot reappear in the clear here.
*/
function restatementPrompt(args) {
const restated = { role: 'assistant', content: args.answer };
if (args.fromTranscript) {
return [...args.messages, restated];
}
// The last user message that actually SAYS something. The loop feeds a step's tool results back
// as an empty `user` message carrying `toolResults`, so scanning for the last `user` role alone
// would hand the pass a blank question on every turn that called a tool.
for (let index = args.messages.length - 1; index >= 0; index -= 1) {
const message = args.messages[index];
if (message?.role === 'user' && message.content.length > 0) {
return [message, restated];
}
}
return [restated];
}
/**
* Restate a finished answer as `deps.outputSchema`, repairing a rejected reply up to
* `outputRepairAttempts` times. Every model call is its own checkpoint (`structured:<step>:<n>`)
* with its own usage row, so a suspend between two attempts resumes on the reply the first one got
* rather than paying for a third.
*
* Validation itself sits OUTSIDE the checkpoints, on the same footing as `HistoryWindow.select`: its
* inputs are the schema (module config, identical on every process of a deployment) and a reply a
* checkpoint already holds, so every replay reaches the same verdict — and therefore the same number
* of attempts — without a checkpoint of its own.
*
* THE OUTPUT CHAIN RULES ON EACH ATTEMPT, before the schema does. This pass is a second route out of
* the model, so anything the chain held back from the prose must not be able to leave through here.
* It is gated on the WHOLE reply regardless of what the chain declared, because nothing here is
* streamed — there is no prefix for an `incremental` processor to release.
*/
async function structureAnswer(args) {
const { schema, deps, input, hooks, messages, step, ctx } = args;
const priceByModel = args.priceByModel ?? new Map();
const instruction = deps.outputInstruction ?? DEFAULT_STRUCTURED_OUTPUT_INSTRUCTION;
const maxAttempts = 1 + (deps.outputRepairAttempts ?? 1);
const outputProcessors = deps.outputProcessors ?? [];
let issues = [];
let text = '';
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const system = attempt === 0 ? instruction : repairInstruction({ instruction, issues });
const reply = await hooks.step(`structured:${step}:${attempt}`, async () => {
// A sink that drops every frame: this call is not the turn's answer, so its tokens must never
// reach the reader's stream alongside the prose they are restating.
const discard = { write: () => { }, end: () => { } };
const turn = await abortableModelCall(hooks, (signal) => deps.model.runTurn({
system,
messages,
tools: [],
sink: discard,
outputSchema: schema,
...signal,
}));
return {
text: turn.text,
usage: turn.usage,
...(turn.object !== undefined ? { object: turn.object } : {}),
...(turn.modelId !== undefined ? { modelId: turn.modelId } : {}),
};
});
await hooks.step(`persist:usage:structured:${step}:${attempt}`, () => deps.store.recordUsage({
threadId: input.threadId,
actorRef: input.actor.id,
runId: hooks.runId,
modelId: reply.modelId ?? deps.modelId ?? 'unknown',
purpose: 'structured_output',
usage: reply.usage,
...usageCost(reply.usage, undefined, resolveModelPrice(priceByModel, reply.modelId ?? deps.modelId ?? 'unknown')),
}));
text = reply.text;
if (outputProcessors.length > 0) {
// Its own checkpoint, after the usage row: the tokens were spent either way, and a refusal
// that also hid its own cost would let a mis-tuned gate burn a budget invisibly — the same
// ordering the streamed answer's gate takes.
const gate = await hooks.step(`process:output:structured:${step}:${attempt}`, () => runOutputProcessors({
processors: outputProcessors,
answer: { text: reply.text, toolCalls: [] },
ctx,
}));
if (gate.rejection !== undefined) {
throw new OutputRejectedError(gate.rejection.processor, gate.rejection.reason);
}
text = gate.text;
}
// A provider that constrained its own generation reports a parsed `object`, but that object
// describes the reply the chain has just rewritten. Once the two disagree, the gated text is the
// only version anything downstream may see, so it is re-parsed rather than trusted.
const reported = text === reply.text ? reply.object : undefined;
const outcome = await validateStructured({ schema, text, reported });
if (outcome.ok) {
return outcome.value;
}
issues = outcome.issues;
}
throw new StructuredOutputError(issues, text, maxAttempts);
}
/** What a turn answered with: the assistant text, plus the validated `outputSchema` value if any. */
/**
* The frame a settled call is streamed on. A refusal is its own kind, ahead of the error branch:
* `denied` and `error` are both set on a declined call — the first for every consumer, the second
* because `error` is the channel a model reads an outcome on — and a client that saw the error frame
* would draw a person's "no" as a malfunction.
*/
export function outcomeFrame(result) {
if (result.denied === true) {
const { output } = result;
const reason = output !== null && typeof output === 'object' && 'reason' in output
? output.reason
: undefined;
return {
kind: 'tool-output-denied',
id: result.id,
...(typeof reason === 'string' && reason !== DEFAULT_REFUSAL_REASON ? { reason } : {}),
};
}
return result.error !== undefined
? { kind: 'tool-output-error', id: result.id, error: result.error }
: { kind: 'tool-output', id: result.id, output: result.output };
}
/**
* Wrap the writer a model turn streams into, noting which tool calls it announced
* (`tool-input-start` / `tool-input-available`), so the loop can announce the rest itself. A
* provider that streams only text — a custom one, a test double — would otherwise leave every call
* unannounced, and the React transport drops a message whose call settles before it exists.
*/
function announcedToolCalls(inner) {
const seen = new Set();
return {
writer: {
write: (frame) => {
if (frame.t === 'event' &&
(frame.event.kind === 'tool-input-start' || frame.event.kind === 'tool-input-available')) {
seen.add(frame.event.id);
}
return inner.write(frame);
},
end: () => inner.end(),
},
async announceRest(calls, tools) {
for (const call of calls) {
if (seen.has(call.id))
continue;
const kind = tools.find((definition) => definition.name === call.name)?.kind;
await inner.write({
t: 'event',
event: {
kind: 'tool-input-available',
id: call.id,
name: call.name,
input: call.input,
toolKind: kind === 'action' ? 'action' : 'read',
},
});
}
},
};
}
/**
* What a person is told when a run fails for a reason that is ours rather than theirs. The error's
* own text is for whoever operates the deployment: it names run ids, checkpoint positions and
* provider internals ("non-determinism at <uuid>#41 …", "No output generated. Check the stream for
* errors."), which mean nothing to the person reading the chat and are not theirs to see.
*/
export const RUN_FAILED_MESSAGE = 'The assistant could not finish this answer. Please try again.';
let streamErrorDetails;
/**
* Whether an error frame carries the error's own message. Unset → only outside production
* (`NODE_ENV !== 'production'`), where the person reading the stream is the one debugging it.
* `true`/`false` decides it outright; `undefined` returns to the default.
*/
export function exposeStreamErrorDetails(expose) {
streamErrorDetails = expose;
}
function detailsExposed() {
return streamErrorDetails ?? process.env.NODE_ENV !== 'production';
}
/**
* The stable `code` of a failed run — what a client branches on, and translates. `quota_exceeded`,
* `output_rejected` and `structured_output_invalid` are outcomes the library words itself;
* `replay_diverged` is the durable runtime refusing a checkpoint position, `model_no_output` a model
* call that produced nothing, and `run_failed` everything else.
*/
export function streamErrorCode(error) {
if (error instanceof QuotaExceededError) {
return 'quota_exceeded';
}
if (error instanceof OutputRejectedError) {
return 'output_rejected';
}
if (error instanceof StructuredOutputError) {
return 'structured_output_invalid';
}
if (isReplayIntegrityError(error)) {
return 'replay_diverged';
}
const name = error instanceof Error ? error.name : '';
return name === 'AI_NoOutputGeneratedError' || name === 'NoOutputGeneratedError'
? 'model_no_output'
: 'run_failed';
}
/** The codes whose message the library wrote for the person, and is therefore safe to show. */
const WORDED_CODES = new Set([
'quota_exceeded',
'output_rejected',
'structured_output_invalid',
]);
/**
* The `event: error` frame a runner closes a failed run's stream with, coded the way the NestJS
* library codes it so a shared client can tell a spent budget from a crash.
*
* The frame is what the PERSON sees. For a crash it carries a stable `code` and
* {@link RUN_FAILED_MESSAGE}; the error itself goes to the log with the run it belongs to. Outside
* production the raw message rides the frame as it always did (see
* {@link exposeStreamErrorDetails}).
*/
export function streamErrorFrame(error, runId) {
const detail = error instanceof Error ? error.message : String(error);
const code = streamErrorCode(error);
if (WORDED_CODES.has(code)) {
return { t: 'error', code, message: detail };
}
console.error(`[@adonis-agora/agent] run ${runId ?? '(unknown)'} failed (${code}): ${error instanceof Error ? (error.stack ?? detail) : detail}`);
return { t: 'error', code, message: detailsExposed() ? detail : RUN_FAILED_MESSAGE };
}
/**
* The provider-agnostic agent turn, reused by both the inline and durable runners.
* It drives the model→tools→model iteration; the runner supplies the `step`/`awaitApproval`
* hooks that make the same loop body either in-process or a replay-safe durable workflow.
*/
export async function runAgentLoop(boundDeps, requested, hooks) {
const startedAt = Date.now();
try {
return await runAgentTurn(boundDeps, requested, hooks);
}
catch (error) {
publishRunEnded(error, requested, hooks, startedAt);
throw error;
}
}
/** Who and what the run is, for its terminal diagnostics. */
function runContext(input) {
return {
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
actorId: input.actor.id,
...(input.actor.tenantRef !== undefined && input.actor.tenantRef !== null
? { tenantRef: input.actor.tenantRef }
: {}),
...(input.parentRunId !== undefined ? { parentRunId: input.parentRunId } : {}),
};
}
/**
* `run.failed` / `run.cancelled` for a loop that threw. A durable control-flow signal (suspend on an
* approval or a question, continue-as-new) is not an ending — the run goes on — so it publishes
* nothing; a run whose abort signal fired was stopped; anything else failed, under the same code the
* stream's `error` frame carries.
*/
function publishRunEnded(error, input, hooks, startedAt) {
if (!isReplayIntegrityError(error) && hooks.isControlFlowError?.(error) === true)
return;
const base = {
runId: hooks.runId,
threadId: input.threadId,
durationMs: Date.now() - startedAt,
...runContext(input),
};
if (hooks.abortSignal?.aborted === true) {
publishAgentRunCancelled(base);
return;
}
publishAgentRunFailed({ ...base, code: streamErrorCode(error) });
}
async function runAgentTurn(boundDeps, requested, hooks) {
// The persona, FIRST and only when the run names one by id: its definition is read from config
// once and frozen in this checkpoint, so a run resumed after the persona was edited or removed
// keeps the prompt and allow-list it started with. A run that names none spends no position here
// — nor does one that carries the whole persona in its input (a direct caller, or a run recorded
// by a release before this checkpoint existed), so those replay on the sequence they recorded.
const turnPersona = typeof requested.persona === 'string'
? ((await hooks.step('persona:resolve', () => resolveTurnPersona(boundDeps, requested, requested.persona))) ?? undefined)
: requested.persona !== undefined
? await inlineTurnPersona(boundDeps, requested, requested.persona)
: undefined;
// From here on the input carries the persona the turn actually runs under, whole and frozen — none,
// when the one it named is no longer declared — so provenance and tool context never claim one
// that did not apply.
const { persona: _requestedPersona, ...withoutPersona } = requested;
const input = turnPersona === undefined
? withoutPersona
: { ...withoutPersona, persona: personaFromTurn(turnPersona) };
// A turn with a selected model runs EVERY call it makes on it — the answer and a structured-output
// pass — and labels its usage with it when the provider reports no model id. The pick rides the
// run's input, so a durable replay makes the same choice.
const deps = input.model === undefined
? boundDeps
: {
...boundDeps,
model: withSelectedModel(boundDeps.model, input.model),
modelId: input.model,
};
const maxSteps = deps.maxSteps ?? 8;
const persona = turnPersona;
let system = await resolveSystemPrompt(deps, input, turnPersona);
// A turn on WhatsApp, Telegram or email: how each component reaches the user there. Derived from
// the config and the catalog like the base prompt, so it needs no checkpoint of its own.
if (deps.uiChannelGuide !== undefined) {
const channelBlock = await deps.uiChannelGuide
.prompt({
actor: input.actor,
threadId: input.threadId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}),
channel: turnChannel(input.pageContext),
})
.catch(() => undefined);
if (channelBlock !== undefined && channelBlock !== '')
system = `${system}\n\n${channelBlock}`;
}
const inputProcessors = deps.inputProcessors ?? [];
const outputProcessors = deps.outputProcessors ?? [];
const gateMode = resolveOutputGateMode(outputProcessors);
const gateLookback = resolveGateLookback(outputProcessors);
const processorContext = (step) => ({
threadId: input.threadId,
actor: input.actor,
step,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(persona !== undefined ? { persona: persona.id } : {}),
});
// Open the run (turn) row FIRST — before the quota gate — so even a quota-rejected run is tracked
// (the runner settles it `failed`). A checkpointed step so a durable replay reuses the ONE row (no
// duplicate on resume); `run_id` is then stamped onto every message / tool call / usage row below.
await hooks.step('persist:run:start', () => deps.store.recordRunStart({
runId: hooks.runId,
threadId: input.threadId,
actor: input.actor,
durable: hooks.durable ?? false,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(input.parentRunId !== undefined ? { parentRunId: input.parentRunId } : {}),
}));
if (deps.quota !== undefined) {
const quota = deps.quota;
const state = await hooks.step('quota:check', () => quota.check(input.actor.id, deps.day));
if (!state.withinLimit) {
throw new QuotaExceededError();
}
}
// Attachments arrive already staged (the upload route + AttachmentStagingStore turned bytes into a
// model-fetchable url), so persisting them here is a plain field write — no IO to wrap in a step.
// The multimodal content parts are built by the model adapter at turn time from the url.
if (input.regenerate === true) {
// Answer the last exchange again: drop every message after the thread's last user message
// (keeping it), then answer it. No user message is appended. Its own step name, so a run that
// is not a regenerate journals exactly what it always did.
await hooks.step('regenerate:truncate', async () => {
const existing = await deps.store.getThread(input.threadId);
const messages = existing?.messages ?? [];
let lastUserIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.role === 'user') {
lastUserIndex = index;
break;
}
}
const firstDropped = messages[lastUserIndex + 1];
if (firstDropped !== undefined) {
await deps.store.truncateFrom(input.threadId, firstDropped.id);
}
});
}
else {
await hooks.step('persist:user', () => deps.store.appendMessage({
threadId: input.threadId,
role: 'user',
content: input.userText,
runId: hooks.runId,
...(persona !== undefined ? { persona: persona.id } : {}),
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
...(input.sentAt !== undefined ? { sentAt: input.sentAt } : {}),
}));
}
const thread = threadForTurn(await hooks.step('load:thread', () => readThreadForTurn(deps, input, turnMessageLimit(deps.historyWindow)).then((loaded) => loaded === null
? { messages: [], actionApprovalMode: deps.actionApprovalMode ?? 'blocking' }
: { ...loaded, actionApprovalMode: deps.actionApprovalMode ?? 'blocking' })));
// With no window configured this whole block is skipped, so an existing deployment sends the
// messages `load:thread` returned and records byte-identical checkpoints.
let modelMessages = thread.messages;
// The folded summary of what the window left out, appended to the system prompt just before the
// first model call — see {@link liftLeadingSystem}.
let historyBlocks = [];
if (deps.historyWindow !== undefined) {
const window = deps.historyWindow;
modelMessages = (await (hooks.patched?.(HISTORY_SELECT_PATCH) ?? Promise.resolve(true)))
? await applyHistoryWindow(window, deps, input, hooks, thread.messages, hooks.step)
: // A run whose history holds the single `history:window` checkpoint replays against it: a
// completed one returns its recorded messages without re-running anything, and one that has
// yet to reach it runs the whole fold INSIDE that one checkpoint — nested checkpoints would
// take positions the recorded shape never had, and go missing the moment the outer one
// replays from cache.
await hooks.step('history:window', () => applyHistoryWindow(window, deps, input, hooks, thread.messages, (_name, fn) => fn()));
const lifted = liftLeadingSystem(modelMessages);
modelMessages = lifted.messages;
historyBlocks = lifted.system;
}
// Generative-UI telemetry: when each UI call's frames reach the stream (only watched when the
// registry has a UI tool and the store records it). The turn's start is this process's clock: a
// run resumed elsewhere measures from its resume.
const turnStartedAt = Date.now();
const genuiOn = deps.store.recordGenuiCalls !== undefined &&
deps.registry
.allSpecs()
.some((spec) => spec.genui !== undefined && spec.genui.telemetry !== false);
const genuiFrames = genuiOn ? observeGenuiFrames(await hooks.openSink()) : undefined;
const firstText = observeFirstText(genuiFrames?.writer ?? (await hooks.openSink()));
const writer = firstText.writer;
const genuiTurn = new GenuiTurnTelemetry();
let lastText = '';
let steps = 0;
let totalInput = 0;
let totalOutput = 0;
// Run-level cost rollup: sum only the steps whose cost we actually know (provider-reported or
// priced). If NO step had a cost, the run's `cost_usd` stays `null` — never a fabricated `0`.
let totalCost = 0;
let hasCost = false;
let lastModelId;
let structured;
publishAgentRunStarted({
runId: hooks.runId,
threadId: input.threadId,
actorId: input.actor.id,
...(persona !== undefined ? { persona: persona.id } : {}),
});
// What the assistant already believes about this person, folded in ABOVE the retrieved passages
// and the skills catalog — most durable first, so a reader meets the standing facts before the
// passages this one question pulled.
//
// One checkpoint, holding the WHOLE digest: the scopes the resolver returned and the entries that
// survived selection, precedence and the ceiling. That payload is what the block is rendered from
// AND what a later `remember` call is authorized against — so which memories entered this prompt,
// and which scopes this turn could write, are facts the journal holds rather than answers a
// replaying process's provider would give afresh. Where the host runs an index, the SEARCH happens
// in here too: a ranking is the most re-derivable decision in this library, so a replay has to
// read its result back rather than ask an index that has since moved.
//
// THE QUERY IS THE USER'S OWN MESSAGE, and nothing else. It is the only thing available before the
// first model call — which is where memory has to be, since a memory is worthless unless it is in
// front of the model on the turn nobody thought to look — and it is already a journaled input to
// this run, so it needs no determinism machinery of its own. What it fails at is a turn with no
// topic ("and the other thing?"): `pinned` is the answer to those, not a cleverer query.
let memoryDigest;
if (deps.memory !== undefined) {
const config = deps.memory;
memoryDigest = await hooks.step('memory:digest', () => offerMemories({ config, ctx: skillContext(input), query: input.userText }));
// No entries, no block: an actor the assistant has concluded nothing about pays nothing, rather
// than reading a heading over an empty list and inferring something from its presence.
const digest = memoryDigest;
if (digest.entries.length > 0) {
system = `${system}\n\n${buildMemoryBlock({
entries: digest.entries,
writable: memoryIsWritable(deps),
partial: digest.omitted > 0 || digest.recalled === true,
})}`;
}
}
// Inject-mode RAG: retrieve once for the user message and fold the passages into the system prompt.
// Wrapped in `hooks.step` so a durable replay reuses the SAME passages (no re-query, deterministic
// prompt). Recorded below as a synthetic auto-executed `retrieve` tool call on the first assistant
// message, so citations surface through the same machinery as an agentic search would.
let injectedPassages;
if (deps.retriever !== undefined) {
const retriever = deps.retriever;
const topK = deps.retrievalTopK ?? 5;
// Span the retrieval INSIDE the step body so durable replay (which returns the cached passages)
// never re-emits it. traceId = runId correlates it into the turn's waterfall.
//
// The filter is derived HERE, inside the step body, not before `hooks.step` is called: durable
// replay must reuse the cached passages rather than recomputing a filter that might have
// changed since. `retrievalFilter` is called synchronously — if it throws, that throw
// propagates out of the step (the `async` wrapper below turns a synchronous throw into a
// rejection) and fails the turn. It must NEVER be caught here and treated as "no filter": a
// filter that fails open is worse than no filter, because the operator believes it is on.
// With no hook configured, `filter` is omitted from `options` entirely (not set to
// `undefined`) so existing single-tenant deployments send byte-identical options.
const retrieved = await hooks.step('retrieve', async () => {
const options = {
topK,
...(deps.retrievalFilter !== undefined
? { filter: deps.retrievalFilter(input.actor) }
: {}),
};
return spannedAgent('retrieval', hooks.runId, { runId: hooks.runId, queryLength: input.userText.length, topK },
// `retrieveWithUsage` quando o retriever sabe contar; senão o `retrieve` de sempre,
// embrulhado no mesmo formato. É assim que um retriever de terceiro, escrito antes desta
// capacidade, continua funcionando sem mudar uma linha.
async () => typeof retriever.retrieveWithUsage === 'function'
? retriever.retrieveWithUsage(input.userText, options)
: { passages: await retriever.retrieve(input.userText, options) }, (result) => ({ count: result.passages.length }));
});
const passages = retrieved.passages;
// O embedding da consulta é gasto, e vai para o ledger como qualquer outro. Dentro de um
// `hooks.step` porque uma retomada durable NÃO pode cobrar duas vezes pelo mesmo embedding.
//
// O `modelId` vem do que o provider REPORTOU; sem ele não há como precificar, e gravar um
// placeholder faria a linha parecer precificável e sair como $0.00 — pior do que a ausência.
if (retrieved.usage !== undefined && retrieved.usage.inputTokens > 0) {
const embeddingUsage = retrieved.usage;
await hooks.step('persist:usage:embedding', () => deps.store.recordUsage({
threadId: input.threadId,
actorRef: input.actor.id,
runId: hooks.runId,
modelId: embeddingUsage.modelId ?? 'unknown',
purpose: 'embedding',
// Embedding só tem lado de entrada. `outputTokens: 0` é o fato, não um placeholder.
usage: { inputTokens: embeddingUsage.inputTokens, outputTokens: 0 },
}));
}
if (passages.length > 0) {
injectedPassages = passages;
system = `${system}\n\n${buildContextBlock(passages)}`;
}
publishAgentRetrieved({
runId: hooks.runId,
queryLength: input.userText.length,
count: passages.length,
});
}
// The skills catalog, LAST of the agent's own blocks in the system prompt (base prompt, persona,
// retrieved context, this) — so a reader of the assembled prompt meets the agent's own
// instructions before the menu of ones it could go and fetch.
//
// One checkpoint, holding the WHOLE offer: the scopes the resolver returned and the entries that
// survived precedence. That payload is what the block is rendered from and what a later `skill`
// call is served against, so the two things a turn's prompt depends on — which scopes applied, and
// which skills they yielded — are facts the journal holds rather than answers a replaying
// process's provider would give afresh.
let skillOffer;
if (deps.skills !== undefined) {
const config = deps.skills;
skillOffer = await hooks.step('skills:catalog', () => offerSkills(config, skillContext(input)));
// No entries, no block: an actor whose scopes yield nothing pays nothing, rather than reading a
// heading over an empty list and wondering what it was for.
if (skillOffer.entries.length > 0) {
system = `${system}\n\n${buildSkillsBlock(skillOffer.entries)}`;
}
}
// Fetched ONCE per run (not per step) and reused for every step's cost estimate below. Returned as
// a plain array (not the Map built from it) so a durable runner can JSON-cache the step's result.
let prices = [];
if (deps.pricingStore !== undefined) {
const pricingStore = deps.pricingStore;
prices = await hooks.step('pricing:list', () => pricingStore.listCurrentPrices());
}
const priceByModel = new Map(prices.map((price) => [price.modelId, price]));
// The configured intake, LAST before the first model call: everything the run records about itself
// (the run row, retrieval, prices) is settled before the turn parks on a person, who may take a
// day. The whole block is reachable only through `deps.intake`.
if (deps.intake !== undefined) {
const asked = await runIntake({
intake: deps.intake,
deps,
input,
hooks,
writer,
// Whether this thread had already been answered when the turn began — taken off
// `load:thread`'s CACHED result so a replay decides the intake the way the first attempt did.
threadHasAssistant: thread.hasAssistantMessage,
});
if (asked !== null) {
modelMessages.push(asked);
}
}
// The history summary, LAST in the system block: it stands in for the oldest part of the
// conversation, so it reads right before the messages that continue it — and, being the part that
// changes as the thread grows, it sits after every stable block a provider's prompt cache keys on.
for (const block of historyBlocks) {
if (block !== '')
system = `${system}\n\n${block}`;
}
// NOTE: no try/finally around this loop. A durable runner suspends by THROWING through the stack
// at `awaitApproval` (ctx.waitForSignal); a finally would then call writer.end() on every suspend
// and prematurely close the live stream. We only end on normal completion — the throw propagates
// to the engine, and the resumed replay reaches the writer.end() below.
for (let i = 0; i < maxSteps; i += 1) {
// Between steps, and therefore before the next model call: the cheapest point to stop at.
await haltIfCancelled(hooks);
const tools = withBuiltInTools({
tools: await deps.registry.definitionsFor(input.actor, deps.rolesPolicy, intersectAllow(persona?.allowedTools, deps.toolAllowList), {
threadId: input.threadId,
...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}),
channel: turnChannel(input.pageContext),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
}),
ask: deps.ask,
// Nothing to load (no skill for this actor — e.g. a personal agent), nothing to offer.
skills: skillOffer !== undefined && skillOffer.entries.length > 0,
// Not to an actor with no memory scope (a personal agent: `personalAgentScopes`) — it could
// write nowhere, and offering it makes the model promise to remember.
memory: memoryDigest !== undefined && memoryDigest.scopes.length > 0 && memoryIsWritable(deps),
});
// Every step, not once per run: the transcript grows between steps, so a processor that only saw
// the opening prompt would wave through whatever a tool result carried back. The result is a
// per-step DERIVED prompt — `modelMessages` stays the loop's canonical transcript, so a redaction
// never becomes the thread's own memory of what was said.
let prompt = { system, messages: modelMessages };
if (inputProcessors.length > 0) {
prompt = await hooks.step(`process:input:${i}`, () => runInputProcessors({ processors: inputProcessors, prompt, ctx: processorContext(i) }));
}
// Span the model call INSIDE the step body (replay-safe). The turn's raw text/output never rides
// the span — only token counts + name/length metadata (the point events' redaction posture).
// Stamped from THIS process's registry, which is the one that built `tools` for the call above —
// and stamped INSIDE the checkpoint, so the kinds are journaled with the calls they describe
// rather than re-derived by whatever process replays this turn.
let turn = await hooks.step(`llm:${i}`, async () => stampToolKinds(await spannedAgent('llm.turn', hooks.runId, { runId: hooks.runId, step: i }, async () => {
const incremental = gateMode === 'incremental'
? createIncrementalGate({
processors: outputProcessors,
ctx: processorContext(i),
lookbackChars: gateLookback,
writer,
})
: undefined;
const buffer = gateMode === 'whole' ? createFrameBuffer() : undefined;
// Written from inside this checkpoint, like every frame the model streams, so a replay
// that returns the journaled turn never opens the step twice.
await writer.write({ t: 'event', event: { kind: 'step-start' } });
const announced = announcedToolCalls(incremental?.writer ?? buffer?.writer ?? writer);
// A tool that previews its input (genui's streaming tree) gets partial `ui` frames
// while the model writes its arguments. Added BELOW the observer: a preview is shown,
// never part of what the turn persists.
const previews = previewToolInputs(announced.writer, async (name, toolCallId) => tools.some((definition) => definition.name === name)
? deps.registry.previewInput(name, {
actor: input.actor,
threadId: input.threadId,
toolCallId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(input.uiCapabilities !== undefined
? { uiCapabilities: input.uiCapabilities }
: {}),
channel: turnChannel(input.pageContext),
})
: undefined);
// Reasoning and pushed UI are read off the frames INSIDE this checkpoint, so they are
// journaled with the turn and a replay persists the same thinking instead of none.
const frames = observeTurnFrames(previews.writer);
const result = withTurnFrames(await abortableModelCall(hooks, (signal) => deps.model.runTurn({
system: prompt.system,
messages: prompt.messages,
tools,
sink: frames.writer,
...signal,
})), frames.summary());
// A provider that streams no tool frames still gets its calls announced — an outcome
// for a call the client never saw announced drops the whole message there.
await announced.announceRest(result.toolCalls, tools);
// Journaled with the turn, so whichever process settles the calls withdraws the same
// previews the stream showed.
const shownPreviews = previews.shown();
const previewed = shownPreviews.length > 0 ? { previews: shownPreviews } : {};
if (incremental !== undefined) {
await incremental.settled();
const refusal = incremental.rejection();
return {
...result,
...previewed,
releasedText: incremental.released(),
...(refusal !== undefined ? { gateRejection: refusal } : {}),
};
}
return buffer === undefined
? { ...result, ...previewed }
: { ...result, ...previewed, bufferedFrames: buffer.frames() };
}, (result) => ({
...(result.modelId !== undefined ? { modelId: result.modelId } : {}),
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
textLength: result.text.length,
toolCalls: result.toolCalls.length,
})), { ...deps, actionApprovalMode: thread.actionApprovalMode ?? 'blocking' }, input, hooks));
// The gate runs before ANYTHING downstream: before the held frames reach the subscriber, before
// the assistant message is persisted, before this text becomes the next step's context.
let rejection;
if (gateMode !== 'off') {
const gate = await hooks.step(`process:output:${i}`, async () => {
// Which release this turn still owes is read from the JOURNALED model result, never from the
// chain configured on whichever process is running now: `releasedText` is the record that a
// prefix already reached the reader, so a run that resumes under a re-declared chain cannot
// flush the same answer a second time.
const released = turn.releasedText;
// A refusal an incremental gate already reached. Re-running the chain would bill the same
// verdict twice.
if (turn.gateRejection !== undefined) {
return { text: turn.text, rejection: turn.gateRejection };
}
const settled = await runOutputProcessors({
processors: outputProcessors,
answer: { text: turn.text, toolCalls: turn.toolCalls },
ctx: processorContext(i),
});
// Releasing inside the same checkpoint as the verdict is what makes "refused" and "nothing
// was streamed" one fact rather than two that a suspend could separate. For an incremental
// turn the second half of that is weaker by construction — a prefix is already out — and the
// whole-answer pass stays authoritative for both the stream and the store.
if (settled.rejection === undefined) {
if (released === undefined) {
for (const frame of releaseGatedFrames({
frames: turn.bufferedFrames ?? [],
text: settled.text,
})) {
await writer.write(frame);
}
}
else {
const tail = gateTail({ processors: outputProcessors, released, text: settled.text });
if (tail.length > 0) {
await writer.write({ t: 'text', v: tail });
}
}
}
return settled;
});
rejection = gate.rejection;
turn = { ...turn, text: gate.text };
}
// provider-reported model wins over the configured fallback, so cost can't misattribute
const resolvedModelId = turn.modelId ?? deps.modelId ?? 'unknown';
lastModelId = resolvedModelId;
// Provider-reported spend wins; else an estimate from the (once-per-run cached) price list; else
// `null` — stamped onto the persisted assistant message's `usage.costUsd` below.
// `resolveModelPrice` e não `priceByModel.get`: o provider responde com o snapshot datado
// (`gpt-4o-mini-2024-07-18`) e o operador precifica o alias (`gpt-4o-mini`). Ver o helper.
const costUsd = resolveCostUsd(turn.usage, turn.costUsd, resolveModelPrice(priceByModel, resolvedModelId));
await hooks.step(`persist:usage:${i}`, () => deps.store.recordUsage({
threadId: input.threadId,
actorRef: input.actor.id,
runId: hooks.runId,
modelId: resolvedModelId,
purpose: 'chat',
usage: turn.usage,
// The provider's cost when reported, else the estimate (marked `costSource: 'estimate'`), so
// the ledger, the quota's USD windows, the run and the message all carry the same figure.
...usageCost(turn.usage, turn.costUsd, resolveModelPrice(priceByModel, resolvedModelId)),
}));
if (costUsd !== null) {
totalCost += costUsd;
hasCost = true;
}
if (deps.quota !== undefined) {
const quota = deps.quota;
await hooks.step(`quota:bump:${i}`, () => quota.bump(input.actor.id, deps.day, turn.usage.inputTokens + turn.usage.outputTokens));
}
// Thrown only AFTER the two accounting checkpoints above: those tokens were genuinely spent, and
// a refusal that also hid its own cost would let a mis-tuned gate burn a budget invisibly.
if (rejection !== undefined) {
throw new OutputRejectedError(rejection.processor, rejection.reason);
}
steps += 1;
totalInput += turn.usage.inputTokens;
totalOutput += turn.usage.outputTokens;
lastText = turn.text;
// Restating the GATED text, never the model's raw reply: the formatting pass is a translation of
// the answer that survived the output chain, not a second route out of the model.
if (turn.toolCalls.length === 0 && deps.outputSchema !== undefined) {
structured = await structureAnswer({
schema: deps.outputSchema,
deps,
input,
hooks,
messages: restatementPrompt({
messages: prompt.messages,
answer: turn.text,
fromTranscript: deps.outputFromTranscript === true,
}),
step: i,
priceByModel,
ctx: processorContext(i),
});
}
publishAgentMessage({
runId: hooks.runId,
threadId: input.threadId,
role: 'assistant',
textLength: turn.text.length,
});
// Inject-mode retrieval and the structured answer both reach a reader as ordinary tool calls.
// Their ids come off the RUN rather than the message they hang from, because the message does
// not exist yet and both have to be on the append below: a reader pairs a call with its result
// off the message, so a call added afterwards renders as a tool still running. Every value here
// is already settled by a checkpoint this turn ran (`retrieve`, `structured:<step>:<n>`), so a
// replay rebuilds the same pair rather than minting one.
const synthetic = [];
if (i === 0 && injectedPassages !== undefined) {
const call = {
id: `retrieve-${hooks.runId}`,
name: 'retrieve',
input: { query: input.userText },
};
const output = { passages: injectedPassages };
synthetic.push({
step: 'persist:retrieval',
call,
result: { id: call.id, name: call.name, output },
});
}
if (structured !== undefined) {
const call = {
id: `structured-${hooks.runId}`,
name: 'structured_output',
input: {},
};
synthetic.push({
step: 'persist:structured',
call,
result: { id: call.id, name: call.name, output: structured },
});
}
const messageCalls = [...turn.toolCalls, ...synthetic.map((entry) => entry.call)];
const syntheticResults = synthetic.map((entry) => entry.result);
// A Stop that landed while the model was answering: nothing of this step is persisted, so the
// thread does not gain an answer after whatever the stop made room for.
await haltIfCancelled(hooks);
const finishFrame = {
t: 'event',
event: {
kind: 'step-finish',
usage: turn.usage,
costUsd,
...(turn.reasoningMs !== undefined ? { reasoningMs: turn.reasoningMs } : {}),
...(turn.modelId !== undefined ? { model: turn.modelId } : {}),
},
};
// A step that ended with no text and nothing else on it (no call, no pushed UI, no reasoning)
// is not written: the row would say nothing to a reader, and replayed on the next turn its empty
// content gets the WHOLE request refused by Anthropic and Bedrock. Such a step has no tool calls,
// so it is the final one. The checkpoint still runs (holding `null`) and still ends the step.
const blankStep = turn.text.trim().length === 0 &&
messageCalls.length === 0 &&
(turn.ui === undefined || turn.ui.length === 0) &&
turn.reasoning === undefined;
const assistant = await hooks.step(`persist:assistant:${i}`, async () => {
if (blankStep) {
await writer.write(finishFrame);
return null;
}
const appended = await deps.store.appendMessage({
threadId: input.threadId,
role: 'assistant',
content: turn.text,
runId: hooks.runId,
usage: { ...turn.usage, costUsd },
...(persona !== undefined ? { persona: persona.id } : {}),
...(turn.reasoning !== undefined ? { reasoning: turn.reasoning } : {}),
...(turn.reasoningMs !== undefined ? { reasoningMs: turn.reasoningMs } : {}),
...(turn.ui !== undefined && turn.ui.length > 0 ? { ui: turn.ui } : {}),
...(messageCalls.length > 0 ? { toolCalls: messageCalls } : {}),
...(syntheticResults.length > 0 ? { toolResults: syntheticResults } : {}),
});
// The synthetic calls go out as the same pair of frames an executed read writes, so a live
// reader draws them with the tool-call machinery it already has. Streamed from inside this
// checkpoint — which spends no new position — so a replay never re-sends them.
for (const entry of synthetic) {
await writer.write({
t: 'event',
event: {
kind: 'tool-input-available',
id: entry.call.id,
name: entry.call.name,
input: entry.call.input,
toolKind: 'read',
},
});
await writer.write({
t: 'event',
event: { kind: 'tool-output', id: entry.call.id, output: entry.result.output },
});
}
// A turn that called no tools ends its step here; one that did ends it once the tools
// settle (`persist:toolresults`), so the outputs land inside the step that asked for them.
if (turn.toolCalls.length === 0) {
await writer.write(finishFrame);
}
return appended;
});
if (assistant === null) {
break;
}
modelMessages.push({
role: 'assistant',
content: turn.text,
...(messageCalls.length > 0 ? { toolCalls: messageCalls } : {}),
...(syntheticResults.length > 0 ? { toolResults: syntheticResults } : {}),
});
for (const entry of synthetic) {
const { call, result } = entry;
await hooks.step(`${entry.step}:${assistant.id}`, async () => {
await deps.store.recordToolCall({
toolCallId: call.id,
messageId: assistant.id,
toolName: call.name,
toolType: 'read',
input: call.input,
status: 'auto_executed',
runId: hooks.runId,
});
await deps.store.updateToolCall({
toolCallId: call.id,
runId: hooks.runId,
status: 'executed',
output: result.output,
});
});
}
if (turn.toolCalls.length === 0) {
break;
}
const results = [];
const turnCalls = {
deps,
input,
hooks,
messageId: assistant.id,
writer,
toolUi: new Map(),
toolText: new Map(),
halt: { terminal: false },
actionApprovalMode: thread.actionApprovalMode ?? 'blocking',
...(skillOffer !== undefined ? { skills: skillOffer } : {}),
...(memoryDigest !== undefined ? { memory: memoryDigest } : {}),
};
// A model routinely asks for several tools at once, and running them back to back makes the
// turn cost their sum. Overlapping them is safe here because a checkpoint position is handed
// out on the CALL, not when the work settles: launching every invocation in one tick — what
// `hooks.parallel` promises — pins the `tool:` block in call order however the tools then
// finish. The claim and persist checkpoints stay sequential around it.
//
// Only a turn whose every call is a plain `read` qualifies:
// - an `action` suspends on an approval signal, which is human time rather than I/O, so
// overlapping what follows it buys nothing — and reserving an invocation position for a
// call that may yet be REJECTED spends a position the rejected branch never fills.
// - an `agent` delegation is `ctx.child`, whose parallel form is the runtime's own `ctx.all`:
// one workflow ref over a reserved block, carrying the `parallelGroup` bookkeeping that
// makes a fan render as a fan. That is not N independent task closures, so it cannot ride
// this hook.
// The kinds come from the `persist:toolcall` checkpoints, never from a local registry lookup,
// so every process replaying this turn reaches the same verdict.
const parallel = hooks.parallel;
if (parallel !== undefined &&
turn.toolCalls.length > 1 &&
(await (hooks.patched?.(PARALLEL_TOOLS_PATCH) ?? Promise.resolve(true)))) {
// Claiming the whole turn first is what makes the kinds knowable before the first execution —
// and it is also what moves the checkpoints, hence the `patched` gate above.
const claimed = [];
for (const call of turn.toolCalls) {
claimed.push(await claimToolCall(turnCalls, call));
}
// A `skill` load and a `remember` write qualify alongside a `read`: each takes its position on
// the call exactly as a read does, and each spends a read's `tool:`/`persist:` names. What
// disqualifies the other kinds is not that they have effects — it is that an `action` suspends
// on human time and an `agent` delegation is the runtime's own child-workflow fan.
if (claimed.every((entry) => entry.toolType === 'read' || entry.toolType === 'skill' || entry.toolType === 'memory')) {
results.push(...(await invokeClaimedToolsTogether(turnCalls, parallel, claimed)));
}
else {
for (const entry of claimed) {
results.push(await runClaimedToolCall(turnCalls, entry));
}
}
}
else {
for (const call of turn.toolCalls) {
results.push(await runClaimedToolCall(turnCalls, await claimToolCall(turnCalls, call)));
}
}
// The outputs land on the assistant message that made the calls. A reader reopening the thread
// pairs a call with its result off THAT message, so results that only reached the tool-call
// table leave every tool in the turn rendering as one still running.
//
// A position of its own, hence the marker: a run that suspended mid-turn under the shape
// without it has no room for a checkpoint between the last tool's persist and the next `llm:`.
// Every value written here comes from a checkpoint above, so a replay writes the same list.
// Worked out OUTSIDE the checkpoint below (the attempt count carries across steps, and a replay
// must count the same), recorded inside it (a replay does not record twice).
const genuiRows = genuiFrames === undefined
? []
: genuiTurn.rows({
runId: hooks.runId,
threadId: input.threadId,
actorRef: input.actor.id,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
step: i,
channel: turnChannel(input.pageContext),
modelId: resolvedModelId,
startedAt: turnStartedAt,
usage: turn.usage,
costUsd,
...(costUsd !== null
? {
costSource: usageCost(turn.usage, turn.costUsd, resolveModelPrice(priceByModel, resolvedModelId)).costSource,
}
: {}),
text: turn.text,
calls: turn.toolCalls,
results,
markerOf: (name) => deps.registry.spec(name)?.genui,
frames: genuiFrames.times,
now: Date.now(),
});
if (await (hooks.patched?.(MESSAGE_TOOL_RESULTS_PATCH) ?? Promise.resolve(true))) {
await hooks.step(`persist:toolresults:${i}`, async () => {
await deps.store.setMessageToolResults(assistant.id, [...results, ...syntheticResults]);
// What the tools pushed joins what the model turn streamed, on the same message, in call
// order. Every list here was read off a checkpoint above, so a replay writes the same value.
const fallbackText = escapeUnsafeToolUiText(turn.toolCalls
.map((call) => turnCalls.toolText.get(call.id))
.filter(Boolean)
.join('\n'));
if (fallbackText)
await deps.store.appendMessage({
threadId: input.threadId,
role: 'assistant',
content: fallbackText,
});
const toolUi = turn.toolCalls.flatMap((call) => turnCalls.toolUi.get(call.id) ?? []);
if (toolUi.length > 0) {
await deps.store.setMessageUi?.(assistant.id, mergeUi(turn.ui, toolUi));
}
// A preview of a call's input that the call's own push did not replace (it failed, was
// refused, or degraded to text) is withdrawn before its outcome: a live client never keeps
// a half-written layout the message will not have.
for (const preview of turn.previews ?? []) {
const pushed = turnCalls.toolUi.get(preview.toolCallId) ?? [];
if (!pushed.some((component) => component.id === preview.id)) {
await writer.write(withdrawPreviewFrame(preview));
}
}
// Every value here comes from a checkpoint above, so a replay would write the same frames —
// and, being inside this one, it never writes them at all.
for (const result of results) {
await writer.write({ t: 'event', event: outcomeFrame(result) });
}
await writer.write(finishFrame);
if (genuiRows.length > 0) {
try {
await deps.store.recordGenuiCalls?.(genuiRows);
}
catch {
// Telemetry is best-effort: it never fails the turn.
}
}
});
}
modelMessages.push({ role: 'user', content: '', toolResults: results });
const toolText = escapeUnsafeToolUiText(turn.toolCalls
.map((call) => turnCalls.toolText.get(call.id))
.filter(Boolean)
.join('\n'));
if (toolText) {
modelMessages.push({ role: 'assistant', content: toolText });
lastText += `\n${toolText}`;
}
// A `terminal` tool succeeded: its effect is the answer, so no model call narrates it. Read off
// the calls' `persist:toolcall` checkpoints and their outcomes, so a replay ends here too.
if (turnCalls.halt.terminal || turnCalls.halt.independentProposal) {
break;
}
}
if (thread.title === '' || thread.title === 'New chat') {
await hooks.step('persist:title', async () => {
const title = deriveTitle(input.userText);
await deps.store.setTitle(input.threadId, title);
await writer.write({ t: 'event', event: { kind: 'title', title } });
});
}
const delivery = input.deliverTo;
if (delivery !== undefined) {
// A detached run's answer has nowhere to go but a message of its own: the turn that delegated it
// ended without it, so there is no tool result left to fill and no live stream to write into.
// Stamped with THIS run and agent, which is how a reader tells "the research agent finished" from
// the assistant's next reply.
//
// The position exists only for a run carrying a delivery address, and only a detached
// delegation's own child run carries one — so every other run's sequence is untouched, and no
// run journaled before detached delegation existed can reach it.
await hooks.step('deliver:detached', async () => {
if ((await deps.store.getThread(delivery.threadId)) === null) {
// The conversation was deleted while this ran. Delivering into it would resurrect nothing a
// reader kept, and the tool-call row it would settle went with it.
return;
}
const agent = input.agentName ?? 'default';
// A run that ended on a blank step has no answer to deliver, and an empty assistant message
// would poison the delegating thread's next turn. The tool call is still settled below.
if (lastText.trim().length > 0) {
await deps.store.appendMessage({
threadId: delivery.threadId,
role: 'assistant',
content: lastText,
agentName: agent,
runId: hooks.runId,
});
}
await deps.store.updateToolCall({
toolCallId: delivery.toolCallId,
// The call is a row of the run that delegated, not of this one.
...(input.parentRunId !== undefined ? { runId: input.parentRunId } : {}),
status: 'executed',
output: detachedDelivered({ agent, runId: hooks.runId, text: lastText }),
});
});
}
// Every text frame of the turn has gone out by now, so its time to first token is known.
const firstTextAt = firstText.firstTextAt();
// Settle the run `completed` with its rollup. Normal completion only — the loop never records a
// failure (it doesn't catch its own crash; that's the runner's job). A checkpointed step so a
// resumed durable replay settles the ONE row exactly once, and `recordRunEnd` is first-terminal so
// this can never overwrite a `failed`/`cancelled` a concurrent cancel already wrote.
await hooks.step('persist:run:end', () => deps.store.recordRunEnd({
runId: hooks.runId,
status: 'completed',
finishedAt: Date.now(),
stepCount: steps,
inputTokens: totalInput,
outputTokens: totalOutput,
...(hasCost ? { costUsd: totalCost } : {}),
...(firstTextAt !== undefined ? { ttftMs: firstTextAt - turnStartedAt } : {}),
...(lastModelId !== undefined ? { modelId: lastModelId } : {}),
}));
await writer.end();
publishAgentRunFinished({
runId: hooks.runId,
threadId: input.threadId,
steps,
inputTokens: totalInput,
outputTokens: totalOutput,
durationMs: Date.now() - turnStartedAt,
...(firstTextAt !== undefined ? { ttftMs: firstTextAt - turnStartedAt } : {}),
...(hasCost ? { costUsd: totalCost } : {}),
...(lastModelId !== undefined ? { modelId: lastModelId } : {}),
...runContext(input),
});
return { text: lastText, ...(structured !== undefined ? { object: structured } : {}) };
}
//# sourceMappingURL=agent-loop.js.map