import { tool } from '@langchain/core/tools'; import { z } from 'zod'; import { Constants } from '@/common'; export const AskUserToolName = Constants.ASK_USER; export const AskUserDescription = 'Ask the user clarification questions with structured options before taking action. ' + 'For a single key question, use question+options. For multiple related questions, use steps[] (max 3 steps). ' + 'Each step can be single-select or multi-select. After receiving answers, proceed immediately. ' + 'BEFORE calling this tool, ALWAYS emit one short sentence of plain-text output (one line, <= 20 words) ' + 'explaining why you need more information — e.g. "I need a few details to help with that.". ' + 'NEVER include an option like "I\'ll give it myself", "Other", "Something else", "Custom", "None of the above", ' + 'or any equivalent free-text escape hatch — the UI already provides a dedicated "Something else" button ' + 'for free-text responses. Only list concrete, mutually-exclusive choices.'; const AskUserOptionSchema = z.object({ label: z.string().describe('Short display label'), value: z.string().describe('Machine-readable value returned when selected'), description: z.string().optional().describe('Optional longer description'), }); export const AskUserStepSchema = z.object({ question: z.string().describe('The question to ask the user for this step'), options: z .array(AskUserOptionSchema) .min(2) .max(6) .describe('Structured options for this step (2-6 choices)'), type: z .enum(['single', 'multi']) .default('single') .describe('Selection type: single-select or multi-select'), }); export type AskUserStep = z.infer; const AskUserSchema = z .object({ question: z .string() .optional() .describe('The question to ask the user. Be specific and clear.'), options: z .array(AskUserOptionSchema) .min(2) .max(6) .optional() .describe('Structured options for the user (2-6 choices)'), context: z.string().optional().describe('Why you are asking this question'), steps: z .array(AskUserStepSchema) .min(1) .max(5) .optional() .describe('Multi-step wizard: array of question steps (1-5 steps)'), }) // passthrough() preserves extra fields like _selectedOption injected by the // approval flow's modifiedArgs — without this, Zod strips unknown keys. .passthrough() .refine( (data) => { // Must have either steps[] OR question+options (legacy format) const hasSteps = Array.isArray(data.steps) && data.steps.length > 0; const hasLegacy = typeof data.question === 'string' && Array.isArray(data.options); return hasSteps || hasLegacy; }, { message: 'Either steps[] or question+options must be provided', } ); /** * Represents a single step selection from the multi-step wizard UI. * Exported for use by both the host web client and browser extension. */ export type StepSelection = { values: string[]; customInput?: string; }; /** Field names injected into modifiedArgs by the approval flow */ export const HITL_FIELDS = { SELECTIONS: '_selections', SELECTED_OPTION: '_selectedOption', CUSTOM_INPUT: '_customInput', } as const; /** * Creates an ask_user tool that lets the LLM ask clarification questions mid-conversation. * * How it works: * 1. LLM calls ask_user(question, options) OR ask_user(steps) * 2. ToolNode.requiresApproval() → always true (per-tool rule in askUserRules.js) * 3. ToolNode.requestApproval() promotes options/question/steps to top-level fields on the event * 4. Frontend HumanInputPopover renders option cards + freeform text input * 5. User selects option(s) OR types custom input → * backend resolves with modifiedArgs containing _selections, _selectedOption, or _customInput * 6. Tool executes, reads the user's choice, returns result to LLM */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createAskUserTool() { return tool( async (args) => { const extArgs = args as Record; const selections = extArgs[HITL_FIELDS.SELECTIONS] as | StepSelection[] | undefined; const customInput = extArgs[HITL_FIELDS.CUSTOM_INPUT] as | string | undefined; const selectedOption = extArgs[HITL_FIELDS.SELECTED_OPTION] as | string | undefined; // Multi-step wizard flow: _selections is an array of { values, customInput? } per step if ( selections && Array.isArray(selections) && Array.isArray(args.steps) ) { const lines: string[] = []; for (let i = 0; i < args.steps.length; i++) { const step = args.steps[i]; const sel = selections[i]; if (!sel || (sel.values.length === 0 && !sel.customInput)) { lines.push(`${i + 1}. ${step.question} → (skipped)`); continue; } if (sel.customInput) { lines.push(`${i + 1}. ${step.question} → "${sel.customInput}"`); continue; } // Resolve value(s) to labels const labels = sel.values.map((v) => { const opt = step.options.find((o) => o.value === v); return opt?.label || v; }); lines.push(`${i + 1}. ${step.question} → ${labels.join(', ')}`); } return `User responses:\n${lines.join('\n')}`; } // Legacy: custom freeform input takes priority — user explicitly chose "none of these" if (customInput) { return `User provided custom response: "${customInput}"`; } if (selectedOption) { const option = args.options?.find((o) => o.value === selectedOption); return `User selected: "${option?.label || selectedOption}" (value: ${selectedOption})`; } // Fallback — should not happen in interactive mode since approval always fires first return 'No user response received.'; }, { name: AskUserToolName, description: AskUserDescription, schema: AskUserSchema, } ); }