🍡 feat: Batched User Questions With A Single Bounded Answer Form (#14737)

* feat: support batched user questions

* test: align batched question fixtures

* fix: harden batched question lifecycle

* test: submit batched HITL answers in e2e

* fix: address batched question review findings

* fix: preserve invoke return typing
This commit is contained in:
Danny Avila 2026-08-11 01:06:16 -04:00 committed by GitHub
parent d89b11d34d
commit 7347cfc195
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1573 additions and 281 deletions

View file

@ -46,7 +46,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.4.4",
"@librechat/agents": "^3.4.5",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View file

@ -166,6 +166,20 @@ function makeAskUserJob(overrides = {}) {
return job;
}
function makeAskUserBatchJob(overrides = {}) {
const job = makeToolApprovalJob(overrides);
job.metadata.pendingAction.payload = {
type: 'ask_user_question',
question: { question: 'Which environment?' },
questions: [
{ id: 'environment', question: 'Which environment?' },
{ id: 'window', question: 'Which time window?' },
],
tool_call_id: 'tc1',
};
return job;
}
/** A mock reconstructed client for the post-ACK path. */
function makeClient(overrides = {}) {
return {
@ -629,6 +643,30 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('400 when a batched answer omits a question or includes an unknown id', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserBatchJob());
const missing = await post({
conversationId: CONVO_ID,
actionId: ACTION_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
answers: { environment: 'staging' },
});
expect(missing.status).toBe(400);
expect(missing.body.error).toMatch(/every question/i);
mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserBatchJob());
const extra = await post({
conversationId: CONVO_ID,
actionId: ACTION_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
answers: { environment: 'staging', window: '7d', region: 'us-east-2' },
});
expect(extra.status).toBe(400);
expect(extra.body.error).toMatch(/unknown question id/i);
});
it('400 on an unsupported pending-action type', async () => {
const job = makeToolApprovalJob();
job.metadata.pendingAction.payload = { type: 'totally_unknown' };
@ -1313,6 +1351,26 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
);
});
it('resumes a batched ask_user_question with answers keyed by question id', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserBatchJob());
const answers = { environment: 'staging', window: '7d' };
const res = await post({
conversationId: CONVO_ID,
actionId: ACTION_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
answers,
});
expect(res.status).toBe(200);
await settled;
await flush();
const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client);
expect(client.resumeCompletion).toHaveBeenCalledWith(
expect.objectContaining({ resumeValue: { answers } }),
);
});
it('generates a title for a first-turn pause before completing the stream', async () => {
const job = makeToolApprovalJob();
job.metadata.userMessage.parentMessageId = Constants.NO_PARENT;

View file

@ -2371,7 +2371,9 @@ class AgentClient extends BaseClient {
if (interrupt.payload?.type === 'ask_user_question' && Array.isArray(this.contentParts)) {
const stamped = attachAskUserQuestionArgs(
this.contentParts,
interrupt.payload.question,
Array.isArray(interrupt.payload.questions)
? { questions: interrupt.payload.questions }
: interrupt.payload.question,
interrupt.payload.tool_call_id,
);
if (stamped !== this.contentParts) {

View file

@ -4,7 +4,7 @@ const {
GenerationJobManager,
isPendingActionStale,
mapToolApprovalResolutions,
mapAskUserAnswer,
resolveAskUserQuestionResume,
attachAskUserQuestionAnswer,
findUndecidedToolCalls,
findDisallowedDecisions,
@ -41,13 +41,6 @@ function sendGenerationJson(res, status, body, generationProtocolVersion) {
return res.status(status).json({ ...body, generationProtocolVersion });
}
/**
* Upper bound on an `ask_user_question` answer (characters). Generous for any real
* reply typed into the question card while still bounding what a crafted POST can
* inject into the resumed run's ToolMessage.
*/
const MAX_ASK_ANSWER_LENGTH = 16_000;
/**
* How long a resume waits on best-effort steering bookkeeping before answering
* anyway. The approval is already consumed by that point, so a stalled Redis
@ -231,15 +224,7 @@ function resolveResumeValue(pendingAction, body) {
return { resumeValue: mapToolApprovalResolutions(resolutions) };
}
if (payload?.type === 'ask_user_question') {
if (typeof body.answer !== 'string' || body.answer.length === 0) {
return { status: 400, error: 'An answer is required' };
}
// The answer becomes a ToolMessage the model must ingest — bound it like any
// other user-controlled wire field rather than trusting the client.
if (body.answer.length > MAX_ASK_ANSWER_LENGTH) {
return { status: 400, error: 'Answer exceeds the maximum length' };
}
return { resumeValue: mapAskUserAnswer({ answer: body.answer }) };
return resolveAskUserQuestionResume(payload, body);
}
return { status: 400, error: 'Unsupported pending action type' };
}
@ -908,10 +893,15 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
// no completion event ever fires for this tool — without this the saved part is
// an empty "cancelled-looking" tool call. See attachAskUserQuestionAnswer.
if (pendingAction.payload?.type === 'ask_user_question') {
const batched = Array.isArray(pendingAction.payload.questions);
const request = batched
? { questions: pendingAction.payload.questions }
: pendingAction.payload.question;
const output = batched ? JSON.stringify({ answers: req.body.answers }) : req.body.answer;
seedContent = attachAskUserQuestionAnswer(
seedContent,
pendingAction.payload.question,
req.body.answer,
request,
output,
pendingAction.payload.tool_call_id,
);
}

View file

@ -1,5 +1,10 @@
const axios = require('axios');
const { isEnabled, getReferencedQuotes, mergeQuotedText } = require('@librechat/api');
const {
isEnabled,
getReferencedQuotes,
mergeQuotedText,
serializeAskUserAnswerVariants,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const denyRequest = require('./denyRequest');
@ -32,6 +37,18 @@ async function moderateText(req, res, next) {
if (safeText.length > 0) {
inputs.push(safeText);
}
if (
req.body.answers != null &&
typeof req.body.answers === 'object' &&
!Array.isArray(req.body.answers)
) {
for (const answer of Object.values(req.body.answers)) {
if (typeof answer === 'string' && answer.length > 0) {
inputs.push(answer);
}
}
inputs.push(...serializeAskUserAnswerVariants(req.body.answers));
}
const quotes = getReferencedQuotes(req.body.quotes);
if (quotes != null) {
inputs.push(...quotes);

View file

@ -658,7 +658,13 @@ router.post('/chat/abort', configMiddleware, async (req, res, next) => {
expectedCreatedAt: job.createdAt,
transformAbortContent: (content) =>
abortedAskPayload?.type === 'ask_user_question' && Array.isArray(content)
? attachAskUserQuestionArgs(content, abortedAskPayload.question)
? attachAskUserQuestionArgs(
content,
Array.isArray(abortedAskPayload.questions)
? { questions: abortedAskPayload.questions }
: abortedAskPayload.question,
abortedAskPayload.tool_call_id,
)
: content,
/** Persist every parent-row prerequisite before publishing the ordinary
* abort FINAL. That frame can immediately drain a queued follow-up, whose

View file

@ -2,6 +2,7 @@ import { memo, useEffect, useRef } from 'react';
import { useWatch } from 'react-hook-form';
import { Button } from '@librechat/client';
import { Check, ChevronDown, CornerDownLeft, TriangleAlert, X } from 'lucide-react';
import AskUserQuestions from '~/components/Chat/Messages/Content/AskUserQuestions';
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import { useChatFormContext } from '~/Providers';
import { useLocalize } from '~/hooks';
@ -33,9 +34,56 @@ function AskUserQuestionPopoverContent({
return null;
}
if (ask.liveAsk.questions != null && ask.liveAsk.questions.length > 0) {
return <AskUserQuestionsPopoverPanel ask={ask} />;
}
return <AskUserQuestionPopoverPanel ask={ask} textAreaRef={textAreaRef} />;
}
function AskUserQuestionsPopoverPanel({ ask }: { ask: ReturnType<typeof useAskAnswerMode> }) {
const localize = useLocalize();
const { liveAsk, collapse, dismiss } = ask;
const questions = liveAsk?.questions;
if (liveAsk == null || questions == null || questions.length === 0) {
return null;
}
return (
<div className="absolute bottom-28 z-10 w-full">
<div className="popover border-token-border-light flex max-h-[70vh] flex-col rounded-2xl border bg-surface-secondary shadow-lg">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border-light px-3 py-2">
<p className="text-sm font-medium text-text-primary">
{localize(
questions.length === 1 ? 'com_ui_asking_questions_one' : 'com_ui_asking_questions',
{ 0: questions.length },
)}
</p>
<div className="flex items-center">
<button
type="button"
aria-label={localize('com_ui_collapse')}
className="rounded p-1 text-text-secondary hover:bg-surface-hover"
onClick={collapse}
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label={localize('com_ui_close')}
className="rounded p-1 text-text-secondary hover:bg-surface-hover"
onClick={dismiss}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
<AskUserQuestions actionId={liveAsk.actionId} questions={questions} />
</div>
</div>
);
}
/**
* Split from the gate above so the per-keystroke `useWatch` subscription only
* exists while the popover is actually visible the invisible popover was

View file

@ -173,6 +173,9 @@ const ChatForm = memo(function ChatForm({
}, []);
const answerMode = useAskAnswerMode(conversationId);
const answerPlaceholder = answerMode.batchMode
? localize('com_ui_answer_questions_above')
: (answerMode.otherLabel ?? localize('com_ui_something_else'));
useAutoSave({
files,
@ -386,9 +389,7 @@ const ChatForm = memo(function ChatForm({
setIsScrollable,
disabled: disableInputs,
// The composer IS the free-form answer box while a question pause is live.
placeholder: answerMode.active
? (answerMode.otherLabel ?? localize('com_ui_something_else'))
: placeholder,
placeholder: answerMode.active ? answerPlaceholder : placeholder,
// Enter stays live during a run when it can steer/queue instead of send.
allowSubmitWhileGenerating: steering.duringRunActive,
onDuringRunModifier: steering.duringRunActive ? handleDuringRunModifier : undefined,
@ -595,7 +596,11 @@ const ChatForm = memo(function ChatForm({
textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>
).current = e;
}}
disabled={disableInputs || isNotAppendable}
disabled={
disableInputs ||
isNotAppendable ||
(answerMode.active && answerMode.batchMode)
}
onPaste={handlePaste}
onKeyDown={(e) => {
// Answer mode consumes option-navigation keys from the
@ -698,6 +703,7 @@ const ChatForm = memo(function ChatForm({
filesLoading ||
disableInputs ||
isNotAppendable ||
(answerMode.active && answerMode.batchMode) ||
(isSubmitting && !answerMode.active)
}
/>

View file

@ -381,9 +381,15 @@ export function useResumeSubmit() {
);
const submitAskAnswer = useCallback(
(actionId: string, answer: string, opts?: { onSuccess?: () => void }) => {
(
actionId: string,
resolution: string | Record<string, string>,
opts?: { onSuccess?: () => void },
) => {
const fields = buildResumeFields();
if (!fields || answer.length === 0) {
const isBatch = typeof resolution !== 'string';
const hasAnswer = isBatch ? Object.keys(resolution).length > 0 : resolution.length > 0;
if (!fields || !hasAnswer) {
return;
}
if (submittingAskActionIdsRef.current.has(actionId)) {
@ -392,7 +398,11 @@ export function useResumeSubmit() {
submittingAskActionIdsRef.current.add(actionId);
setAskStatus(actionId, 'submitting');
askMutation.mutate(
{ ...fields, actionId, answer },
{
...fields,
actionId,
...(isBatch ? { answers: resolution } : { answer: resolution }),
},
{
onSuccess: () => {
setAskStatus(actionId, 'submitted');
@ -408,7 +418,7 @@ export function useResumeSubmit() {
if (messages && chatContext?.setMessages) {
let changed = false;
const next = messages.map((message) => {
const resolved = resolveAskUserQuestionPart(message, actionId, answer);
const resolved = resolveAskUserQuestionPart(message, actionId, resolution);
if (resolved !== message) {
changed = true;
}

View file

@ -6,6 +6,7 @@ import { useApprovalContext, useAskSubmitStatus, useResumeSubmit } from './Appro
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import { ChatContext } from '~/Providers/ChatContext';
import { splitOtherOption } from '~/utils/approval';
import AskUserQuestions from './AskUserQuestions';
import { useLocalize } from '~/hooks';
/**
@ -20,9 +21,39 @@ import { useLocalize } from '~/hooks';
export default function AskUserQuestion({
actionId,
question,
questions,
}: {
actionId: string;
question: Agents.AskUserQuestionRequest;
questions?: Agents.AskUserQuestionBatchItem[];
}) {
const conversationId = useContext(ChatContext)?.conversation?.conversationId;
const answerMode = useAskAnswerMode(conversationId);
const isLivePause = answerMode.liveAsk?.actionId === actionId;
if (questions != null && questions.length > 0) {
if (answerMode.popoverVisible && isLivePause) {
return null;
}
return (
<AskUserQuestions
actionId={actionId}
questions={questions}
className="my-2 max-h-[70vh] w-full rounded-lg border border-border-light bg-surface-secondary"
onExpand={answerMode.collapsed && isLivePause ? answerMode.expand : undefined}
/>
);
}
return <AskUserQuestionSingle actionId={actionId} question={question} answerMode={answerMode} />;
}
function AskUserQuestionSingle({
actionId,
question,
answerMode,
}: {
actionId: string;
question: Agents.AskUserQuestionRequest;
answerMode: ReturnType<typeof useAskAnswerMode>;
}) {
const localize = useLocalize();
const { getAskAnswerDraft, setAskAnswerDraft } = useApprovalContext();
@ -37,8 +68,6 @@ export default function AskUserQuestion({
* chevron re-expands it) or dismissed (and in contexts without a
* ChatContext, where the popover can't exist).
*/
const conversationId = useContext(ChatContext)?.conversation?.conversationId;
const answerMode = useAskAnswerMode(conversationId);
const { popoverVisible, collapsed, expand, liveAsk } = answerMode;
const isLivePause = liveAsk?.actionId === actionId;

View file

@ -1,5 +1,10 @@
import { MessageCircleQuestion, TriangleAlert } from 'lucide-react';
import { getSubmittedAskAnswer, parseAskUserQuestionArgs } from '~/utils/approval';
import type { Agents } from 'librechat-data-provider';
import {
getSubmittedAskAnswer,
parseAskUserQuestionArgs,
parseAskUserQuestionsArgs,
} from '~/utils/approval';
import AskUserQuestionProgress from './AskUserQuestionProgress';
import EmptyText from './Parts/EmptyText';
import { useLocalize } from '~/hooks';
@ -29,6 +34,7 @@ export default function AskUserQuestionCall({
}) {
const localize = useLocalize();
const question = parseAskUserQuestionArgs(args);
const batch = parseAskUserQuestionsArgs(args);
/**
* The part's own output arrives from the server only at finalize, and the
* streaming handler's message copy can overwrite the optimistic store stamp
@ -36,7 +42,24 @@ export default function AskUserQuestionCall({
* Q&A record never blinks out while the resumed segment streams.
*/
const effectiveOutput = output.length > 0 ? output : (getSubmittedAskAnswer(toolCallId) ?? '');
const answered = effectiveOutput.length > 0 && !failed;
const batchAnswers = (() => {
if (batch == null || effectiveOutput.length === 0) {
return null;
}
try {
const parsed = JSON.parse(effectiveOutput) as { answers?: unknown };
return parsed.answers != null && typeof parsed.answers === 'object'
? (parsed.answers as Record<string, string>)
: null;
} catch {
return null;
}
})();
const answered =
!failed &&
(batch != null
? batch.questions.every((item) => typeof batchAnswers?.[item.id] === 'string')
: effectiveOutput.length > 0);
/**
* While the turn is live and unanswered, the INTERACTIVE card (rendered from
@ -65,6 +88,68 @@ export default function AskUserQuestionCall({
</Container>
) : null;
if (batch != null) {
let statusLabel = localize('com_ui_asking');
if (failed) {
statusLabel = localize('com_ui_question_failed');
} else if (answered) {
statusLabel = localize('com_ui_asked');
}
return (
<>
<div className="my-2 flex w-full flex-col rounded-lg border border-border-light bg-surface-secondary">
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-text-secondary">
{failed ? (
<TriangleAlert className="h-4 w-4 text-text-warning" aria-hidden="true" />
) : (
<MessageCircleQuestion className="h-4 w-4" aria-hidden="true" />
)}
{statusLabel}
</div>
<div className="px-3 pb-3">
{batch.questions.map((item, index) => {
const answer = batchAnswers?.[item.id];
return (
<div key={item.id} className={index > 0 ? 'border-t border-border-light pt-3' : ''}>
{item.header != null && (
<p className="text-xs font-medium text-text-secondary">{item.header}</p>
)}
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
{item.question}
</p>
{item.description != null && (
<p className="mt-1 text-sm text-text-secondary [overflow-wrap:anywhere]">
{item.description}
</p>
)}
{typeof answer === 'string' && (
<p className="mt-1 text-sm text-text-primary [overflow-wrap:anywhere]">
<span className="font-medium text-text-secondary">
{localize('com_ui_you_answered')}
</span>{' '}
{formatAnswerLabel(item, answer)}
</p>
)}
{typeof answer !== 'string' && !failed && (
<p className="mt-1 text-sm italic text-text-secondary">
{localize('com_ui_question_unanswered')}
</p>
)}
</div>
);
})}
{failed && (
<p className="mt-3 text-sm text-text-secondary">
{localize('com_ui_question_failed_description')}
</p>
)}
</div>
</div>
{resumingCursor}
</>
);
}
if (failed) {
return (
<>
@ -98,17 +183,8 @@ export default function AskUserQuestionCall({
* such a value into fragments that relabel as options the user never
* picked. When any segment misses, show the raw answer untouched.
*/
const exactLabel = question?.options?.find((option) => option.value === effectiveOutput)?.label;
const mappedMultiLabel = (() => {
if (exactLabel != null || question?.multiSelect !== true || question.options == null) {
return null;
}
const labels = effectiveOutput
.split(', ')
.map((segment) => question.options?.find((option) => option.value === segment)?.label);
return labels.every((label) => label != null) ? labels.join(', ') : null;
})();
const answerLabel = exactLabel ?? mappedMultiLabel ?? effectiveOutput;
const answerLabel =
question == null ? effectiveOutput : formatAnswerLabel(question, effectiveOutput);
return (
<>
@ -142,3 +218,14 @@ export default function AskUserQuestionCall({
</>
);
}
function formatAnswerLabel(question: Agents.AskUserQuestionRequest, answer: string): string {
const exactLabel = question.options?.find((option) => option.value === answer)?.label;
if (exactLabel != null || question.multiSelect !== true || question.options == null) {
return exactLabel ?? answer;
}
const labels = answer
.split(', ')
.map((segment) => question.options?.find((option) => option.value === segment)?.label);
return labels.every((label) => label != null) ? labels.join(', ') : answer;
}

View file

@ -1,9 +1,9 @@
import { useContext } from 'react';
import { MessageCircleQuestion } from 'lucide-react';
import parseJsonField, { parseJsonFieldOccurrences } from './Parts/parseJsonField';
import { collectLiveAskToolCallIds } from '~/utils/approval';
import { useGetMessagesByConvoId } from '~/data-provider';
import { ChatContext } from '~/Providers/ChatContext';
import parseJsonField from './Parts/parseJsonField';
import { useLocalize } from '~/hooks';
/**
@ -13,9 +13,8 @@ import { useLocalize } from '~/hooks';
* interactive card). Without it a long question, many options, or several
* parallel questions leave a dead gap after the last streamed token.
*
* The question text streams in live: `question` is the schema's first (and
* only required) property, so providers emit it as the first args key and
* `parseJsonField`'s partial-JSON path can render it delta by delta.
* The first question text streams in live from either the legacy top-level
* field or the first item in the batched `questions` array.
*
* Mounted only for a live, unanswered call ({@link AskUserQuestionCall}
* gates on `isSubmitting`), so the live-ask subscription below never runs
@ -35,7 +34,23 @@ export default function AskUserQuestionProgress({
enabled,
select: collectLiveAskToolCallIds,
});
const question = parseJsonField(args, 'question');
const legacyQuestion = parseJsonField(args, 'question');
const batchQuestion = (() => {
if (typeof args === 'string') {
return parseJsonFieldOccurrences(args, 'question')[0] ?? '';
}
const questions = args?.questions;
if (!Array.isArray(questions)) {
return '';
}
const first = questions[0];
if (first == null || typeof first !== 'object') {
return '';
}
const firstQuestion = (first as { question?: unknown }).question;
return typeof firstQuestion === 'string' ? firstQuestion : '';
})();
const question = legacyQuestion || batchQuestion;
/**
* THIS call's pause went interactive: the popover (or the interactive card)

View file

@ -0,0 +1,136 @@
import { Button, TextareaAutosize } from '@librechat/client';
import { Check, ChevronUp, TriangleAlert } from 'lucide-react';
import type { Agents } from 'librechat-data-provider';
import useAskQuestionsForm from '~/hooks/Input/useAskQuestionsForm';
import { splitOtherOption } from '~/utils/approval';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function AskUserQuestions({
actionId,
questions,
className,
onExpand,
}: {
actionId: string;
questions: Agents.AskUserQuestionBatchItem[];
className?: string;
onExpand?: () => void;
}) {
const localize = useLocalize();
const form = useAskQuestionsForm(actionId, questions);
if (form.status === 'submitted') {
return null;
}
return (
<div className={cn('flex min-h-0 flex-col', className)}>
{onExpand != null && (
<div className="flex shrink-0 justify-end border-b border-border-light px-2 py-1">
<button
type="button"
aria-label={localize('com_ui_expand')}
className="rounded p-1 text-text-secondary hover:bg-surface-hover"
onClick={onExpand}
>
<ChevronUp className="h-4 w-4" aria-hidden="true" />
</button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-3">
{questions.map((question, questionIndex) => {
const { choices, otherLabel } = splitOtherOption(question.options);
const selected = Object.hasOwn(form.state.selected, question.id)
? form.state.selected[question.id]
: [];
const text = Object.hasOwn(form.state.text, question.id)
? form.state.text[question.id]
: '';
return (
<fieldset
key={question.id}
className={cn('py-3', questionIndex > 0 && 'border-t border-border-light')}
>
<legend className="mb-1 text-xs font-medium text-text-secondary">
{question.header ?? localize('com_ui_question_number', { 0: questionIndex + 1 })}
</legend>
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
{question.question}
</p>
{question.description != null && question.description.length > 0 && (
<p className="mt-1 text-xs text-text-secondary [overflow-wrap:anywhere]">
{question.description}
</p>
)}
{choices.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2" role="group">
{choices.map((option) => {
const isSelected = selected.includes(option.value);
return (
<Button
key={option.value}
type="button"
size="sm"
variant={isSelected ? 'submit' : 'outline'}
role={question.multiSelect === true ? 'checkbox' : undefined}
aria-checked={question.multiSelect === true ? isSelected : undefined}
aria-pressed={question.multiSelect === true ? undefined : isSelected}
disabled={form.locked}
className="h-auto min-h-9 max-w-full whitespace-normal py-1.5 text-left [overflow-wrap:anywhere]"
onClick={() => form.selectOption(question, option.value)}
>
{question.multiSelect === true && isSelected && (
<Check className="mr-1.5 h-4 w-4 shrink-0" aria-hidden="true" />
)}
{option.label}
</Button>
);
})}
</div>
)}
<TextareaAutosize
value={text}
disabled={form.locked}
onChange={(event) => form.setText(question, event.target.value)}
minRows={1}
maxRows={6}
placeholder={otherLabel ?? localize('com_ui_your_answer')}
className="mt-2 w-full resize-none rounded-md border border-border-light bg-surface-primary p-2 text-sm"
aria-label={`${question.question} ${localize('com_ui_your_answer')}`}
/>
</fieldset>
);
})}
</div>
{(form.status === 'error' || form.status === 'expired') && (
<div className="flex items-center gap-1.5 px-3 py-1 text-xs text-text-warning">
<TriangleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
{form.status === 'expired'
? localize('com_ui_approval_expired')
: localize('com_ui_ask_answer_error')}
</div>
)}
<div className="flex shrink-0 justify-end gap-2 border-t border-border-light p-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={form.locked}
onClick={form.skip}
>
{localize('com_ui_skip')}
</Button>
<Button
type="button"
size="sm"
variant="submit"
disabled={!form.canSubmit}
onClick={form.submit}
>
{form.status === 'submitting' ? localize('com_ui_submitting') : localize('com_ui_submit')}
</Button>
</div>
</div>
);
}

View file

@ -70,6 +70,7 @@ const Part = memo(function Part({
key={askUserQuestion.ask_user_question.actionId}
actionId={askUserQuestion.ask_user_question.actionId}
question={askUserQuestion.ask_user_question.question}
questions={askUserQuestion.ask_user_question.questions}
/>
);
}

View file

@ -24,6 +24,10 @@ jest.mock('~/utils/approval', () => ({
}
return args ?? null;
},
parseAskUserQuestionsArgs: (args: string | Record<string, unknown> | undefined) => {
const parsed = typeof args === 'string' ? JSON.parse(args) : args;
return Array.isArray(parsed?.questions) ? parsed : null;
},
}));
jest.mock('../AskUserQuestionProgress', () => ({
@ -113,4 +117,47 @@ describe('AskUserQuestionCall', () => {
expect(screen.getByText(output)).toBeInTheDocument();
expect(screen.queryByText("Question wasn't shown")).not.toBeInTheDocument();
});
test('renders each question and answer from one completed batch', () => {
render(
<AskUserQuestionCall
args={JSON.stringify({
questions: [
{
id: 'environment',
header: 'Environment',
question: 'Where should this run?',
description: 'Choose the deployment target.',
options: [{ label: 'Staging', value: 'staging' }],
},
{ id: 'window', question: 'Which window?' },
],
})}
output={JSON.stringify({ answers: { environment: 'staging', window: '7d' } })}
/>,
);
expect(screen.getByText('Environment')).toBeInTheDocument();
expect(screen.getByText('Where should this run?')).toBeInTheDocument();
expect(screen.getByText('Choose the deployment target.')).toBeInTheDocument();
expect(screen.getByText('Staging')).toBeInTheDocument();
expect(screen.getByText('Which window?')).toBeInTheDocument();
expect(screen.getByText('7d')).toBeInTheDocument();
});
test('renders a failed batch without implying the user declined to answer', () => {
render(
<AskUserQuestionCall
args={{ questions: [{ id: 'environment', question: 'Where should this run?' }] }}
output="Error processing tool"
failed
/>,
);
expect(screen.getByText("Question wasn't shown")).toBeInTheDocument();
expect(
screen.getByText("The agent couldn't show this question and may retry automatically."),
).toBeInTheDocument();
expect(screen.queryByText('No answer was given')).not.toBeInTheDocument();
});
});

View file

@ -51,6 +51,28 @@ describe('AskUserQuestionProgress', () => {
expect(screen.getByText('Café or "bar"')).toBeInTheDocument();
});
test('streams the first question from partial batched args', () => {
render(
<AskUserQuestionProgress
args={'{"questions":[{"id":"environment","question":"Which environ'}
toolCallId="call_1"
/>,
);
expect(screen.getByText('Which environ')).toBeInTheDocument();
});
test('reads the first question from settled batched args', () => {
render(
<AskUserQuestionProgress
args={{ questions: [{ id: 'environment', question: 'Which environment?' }] }}
toolCallId="call_1"
/>,
);
expect(screen.getByText('Which environment?')).toBeInTheDocument();
});
test('renders a skeleton line before any question text streams', () => {
render(<AskUserQuestionProgress args="" toolCallId="call_1" />);

View file

@ -0,0 +1,113 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { fireEvent, render, screen } from '@testing-library/react';
import AskUserQuestions from '../AskUserQuestions';
const mockSubmitAskAnswer = jest.fn();
jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({
useAskSubmitStatus: () => ({ getAskStatus: () => 'idle' }),
useResumeSubmit: () => ({ submitAskAnswer: mockSubmitAskAnswer }),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: Record<number, number>) => {
const labels: Record<string, string> = {
com_ui_question_number: `Question ${values?.[0] ?? ''}`,
com_ui_your_answer: 'Your answer',
com_ui_skip: 'Skip',
com_ui_submit: 'Submit',
com_ui_submitting: 'Submitting',
};
return labels[key] ?? key;
},
}));
const questions = [
{
id: 'environment',
header: 'Environment',
question: 'Where should this run?',
options: [
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'production' },
],
},
{ id: 'window', question: 'Which time window?' },
];
describe('AskUserQuestions', () => {
beforeEach(() => mockSubmitAskAnswer.mockClear());
test('submits one answer map after every question is complete', () => {
render(
<RecoilRoot>
<AskUserQuestions actionId="ask-batch" questions={questions} />
</RecoilRoot>,
);
const submit = screen.getByRole('button', { name: 'Submit' });
expect(submit).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'Staging' }));
fireEvent.change(screen.getByRole('textbox', { name: /Which time window/ }), {
target: { value: 'Last seven days' },
});
expect(submit).toBeEnabled();
fireEvent.click(submit);
expect(mockSubmitAskAnswer).toHaveBeenCalledWith(
'ask-batch',
{
environment: 'staging',
window: 'Last seven days',
},
expect.objectContaining({ onSuccess: expect.any(Function) }),
);
});
test('retains partial answers across surface remounts', () => {
const view = render(
<RecoilRoot>
<AskUserQuestions actionId="ask-remount" questions={questions} />
</RecoilRoot>,
);
fireEvent.change(screen.getByRole('textbox', { name: /Which time window/ }), {
target: { value: 'Today' },
});
view.rerender(
<RecoilRoot>
<AskUserQuestions actionId="ask-remount" questions={questions} />
</RecoilRoot>,
);
expect(screen.getByRole('textbox', { name: /Which time window/ })).toHaveValue('Today');
});
test('supports question ids inherited by ordinary objects', () => {
render(
<RecoilRoot>
<AskUserQuestions
actionId="ask-prototype-id"
questions={[
{
id: 'constructor',
question: 'Continue?',
options: [{ label: 'Yes', value: 'yes' }],
},
]}
/>
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'Yes' }));
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(mockSubmitAskAnswer).toHaveBeenCalledWith(
'ask-prototype-id',
{ constructor: 'yes' },
expect.objectContaining({ onSuccess: expect.any(Function) }),
);
});
});

View file

@ -132,8 +132,10 @@ export function useSubmitToolApprovalMutation() {
export interface SubmitAskAnswerParams extends ResumeAgentFields {
actionId: string;
/** Free-form answer to the agent's ask-user question. */
answer: string;
/** Free-form answer for a legacy single-question pause. */
answer?: string;
/** Answers keyed by question id for a batched pause. */
answers?: Record<string, string>;
}
/**
@ -141,11 +143,12 @@ export interface SubmitAskAnswerParams extends ResumeAgentFields {
* POSTs to the shared resume route; the continuation streams over the existing SSE.
*/
export const submitAskAnswer = async (params: SubmitAskAnswerParams): Promise<ResumeResponse> => {
const { actionId, answer, ...fields } = params;
const { actionId, answer, answers, ...fields } = params;
return postGenerationRequest<ResumeResponse>(`${apiBaseUrl()}/api/agents/chat/resume`, {
...buildResumeBase(fields),
actionId,
answer,
...(answer != null && { answer }),
...(answers != null && { answers }),
});
};

View file

@ -75,6 +75,27 @@ describe('useAskAnswerMode', () => {
expect(result.current.popoverVisible).toBe(false);
});
it('keeps batch mode active while reserving answers for the bounded form', () => {
mockUseGetMessages.mockReturnValue({
data: {
...liveAsk,
questions: [
{ id: 'environment', question: 'Which environment?' },
{ id: 'window', question: 'Which window?' },
],
},
});
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
expect(result.current.active).toBe(true);
expect(result.current.batchMode).toBe(true);
expect(result.current.options).toEqual([]);
expect(result.current.draftId).toBeNull();
expect(result.current.submitText('must stay out of the normal send path')).toBe(true);
expect(mockSubmitAskAnswer).not.toHaveBeenCalled();
});
it('disables the query and forces liveAsk null for a new (unsaved) conversation', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });

View file

@ -131,14 +131,16 @@ export default function useAskAnswerMode(conversationId?: string | null) {
/** The popover renders only while expanded; collapse keeps `active` (and the
* composer's answer role) but hands the question display to the chat card. */
const popoverVisible = active && !collapsed;
const multiSelect = liveAsk != null && liveAsk.question.multiSelect === true;
const batchMode = (liveAsk?.questions?.length ?? 0) > 0;
const multiSelect = !batchMode && liveAsk != null && liveAsk.question.multiSelect === true;
/** Answer-phase draft key: handed to useAutoSave so the composer drafts
* under the question's own key while answer mode is live, leaving the
* conversation draft untouched until the swap-back restores it. */
const draftId = active && liveAsk != null ? getAskAnswerDraftId(liveAsk.actionId) : null;
const draftId =
active && liveAsk != null && !batchMode ? getAskAnswerDraftId(liveAsk.actionId) : null;
const { choices: options, otherLabel } = useMemo(
() => splitOtherOption(liveAsk?.question.options),
[liveAsk],
() => splitOtherOption(batchMode ? undefined : liveAsk?.question.options),
[batchMode, liveAsk],
);
/** Selection state is per-question: a new pause must never inherit a stale
@ -296,13 +298,16 @@ export default function useAskAnswerMode(conversationId?: string | null) {
if (!active || !liveAsk) {
return false;
}
if (batchMode) {
return true;
}
const trimmed = text.trim();
if (trimmed.length > 0) {
submitValues(multiSelect ? [...checkedValues(), trimmed] : [trimmed], true);
}
return true;
},
[active, liveAsk, multiSelect, checkedValues, submitValues],
[active, liveAsk, batchMode, multiSelect, checkedValues, submitValues],
);
/**
@ -446,6 +451,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
return {
active,
batchMode,
liveAsk,
options,
dismissed,

View file

@ -0,0 +1,118 @@
import { useCallback, useMemo } from 'react';
import { atomFamily, useRecoilState, useResetRecoilState } from 'recoil';
import type { Agents } from 'librechat-data-provider';
import {
useAskSubmitStatus,
useResumeSubmit,
} from '~/components/Chat/Messages/Content/ApprovalContext';
import { ASK_USER_DECLINED_ANSWER } from '~/utils/approval';
interface AskQuestionsFormState {
text: Record<string, string>;
selected: Record<string, string[]>;
}
const askQuestionsFormState = atomFamily<AskQuestionsFormState, string>({
key: 'askQuestionsFormState',
default: { text: {}, selected: {} },
});
function ownValue<T>(record: Record<string, T>, key: string): T | undefined {
return Object.hasOwn(record, key) ? record[key] : undefined;
}
export default function useAskQuestionsForm(
actionId: string,
questions: Agents.AskUserQuestionBatchItem[],
) {
const [state, setState] = useRecoilState(askQuestionsFormState(actionId));
const resetState = useResetRecoilState(askQuestionsFormState(actionId));
const { submitAskAnswer } = useResumeSubmit();
const { getAskStatus } = useAskSubmitStatus();
const status = getAskStatus(actionId);
const locked = status === 'submitting' || status === 'submitted' || status === 'expired';
const setText = useCallback(
(question: Agents.AskUserQuestionBatchItem, value: string) => {
setState((previous) => ({
text: { ...previous.text, [question.id]: value },
selected:
question.multiSelect === true || value.length === 0
? previous.selected
: { ...previous.selected, [question.id]: [] },
}));
},
[setState],
);
const selectOption = useCallback(
(question: Agents.AskUserQuestionBatchItem, value: string) => {
setState((previous) => {
const current = ownValue(previous.selected, question.id) ?? [];
let selected = [value];
if (question.multiSelect === true) {
selected = current.includes(value)
? current.filter((item) => item !== value)
: [...current, value];
}
return {
text:
question.multiSelect === true ? previous.text : { ...previous.text, [question.id]: '' },
selected: { ...previous.selected, [question.id]: selected },
};
});
},
[setState],
);
const answers = useMemo(() => {
const resolved = Object.create(null) as Record<string, string>;
for (const question of questions) {
const text = ownValue(state.text, question.id)?.trim() ?? '';
const selected = ownValue(state.selected, question.id) ?? [];
const values = question.multiSelect === true ? [...selected] : [];
if (text.length > 0) {
values.push(text);
} else if (question.multiSelect !== true && selected[0] != null) {
values.push(selected[0]);
}
if (values.length > 0) {
resolved[question.id] = values.join(', ');
}
}
return resolved;
}, [questions, state]);
const canSubmit =
!locked && questions.every((question) => (ownValue(answers, question.id)?.length ?? 0) > 0);
const submit = useCallback(() => {
if (!canSubmit) {
return false;
}
submitAskAnswer(actionId, answers, { onSuccess: resetState });
return true;
}, [actionId, answers, canSubmit, resetState, submitAskAnswer]);
const skip = useCallback(() => {
if (locked) {
return false;
}
const declined = Object.fromEntries(
questions.map((question) => [question.id, ASK_USER_DECLINED_ANSWER]),
);
submitAskAnswer(actionId, declined, { onSuccess: resetState });
return true;
}, [actionId, locked, questions, resetState, submitAskAnswer]);
return {
state,
status,
locked,
canSubmit,
setText,
selectOption,
submit,
skip,
};
}

View file

@ -1687,6 +1687,10 @@
"com_ui_quality": "Quality",
"com_ui_question_failed": "Question wasn't shown",
"com_ui_question_failed_description": "The agent couldn't show this question and may retry automatically.",
"com_ui_question_number": "Question {{0}}",
"com_ui_answer_questions_above": "Answer the questions above",
"com_ui_asking_questions_one": "Asking {{0}} question",
"com_ui_asking_questions": "Asking {{0}} questions",
"com_ui_question_unanswered": "No answer was given",
"com_ui_queue": "Queue",
"com_ui_queue_send": "Queue message for after the response",

View file

@ -8,6 +8,7 @@ import {
findPendingActionMessageIndex,
removeAskUserQuestionPart,
parseAskUserQuestionArgs,
parseAskUserQuestionsArgs,
resolveAskUserQuestionPart,
getSubmittedAskAnswer,
findLiveAskUserQuestion,
@ -295,6 +296,50 @@ describe('parseAskUserQuestionArgs', () => {
});
});
describe('parseAskUserQuestionsArgs', () => {
it('parses a complete batch and preserves headers and options', () => {
const parsed = parseAskUserQuestionsArgs({
questions: [
{
id: 'environment',
header: 'Environment',
question: 'Which environment?',
options: [{ label: 'Staging', value: 'staging' }],
},
{ id: 'window', question: 'Which window?' },
],
});
expect(parsed?.questions).toHaveLength(2);
expect(parsed?.questions[0]).toMatchObject({ id: 'environment', header: 'Environment' });
});
it('rejects malformed and duplicate-id batches', () => {
expect(parseAskUserQuestionsArgs({ questions: [] })).toBeNull();
expect(
parseAskUserQuestionsArgs({
questions: [
{ id: 'same', question: 'First?' },
{ id: 'same', question: 'Second?' },
],
}),
).toBeNull();
});
it('trims valid headers and omits whitespace-only or oversized headers', () => {
const parsed = parseAskUserQuestionsArgs({
questions: [
{ id: 'trimmed', header: ' Context ', question: 'First?' },
{ id: 'blank', header: ' ', question: 'Second?' },
{ id: 'large', header: 'x'.repeat(81), question: 'Third?' },
],
});
expect(parsed?.questions[0].header).toBe('Context');
expect(parsed?.questions[1].header).toBeUndefined();
expect(parsed?.questions[2].header).toBeUndefined();
});
});
describe('removeAskUserQuestionPart', () => {
it('strips the synthetic part for the matching actionId and keeps everything else', () => {
const withCard = applyPendingAction(msg({ content: [textPart('hello')] }), askAction());
@ -400,6 +445,43 @@ describe('resolveAskUserQuestionPart', () => {
expect(content[0]?.tool_call?.output).toBe('us-east');
expect(content[1]?.tool_call?.output).toBeUndefined();
});
it('stamps one batched tool call with structured args and answers', () => {
const base = msg({
content: [
{
type: 'tool_call',
tool_call: { id: 'tc-batch', name: 'ask_user_question', args: '', type: 'tool_call' },
} as unknown as TMessageContentParts,
],
});
const questions = [
{ id: 'environment', question: 'Which environment?' },
{ id: 'window', question: 'Which window?' },
];
const withCard = applyPendingAction(
base,
askAction({
actionId: 'a-batch',
payload: {
type: 'ask_user_question',
question: questions[0],
questions,
tool_call_id: 'tc-batch',
},
}),
);
const resolved = resolveAskUserQuestionPart(withCard, 'a-batch', {
environment: 'staging',
window: '7d',
});
const toolCall = (resolved.content as Array<{ tool_call?: Record<string, unknown> }>)[0]
?.tool_call as Record<string, unknown>;
expect(JSON.parse(toolCall.args as string)).toEqual({ questions });
expect(JSON.parse(toolCall.output as string)).toEqual({
answers: { environment: 'staging', window: '7d' },
});
});
});
describe('splitOtherOption', () => {

View file

@ -16,6 +16,9 @@ export const ASK_USER_QUESTION = 'ask_user_question' as const;
* and the model needs to know the user declined rather than answered.
*/
export const ASK_USER_DECLINED_ANSWER = 'The user chose not to answer this question.';
const ASK_USER_QUESTION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
const MAX_ASK_USER_QUESTIONS = 4;
const MAX_ASK_USER_QUESTION_HEADER_LENGTH = 80;
/** Shape of the synthetic content part carrying an ask-user pending action. */
export interface AskUserQuestionPart {
@ -23,6 +26,7 @@ export interface AskUserQuestionPart {
[ASK_USER_QUESTION]: {
actionId: string;
question: Agents.AskUserQuestionRequest;
questions?: Agents.AskUserQuestionBatchItem[];
/** The ask tool call that raised the pause (present from
* `@librechat/agents` > 3.3.8) lets the answer stamp target the exact
* tool-call part in multi-ask turns. */
@ -178,6 +182,7 @@ function applyAskUserQuestion(
[ASK_USER_QUESTION]: {
actionId,
question: payload.question,
...(payload.questions != null && { questions: payload.questions }),
...(payload.tool_call_id != null && { tool_call_id: payload.tool_call_id }),
},
} as unknown as TMessageContentParts;
@ -246,6 +251,60 @@ export function parseAskUserQuestionArgs(
};
}
/** Parse and validate the batched form of an ask-user tool call. */
export function parseAskUserQuestionsArgs(
args: string | Record<string, unknown> | undefined,
): Agents.AskUserQuestionsRequest | null {
let parsed: unknown = args;
if (typeof args === 'string') {
if (args.trim().length === 0) {
return null;
}
try {
parsed = JSON.parse(args);
} catch {
return null;
}
}
const rawQuestions =
parsed != null && typeof parsed === 'object'
? (parsed as { questions?: unknown }).questions
: undefined;
if (
!Array.isArray(rawQuestions) ||
rawQuestions.length === 0 ||
rawQuestions.length > MAX_ASK_USER_QUESTIONS
) {
return null;
}
const questions: Agents.AskUserQuestionBatchItem[] = [];
const ids = new Set<string>();
for (const item of rawQuestions) {
if (item == null || typeof item !== 'object') {
return null;
}
const id = (item as { id?: unknown }).id;
const request = parseAskUserQuestionArgs(item as Record<string, unknown>);
if (
typeof id !== 'string' ||
!ASK_USER_QUESTION_ID_PATTERN.test(id) ||
ids.has(id) ||
request == null
) {
return null;
}
ids.add(id);
const rawHeader = (item as { header?: unknown }).header;
const header = typeof rawHeader === 'string' ? rawHeader.trim() : '';
questions.push({
id,
...(header.length > 0 && header.length <= MAX_ASK_USER_QUESTION_HEADER_LENGTH && { header }),
...request,
});
}
return { questions };
}
/**
* Removes the synthetic ask-user-question part for `actionId` from a message.
* Pure returns the same message reference when nothing matched.
@ -318,7 +377,7 @@ export const isAnsweredAskUserQuestionPart = (
export function resolveAskUserQuestionPart(
message: TMessage,
actionId: string,
answer: string,
resolution: string | Record<string, string>,
): TMessage {
const content = message.content;
if (!Array.isArray(content)) {
@ -337,6 +396,13 @@ export function resolveAskUserQuestionPart(
* id several ask cards in one turn each resolve their own part. Absent
* (older server/SDK), the newest-unanswered fallback below applies. */
const targetToolCallId = syntheticPart[ASK_USER_QUESTION].tool_call_id;
const questions = syntheticPart[ASK_USER_QUESTION].questions;
const args =
questions != null
? JSON.stringify({ questions })
: JSON.stringify(syntheticPart[ASK_USER_QUESTION].question);
const output =
typeof resolution === 'string' ? resolution : JSON.stringify({ answers: resolution });
let patched = false;
const nextContent: TMessageContentParts[] = [];
@ -369,13 +435,13 @@ export function resolveAskUserQuestionPart(
...(part as object),
tool_call: {
...toolCall,
...(hasArgs ? {} : { args: JSON.stringify(syntheticPart[ASK_USER_QUESTION].question) }),
output: answer,
...(hasArgs ? {} : { args }),
output,
progress: 1,
},
} as TMessageContentParts;
if (typeof toolCall.id === 'string' && toolCall.id.length > 0) {
submittedAskAnswers.set(toolCall.id, answer);
submittedAskAnswers.set(toolCall.id, output);
}
patched = true;
break;
@ -424,9 +490,12 @@ export function splitOtherOption(options: Agents.AskUserQuestionOption[] | undef
* in-flight cache, a replayed event) can put one back. Honouring it would
* reopen the popover on a question the user already answered.
*/
export function findLiveAskUserQuestion(
messages: TMessage[] | null | undefined,
): { actionId: string; question: Agents.AskUserQuestionRequest; messageId: string } | null {
export function findLiveAskUserQuestion(messages: TMessage[] | null | undefined): {
actionId: string;
question: Agents.AskUserQuestionRequest;
questions?: Agents.AskUserQuestionBatchItem[];
messageId: string;
} | null {
if (!Array.isArray(messages)) {
return null;
}
@ -440,7 +509,12 @@ export function findLiveAskUserQuestion(
const part = content[j];
if (isAskUserQuestionPart(part) && !isAnsweredAskUserQuestionPart(part)) {
const ask = (part as unknown as AskUserQuestionPart)[ASK_USER_QUESTION];
return { actionId: ask.actionId, question: ask.question, messageId: message.messageId };
return {
actionId: ask.actionId,
question: ask.question,
questions: ask.questions,
messageId: message.messageId,
};
}
}
}

View file

@ -1172,11 +1172,17 @@ function askUserQuestionResponses(label, toolNames) {
id: `call_e2e_ask_user_question_${label}`,
name: ASK_USER_QUESTION_TOOL_NAME,
args: {
question: `Which environment should Bombadil use for ${label}?`,
description: 'This deterministic pause exercises the HITL answer and resume lifecycle.',
options: [
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'production' },
questions: [
{
id: 'environment',
question: `Which environment should Bombadil use for ${label}?`,
description:
'This deterministic pause exercises the HITL answer and resume lifecycle.',
options: [
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'production' },
],
},
],
},
type: 'tool_call',
@ -1716,8 +1722,13 @@ function deferredHitlInvocationResponse({ graph, messages, options, runManager }
id: askCallId,
name: ASK_USER_QUESTION_NAME,
args: {
question: `Continue deferred schema check ${label}?`,
options: [{ label: `Continue ${label}`, value: `continue-${label}` }],
questions: [
{
id: 'confirmation',
question: `Continue deferred schema check ${label}?`,
options: [{ label: `Continue ${label}`, value: `continue-${label}` }],
},
],
},
type: 'tool_call',
},

View file

@ -29,7 +29,7 @@ type MCPToolsResponse = {
type AskResumeBody = {
actionId?: string;
agent_id?: string;
answer?: string;
answers?: Record<string, string>;
conversationId?: string;
endpoint?: string;
};
@ -141,19 +141,22 @@ test.describe('deferred tools across HITL resume', () => {
name: new RegExp(`${escapeRegExp(optionLabel)}$`),
});
await expect(option).toBeVisible();
await option.click();
const submit = page.getByRole('button', { name: 'Submit', exact: true });
await expect(submit).toBeEnabled();
const [resumeRequest, resumeResponse] = await Promise.all([
page.waitForRequest(isResumeRequest),
page.waitForResponse(
(candidate) => isResumeRequest(candidate.request()) && candidate.status() === 200,
),
option.click(),
submit.click(),
]);
const conversationId = conversationPath.replace('/c/', '');
const body = resumeRequest.postDataJSON() as AskResumeBody;
expect(body.actionId).toBeTruthy();
expect(body.agent_id).toBe(agentId);
expect(body.answer).toBe(answer);
expect(body.answers).toEqual({ confirmation: answer });
expect(body.conversationId).toBe(conversationId);
expect(body.endpoint).toBe('agents');
expect(resumeResponse.ok()).toBeTruthy();

10
package-lock.json generated
View file

@ -63,7 +63,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.4.4",
"@librechat/agents": "^3.4.5",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -10628,9 +10628,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.4.4.tgz",
"integrity": "sha512-Sv1+my/28jqjV+Dz+RQjxM53itVVcQNOfI9hJ4puxpdjZpDYCPDQzUJT73d9yvI5IO1NoDYA8wULlIJJ5FCI9w==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.4.5.tgz",
"integrity": "sha512-o3wSWG3P639i0ApRZ5uax88yds8ogNJEe2dUuD6727XRAwOm9dcrXmx2ioPCIZzF6cOT/iiBFL3xia0wWRSOdQ==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
@ -42849,7 +42849,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.4.4",
"@librechat/agents": "^3.4.5",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.0",

View file

@ -113,7 +113,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.4.4",
"@librechat/agents": "^3.4.5",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.0",

View file

@ -5,11 +5,8 @@ import {
createAskUserQuestionTool,
} from './askUserQuestionTool';
/**
* Contract-shape coverage. The runtime behavior (interrupt from the tool body,
* durable pause, answer round-trip over the real resume controller) is covered
* end-to-end in `api/server/controllers/agents/__tests__/askUserQuestion.e2e.spec.js`.
*/
const question = (id = 'environment') => ({ id, question: 'Deploy where?' });
describe('ask_user_question tool contract', () => {
test('the name matches the SDK interrupt discriminator the pipeline keys on', () => {
expect(ASK_USER_QUESTION_TOOL_NAME).toBe('ask_user_question');
@ -17,61 +14,58 @@ describe('ask_user_question tool contract', () => {
expect(createAskUserQuestionTool().name).toBe(ASK_USER_QUESTION_TOOL_NAME);
});
describe('zod schema (the wire shape the client card receives)', () => {
test('accepts a bare question', () => {
expect(askUserQuestionToolSchema.parse({ question: 'Deploy where?' })).toEqual({
question: 'Deploy where?',
});
});
test('accepts question + description + options', () => {
describe('zod schema', () => {
test('accepts one to four related questions', () => {
const input = {
question: 'Deploy where?',
description: 'Two environments are configured.',
options: [
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'production' },
questions: [
{
...question(),
header: 'Environment',
description: 'Two environments are configured.',
options: [
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'production' },
],
},
{ ...question('window'), question: 'Which time window?', multiSelect: true },
],
};
expect(askUserQuestionToolSchema.parse(input)).toEqual(input);
});
test('rejects an empty question', () => {
expect(askUserQuestionToolSchema.safeParse({ question: '' }).success).toBe(false);
test('rejects empty, oversized, duplicate-id, and unsafe-id batches', () => {
expect(askUserQuestionToolSchema.safeParse({ questions: [] }).success).toBe(false);
expect(
askUserQuestionToolSchema.safeParse({
questions: Array.from({ length: 5 }, (_, index) => question(`q${index}`)),
}).success,
).toBe(false);
expect(
askUserQuestionToolSchema.safeParse({ questions: [question(), question()] }).success,
).toBe(false);
expect(
askUserQuestionToolSchema.safeParse({ questions: [question('time.window')] }).success,
).toBe(false);
});
test('rejects an over-cap question (model-generated text is bounded)', () => {
expect(askUserQuestionToolSchema.safeParse({ question: 'x'.repeat(2001) }).success).toBe(
false,
);
});
test('rejects more than 12 options', () => {
const options = Array.from({ length: 13 }, (_, i) => ({
label: `opt ${i}`,
value: `v${i}`,
test('rejects over-cap question and option content', () => {
expect(
askUserQuestionToolSchema.safeParse({
questions: [{ ...question(), question: 'x'.repeat(2001) }],
}).success,
).toBe(false);
const options = Array.from({ length: 13 }, (_, index) => ({
label: `opt ${index}`,
value: `v${index}`,
}));
expect(askUserQuestionToolSchema.safeParse({ question: 'pick', options }).success).toBe(
false,
);
});
test('rejects an option missing label or value', () => {
expect(
askUserQuestionToolSchema.safeParse({
question: 'pick',
options: [{ label: 'only label' }],
}).success,
).toBe(false);
expect(
askUserQuestionToolSchema.safeParse({
question: 'pick',
options: [{ label: '', value: 'v' }],
questions: [{ ...question(), options }],
}).success,
).toBe(false);
});
test('rejects an overlong option label with guidance and records the real tool call', async () => {
test('records an overlong option label against the real tool call', async () => {
const validationErrors = new Map();
await expect(
createAskUserQuestionTool(validationErrors).invoke({
@ -79,94 +73,52 @@ describe('ask_user_question tool contract', () => {
name: ASK_USER_QUESTION_TOOL_NAME,
type: 'tool_call',
args: {
question: 'How should I get the data?',
options: [{ label: 'x'.repeat(281), value: 'public-data' }],
questions: [
{
...question(),
options: [{ label: 'x'.repeat(281), value: 'public-data' }],
},
],
},
}),
).rejects.toThrow(
'Option labels must be 280 characters or fewer. Shorten the label and retry.',
);
expect(validationErrors).toEqual(
new Map([['tool-1', { fieldPath: 'options[0].label', isLengthLimit: true }]]),
new Map([['tool-1', { fieldPath: 'questions[0].options[0].label', isLengthLimit: true }]]),
);
});
test('accepts multiSelect as an optional boolean and rejects other types', () => {
const input = {
question: 'Which apply?',
options: [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
],
multiSelect: true,
};
expect(askUserQuestionToolSchema.parse(input)).toEqual(input);
expect(
askUserQuestionToolSchema.safeParse({ question: 'pick', multiSelect: 'yes' }).success,
).toBe(false);
test('accepts checkpointed legacy arguments without advertising them', async () => {
const promise = createAskUserQuestionTool().invoke({
id: 'legacy-call',
name: ASK_USER_QUESTION_TOOL_NAME,
type: 'tool_call',
args: { question: 'Continue the pre-deploy run?' },
});
await expect(promise).rejects.toThrow('No configurable found in config');
expect(AskUserQuestionToolDefinition.schema.required).toEqual(['questions']);
});
});
describe('registry definition (schema-only twin)', () => {
test('question is required; options items require label + value', () => {
expect(AskUserQuestionToolDefinition.schema.required).toEqual(['question']);
expect(AskUserQuestionToolDefinition.schema.properties.options.items.required).toEqual([
'label',
'value',
]);
describe('registry definition', () => {
test('mirrors the nested batch schema and caps', () => {
const questions = AskUserQuestionToolDefinition.schema.properties.questions;
expect(AskUserQuestionToolDefinition.schema.required).toEqual(['questions']);
expect(questions.minItems).toBe(1);
expect(questions.maxItems).toBe(4);
expect(questions.items.required).toEqual(['id', 'question']);
expect(questions.items.properties.options.items.required).toEqual(['label', 'value']);
expect(questions.items.properties.options.items.properties.label.maxLength).toBe(280);
});
test('multiSelect is declared as an optional boolean in both schemas', () => {
expect(AskUserQuestionToolDefinition.schema.properties.multiSelect.type).toBe('boolean');
expect(AskUserQuestionToolDefinition.schema.required).not.toContain('multiSelect');
expect(AskUserQuestionToolDefinition.description).toContain('multiSelect');
});
test('definition caps agree with the zod schema caps', () => {
const { properties } = AskUserQuestionToolDefinition.schema;
expect(
askUserQuestionToolSchema.safeParse({ question: 'x'.repeat(properties.question.maxLength) })
.success,
).toBe(true);
expect(
askUserQuestionToolSchema.safeParse({
question: 'x'.repeat(properties.question.maxLength + 1),
}).success,
).toBe(false);
const atCap = Array.from({ length: properties.options.maxItems }, (_, i) => ({
label: `l${i}`,
value: `v${i}`,
}));
expect(
askUserQuestionToolSchema.safeParse({ question: 'pick', options: atCap }).success,
).toBe(true);
const labelMax = properties.options.items.properties.label.maxLength;
expect(
askUserQuestionToolSchema.safeParse({
question: 'pick',
options: [{ label: 'x'.repeat(labelMax), value: 'v' }],
}).success,
).toBe(true);
expect(
AskUserQuestionToolDefinition.schema.properties.options.items.properties.label.maxLength,
).toBe(280);
expect(
askUserQuestionToolSchema.safeParse({
question: 'pick',
options: [{ label: 'x'.repeat(labelMax + 1), value: 'v' }],
}).success,
).toBe(false);
});
test('descriptions match between the instance, the definition, and the constant name', () => {
test('describes one batched interaction and forbids sibling calls', () => {
const instance = createAskUserQuestionTool();
expect(instance.description).toBe(AskUserQuestionToolDefinition.description);
expect(instance.description).toContain('exactly ONE question per turn');
expect(instance.description).toContain('one to four related');
expect(instance.description).toContain('ONE tool call');
expect(instance.description).toContain('NEVER call this tool in parallel');
expect(instance.description).toContain('option label within 280 characters');
expect(
AskUserQuestionToolDefinition.schema.properties.options.items.properties.label.description,
).toContain('Maximum 280 characters');
});
});
});

View file

@ -1,6 +1,11 @@
import { z } from 'zod';
import { askUserQuestion } from '@librechat/agents';
import { tool } from '@librechat/agents/langchain/tools';
import {
ASK_USER_QUESTION_ID_PATTERN,
MAX_ASK_USER_QUESTIONS,
askUserQuestion,
askUserQuestions,
} from '@librechat/agents';
import type { DynamicStructuredTool } from '@librechat/agents/langchain/tools';
import type { ToolInputValidationError } from '../toolValidation';
import { recordToolInputValidationError } from '../toolValidation';
@ -20,6 +25,7 @@ export const ASK_USER_QUESTION_TOOL_NAME = 'ask_user_question';
*/
const QUESTION_MAX = 2000;
const DESCRIPTION_MAX = 4000;
const HEADER_MAX = 80;
const OPTION_LABEL_MAX = 280;
const OPTION_VALUE_MAX = 500;
const OPTIONS_MAX = 12;
@ -32,10 +38,12 @@ const OPTION_LABEL_MAX_ERROR =
'Shorten the label and retry.';
const ASK_USER_QUESTION_DESCRIPTION = [
'Ask the user a clarifying question and pause the run until they answer; their answer is',
"returned as this tool's result. Use it only when you are genuinely blocked on a decision",
'you cannot resolve from the conversation or your other tools. Ask exactly ONE question per',
'turn, and NEVER call this tool in parallel with any other tool call. When the realistic',
'Ask the user one to four related clarifying questions and pause the run until they answer;',
"their answers are returned as this tool's result, keyed by each question id. Use it only",
'when you are genuinely blocked on decisions you cannot resolve from the conversation or your',
'other tools. Put every related question in this ONE tool call, and NEVER call this tool in',
'parallel with any other tool call. Use stable, concise ids matching',
'[A-Za-z][A-Za-z0-9_-]{0,63}. When the realistic',
'answers are enumerable, provide 2-6 concise options; set multiSelect to true only when',
'several options may sensibly apply at once (the selected option values are returned joined',
`by ", "). Keep every option label within ${OPTION_LABEL_MAX} characters and put supporting`,
@ -46,12 +54,14 @@ const ASK_USER_QUESTION_DESCRIPTION = [
].join(' ');
/**
* Mirrors the SDK's `AskUserQuestionRequest` (question / description? / options?) the
* validated input is passed to `askUserQuestion()` unchanged, so this schema IS the wire
* shape the client card receives inside the pendingAction payload.
* Mirrors the SDK's `AskUserQuestionsRequest`; the validated input is passed to
* `askUserQuestions()` unchanged, so this schema is the wire shape inside the
* pending-action payload.
*/
export const askUserQuestionToolSchema: z.ZodObject<
const askUserQuestionItemSchema: z.ZodObject<
{
id: z.ZodString;
header: z.ZodOptional<z.ZodString>;
question: z.ZodString;
description: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<
@ -61,11 +71,21 @@ export const askUserQuestionToolSchema: z.ZodObject<
},
'strip'
> = z.object({
id: z
.string()
.regex(ASK_USER_QUESTION_ID_PATTERN)
.describe('Unique answer key for this question.'),
header: z
.string()
.min(1)
.max(HEADER_MAX)
.optional()
.describe('Optional short heading shown above the question.'),
question: z
.string()
.min(1)
.max(QUESTION_MAX)
.describe('The single clarifying question to ask the user.'),
.describe('One clarifying question to ask the user.'),
description: z
.string()
.max(DESCRIPTION_MAX)
@ -97,6 +117,27 @@ export const askUserQuestionToolSchema: z.ZodObject<
.describe('Allow the user to pick several options; their values are returned joined by ", ".'),
});
const askUserQuestionsArraySchema: z.ZodEffects<z.ZodArray<typeof askUserQuestionItemSchema>> = z
.array(askUserQuestionItemSchema)
.min(1)
.max(MAX_ASK_USER_QUESTIONS)
.refine(
(questions) => new Set(questions.map((question) => question.id)).size === questions.length,
{
message: 'Question ids must be unique.',
},
)
.describe('One to four related questions presented to the user in one interaction.');
const legacyAskUserQuestionToolSchema = askUserQuestionItemSchema.omit({ id: true, header: true });
export const askUserQuestionToolSchema: z.ZodObject<
{ questions: typeof askUserQuestionsArraySchema },
'strip'
> = z.object({
questions: askUserQuestionsArraySchema,
});
export type AskUserQuestionToolInput = z.infer<typeof askUserQuestionToolSchema>;
/** Explicit shape of {@link AskUserQuestionToolDefinition} (isolatedDeclarations). */
@ -106,22 +147,51 @@ export interface AskUserQuestionToolDefinitionShape {
schema: {
type: 'object';
properties: {
question: { type: 'string'; minLength: number; maxLength: number; description: string };
description: { type: 'string'; maxLength: number; description: string };
options: {
questions: {
type: 'array';
minItems: number;
maxItems: number;
description: string;
items: {
type: 'object';
properties: {
label: { type: 'string'; minLength: number; maxLength: number; description: string };
value: { type: 'string'; minLength: number; maxLength: number; description: string };
id: { type: 'string'; pattern: string; description: string };
header: { type: 'string'; minLength: number; maxLength: number; description: string };
question: {
type: 'string';
minLength: number;
maxLength: number;
description: string;
};
description: { type: 'string'; maxLength: number; description: string };
options: {
type: 'array';
maxItems: number;
description: string;
items: {
type: 'object';
properties: {
label: {
type: 'string';
minLength: number;
maxLength: number;
description: string;
};
value: {
type: 'string';
minLength: number;
maxLength: number;
description: string;
};
};
required: string[];
};
};
multiSelect: { type: 'boolean'; description: string };
};
required: string[];
};
};
multiSelect: { type: 'boolean'; description: string };
};
required: string[];
};
@ -138,57 +208,78 @@ export const AskUserQuestionToolDefinition: AskUserQuestionToolDefinitionShape =
schema: {
type: 'object',
properties: {
question: {
type: 'string',
minLength: 1,
maxLength: QUESTION_MAX,
description: 'The single clarifying question to ask the user.',
},
description: {
type: 'string',
maxLength: DESCRIPTION_MAX,
description: 'Optional context rendered alongside the question (why you are asking).',
},
options: {
questions: {
type: 'array',
maxItems: OPTIONS_MAX,
description:
'Optional pre-defined choices (2-6 recommended). Omit to require a free-form answer.',
minItems: 1,
maxItems: MAX_ASK_USER_QUESTIONS,
description: 'One to four related questions presented in one interaction.',
items: {
type: 'object',
properties: {
label: {
id: {
type: 'string',
minLength: 1,
maxLength: OPTION_LABEL_MAX,
description: OPTION_LABEL_DESCRIPTION,
pattern: ASK_USER_QUESTION_ID_PATTERN.source,
description: 'Unique answer key for this question.',
},
value: {
header: {
type: 'string',
minLength: 1,
maxLength: OPTION_VALUE_MAX,
description: 'Value returned as the answer if this option is picked.',
maxLength: HEADER_MAX,
description: 'Optional short heading shown above the question.',
},
question: {
type: 'string',
minLength: 1,
maxLength: QUESTION_MAX,
description: 'One clarifying question to ask the user.',
},
description: {
type: 'string',
maxLength: DESCRIPTION_MAX,
description: 'Optional context rendered alongside the question.',
},
options: {
type: 'array',
maxItems: OPTIONS_MAX,
description: 'Optional pre-defined choices. Omit for free-form only.',
items: {
type: 'object',
properties: {
label: {
type: 'string',
minLength: 1,
maxLength: OPTION_LABEL_MAX,
description: OPTION_LABEL_DESCRIPTION,
},
value: {
type: 'string',
minLength: 1,
maxLength: OPTION_VALUE_MAX,
description: 'Value returned if this option is picked.',
},
},
required: ['label', 'value'],
},
},
multiSelect: {
type: 'boolean',
description: 'Allow several option values for this question.',
},
},
required: ['label', 'value'],
required: ['id', 'question'],
},
},
multiSelect: {
type: 'boolean',
description:
'Allow the user to pick several options; their values are returned joined by ", ".',
},
},
required: ['question'],
required: ['questions'],
},
};
/**
* Create the `ask_user_question` tool instance. The func calls the SDK's
* `askUserQuestion()` helper, which raises a LangGraph `interrupt()` on the first
* `askUserQuestions()` helper, which raises a LangGraph `interrupt()` on the first
* pass execution unwinds (the run pauses; `run.getInterrupt().payload.type ===
* 'ask_user_question'`), and on the resume pass it returns the host-supplied
* `{ answer }`, which becomes the ToolMessage content the model sees.
* `{ answers }`, which becomes the ToolMessage content the model sees.
*
* Requirements at the run level (wired in `agents/run.ts`): a checkpointer must be
* attached (the interrupt must be durable to be resumable) and the tool must be
@ -201,26 +292,27 @@ export const AskUserQuestionToolDefinition: AskUserQuestionToolDefinitionShape =
* from the top on the resume pass, and sibling tools in the same batch re-execute
* which is why the description forbids parallel calls.
*/
/**
* `askUserQuestion` with the optional attribution argument shipped in
* `@librechat/agents` > 3.3.8 (interrupt payload gains `tool_call_id`, letting
* the host stamp the question/answer onto the exact tool-call part in
* multi-ask turns). The pinned release still types the single-arg form; the
* extra argument is ignored at runtime by older versions. Drop this alias once
* the dependency pin includes the two-arg signature.
*/
const askUserQuestionWithId = askUserQuestion as (
question: AskUserQuestionToolInput,
options?: { toolCallId?: string },
) => ReturnType<typeof askUserQuestion>;
export function createAskUserQuestionTool(
validationErrorsByToolCallId?: Map<string, ToolInputValidationError>,
): DynamicStructuredTool<typeof askUserQuestionToolSchema> {
/** Kept out of the provider-facing definition. A graph rebuilt during a
* rolling deploy can still rerun checkpointed legacy `{ question, ... }`
* arguments through this schema and consume its retained `{ answer }` resume. */
const legacyAskTool = tool(
async (input, config?: { toolCall?: { id?: string } }) => {
const resolution = askUserQuestion(input, { toolCallId: config?.toolCall?.id });
return JSON.stringify(resolution);
},
{
name: ASK_USER_QUESTION_TOOL_NAME,
description: ASK_USER_QUESTION_DESCRIPTION,
schema: legacyAskUserQuestionToolSchema,
},
);
const askTool = tool(
async (input: AskUserQuestionToolInput, config?: { toolCall?: { id?: string } }) => {
const { answer } = askUserQuestionWithId(input, { toolCallId: config?.toolCall?.id });
return answer;
const resolution = askUserQuestions(input, { toolCallId: config?.toolCall?.id });
return JSON.stringify(resolution);
},
{
name: ASK_USER_QUESTION_TOOL_NAME,
@ -233,8 +325,16 @@ export function createAskUserQuestionTool(
* the tool boundary so the thrown validation error can be correlated with
* the real call ID without inferring failure from persisted output text. */
const invoke = askTool.invoke.bind(askTool);
askTool.invoke = async (input, config) => {
askTool.invoke = (async (input, config) => {
try {
const candidate =
typeof input === 'object' && input != null && 'args' in input ? input.args : input;
if (legacyAskUserQuestionToolSchema.safeParse(candidate).success) {
return await legacyAskTool.invoke(
input as unknown as Parameters<typeof legacyAskTool.invoke>[0],
config,
);
}
return await invoke(input, config);
} catch (error) {
const toolCallId =
@ -242,7 +342,7 @@ export function createAskUserQuestionTool(
recordToolInputValidationError(validationErrorsByToolCallId, error, toolCallId);
throw error;
}
};
}) as typeof askTool.invoke;
return askTool;
}

View file

@ -9,6 +9,9 @@ import type { Agents } from 'librechat-data-provider';
import {
mapToolApprovalResolutions,
mapAskUserAnswer,
mapAskUserAnswers,
serializeAskUserAnswerVariants,
resolveAskUserQuestionResume,
findUndecidedToolCalls,
findDisallowedDecisions,
findIncompleteDecisions,
@ -70,6 +73,105 @@ describe('mapAskUserAnswer', () => {
});
});
describe('mapAskUserAnswers', () => {
it('preserves answers keyed by question id', () => {
expect(mapAskUserAnswers({ answers: { environment: 'staging', window: '7d' } })).toEqual({
answers: { environment: 'staging', window: '7d' },
});
});
});
describe('serializeAskUserAnswerVariants', () => {
it('covers every bounded downstream answer-map ordering with the resolution wrapper', () => {
expect(serializeAskUserAnswerVariants({ second: '456', first: '123' })).toEqual([
'{"answers":{"second":"456","first":"123"}}',
'{"answers":{"first":"123","second":"456"}}',
]);
});
it('does not expand invalid or unbounded answer maps', () => {
expect(serializeAskUserAnswerVariants(['one'])).toEqual([]);
expect(
serializeAskUserAnswerVariants({
one: '1',
two: '2',
three: '3',
four: '4',
five: '5',
}),
).toEqual([]);
});
});
describe('resolveAskUserQuestionResume', () => {
const payload: Agents.AskUserQuestionInterruptPayload = {
type: 'ask_user_question',
question: { question: 'Where and when?' },
questions: [
{ id: 'environment', question: 'Where?' },
{ id: 'window', question: 'When?' },
],
};
test('validates and maps a complete batch', () => {
expect(
resolveAskUserQuestionResume(payload, {
answers: { environment: 'staging', window: '7d' },
}),
).toEqual({ resumeValue: { answers: { environment: 'staging', window: '7d' } } });
});
test('rejects missing, unknown, and array-shaped answers', () => {
expect(resolveAskUserQuestionResume(payload, { answers: { environment: 'staging' } })).toEqual({
status: 400,
error: 'Answers are required for every question',
});
expect(
resolveAskUserQuestionResume(payload, {
answers: { environment: 'staging', window: '7d', region: 'us-east-2' },
}),
).toEqual({ status: 400, error: 'Answers contain an unknown question id' });
expect(resolveAskUserQuestionResume(payload, { answers: ['staging', '7d'] })).toEqual({
status: 400,
error: 'Answers are required for every question',
});
});
test('rejects invalid batches and oversized answers', () => {
expect(
resolveAskUserQuestionResume(
{ ...payload, questions: [{ id: 'bad id', question: 'Invalid?' }] },
{ answers: { 'bad id': 'yes' } },
),
).toEqual({ status: 400, error: 'The pending question batch is invalid' });
expect(
resolveAskUserQuestionResume(
{
...payload,
questions: [{ id: undefined, question: 'Invalid?' }],
} as unknown as Agents.AskUserQuestionInterruptPayload,
{ answers: { undefined: 'yes' } },
),
).toEqual({ status: 400, error: 'The pending question batch is invalid' });
expect(
resolveAskUserQuestionResume(payload, {
answers: { environment: 'x'.repeat(16_001), window: '7d' },
}),
).toEqual({ status: 400, error: 'An answer exceeds the maximum length' });
});
test('preserves legacy single-answer validation and mapping', () => {
const legacy = { ...payload, questions: undefined };
expect(resolveAskUserQuestionResume(legacy, { answer: 'staging' })).toEqual({
resumeValue: { answer: 'staging' },
});
expect(resolveAskUserQuestionResume(legacy, {})).toEqual({
status: 400,
error: 'An answer is required',
});
});
});
describe('findUndecidedToolCalls', () => {
const payload: Agents.ToolApprovalInterruptPayload = {
type: 'tool_approval',
@ -463,6 +565,22 @@ describe('attachAskUserQuestionAnswer', () => {
attachAskUserQuestionAnswer(content as never, question as never, 'x', 'tc_missing'),
).toBe(content);
});
it('stamps batched args and structured answers onto one ask tool call', () => {
const request = {
questions: [
{ id: 'environment', question: 'Which env?' },
{ id: 'window', question: 'Which window?' },
],
};
const output = JSON.stringify({ answers: { environment: 'staging', window: '7d' } });
const next = attachAskUserQuestionAnswer([askPart()] as never, request, output);
const toolCall = (next[0] as { tool_call: { args: string; output: string } }).tool_call;
expect(JSON.parse(toolCall.args)).toEqual(request);
expect(JSON.parse(toolCall.output)).toEqual({
answers: { environment: 'staging', window: '7d' },
});
});
});
describe('attachAskUserQuestionArgs (pause-time stamp)', () => {
@ -500,4 +618,20 @@ describe('attachAskUserQuestionArgs (pause-time stamp)', () => {
);
expect((next[1] as { tool_call: { args: string } }).tool_call.args).toBe('');
});
it('stamps the complete batched request at pause time', () => {
const content = [
{ type: 'tool_call', tool_call: { id: 'tc1', name: 'ask_user_question', args: '' } },
];
const request = {
questions: [
{ id: 'environment', question: 'Which env?' },
{ id: 'window', question: 'Which window?' },
],
};
const next = attachAskUserQuestionArgs(content as never, request);
expect(JSON.parse((next[0] as { tool_call: { args: string } }).tool_call.args)).toEqual(
request,
);
});
});

View file

@ -3,6 +3,7 @@ import type {
ToolApprovalDecision,
ToolApprovalDecisionMap,
AskUserQuestionResolution,
AskUserQuestionsResolution,
EventHandler,
RunStep,
} from '@librechat/agents';
@ -57,6 +58,112 @@ export function mapAskUserAnswer(
return { answer: resolution.answer };
}
/** Translate batched ask-user wire answers into the SDK's resume value. */
export function mapAskUserAnswers(
resolution: Agents.AskUserQuestionsResolution,
): AskUserQuestionsResolution {
return { answers: resolution.answers };
}
const MAX_ASK_ANSWER_LENGTH = 16_000;
const ASK_QUESTION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
const MAX_ASK_QUESTIONS = 4;
/**
* Serialize every ordering a validated batch can take after the SDK rebuilds
* its answer map in question order. Batches are capped at four questions, so
* this remains bounded at 24 candidates and lets pre-controller PII/moderation
* checks inspect the exact ToolMessage even for a crafted key order.
*/
export function serializeAskUserAnswerVariants(answers: unknown): string[] {
if (answers == null || typeof answers !== 'object' || Array.isArray(answers)) {
return [];
}
const entries = Object.entries(answers);
if (
entries.length === 0 ||
entries.length > MAX_ASK_QUESTIONS ||
entries.some(([, value]) => typeof value !== 'string')
) {
return [];
}
const variants: string[] = [];
const visit = (remaining: Array<[string, unknown]>, ordered: Array<[string, unknown]>) => {
if (remaining.length === 0) {
const normalized = Object.create(null) as Record<string, unknown>;
for (const [key, value] of ordered) {
normalized[key] = value;
}
variants.push(JSON.stringify({ answers: normalized }));
return;
}
for (let index = 0; index < remaining.length; index++) {
visit(
[...remaining.slice(0, index), ...remaining.slice(index + 1)],
[...ordered, remaining[index]],
);
}
};
visit(entries, []);
return variants;
}
interface AskUserResumeBody {
answer?: unknown;
answers?: unknown;
}
type AskUserResumeResult =
| { resumeValue: AskUserQuestionResolution | AskUserQuestionsResolution }
| { status: 400; error: string };
/** Validate an ask-user resume payload and translate it to the SDK contract. */
export function resolveAskUserQuestionResume(
payload: Agents.AskUserQuestionInterruptPayload,
body: AskUserResumeBody,
): AskUserResumeResult {
if (!Array.isArray(payload.questions)) {
if (typeof body.answer !== 'string' || body.answer.length === 0) {
return { status: 400, error: 'An answer is required' };
}
if (body.answer.length > MAX_ASK_ANSWER_LENGTH) {
return { status: 400, error: 'Answer exceeds the maximum length' };
}
return { resumeValue: mapAskUserAnswer({ answer: body.answer }) };
}
if (payload.questions.length === 0 || payload.questions.length > MAX_ASK_QUESTIONS) {
return { status: 400, error: 'The pending question batch is invalid' };
}
if (body.answers == null || typeof body.answers !== 'object' || Array.isArray(body.answers)) {
return { status: 400, error: 'Answers are required for every question' };
}
const submittedAnswers = body.answers as Record<string, unknown>;
const answers: Record<string, string> = Object.create(null);
const expectedIds = new Set<string>();
for (const question of payload.questions) {
const { id } = question;
if (typeof id !== 'string' || !ASK_QUESTION_ID_PATTERN.test(id) || expectedIds.has(id)) {
return { status: 400, error: 'The pending question batch is invalid' };
}
expectedIds.add(id);
const answer = Object.getOwnPropertyDescriptor(submittedAnswers, id)?.value;
if (typeof answer !== 'string' || answer.length === 0) {
return { status: 400, error: 'Answers are required for every question' };
}
if (answer.length > MAX_ASK_ANSWER_LENGTH) {
return { status: 400, error: 'An answer exceeds the maximum length' };
}
answers[id] = answer;
}
if (Object.keys(submittedAnswers).some((id) => !expectedIds.has(id))) {
return { status: 400, error: 'Answers contain an unknown question id' };
}
return { resumeValue: mapAskUserAnswers({ answers }) };
}
/**
* Validate that a set of resolutions covers exactly the tool calls a pending
* `tool_approval` action is waiting on. Returns the list of `tool_call_id`s that
@ -350,8 +457,8 @@ export function attachAskUserQuestionAnswer<
TPart extends { type?: string; tool_call?: { id?: string; name?: string; output?: unknown } },
>(
content: TPart[],
question: Agents.AskUserQuestionRequest,
answer: string,
request: Agents.AskUserQuestionRequest | Agents.AskUserQuestionsRequest,
output: string,
toolCallId?: string,
): TPart[] {
const index = findAskPartIndex(
@ -368,8 +475,8 @@ export function attachAskUserQuestionAnswer<
...part,
tool_call: {
...part.tool_call,
args: JSON.stringify(question),
output: answer,
args: JSON.stringify(request),
output,
progress: 1,
},
};
@ -391,7 +498,11 @@ export function attachAskUserQuestionArgs<
type?: string;
tool_call?: { id?: string; name?: string; args?: unknown; output?: unknown };
},
>(content: TPart[], question: Agents.AskUserQuestionRequest, toolCallId?: string): TPart[] {
>(
content: TPart[],
request: Agents.AskUserQuestionRequest | Agents.AskUserQuestionsRequest,
toolCallId?: string,
): TPart[] {
const index = findAskPartIndex(content, toolCallId, (part) => {
const toolCall = part.tool_call;
const hasArgs =
@ -406,6 +517,6 @@ export function attachAskUserQuestionArgs<
}
const part = content[index];
const next = [...content];
next[index] = { ...part, tool_call: { ...part.tool_call, args: JSON.stringify(question) } };
next[index] = { ...part, tool_call: { ...part.tool_call, args: JSON.stringify(request) } };
return next;
}

View file

@ -70,17 +70,13 @@ jest.mock('~/utils', () => ({
const { createSafeUser } = jest.requireMock('~/utils');
jest.mock('@librechat/agents', () => {
const actual = jest.requireActual('@librechat/agents');
return {
Run: {
create: jest.fn(() => ({
beforeEach(() => {
jest.spyOn(Run, 'create').mockImplementation(
() =>
({
processStream: jest.fn(() => Promise.resolve('success')),
})),
},
Providers: actual.Providers,
GraphEvents: actual.GraphEvents,
};
}) as never,
);
});
function createTestUser(overrides: Partial<IUser> = {}): IUser {

View file

@ -30,6 +30,24 @@ describe('getToolInputValidationDetails', () => {
expect(JSON.stringify(details)).not.toContain('Shorten the label');
});
test('classifies an overlong option label nested in a question batch', () => {
const validationError = parseToolInputValidationError(
new Error(
'Received tool input did not match expected schema\n' +
'✖ String must contain at most 120 character(s)\n' +
' → at questions[0].options[0].label',
),
);
expect(
getToolInputValidationDetails({ tool_call: { name: 'ask_user_question' } }, validationError),
).toEqual({
toolName: 'ask_user_question',
reason: 'option_label_too_long',
fieldPath: 'questions[0].options[0].label',
});
});
test('classifies other schema failures without requiring a field path', () => {
expect(
getToolInputValidationDetails(

View file

@ -1,6 +1,6 @@
const TOOL_INPUT_SCHEMA_ERROR = 'Received tool input did not match expected schema';
const SCHEMA_ERROR_PATH_PATTERN = /(?:→|->)\s+at\s+([A-Za-z0-9_.[\]-]{1,120})/;
const ASK_OPTION_LABEL_PATH_PATTERN = /^options\[\d+\]\.label$/;
const ASK_OPTION_LABEL_PATH_PATTERN = /^(?:questions\[\d+\]\.)?options\[\d+\]\.label$/;
const OPTION_LABEL_LIMIT_PATTERN = /(?:at most \d+|\d+ characters or fewer)/i;
interface CompletedToolCall {

View file

@ -115,6 +115,45 @@ describe('messageFilterPii middleware', () => {
expect(capturedRes.status).toBe(400);
});
it('rejects a batched ask-user answer containing a blocked token', () => {
const { capturedRes, nextCalls } = runMiddleware(
{},
{ answers: { environment: 'staging', credentials: `the key is ${SK}` } },
);
expect(nextCalls).toBe(0);
expect(capturedRes.status).toBe(400);
});
it('rejects a blocked pattern spanning serialized batch answers', () => {
const { capturedRes, nextCalls } = runMiddleware(
{
starterPatterns: [],
customPatterns: [{ id: 'split', label: 'Split token', regex: '123[^0-9]+456' }],
},
{ answers: { first: '123', second: '456' } },
);
expect(nextCalls).toBe(0);
expect(capturedRes.status).toBe(400);
});
it('rejects the normalized ToolMessage ordering when request keys arrive out of order', () => {
const { capturedRes, nextCalls } = runMiddleware(
{
starterPatterns: [],
customPatterns: [
{
id: 'ordered',
label: 'Ordered token',
regex: '\\{"answers":\\{"first":"123","second":"456"\\}\\}',
},
],
},
{ answers: { second: '456', first: '123' } },
);
expect(nextCalls).toBe(0);
expect(capturedRes.status).toBe(400);
});
it('rejects a tool-approval decision responseText containing a blocked token', () => {
const { capturedRes, nextCalls } = runMiddleware(
{},

View file

@ -8,6 +8,7 @@ import type {
Response as ServerResponse,
} from 'express';
import type { MessageFilterPiiConfig } from 'librechat-data-provider';
import { serializeAskUserAnswerVariants } from '../agents/hitl/resume';
import { getReferencedQuotes, mergeQuotedText } from '../utils/quotes';
/**
@ -196,6 +197,18 @@ export function createMessageFilterPii(options: CreateMessageFilterPiiOptions):
if (typeof req.body?.answer === 'string' && req.body.answer.length > 0) {
candidates.push(req.body.answer);
}
if (
req.body?.answers != null &&
typeof req.body.answers === 'object' &&
!Array.isArray(req.body.answers)
) {
for (const answer of Object.values(req.body.answers)) {
if (typeof answer === 'string' && answer.length > 0) {
candidates.push(answer);
}
}
candidates.push(...serializeAskUserAnswerVariants(req.body.answers));
}
if (Array.isArray(req.body?.decisions)) {
for (const decision of req.body.decisions) {
if (typeof decision?.responseText === 'string' && decision.responseText.length > 0) {

View file

@ -380,10 +380,25 @@ export namespace Agents {
multiSelect?: boolean;
}
/** One independently answerable question in a batched clarification. */
export interface AskUserQuestionBatchItem extends AskUserQuestionRequest {
/** Batch-unique identifier used to map the submitted answer. */
id: string;
/** Optional short heading rendered above the question. */
header?: string;
}
/** Input shape for one tool call that asks several related questions. */
export interface AskUserQuestionsRequest {
questions: AskUserQuestionBatchItem[];
}
/** Interrupt payload for an ask-user-question pause. */
export interface AskUserQuestionInterruptPayload {
type: 'ask_user_question';
question: AskUserQuestionRequest;
/** Present for a batched clarification; `question` remains the first-item fallback. */
questions?: AskUserQuestionBatchItem[];
/**
* The ask tool call that raised this interrupt (mirrors the SDK field,
* present from `@librechat/agents` > 3.3.8). Lets the question/answer
@ -478,6 +493,11 @@ export namespace Agents {
answer: string;
}
/** Wire format for a batched ask-user-question response. */
export interface AskUserQuestionsResolution {
answers: Record<string, string>;
}
export interface ExtendedMessageContent {
type?: string;
text?: string;