UNPKG

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