import { TheokitAgentError } from '@theokit/sdk/errors'; export { TheokitAgentError } from '@theokit/sdk/errors'; /** * M77 — the ask channel: the agent asks, a human answers, the turn waits. * * ## The asymmetry this closes * * "Pause the turn for a human" existed only for tool APPROVAL. Its sibling — the agent asking a * question mid-turn — had a tool and no channel: the SDK's `createQuestionTool` takes an `askUser` * callback (preferring `ctx.context.askUser`), and nothing in this layer ever supplied one. A tool * that cannot reach a human is a tool that times out, five minutes later, with no diagnosis. * * ## Why this is framework and not runtime * * It makes no model call, dispatches no tool and stores no conversation. It is a rendezvous between * a paused turn and a surface — the "home the agent lives in" (ADR 0038 / ADR-0040 § D2), the same * category as the approval gate this is modelled on. * * ## Modelled on `ApprovalRegistry`, deliberately * * That registry already solved this exact problem for approvals: hold the live resolver in memory, * settle it from outside, and keep ONE instance per process because the promise being awaited and * the promise being settled must be the same object. Inventing a second shape for the same problem * would be the duplication G12 forbids. What differs is the key — a thread, not an approval id — * because a question belongs to a conversation and a surface renders one at a time. * * ## What it refuses, and why refusing beats resolving * * Two questions on one thread have no coherent UI: the answer cannot be attributed to either. Two * listeners on one thread means the prompt is rendered twice and answered by whoever wins. Both are * refused with a typed error rather than resolved by picking a winner — silently replacing a * listener makes the first surface go deaf with no signal at all. */ /** Raised when a thread already has a question in flight. */ declare class ConcurrentQuestionError extends TheokitAgentError { readonly name = "ConcurrentQuestionError"; constructor(threadId: string); } /** Raised when a thread already has a listener attached. */ declare class ConcurrentListenerError extends TheokitAgentError { readonly name = "ConcurrentListenerError"; constructor(threadId: string); } /** * Raised when a pending question will never be answered — the run was cancelled, the surface * detached, or nobody was listening in the first place. */ declare class QuestionAbandonedError extends TheokitAgentError { readonly name = "QuestionAbandonedError"; constructor(threadId: string, why: string); } /** A question as it reaches the surface. */ interface PendingQuestion { /** Correlates {@link AskBridge.answer} with the promise the tool is awaiting. */ readonly id: string; /** The thread the question belongs to. */ readonly threadId: string; /** What to show the human. */ readonly question: string; } /** Extra hooks a surface may attach alongside its listener. */ interface ListenerOptions { /** * Called when a pending question is abandoned, so the surface can release its slot. * * Without it, cancelling a run leaves the prompt on screen waiting for an answer nobody awaits — * and the next question fails with "one already pending" against a question that is gone. */ readonly onAbandon?: (threadId: string) => void; } /** Detaches a listener. Calling it twice is harmless. */ type DisposeListener = () => void; interface AskBridge { /** * Ask, and resolve when a human answers. * * Rejects — never hangs — when there is no listener, when the thread is already asking, or when * the question is abandoned. Hanging is the failure this module exists to remove. */ ask(threadId: string, question: string): Promise; /** Settle a pending question. `false` when the id is unknown or already settled. */ answer(id: string, answer: string): boolean; /** Reject whatever is pending on a thread. `false` when nothing was pending. */ abandon(threadId: string): boolean; /** Attach the surface that renders questions for a thread. */ setListener(threadId: string, listener: (question: PendingQuestion) => void, options?: ListenerOptions): DisposeListener; } /** * Build a bridge. * * A factory rather than a class with a process singleton baked in: tests get a fresh instance, and * the surface that owns the process decides where the shared one lives — exactly the shape * `createInProcessApprovalRegistry` established. */ declare function createAskBridge(): AskBridge; /** * M77 — the client-side ledger of pending human decisions. * * ## Why a surface needs this at all * * The framework's own lookup is STATELESS. `ApprovalRegistry.list()` answers "what is pending right * now", and nothing remembers what a surface already showed or already answered. Two defects fall * straight out of that, and the consumer that prompted this milestone hit both: * * - **the dismissed card comes back** — the surface polls again, the same approval is still in the * list, and it renders a second time; * - **a second answer is sent for an already-answered request** — the user clicks twice, or two * surfaces answer, and the second send is a decision nobody made. * * Both are memory problems, and memory is the surface's to keep. Making the registry remember what * each surface has seen would put per-client state inside a process-wide primitive. * * ## What this deliberately is not * * No policy. It never decides whether to approve, never talks to the registry, never knows what an * approval means. It is bookkeeping over ids — which is why it is a pure function of its own state * and tested without a mock. */ /** A decision waiting for a human, as the surface knows it. */ interface PendingItem { /** The framework's id for the decision (an approval id, a question id). */ readonly id: string; /** * Where in the conversation it belongs. * * Ordering key AND pruning key: a surface shows the oldest first, and a rewind invalidates * everything attached to messages that no longer exist. */ readonly messageIndex: number; /** * The SURFACE's own state for this item — render timestamps, collapsed flags, whatever it needs. * * T2.7. The framework never reads it; it is carried, not interpreted. Without this slot a surface * adopting the ledger has to keep a SECOND map keyed by the same id, which is strictly worse than * the single map it already maintains — and that is the measured reason `createPendingLedger` * shipped and went unused while a hand-written ledger stayed in the only real consumer. * * The default of `undefined` is what keeps this non-breaking: every existing caller writes * `PendingItem` with no argument and passes items with no payload. */ readonly payload?: TPayload; } interface PendingLedger { /** * Record what the framework reports as pending. * * Additive and idempotent: the same list arrives on every poll. An id already settled is NOT * re-added — that single rule is what stops the dismissed card from coming back. */ ingest(items: readonly PendingItem[]): void; /** * Mark one as answered. `false` when it was unknown or already settled. * * The caller uses that boolean to decide whether to SEND: returning `true` twice would send a * decision the user made once, twice. */ settle(id: string): boolean; /** The oldest unsettled item, or `undefined`. A surface shows one at a time. */ findNext(): PendingItem | undefined; /** * Forget everything attached to a message before `messageIndex`, settled or not. Returns how many * unsettled items were dropped. * * The cutoff is the first index that STILL EXISTS, so an item sitting exactly on it survives. */ pruneBefore(messageIndex: number): number; } declare function createPendingLedger(): PendingLedger; /** * M77 — adapt an {@link AskBridge} to the `askUser` shape the question tool expects. * * ## Why an adapter and not a re-export * * The two signatures genuinely differ, and not cosmetically: * * | | question tool | {@link AskBridge} | * |---|---|---| * | shape | `(question, threadId?) => Promise` | `(threadId, question) => Promise` | * | thread | optional | required — it is the routing key | * * The tool's `threadId` is optional because the tool cannot know whether the surface tracks threads. * The bridge's is required because a question with no thread has no listener to reach: the channel * routes BY thread, and "ask whoever" is not a destination. * * That mismatch is the whole reason this function exists. Writing the flip at each call site is how * the argument order eventually gets swapped somewhere and a question is asked with the thread id as * its text. * * ## What it does with a missing thread * * Rejects, naming the cause. The alternative — pick a default thread, or ask the most recent * listener — routes a human question to a conversation it does not belong to, which is worse than * failing: the answer comes back attributed to the wrong turn. * * Pass it as `askUser` when building the question tool, or put it on the run context as * `ctx.context.askUser`, which the tool prefers. */ declare function askUserVia(bridge: AskBridge): (question: string, threadId?: string) => Promise; export { type AskBridge, ConcurrentListenerError, ConcurrentQuestionError, type DisposeListener, type ListenerOptions, type PendingItem, type PendingLedger, type PendingQuestion, QuestionAbandonedError, askUserVia, createAskBridge, createPendingLedger };