|
| 1 | +import { randomUUID } from "node:crypto"; |
| 2 | +import { |
| 3 | + defineTool, |
| 4 | + type ExtensionAPI, |
| 5 | + type ExtensionFactory, |
| 6 | +} from "@earendil-works/pi-coding-agent"; |
| 7 | +import type { AskUserAnswer } from "../../protocol/commands.js"; |
| 8 | +import type { AskUserQuestion } from "../../protocol/events.js"; |
| 9 | +import type { AskFrontend } from "./frontend.js"; |
| 10 | +import { AskUserToolParams } from "./schema.js"; |
| 11 | + |
| 12 | +/** The tool id the model calls. */ |
| 13 | +export const ASK_USER_TOOL_NAME = "ask_user_question"; |
| 14 | + |
| 15 | +const TOOL_DESCRIPTION = |
| 16 | + "Ask the user one to four structured multiple-choice questions and wait for their answer. " + |
| 17 | + "Reach for this the moment a decision is ambiguous and costly to reverse, or to let the user " + |
| 18 | + "steer a plan - instead of guessing or listing the options as prose. " + |
| 19 | + "IMPORTANT: when you ask, CALL THIS TOOL as your action; do NOT also write the question or its " + |
| 20 | + "options as assistant text - the card renders them. The UI always offers the user a free-text " + |
| 21 | + '"Something else" answer, so you never need to add your own "other" option. ' + |
| 22 | + "Set `multiSelect` for 'choose any', and give an option a `preview` (markdown, e.g. a fenced " + |
| 23 | + "code/diff block) to show what it would change. The tool returns the user's selections as text."; |
| 24 | + |
| 25 | +const TOOL_PROMPT_SNIPPET = |
| 26 | + "ask_user_question — ask the user 1–4 multiple-choice questions and wait for their pick; use " + |
| 27 | + "when a decision is ambiguous or to let them steer a plan."; |
| 28 | + |
| 29 | +const TOOL_GUIDELINES = [ |
| 30 | + "When a choice is ambiguous and hard to undo, call ask_user_question instead of guessing.", |
| 31 | + "Call the tool directly - do not first write the question or options as plain text; the card " + |
| 32 | + "renders them. Narrating them as prose and then calling the tool duplicates everything.", |
| 33 | + "Keep each question focused with 2–4 distinct options and a one-line description each.", |
| 34 | + "Use one question for a single decision, or several to gather related choices at once " + |
| 35 | + "(typical while planning).", |
| 36 | + "A free-text answer is always available to the user - don't add your own 'other' / " + |
| 37 | + "'something else' option.", |
| 38 | + "Don't use it for trivial or easily reversible choices - just proceed.", |
| 39 | +]; |
| 40 | + |
| 41 | +export interface AskUserExtensionOptions { |
| 42 | + /** Where questions are presented and answers collected. pi-deck injects a GUI frontend. */ |
| 43 | + frontend: AskFrontend; |
| 44 | +} |
| 45 | + |
| 46 | +export interface AskUserController { |
| 47 | + /** Pass to `DefaultResourceLoader({ extensionFactories: [...] })`. */ |
| 48 | + readonly factory: ExtensionFactory; |
| 49 | + /** Cancel any in-flight questions (delegates to the frontend). */ |
| 50 | + dispose(): void; |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * pi-deck's self-contained "ask the user a question" plugin. It registers the |
| 55 | + * `ask_user_question` tool; its `execute()` suspends on the injected {@link AskFrontend} and |
| 56 | + * returns the user's answer as a clean tool result (no terminal dependency, no block-hack). |
| 57 | + * |
| 58 | + * The plugin owns no IO and no network: just the tool and a frontend port, so it can later be |
| 59 | + * lifted into a standalone package. |
| 60 | + */ |
| 61 | +export function createAskUserExtension(options: AskUserExtensionOptions): AskUserController { |
| 62 | + const { frontend } = options; |
| 63 | + |
| 64 | + const factory: ExtensionFactory = (pi: ExtensionAPI) => { |
| 65 | + pi.registerTool( |
| 66 | + defineTool({ |
| 67 | + name: ASK_USER_TOOL_NAME, |
| 68 | + label: "Ask the user", |
| 69 | + description: TOOL_DESCRIPTION, |
| 70 | + promptSnippet: TOOL_PROMPT_SNIPPET, |
| 71 | + promptGuidelines: TOOL_GUIDELINES, |
| 72 | + parameters: AskUserToolParams, |
| 73 | + async execute(toolCallId, params, signal) { |
| 74 | + const questions = params.questions as AskUserQuestion[]; |
| 75 | + const answer = await frontend.present( |
| 76 | + { askId: randomUUID(), toolCallId, questions }, |
| 77 | + signal, |
| 78 | + ); |
| 79 | + return { |
| 80 | + content: [{ type: "text" as const, text: formatAnswers(questions, answer) }], |
| 81 | + details: undefined, |
| 82 | + }; |
| 83 | + }, |
| 84 | + }), |
| 85 | + ); |
| 86 | + }; |
| 87 | + |
| 88 | + return { |
| 89 | + factory, |
| 90 | + dispose() { |
| 91 | + frontend.dispose?.(); |
| 92 | + }, |
| 93 | + }; |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * Render the user's answer as a compact, model-friendly transcript that becomes the tool |
| 98 | + * result. Index-aligned to `questions`, tolerant of a short/partial answers array. |
| 99 | + */ |
| 100 | +export function formatAnswers(questions: AskUserQuestion[], answer: AskUserAnswer): string { |
| 101 | + if (answer.cancelled) { |
| 102 | + return ( |
| 103 | + "The user dismissed the question without answering. Proceed using your best judgment, " + |
| 104 | + "or ask again if you still need a decision." |
| 105 | + ); |
| 106 | + } |
| 107 | + const blocks = questions.map((q, i) => { |
| 108 | + const n = i + 1; |
| 109 | + const a = answer.answers[i]; |
| 110 | + let ans: string; |
| 111 | + if (!a || a.skipped) { |
| 112 | + ans = "(skipped)"; |
| 113 | + } else { |
| 114 | + const picks: string[] = []; |
| 115 | + for (const idx of a.optionIndices) { |
| 116 | + const opt = q.options[idx]; |
| 117 | + if (opt) picks.push(opt.label); |
| 118 | + } |
| 119 | + |
| 120 | + if (a.custom?.trim()) picks.push(a.custom.trim()); |
| 121 | + ans = picks.length > 0 ? picks.join(", ") : "(no selection)"; |
| 122 | + } |
| 123 | + return `Q${n}: ${q.question}\nA${n}: ${ans}`; |
| 124 | + }); |
| 125 | + return blocks.join("\n\n"); |
| 126 | +} |
0 commit comments