mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-25 03:32:12 +00:00
🙊 refactor: Clarify Ask Question Schema Errors and Retry Guidance (#14279)
* fix(agents): clarify ask question validation errors * fix(agents): narrow question failure detection * fix(agents): persist question validation failures * fix(agents): track question validation failures
This commit is contained in:
parent
b7542871b9
commit
7447fddfb2
19 changed files with 525 additions and 14 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { MessageCircleQuestion } from 'lucide-react';
|
||||
import { MessageCircleQuestion, TriangleAlert } from 'lucide-react';
|
||||
import { getSubmittedAskAnswer, parseAskUserQuestionArgs } from '~/utils/approval';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
|
|
@ -14,11 +14,13 @@ export default function AskUserQuestionCall({
|
|||
output,
|
||||
toolCallId,
|
||||
isSubmitting = false,
|
||||
failed = false,
|
||||
}: {
|
||||
args: string | Record<string, unknown> | undefined;
|
||||
output: string;
|
||||
toolCallId?: string;
|
||||
isSubmitting?: boolean;
|
||||
failed?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const question = parseAskUserQuestionArgs(args);
|
||||
|
|
@ -29,7 +31,7 @@ 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;
|
||||
const answered = effectiveOutput.length > 0 && !failed;
|
||||
|
||||
/**
|
||||
* While the turn is live and unanswered, the INTERACTIVE card (rendered from
|
||||
|
|
@ -39,10 +41,30 @@ export default function AskUserQuestionCall({
|
|||
* so the record takes over immediately; an abandoned pause only shows its
|
||||
* "no answer" state after the turn is no longer submitting.
|
||||
*/
|
||||
if (!answered && isSubmitting) {
|
||||
if (!answered && !failed && isSubmitting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3"
|
||||
role="status"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-text-warning">
|
||||
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_question_failed')}
|
||||
</div>
|
||||
{question?.question != null && (
|
||||
<p className="text-sm font-medium text-text-primary">{question.question}</p>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_ui_question_failed_description')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the picked option's label over its wire value when they differ.
|
||||
* Multi-select answers are option values joined by ", " — map the segments
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ const Part = memo(function Part({
|
|||
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
|
||||
toolCallId={toolCall.id}
|
||||
isSubmitting={isSubmitting}
|
||||
failed={'inputValidationError' in toolCall && toolCall.inputValidationError === true}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.name === 'skill') {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AskUserQuestionCall from '../AskUserQuestionCall';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_asked: 'Asked',
|
||||
com_ui_asking: 'Asking',
|
||||
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_unanswered: 'No answer was given',
|
||||
com_ui_you_answered: 'You answered:',
|
||||
};
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => translations[key] ?? key,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils/approval', () => ({
|
||||
getSubmittedAskAnswer: () => undefined,
|
||||
parseAskUserQuestionArgs: (args: string | Record<string, unknown> | undefined) => {
|
||||
if (typeof args === 'string') {
|
||||
return JSON.parse(args) as Record<string, unknown>;
|
||||
}
|
||||
return args ?? null;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AskUserQuestionCall', () => {
|
||||
const args = JSON.stringify({
|
||||
question: 'How would you like me to get the data?',
|
||||
options: [{ label: 'Use public data', value: 'public' }],
|
||||
});
|
||||
|
||||
test('renders a successful tool result as the user answer', () => {
|
||||
render(<AskUserQuestionCall args={args} output="public" />);
|
||||
|
||||
expect(screen.getByText('You answered:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Use public data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders schema rejection as an internal question failure, not a user answer', () => {
|
||||
const output =
|
||||
'Error processing tool: Received tool input did not match expected schema ' +
|
||||
'✖ String must contain at most 120 character(s) → at options[0].label';
|
||||
|
||||
render(<AskUserQuestionCall args={args} output={output} 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('You answered:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Received tool input did not match expected schema/)).toBeNull();
|
||||
});
|
||||
|
||||
test('preserves a user answer that contains the complete schema error text', () => {
|
||||
const output =
|
||||
'Error processing tool: Received tool input did not match expected schema ' +
|
||||
'✖ String must contain at most 120 character(s) → at options[0].label';
|
||||
|
||||
render(<AskUserQuestionCall args={args} output={output} />);
|
||||
|
||||
expect(screen.getByText('You answered:')).toBeInTheDocument();
|
||||
expect(screen.getByText(output)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Question wasn't shown")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1422,6 +1422,7 @@ describe('useStepHandler', () => {
|
|||
name: 'test_tool',
|
||||
args: '{}',
|
||||
output: 'Tool result output',
|
||||
inputValidationError: true as const,
|
||||
type: ToolCallTypes.TOOL_CALL,
|
||||
},
|
||||
},
|
||||
|
|
@ -1445,6 +1446,7 @@ describe('useStepHandler', () => {
|
|||
);
|
||||
expect(toolCallContent?.tool_call?.output).toBe('Tool result output');
|
||||
expect(toolCallContent?.tool_call?.progress).toBe(1);
|
||||
expect(toolCallContent?.tool_call?.inputValidationError).toBe(true);
|
||||
});
|
||||
|
||||
it('signals skill authoring when a completed create_file call targets a skill path', () => {
|
||||
|
|
|
|||
|
|
@ -490,6 +490,12 @@ export default function useStepHandler({
|
|||
if (finalUpdate) {
|
||||
newToolCall.progress = 1;
|
||||
newToolCall.output = contentPart.tool_call.output;
|
||||
if (
|
||||
'inputValidationError' in contentPart.tool_call &&
|
||||
contentPart.tool_call.inputValidationError === true
|
||||
) {
|
||||
Object.assign(newToolCall, { inputValidationError: true });
|
||||
}
|
||||
}
|
||||
|
||||
updatedContent[index] = {
|
||||
|
|
|
|||
|
|
@ -1564,6 +1564,8 @@
|
|||
"com_ui_provider_api_keys_description": "Manage API keys for endpoints configured to use a user-provided key.",
|
||||
"com_ui_provider_api_keys_not_set": "No key set",
|
||||
"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_unanswered": "No answer was given",
|
||||
"com_ui_queue": "Queue",
|
||||
"com_ui_queue_send": "Queue message for after the response",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue