From 414dcd1a603b0f82b3af9a8f96b004d0a4d60e81 Mon Sep 17 00:00:00 2001 From: jbreite Date: Sun, 15 Mar 2026 16:59:06 -0400 Subject: [PATCH] Refactor AskUser tool to deferred client-rendered model Replace server-side onQuestion/onStructuredQuestions handlers with a deferred tool that emits structured questions for the client to render. Each question now has a stable `id` for deterministic answer mapping. Simplifies config from `askUser: { onQuestion }` to `askUser: true`. Bumps version to 0.6.0 (breaking change). Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 3 +- README.md | 11 +- package.json | 2 +- src/index.ts | 9 +- src/tools/AGENTS.md | 12 +- src/tools/ask-user.ts | 270 +++++++------------------------------- src/tools/index.ts | 15 ++- src/types.ts | 7 +- tests/tools/index.test.ts | 6 +- 9 files changed, 76 insertions(+), 259 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5255fa4..d656162 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ See also `CLAUDE.md` for development workflow and conventions. | Tool | Purpose | Config Key | |------|---------|------------| -| `AskUser` | Ask user clarifying questions | `askUser: { onQuestion? }` | +| `AskUser` | Ask user clarifying questions | `askUser: true` | | `EnterPlanMode` | Enter planning/exploration mode | `planMode: true` | | `ExitPlanMode` | Exit planning mode with a plan | `planMode: true` | | `Skill` | Execute skills | `skill: { skills }` | @@ -656,4 +656,3 @@ const cachedTool = cached(myTool, "MyTool", { store: new LRUCacheStore(500), // Max 500 entries }); ``` - diff --git a/README.md b/README.md index 042564c..190af88 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ await sandbox.destroy(); | Tool | Purpose | Config Key | |------|---------|------------| -| `AskUser` | Ask user clarifying questions | `askUser: { onQuestion? }` | +| `AskUser` | Ask user clarifying questions | `askUser: true` | | `EnterPlanMode` | Enter planning/exploration mode | `planMode: true` | | `ExitPlanMode` | Exit planning mode with a plan | `planMode: true` | | `Skill` | Execute skills | `skill: { skills }` | @@ -221,12 +221,7 @@ You can configure tools with security restrictions and limits, and enable option ```typescript const { tools, planModeState } = createAgentTools(sandbox, { // Enable optional tools - askUser: { - onQuestion: async (question) => { - // Return user's answer, or undefined to return awaiting_response - return await promptUser(question); - }, - }, + askUser: true, planMode: true, // Enables EnterPlanMode and ExitPlanMode skill: { skills: discoveredSkills, // From discoverSkills() @@ -917,7 +912,7 @@ Creates a set of agent tools bound to a sandbox instance. ### Optional Tools (also available via config) -- `createAskUserTool(onQuestion?)` - Ask user for clarification +- `createAskUserTool(config?)` - Emit a deferred AskUser tool call for the client - `createEnterPlanModeTool(state)` - Enter planning/exploration mode - `createExitPlanModeTool(state, onPlanSubmit?)` - Exit planning mode with a plan - `createSkillTool(skills)` - Execute loaded skills diff --git a/package.json b/package.json index 7ca7d0b..3d3c98a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bashkit", - "version": "0.5.3", + "version": "0.6.0", "description": "Agentic coding tools for the Vercel AI SDK", "type": "module", "main": "./dist/index.js", diff --git a/src/index.ts b/src/index.ts index 4144f43..719399f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,11 +38,12 @@ export type { // Result type from createAgentTools AgentToolsResult, // AskUser tool - AskUserError, + AskUserAnswers, + AskUserInput, AskUserOutput, - AskUserResponseHandler, - QuestionOption, - StructuredQuestion, + AskUserQuestion, + AskUserQuestionOption, + AskUserToolConfig, // Sandbox tools BashError, BashOutput, diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 15d5f84..0a0132c 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -12,7 +12,7 @@ The tools module implements all 15 AI agent tools in BashKit. These tools bridge | `edit.ts` | String-based find/replace editing with uniqueness validation | | `glob.ts` | Pattern-based file discovery using find command | | `grep.ts` | Ripgrep-powered content search with context and filtering | -| `ask-user.ts` | Interactive Q&A with simple and structured question formats | +| `ask-user.ts` | Deferred structured user Q&A rendered by the client | | `enter-plan-mode.ts` | Enter planning mode for explore-then-execute workflows | | `exit-plan-mode.ts` | Exit planning mode and submit plan for approval | | `skill.ts` | Activate pre-loaded skills from SKILL.md files | @@ -32,7 +32,7 @@ The tools module implements all 15 AI agent tools in BashKit. These tools bridge - `createEditTool(sandbox, config?)` -- String replacement editing - `createGlobTool(sandbox, config?)` -- File pattern matching - `createGrepTool(sandbox, config?)` -- Content search with ripgrep -- `createAskUserTool(config?)` -- User interaction tool +- `createAskUserTool(config?)` -- Deferred user interaction tool - `createEnterPlanModeTool(state, onEnter?)` -- Planning mode entry - `createExitPlanModeTool(onPlanSubmit?)` -- Planning mode exit - `createSkillTool(config)` -- Skill activation @@ -44,14 +44,14 @@ The tools module implements all 15 AI agent tools in BashKit. These tools bridge ### Output Types Each tool exports `Output` for success and `Error` for errors: - Sandbox tools: `BashOutput | BashError`, `ReadOutput | ReadError`, etc. -- Interactive tools: `AskUserOutput | AskUserError`, etc. +- Interactive tools: `AskUserOutput`, etc. - Workflow tools: `TaskOutput | TaskError`, `TodoWriteOutput | TodoWriteError` - Web tools: `WebSearchOutput | WebSearchError`, `WebFetchOutput | WebFetchError` ### Configuration Types - `AgentConfig` -- Top-level config for createAgentTools() - `ToolConfig` -- Per-tool config (timeout, allowedPaths, maxFileSize, etc.) -- `AskUserConfig` -- Ask user handlers +- `AskUserConfig` -- AskUser AI SDK tool options - `SkillConfig` -- Skill metadata and sandbox - `TaskToolConfig` -- Sub-agent configuration (includes optional `budget` for auto-wiring cost tracking) - `ModelRegistryConfig` -- Model registry config (provider, apiKey) for fetching model info @@ -66,7 +66,7 @@ Each tool exports `Output` for success and `Error` for errors: - Bash, Read, Write, Edit, Glob, Grep -- Direct sandbox operations via Sandbox interface **Interactive Tools** (opt-in via config): -- AskUser -- User Q&A with simple and structured formats +- AskUser -- Deferred structured user Q&A rendered by the client - EnterPlanMode, ExitPlanMode -- Plan-then-execute workflow - Skill -- Load specialized instructions from SKILL.md files @@ -81,7 +81,7 @@ Each tool exports `Output` for success and `Error` for errors: ### Data Flow 1. **Tool Creation**: `createAgentTools()` → individual `create*Tool()` factories → `tool()` from AI SDK -2. **Execution**: AI model calls tool → `execute()` function → sandbox operation or external API → return Output or Error +2. **Execution**: AI model calls tool → `execute()` function or deferred client round-trip → sandbox operation or external API → return Output or Error 3. **Caching** (optional): `resolveCache()` wraps cacheable tools with `cached()` from cache module 4. **Model Registry** (optional): `createAgentTools()` fetches model info (pricing + context lengths) from a provider (e.g., OpenRouter). Data is shared with budget tracking and returned as `openRouterModels` in the result. 5. **Budget** (optional): `createAgentTools()` creates a `BudgetTracker` from config, using pricing derived from model registry or manual overrides. Returns it for wiring into `onStepFinish`/`stopWhen`. Auto-wires into Task tool sub-agents. diff --git a/src/tools/ask-user.ts b/src/tools/ask-user.ts index a3a31e6..a6c2760 100644 --- a/src/tools/ask-user.ts +++ b/src/tools/ask-user.ts @@ -1,86 +1,42 @@ import { tool, zodSchema } from "ai"; import { z } from "zod"; -import { - debugEnd, - debugError, - debugStart, - isDebugEnabled, -} from "../utils/debug"; +import type { SDKToolOptions } from "../types"; -// Option for structured questions -export interface QuestionOption { - label: string; - description: string | null; -} - -// Structured question with options -export interface StructuredQuestion { - header: string | null; // Short label (max 12 chars), displayed as chip/tag - question: string; - options: QuestionOption[] | null; - multiSelect: boolean | null; -} - -// Simple question output (backward compatible) -export interface AskUserSimpleOutput { - question: string; - awaiting_response: true; -} - -// Structured questions output -export interface AskUserStructuredOutput { - questions: StructuredQuestion[]; - awaiting_response: true; -} - -export type AskUserOutput = AskUserSimpleOutput | AskUserStructuredOutput; - -export interface AskUserError { - error: string; -} - -// Answer can be a string (simple) or object with answers keyed by question -export interface AskUserAnswerOutput { - answer: string; - answers?: Record; -} +// --- Schemas --- -// Schema for option const questionOptionSchema = z.object({ label: z .string() .describe( - "The display text for this option. Should be concise (1-5 words). Add '(Recommended)' suffix for suggested options.", + "User-facing label (1-5 words). Put the recommended option first and suffix its label with '(Recommended)'.", ), description: z .string() - .nullable() - .default(null) - .describe("Explanation of what this option means or its implications."), + .describe( + "One short sentence explaining the impact or tradeoff if this option is selected.", + ), }); -// Schema for structured question -const structuredQuestionSchema = z.object({ - header: z +const questionSchema = z.object({ + id: z .string() - .nullable() - .default(null) .describe( - "Very short label displayed as a chip/tag (max 12 chars). Examples: 'Auth method', 'Library', 'Approach'.", + "Stable identifier for mapping answers. Use snake_case, e.g. 'time_period' or 'auth_method'.", ), - question: z + header: z .string() .describe( - "The complete question to ask the user. Should be clear and specific.", + "Short header label shown in the UI (12 or fewer chars). Examples: 'Auth method', 'Library', 'Approach'.", ), + question: z + .string() + .describe("Single-sentence prompt shown to the user."), options: z .array(questionOptionSchema) .min(2) - .max(4) - .nullable() - .default(null) + .max(3) .describe( - "Available choices for this question. 2-4 options. An 'Other' option is automatically available to users.", + "2-3 mutually exclusive choices. Do not include an 'Other' option; the client adds a free-form 'Other' option automatically.", ), multiSelect: z .boolean() @@ -91,197 +47,65 @@ const structuredQuestionSchema = z.object({ ), }); -// Input schema supports both simple question string and structured questions const askUserInputSchema = z.object({ - question: z - .string() - .nullable() - .default(null) - .describe( - "Simple question string (for backward compatibility). Use 'questions' for structured multi-choice.", - ), questions: z - .array(structuredQuestionSchema) + .array(questionSchema) .min(1) .max(4) - .nullable() - .default(null) - .describe("Structured questions with options (1-4 questions)."), + .describe("1-4 questions to ask the user. Prefer 1, do not exceed 4."), }); -type AskUserInput = z.infer; +// --- Types --- + +export type AskUserInput = z.infer; +export type AskUserQuestion = z.infer; +export type AskUserQuestionOption = z.infer; -// Handler for simple questions (backward compatible) -export type AskUserResponseHandler = ( - question: string, -) => Promise | string; +/** Answers keyed by question id */ +export type AskUserAnswers = Record; -// Handler for structured questions -export type AskUserStructuredHandler = ( - questions: StructuredQuestion[], -) => - | Promise> - | Record; +/** Tool output returned later by the client keyed by question id */ +export type AskUserOutput = AskUserAnswers; -const ASK_USER_DESCRIPTION = `Use this tool when you need to ask the user questions during execution. +// --- Tool description --- -**Capabilities:** -- Gather user preferences or requirements -- Clarify ambiguous instructions -- Get decisions on implementation choices -- Offer choices about what direction to take +const ASK_USER_DESCRIPTION = `Request structured user input with 1-4 short questions. Each question must have 2-3 mutually exclusive options. Wait for the response before continuing. **When to use:** -- You need clarification on ambiguous requirements - Multiple valid approaches exist and user preference matters - You're about to make a decision with significant consequences - Required information is missing from the context **When NOT to use:** -- You can make a reasonable assumption -- The question is trivial or can be inferred -- You're just being overly cautious +- You can make a reasonable assumption or infer the answer +- You just need to ask a free-form question (use your normal response instead) -**Simple question format:** -Use the 'question' parameter for a single free-form question. +**Format:** +- Prefer 1 question, do not exceed 4 +- Each question needs a unique 'id' (snake_case), a short 'header' (≤12 chars), and 2-3 options +- Put the recommended option first and suffix its label with "(Recommended)" +- Do not include an "Other" option; the client adds one automatically +- Use multiSelect: true only when the user should select multiple options`; -**Structured questions format:** -Use the 'questions' parameter for multiple-choice questions with options: -- 1-4 questions allowed -- Each question can have 2-4 options with labels and descriptions -- Use multiSelect: true to allow multiple answers -- Users can always select "Other" to provide custom text input -- Place recommended option first and add "(Recommended)" to label`; +// --- Config --- -export interface AskUserToolConfig { - /** Handler for simple string questions */ - onQuestion?: AskUserResponseHandler; - /** Handler for structured questions with options */ - onStructuredQuestions?: AskUserStructuredHandler; -} +export type AskUserToolConfig = SDKToolOptions; /** * Creates a tool for asking the user clarifying questions. * - * Supports both simple string questions and structured multi-choice questions. + * Always uses a `questions` array (even for a single question). + * Each question has a stable `id` so answers are keyed deterministically. * - * @param config - Configuration with optional handlers for questions + * This tool is intentionally deferred: it emits a tool call for the client to + * render, and the caller is expected to provide the tool output later. + * + * @param config - Optional AI SDK tool options */ -export function createAskUserTool( - config?: AskUserToolConfig | AskUserResponseHandler, -) { - // Support both old signature (just handler) and new config object - const normalizedConfig: AskUserToolConfig = - typeof config === "function" ? { onQuestion: config } : (config ?? {}); - +export function createAskUserTool(config: AskUserToolConfig = {}) { return tool({ description: ASK_USER_DESCRIPTION, inputSchema: zodSchema(askUserInputSchema), - execute: async ( - input: AskUserInput, - ): Promise => { - const startTime = performance.now(); - const debugId = isDebugEnabled() - ? debugStart("ask-user", { - hasQuestion: !!input.question, - questionCount: input.questions?.length ?? 0, - question: input.question - ? input.question.length > 100 - ? `${input.question.slice(0, 100)}...` - : input.question - : undefined, - }) - : ""; - - try { - // Validate input - must have either question or questions - if (!input.question && !input.questions) { - const error = "Either 'question' or 'questions' must be provided"; - if (debugId) debugError(debugId, "ask-user", error); - return { error }; - } - - // Handle structured questions - if (input.questions && input.questions.length > 0) { - if (normalizedConfig.onStructuredQuestions) { - const answers = await normalizedConfig.onStructuredQuestions( - input.questions, - ); - // Return first answer as 'answer' for compatibility, plus all answers - const firstKey = Object.keys(answers)[0]; - const firstAnswer = answers[firstKey]; - - const durationMs = Math.round(performance.now() - startTime); - if (debugId) { - debugEnd(debugId, "ask-user", { - summary: { - type: "structured", - answerCount: Object.keys(answers).length, - }, - duration_ms: durationMs, - }); - } - - return { - answer: Array.isArray(firstAnswer) - ? firstAnswer.join(", ") - : firstAnswer, - answers, - }; - } - - // No handler - return awaiting state - const durationMs = Math.round(performance.now() - startTime); - if (debugId) { - debugEnd(debugId, "ask-user", { - summary: { type: "structured", awaiting: true }, - duration_ms: durationMs, - }); - } - return { - questions: input.questions, - awaiting_response: true, - }; - } - - // Handle simple question (backward compatible) - if (input.question) { - if (normalizedConfig.onQuestion) { - const answer = await normalizedConfig.onQuestion(input.question); - - const durationMs = Math.round(performance.now() - startTime); - if (debugId) { - debugEnd(debugId, "ask-user", { - summary: { type: "simple", hasAnswer: true }, - duration_ms: durationMs, - }); - } - return { answer }; - } - - // No handler - return awaiting state - const durationMs = Math.round(performance.now() - startTime); - if (debugId) { - debugEnd(debugId, "ask-user", { - summary: { type: "simple", awaiting: true }, - duration_ms: durationMs, - }); - } - return { - question: input.question, - awaiting_response: true, - }; - } - - const error = "No question provided"; - if (debugId) debugError(debugId, "ask-user", error); - return { error }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - if (debugId) debugError(debugId, "ask-user", errorMessage); - return { error: errorMessage }; - } - }, + ...config, }); } diff --git a/src/tools/index.ts b/src/tools/index.ts index b5d8475..73f3b28 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -155,7 +155,7 @@ export interface AgentToolsResult { * // Interactive agent with plan mode * const { tools, planModeState } = await createAgentTools(sandbox, { * planMode: true, - * askUser: { onQuestion: async (q) => await promptUser(q) }, + * askUser: true, * }); * * @example @@ -187,7 +187,9 @@ export async function createAgentTools( // Add AskUser tool if configured if (config?.askUser) { - tools.AskUser = createAskUserTool(config.askUser.onQuestion); + tools.AskUser = createAskUserTool( + config.askUser === true ? undefined : config.askUser, + ); } // Add plan mode tools if configured @@ -288,11 +290,12 @@ export async function createAgentTools( // --- Ask User Tool --- export type { - AskUserError, + AskUserAnswers, + AskUserInput, AskUserOutput, - AskUserResponseHandler, - QuestionOption, - StructuredQuestion, + AskUserQuestion, + AskUserQuestionOption, + AskUserToolConfig, } from "./ask-user"; export { createAskUserTool } from "./ask-user"; diff --git a/src/types.ts b/src/types.ts index b6d73d4..042fd69 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,10 +58,7 @@ export type WebFetchConfig = { model: LanguageModel; } & SDKToolOptions; -export type AskUserConfig = { - /** Callback to handle questions and return answers */ - onQuestion?: (question: string) => Promise | string; -}; +export type AskUserConfig = SDKToolOptions; export type SkillConfig = { /** Map of skill name to metadata */ @@ -170,7 +167,7 @@ export type AgentConfig = { Grep?: GrepToolConfig; }; /** Include AskUser tool for user clarification */ - askUser?: AskUserConfig; + askUser?: true | AskUserConfig; /** Include EnterPlanMode and ExitPlanMode tools for interactive planning */ planMode?: boolean; /** Include Skill tool with this config */ diff --git a/tests/tools/index.test.ts b/tests/tools/index.test.ts index a99caa4..7a06e49 100644 --- a/tests/tools/index.test.ts +++ b/tests/tools/index.test.ts @@ -81,9 +81,7 @@ describe("createAgentTools", () => { describe("AskUser tool", () => { it("should include AskUser when configured", async () => { const { tools } = await createAgentTools(sandbox, { - askUser: { - onQuestion: async (q) => `Answer to: ${q}`, - }, + askUser: true, }); expect(tools.AskUser).toBeDefined(); @@ -240,7 +238,7 @@ describe("createAgentTools", () => { const mockModel = { modelId: "test" } as WebFetchConfig["model"]; const { tools, planModeState } = await createAgentTools(sandbox, { planMode: true, - askUser: { onQuestion: async () => "answer" }, + askUser: true, webSearch: { apiKey: "key" }, webFetch: { apiKey: "key", model: mockModel }, skill: {