UNPKG

@adonis-agora/agent

Version:

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

1,906 lines • 93 kB
import { createHash } from 'node:crypto';
import { DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY, parseTextActionProposalCommand, } from '../action-proposal-text.js';
import { AgentService, AttachmentRefusedError } from '../agent-service.js';
import { onAgentEngine, registeredAgentEngine, registeredAgentStepServer, } from '../durable/agent-run-context.js';
import { uiActionText } from '../genui/actions.js';
import { channelButtonAction, renderChannelMessages, resolveGenuiChannel, } from '../genui/channels.js';
import { AgentGenui } from '../genui/factory.js';
import { DEFAULT_CONFIRM_VERB } from '../tool-presentation.js';
import { durableExecutor, inlineExecutor, inlineIo, isControlFlowSignal, jobId, registerChannelWorkflows, withRetries, } from './executor.js';
import { ChannelMediaTooLargeError } from './http.js';
import { splitTables, toChannelMarkdown } from './markdown.js';
import { formatChannelQuestion, parseChannelAnswer } from './questions.js';
import { splitMessage } from './split.js';
import { InMemoryChannelStore, lucidChannelStore } from './store.js';
import { channelTextsFor, DEFAULT_CHANNEL_TEXTS, mergeTexts, } from './texts.js';
const BUTTON_ID = /^agora:(approve|reject):([^\s]+)$/;
/** A component's button (or list entry): `ui:<ref>`, short enough for Telegram's 64-byte callback data. */
const UI_BUTTON_ID = /^ui:([A-Za-z0-9_-]{6,32})$/;
/** How long a component's buttons stay pressable, and how many per conversation are remembered by label. */
const UI_BUTTON_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_UI_BUTTONS = 40;
/** The tail of a proposal id a button carries — Telegram's `callback_data` holds 64 bytes. */
const BUTTON_REF_LENGTH = 32;
const OUTCOME_POLL_MS = 500;
/** How often a durable job reading a turn looks for a part of it still waiting for a worker. */
const DRIVE_POLL_MS = 500;
/** How long "this outcome was relayed" is remembered. */
const OUTCOME_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const buttonRef = (proposalId) => proposalId.slice(-BUTTON_REF_LENGTH);
/** How long the proposal cards sent to a conversation are remembered, and how many. */
const CARDS_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_CARDS = 20;
const foldLabel = (label) => label.trim().toLowerCase();
/**
 * The approve button's label on a proposal card: the tool's own `confirm.verb` ("Refund"), else the
 * channel's localized `texts.approve` — the library's generic English verb never reaches a person
 * on a channel that words its own.
 */
const approveLabel = (proposal, texts) => {
    const verb = proposal.confirmation?.verb?.trim();
    return verb === undefined || verb === '' || verb === DEFAULT_CONFIRM_VERB ? texts.approve : verb;
};
/** The button ids for a proposal: what {@link ChannelRouteHandler} maps back to its decision. */
export function proposalButtonIds(proposalId) {
    const ref = buttonRef(proposalId);
    return { approve: `agora:approve:${ref}`, reject: `agora:reject:${ref}` };
}
function requestOf(ctx) {
    return {
        method: ctx.request.method().toUpperCase(),
        url: ctx.request.url(true),
        header: (name) => ctx.request.header(name),
        params: (ctx.params ?? {}),
        body: ctx.request.body(),
        rawBody: ctx.request.raw(),
    };
}
/** `AgentService` from the app's container, for code that runs outside a request. */
async function containerService() {
    const { default: app } = await import('@adonisjs/core/services/app');
    return (await app.container.make(AgentService));
}
const registry = new Map();
/** The job runner of the channel registered under `channel` in this process. */
const runnerFor = (channel) => registry.get(channel)?.runner;
let following = false;
/** Register the channel workflows on the agent's durable engine, now or once it is wired. */
function followAgentEngine() {
    if (following)
        return;
    following = true;
    onAgentEngine((engine) => registerChannelWorkflows(engine, runnerFor, registeredAgentStepServer()));
}
function record(value) {
    return value !== null && typeof value === 'object' && !Array.isArray(value)
        ? value
        : undefined;
}
/** `image/jpeg` → `jpeg`, for a file that arrives without a name. */
const extensionOf = (contentType) => (contentType.split('/')[1] ?? 'bin').split(/[;+]/)[0] ?? 'bin';
/** Where a natively drawn component sits in a turn's text, until it is sent. */
const NATIVE_MARK = '\u0000agora-native\u0000';
const replyList = (value) => value === null || value === undefined ? [] : Array.isArray(value) ? value : [value];
/** The component a frame carries (a live one, or a replayed `ui` event), else `null`. */
function componentOf(frame, position) {
    // A preview of a layout the model is still writing: a channel gets the final component (or its
    // fallback text) and nothing before it.
    if ((frame.t === 'component' && frame.partial === true) ||
        (frame.t === 'event' && frame.event.kind === 'ui' && frame.event.partial === true)) {
        return null;
    }
    if (frame.t === 'component') {
        return {
            id: frame.id ?? `ui:${position}`,
            name: frame.name,
            data: frame.data,
            version: frame.version ?? 1,
            ...(frame.componentVersions !== undefined
                ? { componentVersions: frame.componentVersions }
                : {}),
            ...(frame.fallbackText !== undefined ? { fallbackText: frame.fallbackText } : {}),
        };
    }
    if (frame.t === 'event' && frame.event.kind === 'ui') {
        const { event } = frame;
        return {
            id: event.id,
            name: event.component,
            data: event.props,
            version: event.version ?? 1,
            ...(event.componentVersions !== undefined
                ? { componentVersions: event.componentVersions }
                : {}),
            ...(event.fallbackText !== undefined ? { fallbackText: event.fallbackText } : {}),
        };
    }
    return null;
}
/**
 * Keep executing, in this process, what of `runId` waits for a worker (`service.drive`), until
 * stopped. A durable channel job runs inside a worker's tick, and the tick ends only when the job
 * does: a turn the job started with a dispatcher that only persists it — or a delegate that turn
 * started — would wait for the next tick, and the job for the turn, until the read times out.
 */
function keepDriving(drive, runId) {
    let stopped = false;
    let timer;
    const pass = async () => {
        await drive(runId).catch(() => { });
        if (stopped)
            return;
        timer = setTimeout(() => void pass(), DRIVE_POLL_MS);
        timer.unref?.();
    };
    void pass();
    return () => {
        stopped = true;
        clearTimeout(timer);
    };
}
/**
 * The webhook route for one channel. Each request is verified (`401` when it is not the provider's),
 * parsed, deduplicated by the provider's message id, taken (persisted, with a durable engine) and
 * acknowledged with `200`; the message is handled after the response — one at a time per
 * conversation, each phase retried — so a slow model never makes the provider retry. Then:
 *
 * - an unknown sender gets `unknownSender`; a known one goes through `beforeTurn`;
 * - an answer to a question the turn asked resumes it;
 * - a press on a proposal's button decides that proposal (`via` = the adapter's name);
 * - a text decision ("yes", "no #ID", a button label) decides a card delivered to THIS conversation
 *   — never a proposal made elsewhere (`texts.noPendingConfirmation`);
 * - media are downloaded (`prepareMedia`) and attached, or refused with a text;
 * - the message (`transformInbound`) starts a turn with the channel's capabilities;
 * - the reply is converted to the channel's markdown, split at its length limit, and sent — each
 *   message once, even when the work is retried or resumed after a crash;
 * - a proposal the turn left pending is sent with Confirm/Cancel buttons (or a text instruction);
 * - an approved proposal's outcome is relayed once it executed (see `outcomeTimeoutMs`, and
 *   `channels.onSettled` for one that runs later).
 *
 * Wants `actionApprovalMode: 'independent'`: a blocking approval holds the turn open until someone
 * decides it in the app, so the channel sends what it has and `texts.blockingApproval`.
 *
 * Registers the adapter under its `name` for `channels.onSettled` and the durable jobs — names must
 * be unique, and a process that resumes durable channel jobs must create the same handlers at boot.
 */
