UNPKG

@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
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