mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
feat: dedicated UI + durable data for completed ask_user_question calls
The completed ask call rendered as a generic tool card labeled 'Cancelled'
with raw (and empty) JSON args. Two layers fixed:
Data: the saved tool_call part had args:'' and no output — streamed arg
chunks carry no tool name so the aggregator drops them (normal tools recover
via the completion event, which never fires for a tool that interrupts
mid-execution and resumes on a rebuilt run with no step id). The resume
controller now stamps the paused ask part with the pendingAction's
authoritative question as args and the user's answer as output
(attachAskUserQuestionAnswer — pure, targets the newest unanswered ask part,
so sequential questions each keep their own answer).
UI: Part.tsx routes ask_user_question tool calls to AskUserQuestionCall — a
compact Q&A record ('Asked a question' header, question, description, 'You
answered: <label>' preferring the picked option's label, or 'No answer was
given' for an abandoned pause) instead of the generic card. New i18n keys;
parseAskUserQuestionArgs degrades to null on malformed model args.
This commit is contained in:
parent
88a254ee84
commit
d6f0e48a67
8 changed files with 212 additions and 1 deletions
|
|
@ -5,6 +5,7 @@ const {
|
|||
isPendingActionStale,
|
||||
mapToolApprovalResolutions,
|
||||
mapAskUserAnswer,
|
||||
attachAskUserQuestionAnswer,
|
||||
findUndecidedToolCalls,
|
||||
findDisallowedDecisions,
|
||||
findIncompleteDecisions,
|
||||
|
|
@ -612,7 +613,19 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
|
|||
// in-memory store's setContentParts REPLACES the stored array, so reading the
|
||||
// resume state afterward would see the new (empty) client array and lose the seed.
|
||||
const resumeState = await GenerationJobManager.getResumeState(streamId);
|
||||
const seedContent = resumeState?.aggregatedContent ?? [];
|
||||
let seedContent = resumeState?.aggregatedContent ?? [];
|
||||
// Stamp the answered question onto the paused ask_user_question tool-call part
|
||||
// (args = the pendingAction's authoritative question, output = the user's answer):
|
||||
// the streamed arg chunks carry no tool name so the aggregator dropped them, and
|
||||
// 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') {
|
||||
seedContent = attachAskUserQuestionAnswer(
|
||||
seedContent,
|
||||
pendingAction.payload.question,
|
||||
req.body.answer,
|
||||
);
|
||||
}
|
||||
if (client.contentParts) {
|
||||
GenerationJobManager.setContentParts(streamId, client.contentParts);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { MessageCircleQuestion } from 'lucide-react';
|
||||
import { parseAskUserQuestionArgs } from '~/utils/approval';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Static rendering of a COMPLETED (or abandoned) `ask_user_question` tool call —
|
||||
* the durable record of the Q&A after the pause resolves. The generic tool card
|
||||
* is wrong here: it labels a no-output call "cancelled" and shows raw JSON args.
|
||||
* The interactive card ({@link AskUserQuestion}) renders only while the pause is
|
||||
* live; this component owns the part everywhere else (history, reload, exports).
|
||||
*/
|
||||
export default function AskUserQuestionCall({
|
||||
args,
|
||||
output,
|
||||
}: {
|
||||
args: string | Record<string, unknown> | undefined;
|
||||
output: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const question = parseAskUserQuestionArgs(args);
|
||||
const answered = output.length > 0;
|
||||
|
||||
/** Prefer the picked option's label over its wire value when they differ. */
|
||||
const answerLabel = question?.options?.find((option) => option.value === output)?.label ?? output;
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-text-secondary">
|
||||
<MessageCircleQuestion className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_asked_a_question')}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{question?.question ?? localize('com_ui_asked_a_question')}
|
||||
</p>
|
||||
{question?.description != null && question.description.length > 0 && (
|
||||
<p className="text-sm text-text-secondary">{question.description}</p>
|
||||
)}
|
||||
{answered ? (
|
||||
<p className="text-sm text-text-primary">
|
||||
<span className="font-medium text-text-secondary">{localize('com_ui_you_answered')}</span>{' '}
|
||||
{answerLabel}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-text-secondary">
|
||||
{localize('com_ui_question_unanswered')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
SubagentCall,
|
||||
} from './Parts';
|
||||
import { getAskUserQuestionPart } from '~/utils/approval';
|
||||
import AskUserQuestionCall from './AskUserQuestionCall';
|
||||
import { isBashProgrammaticToolCall } from './routing';
|
||||
import { ErrorMessage } from './MessageContent';
|
||||
import AskUserQuestion from './AskUserQuestion';
|
||||
|
|
@ -196,6 +197,15 @@ const Part = memo(function Part({
|
|||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.name === 'ask_user_question') {
|
||||
/** Dedicated Q&A record — the generic tool card would label the
|
||||
* interrupt-resolved call "cancelled" and dump raw JSON args. */
|
||||
return (
|
||||
<AskUserQuestionCall
|
||||
args={toolCall.args}
|
||||
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.name === 'skill') {
|
||||
return (
|
||||
<SkillCall
|
||||
|
|
|
|||
|
|
@ -856,6 +856,7 @@
|
|||
"com_ui_artifacts_options": "Artifacts Options",
|
||||
"com_ui_artifacts_subtext": "Lets the agent render React, HTML, SVG, Markdown, and Mermaid as interactive artifacts in a side panel instead of plain code blocks.",
|
||||
"com_ui_ascending": "Asc",
|
||||
"com_ui_asked_a_question": "Asked a question",
|
||||
"com_ui_assistant": "Assistant",
|
||||
"com_ui_assistant_delete_error": "There was an error deleting the assistant",
|
||||
"com_ui_assistant_deleted": "Successfully deleted assistant",
|
||||
|
|
@ -1528,6 +1529,7 @@
|
|||
"com_ui_quality": "Quality",
|
||||
"com_ui_quote_selections": "{{0}} selections",
|
||||
"com_ui_quotes_queued": "Quotes added for your next message",
|
||||
"com_ui_question_unanswered": "No answer was given",
|
||||
"com_ui_ran_n_agents": "Ran {{0}} agents",
|
||||
"com_ui_read_aloud": "Read aloud",
|
||||
"com_ui_read_file": "Read {{0}}",
|
||||
|
|
@ -2008,6 +2010,7 @@
|
|||
"com_ui_x_selected": "{{0}} selected",
|
||||
"com_ui_xhigh": "Extra High",
|
||||
"com_ui_yes": "Yes",
|
||||
"com_ui_you_answered": "You answered:",
|
||||
"com_ui_you": "You",
|
||||
"com_ui_your_answer": "Type your answer…",
|
||||
"com_ui_your_api_key": "Your API Key",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getAskUserQuestionPart,
|
||||
findPendingActionMessageIndex,
|
||||
removeAskUserQuestionPart,
|
||||
parseAskUserQuestionArgs,
|
||||
} from './approval';
|
||||
|
||||
const toolCallPart = (id: string, extra: Record<string, unknown> = {}): TMessageContentParts =>
|
||||
|
|
@ -226,6 +227,27 @@ describe('applyPendingAction — ask_user_question', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('parseAskUserQuestionArgs', () => {
|
||||
it('parses a JSON-string args payload (the persisted wire shape)', () => {
|
||||
const parsed = parseAskUserQuestionArgs(
|
||||
JSON.stringify({ question: 'Which?', options: [{ label: 'A', value: 'a' }] }),
|
||||
);
|
||||
expect(parsed?.question).toBe('Which?');
|
||||
expect(parsed?.options).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('accepts an already-parsed object', () => {
|
||||
expect(parseAskUserQuestionArgs({ question: 'Which?' })?.question).toBe('Which?');
|
||||
});
|
||||
|
||||
it('degrades to null on empty, malformed, or question-less args', () => {
|
||||
expect(parseAskUserQuestionArgs('')).toBeNull();
|
||||
expect(parseAskUserQuestionArgs('not json')).toBeNull();
|
||||
expect(parseAskUserQuestionArgs('{"no_question": true}')).toBeNull();
|
||||
expect(parseAskUserQuestionArgs(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAskUserQuestionPart', () => {
|
||||
it('strips the synthetic part for the matching actionId and keeps everything else', () => {
|
||||
const withCard = applyPendingAction(msg({ content: [textPart('hello')] }), askAction());
|
||||
|
|
|
|||
|
|
@ -176,6 +176,36 @@ function applyAskUserQuestion(
|
|||
return { ...message, content: [...content, askPart] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an `ask_user_question` tool call's args into the question request shape.
|
||||
* Args arrive as a JSON string on persisted messages (or an object mid-stream);
|
||||
* malformed/empty args degrade to `null` so the caller can render a fallback
|
||||
* label instead of crashing on model output.
|
||||
*/
|
||||
export function parseAskUserQuestionArgs(
|
||||
args: string | Record<string, unknown> | undefined,
|
||||
): Agents.AskUserQuestionRequest | null {
|
||||
let parsed: unknown = args;
|
||||
if (typeof args === 'string') {
|
||||
if (args.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(args);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
parsed == null ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof (parsed as { question?: unknown }).question !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as unknown as Agents.AskUserQuestionRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the synthetic ask-user-question part for `actionId` from a message.
|
||||
* Pure — returns the same message reference when nothing matched.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
findDisallowedDecisions,
|
||||
findIncompleteDecisions,
|
||||
createContentIndexOffsetHandlers,
|
||||
attachAskUserQuestionAnswer,
|
||||
} from './resume';
|
||||
|
||||
describe('mapToolApprovalResolutions', () => {
|
||||
|
|
@ -224,3 +225,40 @@ describe('createContentIndexOffsetHandlers', () => {
|
|||
expect(calls[0].data).toBe(weird);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachAskUserQuestionAnswer', () => {
|
||||
const question = { question: 'Which env?', options: [{ label: 'Staging', value: 'staging' }] };
|
||||
const askPart = (output?: string) => ({
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'tc1',
|
||||
name: 'ask_user_question',
|
||||
args: '',
|
||||
...(output != null && { output }),
|
||||
},
|
||||
});
|
||||
|
||||
it('stamps args (the authoritative question) and output (the answer) onto the last unanswered ask part', () => {
|
||||
const content = [{ type: 'text' } as never, askPart()];
|
||||
const next = attachAskUserQuestionAnswer(content as never, question as never, 'staging');
|
||||
expect(next).not.toBe(content);
|
||||
const patched = (next[1] as { tool_call: Record<string, unknown> }).tool_call;
|
||||
expect(patched.output).toBe('staging');
|
||||
expect(patched.progress).toBe(1);
|
||||
expect(JSON.parse(patched.args as string)).toEqual(question);
|
||||
// original untouched (pure)
|
||||
expect((content[1] as { tool_call: { output?: string } }).tool_call.output).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips already-answered ask parts and targets the newest unanswered one', () => {
|
||||
const content = [askPart('earlier answer'), askPart()];
|
||||
const next = attachAskUserQuestionAnswer(content as never, question as never, 'blue');
|
||||
expect((next[0] as { tool_call: { output: string } }).tool_call.output).toBe('earlier answer');
|
||||
expect((next[1] as { tool_call: { output: string } }).tool_call.output).toBe('blue');
|
||||
});
|
||||
|
||||
it('returns the input array untouched when no ask part matches', () => {
|
||||
const content = [{ type: 'text' } as never, askPart('done')];
|
||||
expect(attachAskUserQuestionAnswer(content as never, question as never, 'x')).toBe(content);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
EventHandler,
|
||||
} from '@librechat/agents';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import { ASK_USER_QUESTION_TOOL_NAME } from './askUserQuestionTool';
|
||||
|
||||
/**
|
||||
* Translate the host-facing approval wire format into the SDK's resume value.
|
||||
|
|
@ -183,3 +184,47 @@ export function createContentIndexOffsetHandlers(
|
|||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the answered question onto the paused `ask_user_question` tool-call part
|
||||
* before the resume run seeds it back into the content pipeline.
|
||||
*
|
||||
* WHY the part is otherwise empty: the streamed arg CHUNKS carry no tool name, and
|
||||
* the aggregator only accepts name-less arg updates on the completion event — which
|
||||
* never fires for this tool (the first pass interrupts mid-execution, and the
|
||||
* rebuilt resume run has no step id to complete against). Saved messages therefore
|
||||
* showed `args: ""` and no `output`, and the client rendered a "cancelled" tool.
|
||||
* The authoritative data exists anyway: the pendingAction payload carries the full
|
||||
* question, and the resume request carries the user's answer.
|
||||
*
|
||||
* Patches the LAST unanswered ask part (a re-pause targets the newest question;
|
||||
* earlier ones already carry their answers). Pure — returns the input array when
|
||||
* nothing matched.
|
||||
*/
|
||||
export function attachAskUserQuestionAnswer<
|
||||
TPart extends { type?: string; tool_call?: { name?: string; output?: unknown } },
|
||||
>(content: TPart[], question: Agents.AskUserQuestionRequest, answer: string): TPart[] {
|
||||
for (let i = content.length - 1; i >= 0; i--) {
|
||||
const part = content[i];
|
||||
const toolCall = part?.tool_call;
|
||||
if (
|
||||
part?.type !== 'tool_call' ||
|
||||
toolCall?.name !== ASK_USER_QUESTION_TOOL_NAME ||
|
||||
(typeof toolCall.output === 'string' && toolCall.output.length > 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const next = [...content];
|
||||
next[i] = {
|
||||
...part,
|
||||
tool_call: {
|
||||
...toolCall,
|
||||
args: JSON.stringify(question),
|
||||
output: answer,
|
||||
progress: 1,
|
||||
},
|
||||
};
|
||||
return next;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue