/** * The `questions` an agent passes with a message to the user: the `message` tool's schema for them, and the checks * the schema cannot express. Rejected questions come back to the agent as the tool's error, so a card never shows a * question it cannot answer. */ import { arr, bool, int, obj, oneOf, opt, str } from '@earendil-works/pi-schema' import { ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable' import type { ConversationId, Tx } from '@earendil-works/pi-durable' import { formatAgo } from './compat.ts' import { Messages } from './api.ts' import { Standing } from '../subagents/api.ts' import type { Message } from './api.ts' import type { Question } from './refs.ts' const optionSchema = obj({ label: str({ minLength: 1, description: 'A few words; the reasoning goes in detail.', }), key: opt(str({ pattern: '^[a-z]$', description: 'Its letter, "a".' })), detail: opt(str()), recommended: opt(bool({ description: 'Your pick.' })), preview: opt(str({ description: 'Markdown shown beside the option.' })), }) /** The `questions` parameter of the `message` tool. */ export const questionsParameter = arr( obj({ text: str({ minLength: 1 }), kind: oneOf(['choice', 'multi', 'confirm', 'text', 'scale']), ref: opt(str({ pattern: '^\\d+$', description: 'Its number, "2".' })), options: opt(arr(optionSchema)), scale: opt( obj({ min: int(), max: int(), low: opt(str()), high: opt(str()), }), ), placeholder: opt(str()), }), { description: 'With to: user, the decisions the message asks, shown as its answer card: choice (one option), multi (any), confirm (yes/no), text (typed), scale (min–max with end labels). The card adds "Something else…" and a note field itself.', }, ) /** * The questions as the message stores them, or the reason they cannot be asked: text and labels must not be blank, a * choice needs two options with distinct labels and keys (a multi answer joins its labels with ", ", so they hold * none), a scale an ascending range of at most 10 steps, and the questions and their numbers must be distinct. Fields * that do not apply to a kind are dropped. */ export function checkQuestions(questions: Question[]): Question[] { if (!questions.length) throw new Error('questions must name at least one question') const refs = new Set() const texts = new Set() return questions.map((question, index) => { const where = `question ${question.ref ?? index + 1}` const text = question.text.trim() if (!text) throw new Error(`${where} has no text`) if (texts.has(text)) throw new Error(`${where} repeats another question's text`) texts.add(text) if (question.ref !== undefined) { if (refs.has(question.ref)) throw new Error(`${where} repeats another question's ref`) refs.add(question.ref) } const kept: Question = { text, kind: question.kind, ...(question.ref ? { ref: question.ref } : {}), } if (question.kind === 'choice' || question.kind === 'multi') { const options = (question.options ?? []).map( ({ label, key, detail, recommended, preview }) => ({ label: label.trim(), ...(key ? { key } : {}), ...(detail?.trim() ? { detail: detail.trim() } : {}), ...(recommended ? { recommended: true } : {}), ...(preview?.trim() ? { preview } : {}), }), ) if (options.length < 2) throw new Error(`${where} needs at least two options`) if (options.some((o) => !o.label)) throw new Error(`${where} has an option without a label`) if (new Set(options.map((o) => o.label)).size !== options.length) throw new Error(`${where} repeats an option label`) const keys = options.flatMap((o) => (o.key ? [o.key] : [])) if (new Set(keys).size !== keys.length) throw new Error(`${where} repeats an option key`) if (options.some((o) => /^(something else|other)[.…]*$/i.test(o.label))) throw new Error(`${where}: the card offers "Something else…" itself; leave that option out`) if (question.kind === 'multi' && options.some((o) => o.label.includes(', '))) throw new Error( `${where}: a multi answer joins its labels with ", ", so its labels hold no ", "`, ) kept.options = options } else if (question.kind === 'scale') { const scale = question.scale if (!scale) throw new Error(`${where} needs scale { min, max }`) if (scale.max <= scale.min || scale.max - scale.min > 9) throw new Error(`${where}: a scale runs upward over at most 10 steps`) kept.scale = { min: scale.min, max: scale.max, ...(scale.low?.trim() ? { low: scale.low.trim() } : {}), ...(scale.high?.trim() ? { high: scale.high.trim() } : {}), } } else if (question.kind === 'text' && question.placeholder?.trim()) { kept.placeholder = question.placeholder.trim() } return kept }) } /** A reply-enabled message to the user that the user has neither answered nor dismissed yet. */ export const openQuestion = (m: Message) => m.to === 'user' && m.reply && m.state === 'delivered' /** A message in one line: its questions by number, else its first line that asks something, else its first line, clipped. */ export const gist = (message: Pick) => { const lines = message.questions?.length ? [`questions ${message.questions.map((q, i) => `${q.ref ?? i + 1}. ${q.text}`).join('; ')}`] : message.text .split('\n') .map((line) => line.replace(/\*\*/g, '').trim()) .filter(Boolean) const line = lines.find((candidate) => candidate.includes('?')) ?? lines[0] ?? '' return line.length > 100 ? `${line.slice(0, 99)}…` : line } /** * The input that starts a run of the conversation that asked the user questions goes in after a line naming those still * open, so the agent treats them as unanswered. A per-input entry, not a system-prompt section, keeps the prompt cached. */ export async function openQuestionsNote(tx: Tx, conversationId: ConversationId, now: number) { // @ultimate sees every open question to the user, since it settles those the user decides in its chat. const primary = (await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate === conversationId const open = (await tx.doc(Messages, ROOT_CONVERSATION_ID)).messages.filter( (m) => openQuestion(m) && (primary || m.from === conversationId), ) if (!open.length) return undefined const asker = (m: Message) => (m.from === conversationId ? '' : ` from #${m.from}`) const text = `Open questions to the user, unanswered until they answer or dismiss them:\n${open .map( (m) => `- message ${m.id}${asker(m)}: ${gist(m)} (asked ${formatAgo(m.at, now, { ago: true })})`, ) .join( '\n', )}${primary ? `\nWhen the user decides one in your chat, settle it with ultimate.action('messages', 'settle', { messageId, text }).` : ''}` return { entries: [ { kind: 'board.open', data: { ids: open.map((m) => m.id) }, model: [{ role: 'user' as const, content: text, timestamp: now }], }, ], } }