export function handleChannel(adapter, options) {
    const dedupeTtlMs = options.dedupeTtlMs ?? 24 * 60 * 60 * 1000;
    const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
    const outcomeTimeoutMs = options.outcomeTimeoutMs ?? 60_000;
    const questionTimeoutMs = options.questionTimeoutMs ?? 30 * 60 * 1000;
    const { capabilities } = adapter;
    const buttons = (capabilities.buttons ?? 0) >= 2;
    const name = adapter.name;
    /** What the channel is (`whatsapp`, `telegram`) — the turn's channel, whatever it is named. */
    const kind = adapter.kind ?? adapter.name;
    let resolvedGenui;
    /** The genui setup, and this channel's options in it — `null` when it draws nothing natively. */
    const genuiFor = () => {
        resolvedGenui ??= (async () => {
            if (options.genui === false)
                return null;
            if (options.genui !== undefined)
                return options.genui;
            // A service handed in (a test, another framework): no container to read the app's genui from.
            if (options.service !== undefined)
                return null;
            try {
                const { default: app } = await import('@adonisjs/core/services/app');
                if (!app.container.hasBinding(AgentGenui))
                    return null;
                return (await app.container.make(AgentGenui));
            }
            catch {
                return null;
            }
        })();
        return resolvedGenui.then((genui) => {
            if (genui === null || genui.channels === undefined)
                return null;
            const channel = resolveGenuiChannel(genui.base ?? {}, genui.channels, kind);
            return channel.configured ? { genui, channel } : null;
        });
    };
    let resolvedService;
    const serviceFor = (given) => {
        if (options.service)
            return Promise.resolve(options.service);
        if (given)
            return Promise.resolve(given);
        resolvedService ??= containerService().catch((error) => {
            resolvedService = undefined;
            throw error;
        });
        return resolvedService;
    };
    let resolvedStore;
    /** The configured store, else Lucid over the agent store's database, else memory — chosen once. */
    const storeFor = (service) => {
        resolvedStore ??= (async () => {
            if (options.store)
                return options.store;
            const agent = options.service ?? service ?? (await containerService().catch(() => undefined));
            const db = agent?.lucidDatabase?.() ?? null;
            return db === null ? new InMemoryChannelStore() : lucidChannelStore(db);
        })();
        return resolvedStore;
    };
    /** The texts for one context, over the defaults in the language of the service's vocabulary. */
    const textsFor = async (service, context) => {
        const base = channelTextsFor(service.actionProposalVocabulary?.() ?? null);
        const overrides = typeof options.texts === 'function' ? await options.texts(context) : options.texts;
        return mergeTexts(overrides, base);
    };
    const questionKey = (conversation) => `${name}:question:${conversation}`;
    const cardsKey = (conversation) => `${name}:cards:${conversation}`;
    const answeredKey = (runId, toolCallId) => `${name}:answered:${runId}:${toolCallId}`;
    /**
     * Settle a question once — by its answer, by its timeout, or skipped: `true` when `owner` settles
     * it (now, or in an earlier run of the same phase), `false` when something else did first.
     */
    const settleQuestion = async (store, runId, toolCallId, owner) => {
        const key = answeredKey(runId, toolCallId);
        if (await store.claim(key, dedupeTtlMs)) {
            await store.set(key, owner, dedupeTtlMs);
            return true;
        }
        return (await store.get(key)) === owner;
    };
    /**
     * Sends to one conversation, each message at most once: every message takes a numbered slot
     * under `key` in the store before it goes out, so a phase that runs again (a retry, a crash
     * recovery) skips what already went. A failed send frees its slot, so the retry sends it.
     */
    const outbox = (store, key, conversation, who) => {
        let next = 0;
        const send = async (outbound, kind) => {
            const slot = `${key}:${next++}`;
            if (!(await store.claim(slot, dedupeTtlMs)))
                return;
            try {
                if (options.canDeliver &&
                    !(await options.canDeliver({
                        channel: name,
                        conversation,
                        outbound,
                        kind,
                        actor: who.actor,
                        actorRef: who.actorRef ?? who.actor?.id ?? null,
                        message: who.message,
                        proposal: who.proposal ?? null,
                    })))
                    return;
                await adapter.send(conversation, outbound);
            }
            catch (error) {
                await Promise.resolve(store.delete(slot)).catch(() => { });
                throw error;
            }
        };
        /** Text in the model's markdown: converted, split. */
        const text = async (value, kind) => {
            for (const piece of splitMessage(toChannelMarkdown(value, capabilities.markdown), capabilities.maxLength))
                await send({ text: piece }, kind);
        };
        /** A hook's reply. */
        const reply = async (value, kind) => {
            if (typeof value === 'string')
                return text(value, kind);
            if ('media' in value) {
                const caption = value.caption ?? '';
                if (capabilities.media === true) {
                    const max = capabilities.maxCaptionLength ?? capabilities.maxLength;
                    return send({
                        text: toChannelMarkdown(caption, capabilities.markdown).slice(0, max),
                        media: value.media,
                    }, kind);
                }
                const fallback = value.fallbackText ?? caption;
                if (fallback.trim() !== '')
                    return text(fallback, kind);
                return;
            }
            if (value.raw === true) {
                for (const piece of splitMessage(value.text, capabilities.maxLength))
                    await send({ text: piece }, kind);
                return;
            }
            return text(value.text, kind);
        };
        return { send, text, reply };
    };
    const commandsFor = (service, proposalId) => {
        const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
        const suffix = proposalId !== null ? ` #${proposalId}` : '';
        return {
            approve: `${vocabulary.approve[0] ?? 'yes'}${suffix}`,
            reject: `${vocabulary.reject[0] ?? 'no'}${suffix}`,
        };
    };
    /** Remember a proposal card sent to `conversation` — the only proposals it can decide. */
    const rememberCard = async (store, conversation, proposalId) => {
        const key = cardsKey(conversation);
        const saved = await store.get(key);
        const ref = buttonRef(proposalId);
        const refs = saved === null ? [] : JSON.parse(saved);
        const next = [...refs.filter((known) => known !== ref), ref].slice(-MAX_CARDS);
        await store.set(key, JSON.stringify(next), CARDS_TTL_MS);
    };
    const cardsOf = async (store, conversation) => {
        const saved = await store.get(cardsKey(conversation));
        return new Set(saved === null ? [] : JSON.parse(saved));
    };
    const uiButtonKey = (ref) => `${name}:ui:${ref}`;
    const uiButtonsKey = (conversation) => `${name}:uibuttons:${conversation}`;
    /**
     * Remember a component's buttons — by id for the press, and by label for a provider that forwards
     * only the label (Whatsmiau). Each id is derived from the run, the component and its place, so a
     * phase that runs again names the same buttons.
     */
    const rememberUiButtons = async (store, conversation, seed, message, entries) => {
        const ids = [];
        const index = await store.get(uiButtonsKey(conversation));
        const known = index === null ? [] : JSON.parse(index);
        for (const [position, button] of entries.entries()) {
            const ref = createHash('sha256')
                .update(`${seed}:${position}`)
                .digest('base64url')
                .slice(0, 16);
            const saved = {
                conversation,
                button,
                ...(message.componentId !== undefined ? { componentId: message.componentId } : {}),
                ...(message.title !== undefined ? { title: message.title } : {}),
            };
            await store.set(uiButtonKey(ref), JSON.stringify(saved), UI_BUTTON_TTL_MS);
            ids.push(`ui:${ref}`);
            known.push(ref);
        }
        await store.set(uiButtonsKey(conversation), JSON.stringify([...new Set(known)].slice(-MAX_UI_BUTTONS)), UI_BUTTON_TTL_MS);
        return ids;
    };
    /** The component button a press (or, for a provider without ids, its label) names, if any. */
    const pressedUiButton = async (store, message) => {
        const match = message.buttonId === undefined ? null : UI_BUTTON_ID.exec(message.buttonId);
        if (match !== null) {
            const saved = await store.get(uiButtonKey(match[1] ?? ''));
            const button = saved === null ? null : JSON.parse(saved);
            return button !== null && button.conversation === message.conversation ? button : null;
        }
        if (message.buttonId !== undefined || message.buttonWithoutId !== true)
            return null;
        const index = await store.get(uiButtonsKey(message.conversation));
        const label = foldLabel(message.text);
        for (const ref of (index === null ? [] : JSON.parse(index)).reverse()) {
            const saved = await store.get(uiButtonKey(ref));
            const button = saved === null ? null : JSON.parse(saved);
            if (button !== null && foldLabel(button.button.label) === label)
                return button;
        }
        return null;
    };
    /**
     * One natively drawn message out: an image with its caption, text with reply buttons, a list (as
     * reply buttons when it fits and the channel has no lists, as numbered text when neither does), or
     * text — in the channel's markdown and limits.
     */
    const sendNative = async (store, out, conversation, seed, message) => {
        const markdown = (text) => toChannelMarkdown(text, capabilities.markdown);
        const text = message.text ?? '';
        if (message.image !== undefined) {
            const { image } = message;
            await out.reply({
                media: {
                    kind: 'image',
                    ...(image.url !== undefined ? { url: image.url } : {}),
                    ...(image.data !== undefined ? { data: Buffer.from(image.data) } : {}),
                    contentType: image.contentType ?? 'image/png',
                },
                caption: text,
                fallbackText: text,
            }, 'reply');
        }
        const choices = message.list?.items ?? message.buttons ?? [];
        if (choices.length === 0) {
            if (message.image === undefined && text.trim() !== '')
                await out.text(text, 'reply');
            return;
        }
        const body = message.image === undefined ? text : '';
        const numbered = [
            body,
            ...(message.list?.title !== undefined ? [`*${message.list.title}*`] : []),
            choices
                .map((choice, index) => `${index + 1}. ${choice.label}${choice.description ? ` — ${choice.description}` : ''}`)
                .join('\n'),
        ]
            .filter((part) => part.trim() !== '')
            .join('\n\n');
        const maxButtons = capabilities.buttons ?? 0;
        const asButtons = message.list === undefined || (capabilities.lists ?? 0) === 0;
        if (asButtons && choices.length <= maxButtons) {
            const ids = await rememberUiButtons(store, conversation, seed, message, choices);
            await out.send({
                text: markdown(body.trim() === '' ? (message.title ?? '…') : body).slice(0, capabilities.maxLength),
                buttons: choices.map((choice, index) => ({ id: ids[index] ?? '', label: choice.label })),
                fallbackText: markdown(numbered).slice(0, capabilities.maxLength),
            }, 'reply');
            return;
        }
        const maxRows = capabilities.lists ?? 0;
        if (maxRows > 0) {
            const rows = choices.slice(0, maxRows);
            const ids = await rememberUiButtons(store, conversation, seed, message, rows);
            await out.send({
                text: markdown(body.trim() === '' ? (message.title ?? '…') : body).slice(0, capabilities.maxLength),
                list: {
                    button: message.list?.button ?? 'Options',
                    ...(message.list?.title !== undefined ? { title: message.list.title } : {}),
                    rows: rows.map((row, index) => ({
                        id: ids[index] ?? '',
                        title: row.label,
                        ...(row.description !== undefined ? { description: row.description } : {}),
                    })),
                },
                fallbackText: markdown(numbered).slice(0, capabilities.maxLength),
            }, 'reply');
            return;
        }
        // Neither buttons nor lists: the choices as numbered text.
        await out.text(numbered, 'reply');
    };
    const sendProposal = async (service, store, texts, out, conversation, proposal, withId) => {
        const summary = texts.proposal(proposal);
        const link = proposal.confirmation?.link;
        if (link !== undefined && link !== '') {
            // The action is opening the link: the card, the link in full, what to do — no buttons, no
            // reply commands, no validity (finishing there decides it; a "yes" here decides nothing).
            const card = toChannelMarkdown([
                summary.includes(link) ? summary : `${summary}\n${link}`,
                texts.linkInstruction ?? DEFAULT_CHANNEL_TEXTS.linkInstruction,
                proposal.expiresAt !== undefined
                    ? (texts.linkValidity ?? DEFAULT_CHANNEL_TEXTS.linkValidity)?.(Math.max(1, Math.round((proposal.expiresAt - Date.now()) / 60_000)))
                    : undefined,
            ]
                .filter((part) => part !== undefined && part !== '')
                .join('\n\n'), capabilities.markdown);
            for (const piece of splitMessage(card, capabilities.maxLength))
                await out.send({ text: piece }, 'card');
            return;
        }
        const footer = typeof texts.footer === 'function' ? texts.footer(proposal) : texts.footer;
        const instruction = texts.instruction(commandsFor(service, withId ? proposal.id : null));
        const asText = toChannelMarkdown([summary, instruction, footer]
            .filter((part) => part !== undefined && part !== '')
            .join('\n\n'), capabilities.markdown);
        // Remembered before it goes: a "yes" can only decide a card this conversation was sent.
        await rememberCard(store, conversation, proposal.id);
        if (!buttons) {
            for (const piece of splitMessage(asText, capabilities.maxLength))
                await out.send({ text: piece }, 'card');
            return;
        }
        const ids = proposalButtonIds(proposal.id);
        await out.send({
            text: toChannelMarkdown(summary, capabilities.markdown).slice(0, capabilities.maxLength),
            buttons: [
                { id: ids.approve, label: approveLabel(proposal, texts) },
                { id: ids.reject, label: texts.reject },
            ],
            fallbackText: asText.slice(0, capabilities.maxLength),
            instruction: toChannelMarkdown(instruction, capabilities.markdown),
            ...(footer !== undefined && footer !== ''
                ? { footer: toChannelMarkdown(footer, capabilities.markdown) }
                : {}),
        }, 'card');
    };
    /** What an executed proposal tells the person, in order; `null` when it did not execute. */
    const outcomeReplies = async (proposal, texts) => {
        const status = proposal.execution?.status;
        if (status !== 'failed' && status !== 'succeeded')
            return null;
        // What its presentation said (`present` / `emitUi` text), else a plain confirmation.
        const text = status === 'failed'
            ? texts.actionFailed
            : proposal.outcome?.text?.trim() || texts.actionSucceeded;
        const formatted = await options.formatOutcome?.(proposal, { text, texts });
        return formatted ?? [text];
    };
    /** Send an executed proposal's outcome unless it was sent already (by this or another replica). */
    const relay = async (proposal, conversation, given, who) => {
        const service = await serviceFor(given);
        const store = await storeFor(service);
        const status = proposal.execution?.status;
        if (status !== 'failed' && status !== 'succeeded')
            return false;
        if (!(await store.claim(`${name}:outcome:${proposal.id}`, OUTCOME_TTL_MS)))
            return false;
        const texts = await textsFor(service, {
            actor: who?.actor ?? null,
            message: who?.message ?? null,
            proposal,
        });
        const replies = (await outcomeReplies(proposal, texts)) ?? [];
        const out = outbox(store, `${name}:out:outcome:${proposal.id}`, conversation, {
            actor: who?.actor ?? null,
            actorRef: who?.actor?.id ?? proposal.actorRef ?? null,
            message: who?.message ?? null,
            proposal,
        });
        for (const reply of replies)
            await out.reply(reply, 'outcome');
        return true;
    };
    /** Wait for an approved proposal to run, then relay what it said (or that it failed). */
    const relayOutcome = async (service, actor, threadId, proposalId, message) => {
        if (outcomeTimeoutMs <= 0 || !service.listActionProposals)
            return;
        const deadline = Date.now() + outcomeTimeoutMs;
        while (Date.now() < deadline) {
            const current = (await service.listActionProposals(actor, threadId)).find((proposal) => proposal.id === proposalId);
            if (current?.decision !== 'approved')
                return;
            const status = current.execution?.status;
            if (status === 'succeeded' || status === 'failed') {
                await relay(current, message.conversation, service, { actor, message });
                return;
            }
            await new Promise((resolve) => setTimeout(resolve, OUTCOME_POLL_MS));
        }
    };
    /**
     * A button press the provider forwarded without its id — only the label (Whatsmiau): the id of
     * the one card it can have come from — a card sent to this conversation whose proposal is still
     * pending. Several such cards → `undefined`: never guess; the label then goes on as a text
     * decision, which asks for the `#id`.
     */
    const recoverButtonId = async (service, store, texts, actor, threadId, message) => {
        if (message.buttonId !== undefined || message.buttonWithoutId !== true)
            return undefined;
        if (threadId === null || !service.listActionProposals)
            return undefined;
        const label = foldLabel(message.text);
        const fixed = label === foldLabel(texts.approve)
            ? 'approve'
            : label === foldLabel(texts.reject)
                ? 'reject'
                : undefined;
        const refs = await cardsOf(store, message.conversation);
        if (refs.size === 0)
            return undefined;
        const live = (await service.listActionProposals(actor, threadId)).filter((proposal) => proposal.decision === 'pending' && refs.has(buttonRef(proposal.id)));
        // The approve button may carry the tool's own verb ("Refund"): a label matching it approves
        // the one card that showed it.
        const candidates = fixed !== undefined
            ? live
            : live.filter((proposal) => foldLabel(approveLabel(proposal, texts)) === label);
        const action = fixed ?? (candidates.length > 0 ? 'approve' : undefined);
        if (action === undefined)
            return undefined;
        const only = candidates.length === 1 ? candidates[0] : undefined;
        return only === undefined ? undefined : `agora:${action}:${buttonRef(only.id)}`;
    };
    /** Decide one proposal by a press or a text, answer, and relay its outcome when it ran. */
    const decide = async (service, out, actor, threadId, proposalId, decision, remember, message) => {
        if (!service.decideActionProposal)
            return;
        const result = await service.decideActionProposal(actor, threadId, proposalId, decision, remember ? { remember: true } : {}, name);
        await out.text(service.actionProposalReply(result, decision), 'notice');
        if (result.status === 'applied' && decision === 'approved')
            await relayOutcome(service, actor, threadId, proposalId, message);
    };
    /** A press on one of our proposal buttons: decide it. `false` → not one of ours. */
    const pressButton = async (service, out, actor, threadId, message) => {
        const match = message.buttonId === undefined ? null : BUTTON_ID.exec(message.buttonId);
        if (!match || !service.decideActionProposal || !service.listActionProposals)
            return false;
        const decision = match[1] === 'approve' ? 'approved' : 'rejected';
        const ref = match[2] ?? '';
        const proposal = threadId === null
            ? undefined
            : (await service.listActionProposals(actor, threadId)).find((candidate) => buttonRef(candidate.id) === ref);
        if (threadId === null || !proposal) {
            await out.text(service.actionProposalReply({ status: 'not_found' }, decision), 'notice');
            return true;
        }
        await decide(service, out, actor, threadId, proposal.id, decision, false, message);
        return true;
    };
    /**
     * A text decision ("yes", "no #ID", a button label the provider forwarded alone), scoped to the
     * cards this conversation was sent. `false` → not a decision: an ordinary message.
     */
    const decideByText = async (service, store, texts, out, actor, threadId, message, text) => {
        if (threadId === null || !service.listActionProposals || !service.decideActionProposal)
            return false;
        const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
        const command = parseTextActionProposalCommand(text, vocabulary);
        if (command.status !== 'command')
            return false;
        const proposals = await service.listActionProposals(actor, threadId);
        const cards = await cardsOf(store, message.conversation);
        const deliverable = proposals.filter((proposal) => proposal.decision === 'pending' && cards.has(buttonRef(proposal.id)));
        const named = command.proposalId;
        if (named !== undefined) {
            const target = deliverable.find((proposal) => proposal.id === named);
            // An `#ID` naming no proposal of the thread is an ordinary message.
            if (target === undefined && !proposals.some((proposal) => proposal.id === named))
                return false;
            if (target === undefined) {
                await out.text(texts.noPendingConfirmation, 'notice');
                return true;
            }
            if (command.remember && options.allowRemember === false) {
                await out.text(texts.rememberRefused, 'notice');
                return true;
            }
            await decide(service, out, actor, threadId, target.id, command.decision, command.remember, message);
            return true;
        }
        if (deliverable.length === 0) {
            // Nothing pending anywhere: "yes" is just a word in the conversation.
            if (!proposals.some((proposal) => proposal.decision === 'pending'))
                return false;
            await out.text(texts.noPendingConfirmation, 'notice');
            return true;
        }
        if (deliverable.length > 1) {
            await out.text(texts.ambiguousDecision(deliverable.map((proposal) => proposal.id), commandsFor(service, null)), 'notice');
            return true;
        }
        if (command.remember && options.allowRemember === false) {
            await out.text(texts.rememberRefused, 'notice');
            return true;
        }
        const [only] = deliverable;
        if (only === undefined)
            return false;
        await decide(service, out, actor, threadId, only.id, command.decision, command.remember, message);
        return true;
    };
    const askNext = (texts, out, pending) => {
        const question = pending.questions[pending.index];
        if (!question)
            return Promise.resolve();
        return out.text(formatChannelQuestion(question, {
            index: pending.index,
            total: pending.questions.length,
            ...(pending.preamble !== undefined ? { preamble: pending.preamble } : {}),
        }, texts.questions), 'question');
    };
    /**
     * The person's message answers the question in front of them; the last one resumes the run.
     * Returns the run to read on, once all are answered.
     */
    const answerQuestion = async (service, store, texts, out, actor, message, pending) => {
        if (!service.answer)
            return null;
        const key = questionKey(message.conversation);
        // This message's answer was applied already (its phase is running again): carry on from there.
        const applied = pending.lastMessageId === message.id;
        let next = pending;
        if (!applied) {
            const question = pending.questions[pending.index];
            if (!question)
                return null;
            const parsed = parseChannelAnswer(question, message.text, texts.questions.skipWord);
            if (parsed.status === 'invalid') {
                await out.text(texts.questions.invalid(parsed.problem), 'question');
                await askNext(texts, out, pending);
                return null;
            }
            next = {
                ...pending,
                index: pending.index + 1,
                answers: parsed.status === 'answer'
                    ? { ...pending.answers, [question.id]: parsed.values }
                    : pending.answers,
                lastMessageId: message.id,
            };
            await store.set(key, JSON.stringify(next), questionTimeoutMs);
        }
        if (next.index < next.questions.length) {
            await askNext(texts, out, next);
            return null;
        }
        if (!(await settleQuestion(store, next.runId, next.toolCallId, `answer:${message.id}`))) {
            // It timed out while this answer was on its way: the run went on without it, and this is a
            // message like any other.
            await store.delete(key);
            return 'expired';
        }
        // The run that asked goes on: what it says next is read from its stream.
        try {
            await service.answer({
                runId: next.runId,
                toolCallId: next.toolCallId,
                answers: next.answers,
                answeredByRef: actor.id,
                answeredVia: name,
            });
        }
        catch (error) {
            // Taken by a run of this phase that died after answering: the run has it.
            if (!applied)
                throw error;
        }
        await store.delete(key);
        return next.streamRunId;
    };
    /** Download a message's files and stage them for the turn; refuse what cannot be attached. */
    const attachMedia = async (service, texts, out, actor, message, coalesced) => {
        const refs = [];
        const extra = [];
        const base = service.attachmentLimits?.() ?? null;
        for (const media of message.media ?? []) {
            const limits = options.mediaLimits ? options.mediaLimits(base, media) : base;
            const refuse = (reason) => out.text(texts.mediaRefused(reason, media, limits), 'notice');
            if (limits === null ||
                !adapter.download ||
                (!service.stageAttachment && !options.prepareMedia)) {
                await refuse('disabled');
                continue;
            }
            const declared = media.contentType?.split(';')[0]?.trim().toLowerCase();
            if (declared !== undefined && !limits.allowedContentTypes.includes(declared)) {
                await refuse('type');
                continue;
            }
            if (media.sizeBytes !== undefined && media.sizeBytes > limits.maxBytes) {
                await refuse('size');
                continue;
            }
            try {
                let file = await adapter.download(media, { maxBytes: limits.maxBytes });
                const prepared = await options.prepareMedia?.(file, media, {
                    ...hookContext(message, coalesced),
                    actor,
                });
                if (prepared && 'refuse' in prepared) {
                    await refuse(prepared.refuse);
                    continue;
                }
                if (prepared && 'text' in prepared) {
                    if (prepared.text.trim() !== '')
                        extra.push(prepared.text);
                    continue;
                }
                if (prepared && 'file' in prepared)
                    file = prepared.file;
                if (!service.stageAttachment) {
                    await refuse('disabled');
                    continue;
                }
                const attachment = await service.stageAttachment(actor, {
                    data: file.data,
                    contentType: file.contentType,
                    filename: file.filename ?? `${media.kind}.${extensionOf(file.contentType)}`,
                });
                refs.push({ mediaId: attachment.mediaId });
            }
            catch (error) {
                if (error instanceof ChannelMediaTooLargeError)
                    await refuse('size');
                else if (error instanceof AttachmentRefusedError)
                    await refuse(error.status === 413 ? 'size' : error.status === 415 ? 'type' : 'failed');
                else {
                    await refuse('failed');
                    options.onError?.(error, message);
                }
            }
        }
        return { refs, extra };
    };
    /**
     * Read a run's frames until it ends (or parks on a question, or on something a text channel
     * cannot settle) and deliver them. Reads the stream from its start: what was delivered before (a
     * read before a question, a read a crash cut short) is skipped by its slot.
     */
    const readTurn = async (service, store, actor, runId, threadId, conversation, message) => {
        const texts = await textsFor(service, { actor, message });
        const out = outbox(store, `${name}:out:${runId}`, conversation, { actor, message });
        const parts = [];
        const proposals = new Map();
        /** Components rendered as files, by id (a later one replaces), sent before the text. */
        const rendered = new Map();
        /** Components drawn natively (genui channels), by id, sent where they came in the text. */
        const natives = new Map();
        const native = await genuiFor();
        const asText = new Set();
        let wroteText = false;
        let failed = false;
        let blocked = false;
        let parked = false;
        let timedOut = false;
        let position = 0;
        const deadlineAt = Date.now() + timeoutMs;
        /** The reply text, its tables through {@link ChannelHandleOptions.renderTable} when there is one. */
        const replyText = async (value) => {
            if (!options.renderTable)
                return out.text(value, 'reply');
            let pending = '';
            const sendPending = async () => {
                if (pending.trim() !== '')
                    await out.text(pending.trim(), 'reply');
                pending = '';
            };
            for (const piece of splitTables(value)) {
                if (piece.kind === 'text') {
                    pending += `${piece.text}\n`;
                    continue;
                }
                const replies = replyList(await options.renderTable(piece.table, { actor, conversation, runId }));
                if (replies.length === 0) {
                    pending += `${piece.source}\n`;
                    continue;
                }
                await sendPending();
                for (const reply of replies)
                    await out.reply(reply, 'reply');
            }
            await sendPending();
        };
        const flush = async (end) => {
            const batch = [...rendered.values()];
            rendered.clear();
            for (const { replies } of batch)
                for (const reply of replies)
                    await out.reply(reply, 'reply');
            // Text and natively drawn components, in the order the turn produced them.
            const segments = parts.join('').split(NATIVE_MARK);
            parts.length = 0;
            for (const [index, segment] of segments.entries()) {
                if (index % 2 === 0) {
                    if (segment.trim() !== '')
                        await replyText(segment.trim());
                    continue;
                }
                const messages = natives.get(segment) ?? [];
                for (const [position, message] of messages.entries())
                    await sendNative(store, out, conversation, `${runId}:${segment}:${position}`, message);
            }
            natives.clear();
            if (end && !wroteText && batch.length > 0) {
                const after = texts.componentsOnly?.(batch.map(({ component }) => component));
                if (after !== undefined && after !== '')
                    await out.text(after, 'reply');
            }
        };
        const stream = service.subscribe(runId)[Symbol.asyncIterator]();
        const drive = service.drive?.bind(service);
        const stopDriving = executor().durable && drive !== undefined ? keepDriving(drive, runId) : undefined;
        try {
            for (;;) {
                let timer;
                const timeout = new Promise((resolve) => {
                    timer = setTimeout(() => resolve('timeout'), Math.max(0, deadlineAt - Date.now()));
                });
                const next = await Promise.race([stream.next(), timeout]).finally(() => clearTimeout(timer));
                if (next === 'timeout') {
                    timedOut = true;
                    failed = parts.length === 0 && rendered.size === 0 && !wroteText;
                    await service.cancel(runId).catch(() => { });
                    break;
                }
                if (next.done)
                    break;
                const frame = next.value;
                const component = componentOf(frame, position++);
                if (component !== null && native !== null) {
                    // A genui channel: the component as this channel draws it, where it came in the text.
                    const replies = options.renderComponent
                        ? replyList(await options.renderComponent(component, {
                            actor,
                            conversation,
                            runId,
                            rendered: rendered.size,
                        }))
                        : [];
                    if (replies.length > 0) {
                        rendered.set(component.id, { component, replies });
                        continue;
                    }
                    const drawn = await renderChannelMessages(native.genui.catalog, {
                        id: component.id,
                        name: component.name,
                        props: (component.data ?? {}),
                        version: component.version,
                        ...(component.componentVersions !== undefined
                            ? { componentVersions: component.componentVersions }
                            : {}),
                        ...(component.fallbackText !== undefined
                            ? { fallbackText: component.fallbackText }
                            : {}),
                    }, native.channel);
                    if (drawn.length === 0)
                        continue;
                    wroteText = true;
                    if (!natives.has(component.id))
                        parts.push(`${NATIVE_MARK}${component.id}${NATIVE_MARK}`);
                    natives.set(component.id, drawn);
                }
                else if (component !== null) {
                    const replies = options.renderComponent
                        ? replyList(await options.renderComponent(component, {
                            actor,
                            conversation,
                            runId,
                            rendered: rendered.size,
                        }))
                        : [];
                    if (replies.length > 0) {
                        rendered.set(component.id, { component, replies });
                        continue;
                    }
                    rendered.delete(component.id);
                    if (component.fallbackText && !asText.has(component.id)) {
                        // Only a component the turn negotiated as drawable comes as a frame; its text is all a
                        // channel that does not render it can show.
                        asText.add(component.id);
                        wroteText = true;
                        parts.push(`\n\n${component.fallbackText}\n\n`);
                    }
                }
                else if (frame.t === 'text') {
                    wroteText ||= frame.v.trim() !== '';
                    parts.push(frame.v);
                }
                else if (frame.t === 'event' && frame.event.kind === 'text') {
                    wroteText ||= frame.event.text.trim() !== '';
                    parts.push(frame.event.text);
                }
                else if (frame.t === 'approval') {
                    if (frame.target?.kind === 'proposal') {
                        proposals.set(frame.target.proposalId, {
                            id: frame.target.proposalId,
                            toolName: frame.toolName,
                            ...(frame.confirmation ? { confirmation: frame.confirmation } : {}),
                            ...(frame.expiresAt !== undefined && Number.isFinite(Date.parse(frame.expiresAt))
                                ? { expiresAt: Date.parse(frame.expiresAt) }
                                : {}),
                        });
                    }
                    else {
                        // Blocking mode: the run waits for a decision this channel cannot make.
                        blocked = true;
                        break;
                    }
                }
                else if (frame.t === 'elicitation') {
                    const questions = frame.request.questions;
                    const asked = {
                        threadId,
                        runId: frame.runId,
                        streamRunId: runId,
                        toolCallId: frame.id,
                        ...(frame.request.preamble !== undefined ? { preamble: frame.request.preamble } : {}),
                        questions,
                        index: 0,
                        answers: {},
                    };
                    if ((await store.get(answeredKey(frame.runId, frame.id))) !== null) {
                        // Answered (or skipped) before, and the run went on past it. What was sent around it
                        // then is sent (skipped, by its slots) the same way now, so what follows keeps its slots.
                        if (service.answer && questions.length > 0) {
                            await flush(false);
                            await askNext(texts, out, asked);
                        }
                        continue;
                    }
                    if (!service.answer || questions.length === 0) {
                        await settleQuestion(store, frame.runId, frame.id, 'skipped');
                        await service
                            .skip({
                            runId: frame.runId,
                            toolCallId: frame.id,
                            answeredByRef: actor.id,
                            answeredVia: name,
                        })
                            .catch(() => { });
                        continue;
                    }
                    await flush(false);
                    const pending = asked;
                    const key = questionKey(conversation);
                    const waiting = await store.get(key);
                    const same = waiting !== null && JSON.parse(waiting).toolCallId === frame.id;
                    if (!same)
                        await store.set(key, JSON.stringify(pending), questionTimeoutMs);
                    await askNext(texts, out, pending);
                    // The answers come as the next messages; nobody answering in time skips it.
                    await executor().later({
                        kind: 'timeout',
                        channel: name,
                        conversation,
                        runId: frame.runId,
                        toolCallId: frame.id,
                        streamRunId: runId,
                        threadId,
                        actor,
                    }, jobId(name, 'timeout', frame.runId, frame.id), Date.now() + questionTimeoutMs);
                    parked = true;
                    break;
                }
                else if (frame.t === 'error') {
                    failed = true;
                    // A failed turn shows none of what it drew before failing.
                    rendered.clear();
                    natives.clear();
                }
            }
        }
        finally {
            stopDriving?.();
            void stream.return?.();
        }
        if (parked)
            return 'parked';
        await flush(true);
        if (failed)
            await out.text(texts.failed, 'notice');
        if (blocked)
            await out.text(texts.blockingApproval, 'notice');
        const left = [...proposals.values()];
        for (const proposal of left)
            await sendProposal(service, store, texts, out, conversation, proposal, left.length > 1);
        return timedOut ? 'timeout' : failed ? 'failed' : blocked ? 'blocked' : 'replied';
    };
    const hookContext = (message, coalesced) => ({
        channel: name,
        conversation: message.conversation,
        message,
        ...(coalesced !== undefined && coalesced.length > 1 ? { coalesced } : {}),
    });
    /** One message's gate: who sent it (`unknownSender` when nobody), and whether `beforeTurn` lets it on. */
    const gate = async (service, store, message, coalesced) => {
        await adapter.acknowledge?.(message).catch(() => { });
        const actor = await options.actor(message);
        if (actor === null) {
            const out = outbox(store, `${name}:out:msg:${message.id}:sender`, message.conversation, {
                actor: null,
                message,
            });
            const replies = options.unknownSender
                ? replyList(await options.unknownSender(message, hookContext(message, coalesced)))
                : replyList((await textsFor(service, { actor: null, message })).unknownSender);
            for (const reply of replies)
                await out.reply(reply, 'gate');
            return { actor: null, pass: false };
        }
        const decided = await options.beforeTurn?.(message, actor, hookContext(message, coalesced));
        if (decided === 'stop')
            return { actor, pass: false };
        if (decided !== undefined && decided !== 'continue') {
            const out = outbox(store, `${name}:out:msg:${message.id}:gate`, message.conversation, {
                actor,
                message,
            });
            for (const reply of 'replies' in decided ? decided.replies : [decided.reply])
                await out.reply(reply, 'gate');
            return { actor, pass: false };
        }
        return { actor, pass: true };
    };
    /**
     * Before the turn: who is talking, and whether the app lets them through. A burst goes through
     * message by message — each one acknowledged, mapped and gated as if alone; the turn is made of
     * those let through, as the first of them's actor.
     */
    const prepare = async (received, batch) => {
        const service = await serviceFor();
        const store = await storeFor(service);
        const messages = batch !== undefined && batch.length > 1 ? batch : [received];
        const coalesced = messages.length > 1 ? messages : undefined;
        let first;
        let known = null;
        const through = [];
        for (const message of messages) {
            const gated = await gate(service, store, message, coalesced);
            known ??= gated.actor;
            if (!gated.pass || gated.actor === null)
                continue;
            first ??= { actor: gated.actor, message };
            through.push(message);
        }
        if (first === undefined)
            return { stop: true, actor: known };
        const threadId = (await options.thread(first.actor, first.message)) ?? null;
        return {
            stop: false,
            actor: first.actor,
            threadId,
            ...(coalesced !== undefined ? { messages: through } : {}),
        };
    };
    /** A burst led by `leader` was handled (its turn started, or every message was stopped). */
    const batchDoneKey = (leader) => `${name}:coalesce:done:${leader}`;
    /**
     * The message itself: an answer, a press, a decision, or a turn. Returns the run to read, if any.
     * A turn is started once per message, however often this runs: its run id is kept under the
     * message id before anything else can fail. A burst (`batch`, from `coalesce`) is one turn: each
     * message's files prepared (and refused) as if alone, texts joined in order, files together.
     */
    const act = async (received, actor, threadId, extra = {}) => {
        const service = await serviceFor();
        const store = await storeFor(service);
        const batch = extra.batch !== undefined && extra.batch.length > 1 ? extra.batch : undefined;
        const turnKey = `${name}:turn:${received.id}`;
        const markDone = async () => {
            if (extra.leader !== undefined)
                await store.set(batchDoneKey(extra.leader), '1', dedupeTtlMs);
        };
        const started = await store.get(turnKey);
        if (started !== null && started !== '') {
            // This phase ran to its end before (a retry, a recovery): the same outcome.
            await markDone();
            return JSON.parse(started);
        }
        /** What this message came to, kept so a phase that runs again does not do it twice. */
        const done = async (result) => {
            await store.set(turnKey, JSON.stringify(result), dedupeTtlMs);
            await markDone();
            return result;
        };
        const texts = await textsFor(service, { actor, message: received });
        const out = outbox(store, `${name}:out:msg:${received.id}`, received.conversation, {
            actor,
            message: received,
        });
        let message = received;
        let uiButton = null;
        if (batch === undefined) {
            // A burst never holds a control message: those are handled alone.
            const recovered = await recoverButtonId(service, store, texts, actor, threadId, received);
            message = recovered === undefined ? received : { ...received, buttonId: recovered };
            const ours = message.buttonId !== undefined && BUTTON_ID.test(message.buttonId);
            if (!ours) {
                const waiting = await store.get(questionKey(message.conversation));
                const pending = waiting === null ? null : JSON.parse(waiting);
                if (pending !== null && (pending.threadId === null || pending.threadId === threadId)) {
                    const resume = await answerQuestion(service, store, texts, out, actor, message, pending);
                    if (resume !== 'expired')
                        return done(resume === null ? null : { runId: resume, threadId: pending.threadId });
                }
            }
            if (await pressButton(service, out, actor, threadId, message))
                return done(null);
            // A component's button (or list entry): the user's next turn, as a UI action — the same thing
            // a sandbox's `agent.send` and an A2UI button become.
            uiButton = await pressedUiButton(store, message);
        }
        let inbound;
        if (batch !== undefined) {
            const parts = [];
            for (const part of batch) {
                const partOut = part.id === received.id
                    ? out
                    : outbox(store, `${name}:out:msg:${part.id}`, part.conversation, {
                        actor,
                        message: part,
                    });
                const media = await attachMedia(service, texts, partOut, actor, part, batch);
                parts.push({
                    message: part,
                    text: [part.text, ...media.extra].filter((piece) => piece.trim() !== '').join('\n\n'),
                    attachments: media.refs,
                });
            }
            inbound = {
                text: parts
                    .map((part) => part.text)
                    .filter((piece) => piece.trim() !== '')
                    .join('\n\n'),
                attachments: parts.flatMap((part) => part.attachments),
                parts,
            };
        }
        else {
            const media = uiButton === null ? await attachMedia(service, texts, out, actor, message) : null;
            inbound = {
                text: uiButton !== null
                    ? uiActionText(channelButtonAction(uiButton.button, {
                        ...(uiButton.componentId !== undefined
                            ? { componentId: uiButton.componentId }
                            : {}),
                        ...(uiButton.title !== undefined ? { title: uiButton.title } : {}),
                    }))
                    : [message.text, ...(media?.extra ?? [])]
                        .filter((part) => part.trim() !== '')
                        .join('\n\n'),
                attachments: media?.refs ?? [],
            };
        }
        if (options.transformInbound)
            inbound = await options.transformInbound(inbound, {
                ...hookContext(message, batch),
                actor,
                threadId,
            });
        if (batch === undefined &&
            inbound.attachments.length === 0 &&
            (await decideByText(service, store, texts, out, actor, threadId, message, inbound.text)))
            return done(null);
        // A file nobody could attach, and no caption: there is nothing left to answer.
        if (inbound.text === '' && inbound.attachments.length === 0)
            return done(null);
        if (!(await store.claim(turnKey, dedupeTtlMs))) {
            // Taken by a run of this phase that died between starting the turn and recording it: never
            // start a second turn for one message.
            throw Object.assign(new Error(`[@adonis-agora/agent] channel ${name}: message ${message.id} may have started a turn already; not starting another`), { name: 'ChannelTurnUncertainError', status: 409 });
        }
        const pageContext = typeof options.pageContext === 'function'
            ? options.pageContext(message)
            : (options.pageContext ?? { kind: name });
        const channel = {
            name,
            conversation: message.conversation,
            ...(adapter.kind !== undefined ? { kind: adapter.kind } : {}),
            choices: { buttons: capabilities.buttons ?? 0, lists: capabilities.lists ?? 0 },
        };
        // A channel genui draws for: the turn may push whatever this channel can draw (the tools are
        // narrowed to it), and the components arrive whole, for the history and for native delivery.
        const drawsNatively = options.uiCapabilities === undefined && (await genuiFor()) !== null;
        const sendOptions = { textDecisions: false };
        // When the person said it: the provider's time — a burst's first message's, when it begins.
        const sentAt = (batch ?? [message]).find((part) => part.sentAt !== undefined)?.sentAt;
        let sent;
        try {
            sent = await service.send({
                actor,
                message: inbound.text,
                ...(threadId !== null ? { threadId } : {}),
                ...(options.agentName !== undefined ? { agentName: options.agentName } : {}),
                ...(inbound.attachments.length > 0 ? { attachments: inbound.attachments } : {}),
                ...(sentAt !== undefined ? { sentAt } : {}),
                ...(drawsNatively
                    ? {}
                    : { uiCapabilities: options.uiCapabilities ?? { components: [] } }),
                pageContext: { ...pageContext, channel },
                hostContext: {
                    channel: name,
                    conversation: message.conversation,
                    messageId: message.id,
                    ...(batch !== undefined ? { messageIds: batch.map((part) => part.id) } : {}),
                },
            }, sendOptions);
        }
        catch (error) {
            // Nothing started: a retry may start it.
            await Promise.resolve(store.delete(turnKey)).catch(() => { });
            throw error;
        }
        if ('proposalDecision' in sent) {
            // A service that decides by text itself (one that ignores `textDecisions`).
            await done(null);
            await out.text(sent.text, 'notice');
            const decided = sent.proposalDecision;
            if ('proposal' in decided &&
                decided.status === 'applied' &&
                decided.proposal?.decision === 'approved')
                await relayOutcome(service, actor, sent.threadId, decided.proposal.id, message);
            return null;
        }
        // A queued message starts later under its own id: its answer is read the same way.
        const runId = sent.queued === true ? (sent.runId ?? sent.messageId) : sent.runId;
        // What a turn grouped (and how long it waited) is told only with `coalesce` on.
        const measured = options.coalesce !== undefined;
        const queueWaitMs = measured && extra.receivedAt !== undefined
            ? Math.max(0, Date.now() - extra.receivedAt)
            : undefined;
        const turn = await done({
            runId,
            threadId: sent.threadId,
            ...(measured ? { attachments: inbound.attachments.length } : {}),
            ...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
        });
        if (threadId === null)
            await options.onThreadCreated?.(sent.threadId, actor, message);
        await options.onTurnStarted?.({
            runId,
            threadId: sent.threadId,
            actor,
            message,
            queued: sent.queued === true,
            ...(batch !== undefined ? { coalesced: batch } : {}),
            ...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
        });
        return turn;
    };
    // ── coalescing: the conversation's burst log ────────────────────────────────
    //
    // With `coalesce` on, the webhook appends each message it takes to its conversation's log (a slot
    // per message, taken with an atomic claim) before starting its job. Jobs still run one at a time
    // per conversation, in order: the first one to run LEADS a burst — it waits (durably) for the
    // conversation to go quiet, then claims every later message of the log that may join it (an owner
    // key per message, claimed once) and makes them one turn. A message's own job, when its turn
    // comes, finds it owned by a leader whose burst was handled and ends there. A message nobody
    // claimed (it came after the burst closed, or is a control message) leads the next one. The log is
    // only how a leader sees what arrived — a message missing from it is still handled by its own job.
    const logKey = (conversation, slot) => `${name}:coalesce:${conversation}:${slot}`;
    const logTailKey = (conversation) => `${name}:coalesce:${conversation}:tail`;
    const logHeadKey = (conversation) => `${name}:coalesce:${conversation}:head`;
    const ownerKey = (messageId) => `${name}:coalesce:owner:${messageId}`;
    /** Consecutive empty slots after which the log is taken to end (a slot claimed, never written). */
    const LOG_GAP = 3;
    const MAX_LOG_PROBES = 500;
    /** The longest log entry (a MySQL `TEXT` holds 64 KB). */
    const MAX_LOG_ENTRY = 60_000;
    /** Append a message to its conversation's log — a slot taken with a claim, then written. */
    const appendToLog = async (store, message, at) => {
        const conversation = message.conversation;
        const tail = Number((await store.get(logTailKey(conversation))) ?? '0');
        let slot = Number.isFinite(tail) && tail >= 0 ? tail : 0;
        // The whole message, for the leader to handle it — without the provider's payload when that
        // is too big for a store's text column; too big even so → left to its own job.
        let value = JSON.stringify({ id: message.id, at, message });
        if (value.length > MAX_LOG_ENTRY)
            value = JSON.stringify({ id: message.id, at, message: { ...message, raw: null } });
        if (value.length > MAX_LOG_ENTRY)
            return;
        for (let probe = 0; probe < MAX_LOG_PROBES; probe++, slot++) {
            if (!(await store.claim(logKey(conversation, slot), dedupeTtlMs)))
                continue;
            await store.set(logKey(conversation, slot), value, dedupeTtlMs);
            await store.set(logTailKey(conversation), String(slot + 1), dedupeTtlMs);
            return;
        }
        // A log this full: the message is handled by its own job, alone.
    };
    /**
     * `leader` owns `messageId` — now, or since an earlier run of this phase. A claim left without its
     * owner (a phase that died between the two writes) is taken: only one job of a conversation looks
     * at its log at a time.
     */
    const own = async (store, messageId, leader) => {
        const key = ownerKey(messageId);
        if (!(await store.claim(key, dedupeTtlMs))) {
            const owner = await store.get(key);
            if (owner !== null && owner !== '')
                return owner === leader;
        }
        await store.set(key, leader, dedupeTtlMs);
        return true;
    };
    /** A message that is never grouped: a press, a text decision — and what `when` turns away. */
    const groupable = (service, message) => {
        if (message.buttonId !== undefined || message.buttonWithoutId === true)
            return false;
        if (message.text.trim() !== '') {
            const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
            if (parseTextActionProposalCommand(message.text, vocabulary).status === 'command')
                return false;
        }
        return options.coalesce?.when?.(message) ?? true;
    };
    /**
     * One look at the burst `job`'s message would lead: wait more (`wakeAt`), or claim it and the
     * messages that join it. A message a leader already took ends its own job (`absorbed`) — unless
     * that leader's burst was never handled (it failed before its turn), then it goes on alone.
     */
    const gather = async (job) => {
        const service = await serviceFor();
        const store = await storeFor(service);
        const self = job.message;
        const alone = { batch: [self] };
        if (!(await own(store, self.id, self.id))) {
            const owner = await store.get(ownerKey(self.id));
            if (owner !== null && (await store.get(batchDoneKey(owner))) !== null)
                return { absorbed: owner };
            return alone;
        }
        const config = options.coalesce;
        if (config === undefined || !(config.quietMs > 0))
            return alone;
        // Every message of a question in progress is an answer, read one at a time.
        if (!groupable(service, self) || (await store.get(questionKey(self.conversation))) !== null)
            return alone;
        const maxMessages = Math.max(1, Math.floor(config.maxMessages ?? 20));
        const maxWaitMs = config.maxWaitMs ?? Math.max(30_000, config.quietMs);
        const receivedAt = job.receivedAt ?? Date.now();
        const conversation = self.conversation;
        const slotOf = (value) => {
            const slot = Number(value ?? '0');
            return Number.isFinite(slot) && slot >= 0 ? Math.floor(slot) : 0;
        };
        const head = slotOf(await store.get(logHeadKey(conversation)));
        const tail = slotOf(await store.get(logTailKey(conversation)));
        const joining = [];
        /** Slots of messages nobody had taken when they were read (but this one). */
        const open = [];
        let lastSlot;
        let seenSelf = false;
        let lastAt = receivedAt;
        let gap = 0;
        let closed = false;
        for (let slot = head; slot < Math.max(head, tail) + MAX_LOG_PROBES; slot++) {
            const saved = await store.get(logKey(conversation, slot));
            if (saved === null) {
                gap++;
                if (gap >= LOG_GAP && slot >= tail)
                    break;
                continue;
            }
            gap = 0;
            lastSlot = slot;
            const entry = JSON.parse(saved);
            if (entry.id === self.id) {
                seenSelf = true;
                continue;
            }
            const owner = await store.get(ownerKey(entry.id));
            if (owner !== null && owner !== '')
                continue;
            open.push(slot);
            // What came before this message (out of order, by a hair) is left to its own job.
            if (closed || (!seenSelf && entry.at < receivedAt))
                continue;
            if (joining.length + 1 >= maxMessages ||
                entry.message.from !== self.from ||
                !groupable(service, entry.message)) {
                closed = true;
                continue;
            }
            joining.push({ ...entry, slot });
            lastAt = Math.max(lastAt, entry.at);
        }
        const now = Date.now();
        const wakeAt = Math.min(lastAt + config.quietMs, receivedAt + maxWaitMs);
        if (!closed && joining.length + 1 < maxMessages && now < wakeAt)
            return { wakeAt };
        const batch = [self];
        const taken = new Set();
        for (const entry of joining)
            if (await own(store, entry.id, self.id)) {
                batch.push(entry.message);
                taken.add(entry.slot);
            }
        // Where the next leader starts reading: the first message nobody has taken.
        const left = open.filter((slot) => !taken.has(slot));
        const next = left.length > 0 ? Math.min(...left) : lastSlot !== undefined ? lastSlot + 1 : head;
        if (next !== head)
            await store.set(logHeadKey(conversation), String(next), dedupeTtlMs);
        return { batch };
    };
    const retry = (fn) => withRetries(fn, options.retry);
    const report = (error, message) => {
        if (options.onError && message !== null)
            options.onError(error, message);
        else
            console.error('[@adonis-agora/agent] Channel message failed', {
                channel: name,
                message: error instanceof Error ? error.message : String(error),
            });
    };
    /** Tell `onTurnEnded` — never failing the job. */
    const turnEnded = async (turn) => {
        if (!options.onTurnEnded)
            return;
        try {
            await options.onTurnEnded(turn);
        }
        catch (error) {
            console.error('[@adonis-agora/agent] Channel onTurnEnded failed', {
                channel: name,
                message: error instanceof Error ? error.message : String(error),
            });
        }
    };
    /**
     * One phase of a job, from its input alone — what a dispatched step runs in whatever process it
     * lands on, and what an inline / local step calls. Each is retried (`retry`) and returns JSON.
     */
    const phase = async (which, input) => {
        const { job } = input;
        switch (which) {
            case 'timeout': {
                if (job.kind !== 'timeout')
                    throw new Error(`phase timeout of a ${job.kind} job`);
                return retry(async () => {
                    const service = await serviceFor();
                    const store = await storeFor(service);
                    // Answered in time: nothing to do.
                    if (!(await settleQuestion(store, job.runId, job.toolCallId, 'timeout')))
                        return false;
                    // Nobody answered: the agent goes on on its own assumptions.
                    const key = questionKey(job.conversation);
                    const waiting = await store.get(key);
                    const pending = waiting === null ? null : JSON.parse(waiting);
                    if (pending?.runId === job.runId && pending.toolCallId === job.toolCallId)
                        await store.delete(key);
                    await service
                        .skip({
                        runId: job.runId,
                        toolCallId: job.toolCallId,
                        answeredByRef: job.actor.id,
                        answeredVia: name,
                    })
                        .catch(() => { });
                    await executor().enqueue({
                        kind: 'resume',
                        channel: name,
                        conversation: job.conversation,
                        runId: job.streamRunId,
                        threadId: job.threadId,
                        actor: job.actor,
                    }, jobId(name, 'resume', job.runId, job.toolCallId));
                    return true;
                });
            }
            case 'gather': {
                if (job.kind !== 'message')
                    throw new Error(`phase gather of a ${job.kind} job`);
                return retry(() => gather(job));
            }
            case 'prepare': {
                if (job.kind !== 'message')
                    throw new Error(`phase prepare of a ${job.kind} job`);
                return retry(async () => {
                    const prepared = await prepare(job.message, input.batch);
                    if (prepared.stop && job.coalesce === true) {
                        // Every message of the burst stopped at its gate: handled.
                        const service = await serviceFor();
                        await (await storeFor(service)).set(batchDoneKey(job.message.id), '1', dedupeTtlMs);
                    }
                    return prepared;
                });
            }
            case 'act': {
                if (job.kind !== 'message')
                    throw new Error(`phase act of a ${job.kind} job`);
                const prepared = input.prepared;
                const messages = prepared.messages ?? [job.message];
                const [first = job.message] = messages;
                return retry(() => act(first, prepared.actor, prepared.threadId, {
                    ...(messages.length > 1 ? { batch: messages } : {}),
                    ...(job.receivedAt !== undefined ? { receivedAt: job.receivedAt } : {}),
                    ...(job.coalesce === true ? { leader: job.message.id } : {}),
                }));
            }
            case 'read': {
                return retry(async () => {
                    const service = await serviceFor();
                    const store = await storeFor(service);
                    if (job.kind === 'resume')
                        return readTurn(service, store, job.actor, job.runId, job.threadId, job.conversation, null);
                    if (job.kind !== 'message')
                        throw new Error(`phase read of a ${job.kind} job`);
                    const prepared = input.prepared;
                    const turn = input.turn;
                    const [first = job.message] = prepared.messages ?? [job.message];
                    return readTurn(service, store, prepared.actor, turn.runId, turn.threadId, job.message.conversation, first);
                });
            }
        }
    };
    /** What a `read` checkpointed: its outcome (a run recorded before it said so: `replied`). */
    const outcomeOf = (value) => typeof value === 'string' ? value : 'replied';
    /** One job, phase by phase — each phase a checkpoint (or a dispatched step) under a durable engine. */
    const run = async (job, io) => {
        const message = job.kind === 'message' ? job.message : null;
        /** What `onTurnEnded` is told — a question's timeout is not a turn (the resume it starts is). */
        const ended = {
            channel: name,
            conversation: job.conversation,
            actor: job.kind === 'resume' ? job.actor : null,
            message,
            runId: job.kind === 'resume' ? job.runId : null,
            threadId: job.kind === 'resume' ? job.threadId : null,
            outcome: 'replied',
        };
        /** Not told: a burst's wait (the job goes on later), or a message another job's turn took. */
        let quiet = false;
        let waiting = false;
        try {
            if (job.kind === 'timeout') {
                await io.phase('timeout', { job });
                return;
            }
            if (job.kind === 'resume') {
                ended.outcome = outcomeOf(await io.phase('read', { job }));
                return;
            }
            let batch;
            if (job.coalesce === true) {
                for (let round = 0;; round++) {
                    const gathered = await io.phase('gather', { job, round });
                    if ('wakeAt' in gathered) {
                        waiting = true;
                        await io.sleepUntil(gathered.wakeAt);
                        waiting = false;
                        continue;
                    }
                    if ('absorbed' in gathered) {
                        quiet = true;
                        return;
                    }
                    if (gathered.batch.length > 1)
                        batch = gathered.batch;
                    break;
                }
            }
            const prepared = await io.phase('prepare', {
                job,
                ...(batch !== undefined ? { batch } : {}),
            });
            ended.actor = prepared.actor ?? null;
            if (prepared.stop) {
                ended.outcome = 'stopped';
                return;
            }
            ended.threadId = prepared.threadId;
            const through = prepared.messages;
            if (through !== undefined && through.length > 0)
                ended.message = through[0] ?? message;
            if (through !== undefined && through.length > 1)
                ended.coalesced = through;
            const turn = await io.phase('act', { job, prepared });
            if (turn === null) {
                ended.outcome = 'handled';
                return;
            }
            ended.runId = turn.runId;
            ended.threadId = turn.threadId;
            if (turn.attachments !== undefined)
                ended.attachments = turn.attachments;
            if (turn.queueWaitMs !== undefined)
                ended.queueWaitMs = turn.queueWaitMs;
            ended.outcome = outcomeOf(await io.phase('read', { job, prepared, turn }));
        }
        catch (error) {
            if (waiting && isControlFlowSignal(error)) {
                // The burst's wait suspended the run: it goes on (here or elsewhere) once it is due.
                quiet = true;
                throw error;
            }
            if (error?.name === 'WorkflowSuspended') {
                ended.outcome = 'interrupted';
                throw error;
            }
            ended.outcome = 'failed';
            ended.error = error;
            report(error, ended.message);
        }
        finally {
            if (job.kind !== 'timeout' && !quiet)
                await turnEnded(ended);
        }
    };
    /** Each dispatched phase's liveness window: what it may take, with a margin. */
    const stepTimeoutMs = (which) => {
        const margin = 2 * 60_000;
        switch (which) {
            case 'read':
                // The turn, the outcome of a proposal it left, and the sends.
                return timeoutMs + Math.max(0, outcomeTimeoutMs) + margin;
            case 'act':
                // Media downloads, `prepareMedia` (a transcription), the turn's start — retried.
                return 15 * 60_000;
            case 'prepare':
                return 10 * 60_000;
            default:
                return margin;
        }
    };
    const runner = {
        run,
        phase,
        stepTimeoutMs,
        ...(options.durableSteps === false ? { dispatched: false } : {}),
    };
    let current;
    /** Durable when an engine is there (given, or the agent's), else in this process. */
    const executor = () => {
        if (current)
            return current;
        const engine = options.durable === false
            ? undefined
            : typeof options.durable === 'object'
                ? options.durable
                : registeredAgentEngine();
        if (engine === undefined && options.durable === true)
            console.warn(`[@adonis-agora/agent] channel ${name}: \`durable: true\` but no durable engine is wired (\`durable: true\` in config/agent.ts) — messages are handled in this process`);
        if (engine !== undefined) {
            registerChannelWorkflows(engine, runnerFor, stepServerFor(engine));
            current = durableExecutor(engine);
            return current;
        }
        current = inlineExecutor((job) => run(job, inlineIo(runner)));
        return current;
    };
    /** Where `engine`'s channel phases are dispatched: the option, else the agent engine's transport. */
    function stepServerFor(engine) {
        if (options.durableSteps === false)
            return undefined;
        if (options.durableSteps !== undefined)
            return options.durableSteps;
        return engine === registeredAgentEngine()
            ? registeredAgentStepServer()
            : undefined;
    }
    registry.set(name, {
        adapter,
        runner,
        relay: (proposal, conversation, service) => relay(proposal, conversation, service),
        follow: (conversation, run) => executor().enqueue({
            kind: 'resume',
            channel: name,
            conversation,
            runId: run.runId,
            threadId: run.threadId,
            actor: run.actor,
        }, jobId(name, 'follow', run.runId)),
    });
    // Registered on the engine as soon as there is one: a restarted process resumes the jobs a crash
    // interrupted only for workflows it knows.
    if (typeof options.durable === 'object')
        registerChannelWorkflows(options.durable, runnerFor, stepServerFor(options.durable));
    else if (options.durable !== false)
        followAgentEngine();
    const notify = async (event) => {
        try {
            await options.onWebhook?.({ channel: name, ...event });
        }
        catch {
            // Observation never changes the answer.
        }
    };
    const handleRequest = async (request, logger) => {
        const empty = { accepted: 0, duplicates: 0, messages: [] };
        const challenge = adapter.challenge?.(request) ?? null;
        if (challenge !== null) {
            await notify({ status: 'challenge', ...empty });
            return {
                status: challenge.status,
                body: challenge.body,
                contentType: challenge.contentType ?? 'text/plain',
            };
        }
        if (request.method !== 'POST') {
            await notify({ status: 'method_not_allowed', ...empty });
            return { status: 405, body: { error: 'method_not_allowed' } };
        }
        if (!(await adapter.verify(request))) {
            await notify({ status: 'unauthorized', ...empty });
            return { status: 401, body: { error: 'unauthorized' } };
        }
        const parsed = adapter.parse(request.body);
        const messages = parsed === null ? [] : Array.isArray(parsed) ? parsed : [parsed];
        if (messages.length === 0) {
            const ignored = adapter.ignored?.(request.body) ?? null;
            const event = ignored?.event ??
                (typeof request.body === 'object' && request.body !== null
                    ? request.body.event
                    : undefined);
            const reason = ignored?.reason ?? 'no message';
            // Logged with the event and the reason — never the content — so a dropped message can be
            // diagnosed.
            if (logger) {
                const log = ignored?.unexpected ? logger.warn : logger.debug;
                log?.call(logger, { channel: name, ...(typeof event === 'string' ? { event } : {}), reason }, 'channels: webhook ignored');
            }
            await notify({
                status: 'ignored',
                reason,
                ...(typeof event === 'string' ? { event } : {}),
                ...empty,
            });
            return { status: 200, body: { ok: true } };
        }
        const fresh = [];
        try {
            const store = await storeFor(await serviceFor());
            // Taken before the 200: a store or an engine that is down answers 500, and the provider
            // retries.
            const coalesce = options.coalesce !== undefined && options.coalesce.quietMs > 0;
            for (const message of messages) {
                const key = `${name}:${message.id}`;
                if (!(await store.claim(key, dedupeTtlMs)))
                    continue;
                try {
                    const receivedAt = Date.now();
                    // A burst's leader finds it in the conversation's log; its own job runs either way.
                    if (coalesce)
                        await appendToLog(store, message, receivedAt);
                    await executor().enqueue({
                        kind: 'message',
                        channel: name,
                        conversation: message.conversation,
                        message,
                        receivedAt,
                        ...(coalesce ? { coalesce: true } : {}),
                    }, jobId(name, 'message', message.id));
                }
                catch (error) {
                    await Promise.resolve(store.delete(key)).catch(() => { });
                    throw error;
                }
                fresh.push(message);
            }
        }
        catch (error) {
            await notify({
                status: 'failed',
                reason: error instanceof Error ? error.message : String(error),
                accepted: fresh.length,
                duplicates: 0,
                messages: fresh,
            });
            return { status: 500, body: { error: 'unavailable' } };
        }
        await notify({
            status: fresh.length > 0 ? 'accepted' : 'duplicate',
            accepted: fresh.length,
            duplicates: messages.length - fresh.length,
            messages: fresh,
        });
        return { status: 200, body: { ok: true } };
    };
    const handler = async (ctx) => {
        const answer = await handleRequest(requestOf(ctx), ctx.logger);
        if (answer.contentType !== undefined)
            ctx.response
                .status(answer.status)
                .header('content-type', answer.contentType)
                .send(answer.body);
        else
            ctx.response.status(answer.status).send(answer.body);
    };
    return Object.assign(handler, {
        drain: () => executor().drain(),
        handleRequest: (request) => handleRequest(request),
    });
}
/**
 * Relay an executed proposal's outcome to the conversation it was proposed in — once: the handler's
 * own wait and this share a claim in the channel's store. `false` when it was not relayed (no
 * channel recorded, the channel is not registered in this process, not executed, already relayed).
 */
