@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
232 lines • 12.4 kB
TypeScript
/**
* Asking the USER a structured question, and waiting for the answer.
*
* `awaitApproval` collects a yes/no about work already proposed; this collects the scope BEFORE the
* work. Two surfaces produce it — a configured intake (`AgentLoopDeps.intake`) and the model-callable
* `ask` tool (`AgentLoopDeps.ask`) — and they deliberately produce the SAME {@link
* ElicitationRequest}, persist through the same tool-call row, and resume through the same
* `tool:<runId>:<callId>` signal. A consumer cannot tell which one asked, and should not have to.
*/
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { type ElicitationInput } from './elicitation-input.js';
import type { Decision, ToolDefinition } from './types.js';
/**
* Whatever a human sent into a parked run. One channel carries both, because a question set is
* persisted as a `pending_approval` action so it lands in the approvals inbox a deployment already
* has — which means an operator pressing Approve there delivers a {@link Decision} to a run that is
* waiting on answers. The loop reduces one to the other; see {@link normalizeElicitationReply}.
*/
export type HumanReply = Decision | ElicitationReply;
/**
* Thrown when a reply is the wrong shape for the wait it was delivered to: answers to a question
* set, addressed to a tool call that is waiting on an approve/reject.
*
* Refused rather than reduced, because there is no honest reduction in that direction. A yes/no can
* be read as "confirmed the pre-picked answers" (see {@link normalizeElicitationReply}); a set of
* answers says nothing at all about whether the proposed work should go ahead, and reading its
* absent `approved` as `false` would record a rejection the person never made.
*/
export declare class HumanReplyMismatchError extends Error {
readonly runId: string;
readonly toolCallId: string;
constructor(runId: string, toolCallId: string);
}
/**
* Is this a human's yes/no {@link Decision}, rather than answers to a question set? Structural
* rather than by class: both shapes are plain JSON off an HTTP body by the time they arrive, and
* `approved` is the only field a `Decision` carries.
*/
export declare function isHumanDecision(reply: HumanReply): reply is Decision;
/** One choice a question offers. */
export interface ElicitationOption {
/** Stable identifier submitted back. Never shown to the user. */
value: string;
/** What the user reads. */
label: string;
/**
* A single character a UI may bind as a keyboard shortcut for this option. Advisory — nothing in
* the library reads it, and a client is free to render its own.
*/
hotkey?: string;
}
/** One question in a set. */
export interface ElicitationQuestion {
/** Unique within its request; the key answers come back under. */
id: string;
prompt: string;
/** A line of help under the prompt. */
description?: string;
/**
* The choices. Required unless {@link input} asks for a typed value — then optional, and a UI may
* offer them as suggestions. An `input` of type `select` still picks from these.
*/
options?: ElicitationOption[];
/**
* Ask for a typed value (a number, a date, a sentence…) instead of — or, for `select`, on top of —
* a pick from `options`. The answer is still a `string[]`, in the type's canonical form; see
* `elicitation-input.ts`.
*/
input?: ElicitationInput;
/** More than one option may be chosen. Omit → single choice. */
multiple?: boolean;
/**
* The options already picked for the user. The claim this whole surface makes is that confirming
* is enough, so a question with no defaults is a question the user must stop and think about —
* which is the case the design is trying to avoid. Empty/omitted is allowed and means exactly
* that: submitting without answering leaves this question unanswered.
*/
defaults?: string[];
/** Accept values that are not among `options` (a typed-in answer). Omit → options only. */
allowFreeText?: boolean;
}
/**
* A question set awaiting a human. Identical in shape whether an `@Agent`'s configured intake or
* the model's `ask` tool authored it — `source` records which, for audit, not for control flow.
*
* `questions.length` is known when the request is written, which is what lets a client render
* "Question 1 of 3" without guessing whether a fourth is coming.
*/
export interface ElicitationRequest {
/** The tool-call id this request is persisted under, and the signal it is answered through. */
id: string;
source: 'intake' | 'ask';
/** What the assistant says above the form. */
preamble?: string;
questions: ElicitationQuestion[];
}
/** What a human sent back for an {@link ElicitationRequest}. */
export interface ElicitationReply {
/**
* questionId → chosen values. A question whose id is ABSENT takes the request's own `defaults` —
* that is what makes "just submit" mean "yes, your pre-picked answers". A present-but-empty array
* is an explicit "none of these" and does NOT fall back.
*/
answers: Record<string, string[]>;
/**
* The user declined to answer and told the agent to proceed on its own assumptions. Distinct from
* confirming the defaults even though the resulting values are the same: one is a decision the
* user made, the other is one they refused to make, and only the first is evidence of intent.
*/
skipped?: boolean;
/** Opaque ref of WHO answered, when it wasn't the run's own actor. */
answeredByRef?: string;
/**
* The surface the answer came through — `'web'`, `'slack'`, `'console'`, … — the counterpart of
* an approval's `decidedVia`.
*/
answeredVia?: string;
}
/** A settled elicitation: what the agent proceeds on, and how it got there. */
export interface ElicitationOutcome {
/** One entry per question, in request order — always present, so a caller never re-applies defaults. */
answers: Record<string, string[]>;
skipped: boolean;
/** Question ids filled from the request's `defaults` rather than by the human. */
defaulted: string[];
/**
* Who answered (or skipped) — the reply's `answeredByRef` (the answering actor's id). The
* counterpart of an approval's `decidedBy`. Absent when the reply did not say.
*/
answeredBy?: string;
/** The surface it came through (`'web'`, `'slack'`, …) — an approval's `decidedVia`. */
answeredVia?: string;
}
/**
* Read whatever the human channel delivered as an {@link ElicitationReply}.
*
* A question set is persisted as an `action` tool call in `pending_approval` — that is what puts it
* in the approvals inbox a deployment already has, instead of needing one of its own. The cost of
* that choice is that the thing which comes back may be a {@link Decision} someone pressed
* Approve/Reject on rather than a set of answers, and a `Decision` carries no `answers` at all.
*
* Approve means every question keeps its own pre-picked `defaults`, which is exactly what "just
* submit" already means on this surface; Reject is the same declining-to-answer a skip is. Neither
* reading is a guess — a yes/no channel cannot say more than that, and saying it here is what lets
* one inbox settle both kinds of pending work.
*
* Returns the reply UNCHANGED when it already carries answers, so the common path allocates nothing
* and a caller can identity-compare.
*/
export declare function normalizeElicitationReply(reply: HumanReply): ElicitationReply;
/**
* Settle a reply against the request it answers: fill every unanswered question from its own
* `defaults`, drop submitted values that aren't on offer, and collapse a single-choice question to
* one value.
*
* PURE, and deliberately so. Both of its inputs are already journaled by the time the loop calls it
* — the request came from module config or from an `llm:<i>` checkpoint, the reply from the signal
* checkpoint — so every process replaying the turn reaches the same values without a checkpoint of
* its own. Resolving defaults in the HTTP layer instead would put them behind a store read that a
* replay would have to repeat.
*/
export declare function resolveElicitation(args: {
request: ElicitationRequest;
reply: HumanReply;
}): ElicitationOutcome;
/**
* What a settled elicitation looks like to everyone downstream: the model reading it back as a tool
* result, the thread reader rendering it, the auditor asking what the agent was told to do. One
* shape for both surfaces — nothing here records which of them asked.
*/
export interface ElicitationResult extends ElicitationOutcome {
/** The questions against the chosen LABELS, so a reader (and a model) can act on it. */
summary: string;
}
/** {@link resolveElicitation} plus its human-readable rendering. Pure, for the same reason. */
export declare function settleElicitation(args: {
request: ElicitationRequest;
reply: HumanReply;
}): ElicitationResult;
/**
* The answers as the model reads them: the question's own prompt against the chosen options' LABELS,
* not their opaque `value`s — a model shown `{"scope":["b"]}` has been told nothing.
*/
export declare function renderElicitationAnswers(args: {
request: ElicitationRequest;
outcome: ElicitationOutcome;
}): string;
/** The reserved tool name the model calls to ask the user something. */
export declare const ASK_TOOL_NAME = "ask";
/** What the model must supply when it calls `ask`. */
export interface AskToolInput {
preamble?: string;
questions: ElicitationQuestion[];
}
/** How many questions one `ask` may carry. A form the user has to scroll is a form they skip. */
export declare const MAX_ASK_QUESTIONS = 5;
/**
* The `ask` tool's input schema, hand-written rather than borrowed from a validation library: core
* depends on no validator, and the schema has to carry a JSON Schema a provider can constrain
* generation against. It publishes one through the Standard JSON Schema extension
* (`~standard.jsonSchema.input`), which is the path the AI SDK adapter already recognises for
* Valibot / ArkType / Zod 4.
*/
export declare const askInputSchema: StandardSchemaV1<unknown, AskToolInput>;
/**
* What the model is told the `ask` tool is for. Written to discourage the two failure modes that
* make a clarifying question worse than a guess: asking about something the conversation already
* settled, and asking without saying what you would have done.
*/
export declare const ASK_TOOL_DESCRIPTION = "Ask the user to settle the scope of the work before you do it. Use it when a reasonable person would produce a materially different result depending on the answer \u2014 not to confirm something the conversation already says. Every question must pre-pick the answer you would choose, so the user can confirm instead of deciding. The user may decline, in which case you proceed on those pre-picked answers.";
/**
* The `ask` tool as the model sees it. NOT a `ToolSpec` and never registered: `ask` has no handler,
* because the loop settles it against a human instead of invoking anything. Keeping it out of the
* `ToolRegistry` is also what keeps the kind decision off a process-local lookup — see
* `claimToolCall`.
*/
export declare function askToolDefinition(): ToolDefinition;
/** A question set an `@Agent` asks before it starts working. See `AgentLoopDeps.intake`. */
export interface AgentIntake {
questions: ElicitationQuestion[];
/** What the assistant says above the form. Omit → {@link DEFAULT_INTAKE_PREAMBLE}. */
preamble?: string;
/**
* `'thread-start'` (default) asks once, on the first turn of a thread; `'every-turn'` asks before
* every turn. Both are decided from what `load:thread` recorded about the thread when the turn
* began, never from anything this process happens to know — by the time a replay reaches the
* question, the thread already holds the assistant message the first attempt wrote.
*/
when?: 'thread-start' | 'every-turn';
}
export declare const DEFAULT_INTAKE_PREAMBLE = "A few questions before I start. I have pre-picked what I would choose, so confirming is enough.";
//# sourceMappingURL=elicitation.d.ts.map