Let bots ask multiple-choice questions before acting

ask_user puts up to six questions on one card (radios for one pick,
checkboxes for several, an optional typed Other) as a [question:ID] chip.
The user's answers are checked against the questions, stored in
bot_questions and sent to the bot as their chat message; Skip says so.
Offered in chat replies and user-started tasks, never in routines, hook
calls or chatroom sessions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 11:10:23 +01:00
co-authored by Claude Opus 5.5
parent 07f87ce4ea
commit d7dfe0e78e
45 changed files with 1448 additions and 6 deletions
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import askUserTool from "./ask-user-tool";
const tool = askUserTool({ user_id: 1, bot_id: 2, session_id: 3, onAsk: () => {} });
const question = { question: "Which environment?", kind: "single", options: ["Staging", "Production"] };
describe("askUserTool", () => {
test("takes a set of questions", () => {
expect(tool.schema?.safeParse({ title: "Before I deploy", questions: [question] }).success).toBe(true);
});
test("needs at least two distinct options", () => {
expect(tool.schema?.safeParse({ questions: [{ ...question, options: ["Staging"] }] }).success).toBe(false);
expect(tool.schema?.safeParse({ questions: [{ ...question, options: ["Staging", "staging"] }] }).success).toBe(false);
});
test("needs a known kind and at least one question", () => {
expect(tool.schema?.safeParse({ questions: [{ ...question, kind: "ranked" }] }).success).toBe(false);
expect(tool.schema?.safeParse({ questions: [] }).success).toBe(false);
});
test("caps how many questions one card holds", () => {
expect(tool.schema?.safeParse({ questions: Array.from({ length: 7 }, () => question) }).success).toBe(false);
});
});
@@ -0,0 +1,81 @@
import { z } from "zod";
import createBotQuestion from "@/src/functions/backend/db/bot-questions/create-bot-question";
import { BotQuestionItemsSchema } from "@/src/schemas/bot-question-schema";
import botQuestionRef from "@/src/utils/bot-question-ref";
import type { HarnessTool } from "../../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The conversation they're asked in, where their card shows.
*/
session_id: number | string;
workspace_id?: number | string | null;
/**
* Whether the run is a task, which stops and reports rather than waits.
*/
in_task?: boolean;
/**
* Called with the questions' ref, so the message always carries their
* card.
*/
onAsk: (params: { ref: string }) => void;
};
type Args = {
title?: string;
questions: z.infer<typeof BotQuestionItemsSchema>;
};
/**
* Lets a bot make sure of what the user wants before it acts: it asks a
* few multiple-choice questions, the user answers them in a card (radios
* for one pick, checkboxes for several, "Other" to type their own), and
* the answers come back as the user's next message. Once per run, so every
* question goes on one card.
*/
export default function askUserTool({ user_id, bot_id, session_id, workspace_id, in_task, onAsk }: Params): HarnessTool<Args> {
let asked = false;
return {
name: "ask_user",
description: `Ask the user multiple-choice questions to be sure what they want before you act, shown as a card in the chat they answer by picking options. Use it when the request is ambiguous or open to more than one reading, when a choice is theirs to make (which target, which items, how far to go), or before something that is hard to undo, costly or visible to others, and you can't tell what they'd pick. Don't ask what they already told you, what you can find out yourself, or what any sensible choice would settle. Put every question in one call (at most one per reply). Their answers arrive as their next message${
in_task ? `. In a task, ask, then stop and report that you're waiting for their answers.` : `: after asking, end your reply and wait; don't act on what you asked about until they answer.`
}`,
schema: z.object({
title: z.string().trim().max(120).optional().describe("A short heading for the card, e.g. \"Before I deploy\""),
questions: BotQuestionItemsSchema.describe("The questions, in the order to answer them"),
}),
run: async ({ args }) => {
if (asked) {
throw new Error(`You already asked the user in this run. Put every question in one ask_user call, then wait for their answers.`);
}
const id = await createBotQuestion({
user_id,
bot_id,
session_id,
workspace_id,
title: args.title?.trim() || "",
questions: args.questions.map((item) => ({
question: item.question,
kind: item.kind,
options: item.options,
allow_other: Boolean(item.allow_other),
})),
});
asked = true;
onAsk({ ref: botQuestionRef({ id }) });
const count = args.questions.length;
return `Asked the user ${count} question${count == 1 ? "" : "s"}: the card is on your message. ${
in_task
? `Stop here and report that you're waiting for their answers.`
: `End your reply now with a short line saying what you need settled; their answers arrive as their next message.`
} Don't repeat the questions or options in your text.`;
},
};
}