async function deliverOutcome(proposal, service) {
    const address = record(proposal.executionContext?.pageContext?.channel);
    const channelName = typeof address?.name === 'string' ? address.name : undefined;
    const conversation = typeof address?.conversation === 'string' ? address.conversation : undefined;
    const registration = channelName === undefined ? undefined : registry.get(channelName);
    if (!registration || conversation === undefined)
        return false;
    return registration.relay(proposal, conversation, service);
}
/**
 * `actionProposalWorker.onSettled` for text channels: relays an executed proposal's outcome to the
 * conversation it was proposed in, through the adapter registered under the recorded channel name
 * (with its `formatOutcome`, `texts` and `canDeliver`). Proposals from other surfaces are ignored.
 * Safe next to the handler's own wait: an outcome is relayed once.
 *
 * ```ts
 * // config/agent.ts
 * actionProposalWorker: { onSettled: channels.onSettled }
 * ```
 */
export async function relayChannelOutcome(proposal) {
    await deliverOutcome(proposal);
}
export const channels = {
    /** See {@link handleChannel}. */
    handle: handleChannel,
    /** See {@link relayChannelOutcome}. */
    onSettled: relayChannelOutcome,
    /** Relay one proposal's outcome now; `true` when it was sent. */
    deliverOutcome: (proposal, service) => deliverOutcome(proposal, service),
    /** The adapter registered under `name` (by `channels.handle`), if any. */
    adapter: (name) => registry.get(name)?.adapter,
    /**
     * Deliver a run started outside the channel (a turn continued on the server, e.g. after a sign-in
     * completed elsewhere) to the conversation `address` names (`pageContext.channel`). `false` when
     * no channel of that name is registered in this process.
     */
    follow: async (address, run) => {
        const at = record(address);
        const channelName = typeof at?.name === 'string' ? at.name : undefined;
        const conversation = typeof at?.conversation === 'string' ? at.conversation : undefined;
        const registration = channelName === undefined ? undefined : registry.get(channelName);
        if (!registration || conversation === undefined)
            return false;
        await registration.follow(conversation, run);
        return true;
    },
};
//# sourceMappingURL=handler.js.map