@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
382 lines • 18.6 kB
JavaScript
/**
* 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 { ELICITATION_INPUT_TYPES, questionOptions, readElicitationInput, validateElicitationValue, } from './elicitation-input.js';
/**
* 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 class HumanReplyMismatchError extends Error {
runId;
toolCallId;
constructor(runId, toolCallId) {
super(`Tool call "${toolCallId}" on run "${runId}" is waiting for an approve/reject, not for answers`);
this.runId = runId;
this.toolCallId = toolCallId;
this.name = 'HumanReplyMismatchError';
}
}
/**
* 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 function isHumanDecision(reply) {
return (typeof reply === 'object' &&
reply !== null &&
typeof reply.approved === 'boolean');
}
/**
* 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 function normalizeElicitationReply(reply) {
if (typeof reply !== 'object' || reply === null) {
return { answers: {} };
}
const candidate = reply;
if (typeof candidate.answers === 'object' && candidate.answers !== null) {
return reply;
}
const answeredByRef = candidate.answeredByRef ?? candidate.executedByRef;
const answeredVia = candidate.answeredVia ?? candidate.decidedVia;
return {
answers: {},
...(candidate.approved === false || candidate.skipped === true ? { skipped: true } : {}),
...(answeredByRef !== undefined ? { answeredByRef } : {}),
...(answeredVia !== undefined ? { answeredVia } : {}),
};
}
/**
* 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 function resolveElicitation(args) {
const { request } = args;
const reply = normalizeElicitationReply(args.reply);
const answers = {};
const defaulted = [];
for (const question of request.questions) {
const submitted = reply.skipped === true ? undefined : reply.answers[question.id];
if (submitted === undefined) {
answers[question.id] = [...(question.defaults ?? [])];
defaulted.push(question.id);
continue;
}
// What cannot be settled is dropped, never guessed at: a value off the offered options, or one a
// typed question's own rules refuse. The HTTP surface refuses both before they are signalled; this
// is the same rule applied where the loop settles, so a reply from anywhere reads the same.
const valid = submitted.filter((value) => value !== '' && validateElicitationValue(question, value) === null);
answers[question.id] = question.multiple === true ? valid : valid.slice(0, 1);
}
return {
answers,
skipped: reply.skipped === true,
defaulted,
...(reply.answeredByRef !== undefined ? { answeredBy: reply.answeredByRef } : {}),
...(reply.answeredVia !== undefined ? { answeredVia: reply.answeredVia } : {}),
};
}
/** {@link resolveElicitation} plus its human-readable rendering. Pure, for the same reason. */
export function settleElicitation(args) {
const outcome = resolveElicitation(args);
return { ...outcome, summary: renderElicitationAnswers({ request: args.request, outcome }) };
}
/**
* 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 function renderElicitationAnswers(args) {
const { request, outcome } = args;
const lines = request.questions.map((question) => {
const chosen = outcome.answers[question.id] ?? [];
const labels = chosen.map((value) => questionOptions(question).find((option) => option.value === value)?.label ?? value);
return `${question.prompt} → ${labels.length > 0 ? labels.join(', ') : '(no answer)'}`;
});
const preface = outcome.skipped
? 'The user declined to answer and asked you to proceed on these assumptions:'
: 'The user answered:';
return `${preface}\n${lines.join('\n')}`;
}
/** The reserved tool name the model calls to ask the user something. */
export const ASK_TOOL_NAME = 'ask';
/** How many questions one `ask` may carry. A form the user has to scroll is a form they skip. */
export const MAX_ASK_QUESTIONS = 5;
function issue(path, message) {
return { message, path };
}
function parseOption(raw, path, issues) {
if (typeof raw !== 'object' || raw === null) {
issues.push(issue(path, 'must be an object'));
return undefined;
}
const candidate = raw;
if (typeof candidate.value !== 'string' || candidate.value.length === 0) {
issues.push(issue([...path, 'value'], 'must be a non-empty string'));
return undefined;
}
if (typeof candidate.label !== 'string' || candidate.label.length === 0) {
issues.push(issue([...path, 'label'], 'must be a non-empty string'));
return undefined;
}
return {
value: candidate.value,
label: candidate.label,
...(typeof candidate.hotkey === 'string' ? { hotkey: candidate.hotkey } : {}),
};
}
function parseQuestion(raw, path, issues) {
if (typeof raw !== 'object' || raw === null) {
issues.push(issue(path, 'must be an object'));
return undefined;
}
const candidate = raw;
if (typeof candidate.id !== 'string' || candidate.id.length === 0) {
issues.push(issue([...path, 'id'], 'must be a non-empty string'));
return undefined;
}
if (typeof candidate.prompt !== 'string' || candidate.prompt.length === 0) {
issues.push(issue([...path, 'prompt'], 'must be a non-empty string'));
return undefined;
}
let input;
if (candidate.input !== undefined) {
input = readElicitationInput(candidate.input);
if (input === undefined) {
issues.push(issue([...path, 'input', 'type'], `must be one of ${ELICITATION_INPUT_TYPES.join(', ')}`));
return undefined;
}
}
const described = typeof candidate.description === 'string' && candidate.description.length > 0
? { description: candidate.description }
: {};
const typed = input !== undefined && input.type !== 'select';
if (!typed && (!Array.isArray(candidate.options) || candidate.options.length === 0)) {
issues.push(issue([...path, 'options'], 'must be a non-empty array'));
return undefined;
}
const options = [];
for (const [index, rawOption] of (Array.isArray(candidate.options)
? candidate.options
: []).entries()) {
const option = parseOption(rawOption, [...path, 'options', index], issues);
if (option !== undefined) {
options.push(option);
}
}
if (typed) {
// A typed question pre-picks when it can (a sensible date, a default count) but need not: there
// is no honest default for "what is your email". What it does pre-pick has to be an answer its
// own rules accept, or confirming would submit something the form would refuse.
const question = {
id: candidate.id,
prompt: candidate.prompt,
...described,
...(options.length > 0 ? { options } : {}),
input: input,
...(candidate.multiple === true ? { multiple: true } : {}),
};
const offered = Array.isArray(candidate.defaults)
? candidate.defaults.filter((value) => typeof value === 'string')
: [];
const defaults = offered.filter((value) => validateElicitationValue(question, value) === null);
if (offered.length > 0 && defaults.length === 0) {
issues.push(issue([...path, 'defaults'], `must be valid ${input.type} values`));
return undefined;
}
return defaults.length > 0 ? { ...question, defaults } : question;
}
// The one rule that carries the design: a question with no pre-picked answer is a question the
// user has to stop and think about, and this surface exists to avoid that.
if (!Array.isArray(candidate.defaults) || candidate.defaults.length === 0) {
issues.push(issue([...path, 'defaults'], 'must pre-pick at least one option — say what you would choose so the user can just confirm'));
return undefined;
}
const offered = new Set(options.map((option) => option.value));
const defaults = candidate.defaults.filter((value) => typeof value === 'string' && offered.has(value));
if (defaults.length === 0) {
issues.push(issue([...path, 'defaults'], "must name values that appear in this question's options"));
return undefined;
}
return {
id: candidate.id,
prompt: candidate.prompt,
...described,
options,
...(input !== undefined ? { input } : {}),
defaults,
...(candidate.multiple === true ? { multiple: true } : {}),
...(candidate.allowFreeText === true ? { allowFreeText: true } : {}),
};
}
const ASK_JSON_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
preamble: {
type: 'string',
description: 'One sentence shown above the form, e.g. "Three questions before I start. I have pre-picked what I would choose, so confirming is enough."',
},
questions: {
type: 'array',
minItems: 1,
maxItems: MAX_ASK_QUESTIONS,
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'prompt'],
properties: {
id: {
type: 'string',
description: 'Unique within this call; answers come back under it.',
},
prompt: { type: 'string' },
description: { type: 'string', description: 'A line of help under the prompt.' },
input: {
type: 'object',
additionalProperties: false,
required: ['type'],
description: 'Ask for a typed value instead of offering options. Omit for a pick from options. Answers come back as strings: numbers as decimals, booleans as "true"/"false", dates as YYYY-MM-DD.',
properties: {
type: { type: 'string', enum: [...ELICITATION_INPUT_TYPES] },
placeholder: { type: 'string' },
required: { type: 'boolean' },
min: {
type: ['number', 'string'],
description: 'number: minimum; text: minimum length; date: earliest YYYY-MM-DD.',
},
max: {
type: ['number', 'string'],
description: 'number: maximum; text: maximum length; date: latest YYYY-MM-DD.',
},
pattern: { type: 'string', description: 'A regex the whole value must match.' },
},
},
multiple: { type: 'boolean', description: 'Allow more than one option to be chosen.' },
allowFreeText: {
type: 'boolean',
description: 'Accept an answer that is not an option.',
},
defaults: {
type: 'array',
minItems: 1,
items: { type: 'string' },
description: 'REQUIRED for a pick from options: the option values you would pick yourself, so the user can confirm rather than decide. For a typed input, the value you would enter, when there is a sensible one.',
},
options: {
type: 'array',
minItems: 2,
items: {
type: 'object',
additionalProperties: false,
required: ['value', 'label'],
properties: {
value: { type: 'string' },
label: { type: 'string' },
hotkey: { type: 'string', maxLength: 1 },
},
},
},
},
},
},
},
required: ['questions'],
};
/**
* 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 const askInputSchema = {
'~standard': {
version: 1,
vendor: 'adonis-agora-agent',
validate: (value) => {
const issues = [];
if (typeof value !== 'object' || value === null) {
return { issues: [issue([], 'must be an object')] };
}
const candidate = value;
if (!Array.isArray(candidate.questions) || candidate.questions.length === 0) {
return { issues: [issue(['questions'], 'must be a non-empty array')] };
}
if (candidate.questions.length > MAX_ASK_QUESTIONS) {
return {
issues: [issue(['questions'], `must hold at most ${MAX_ASK_QUESTIONS} questions`)],
};
}
const questions = [];
for (const [index, raw] of candidate.questions.entries()) {
const question = parseQuestion(raw, ['questions', index], issues);
if (question !== undefined) {
questions.push(question);
}
}
if (issues.length > 0) {
return { issues };
}
return {
value: {
questions,
...(typeof candidate.preamble === 'string' ? { preamble: candidate.preamble } : {}),
},
};
},
jsonSchema: { input: () => ASK_JSON_SCHEMA },
},
};
/**
* 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 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 — 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 function askToolDefinition() {
return {
name: ASK_TOOL_NAME,
kind: 'ask',
description: ASK_TOOL_DESCRIPTION,
inputSchema: askInputSchema,
};
}
export const DEFAULT_INTAKE_PREAMBLE = 'A few questions before I start. I have pre-picked what I would choose, so confirming is enough.';
//# sourceMappingURL=elicitation.js.map