feat(agentStudio): cost controls errors surface (#2878)
This commit is contained in:
parent
cf42ab519b
commit
166b76de2f
12 changed files with 801 additions and 158 deletions
|
|
@ -12,8 +12,10 @@ import type { AIMessage } from './types/AskiAi';
|
|||
import {
|
||||
extractLinksFromMessage,
|
||||
filterExchangesForThreadDepthError,
|
||||
getAskAiBlockingBannerMessage,
|
||||
getMessageContent,
|
||||
getThreadDepthErrorUserFacingMessage,
|
||||
isAskAiPromptBlockingError,
|
||||
showAskAiBlockingBannerNewConversationLink,
|
||||
isThreadDepthError,
|
||||
} from './utils/ai';
|
||||
import { groupConsecutiveToolResults } from './utils/groupConsecutiveToolResults';
|
||||
|
|
@ -60,11 +62,7 @@ export type AskAiScreenTranslations = Partial<{
|
|||
*/
|
||||
errorTitleText: string;
|
||||
/**
|
||||
* Message shown when thread depth limit is exceeded (AI-217 error).
|
||||
*/
|
||||
threadDepthExceededMessage: string;
|
||||
/**
|
||||
* Button text for starting a new conversation after thread depth error.
|
||||
* Button text for starting a new conversation after a blocking Ask AI error.
|
||||
*/
|
||||
startNewConversationButtonText: string;
|
||||
}>;
|
||||
|
|
@ -125,7 +123,7 @@ function AskAiExchangeCard({
|
|||
duringToolCallText = 'Searching...',
|
||||
} = translations;
|
||||
|
||||
const isThreadDepth = isThreadDepthError(askAiError);
|
||||
const isPromptBlockingError = isAskAiPromptBlockingError(askAiError, Boolean(agentStudio));
|
||||
|
||||
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
|
||||
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
|
||||
|
|
@ -156,7 +154,7 @@ function AskAiExchangeCard({
|
|||
</div>
|
||||
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
|
||||
<div className="DocSearch-AskAiScreen-MessageContent">
|
||||
{loadingStatus === 'error' && askAiError && isLastExchange && !isThreadDepth && (
|
||||
{loadingStatus === 'error' && askAiError && isLastExchange && !isPromptBlockingError && (
|
||||
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
|
||||
<AlertIcon />
|
||||
<div className="DocSearch-AskAiScreen-Error-Content">
|
||||
|
|
@ -373,18 +371,21 @@ export function AskAiSourcesPanel({ urlsToDisplay, relatedSourcesText }: AskAiSo
|
|||
export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null {
|
||||
const {
|
||||
disclaimerText = 'Answers are generated with AI which can make mistakes. Verify responses.',
|
||||
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
|
||||
startNewConversationButtonText = 'Start a new conversation',
|
||||
} = translations;
|
||||
|
||||
const { messages, askAiError, status, agentStudio } = props;
|
||||
|
||||
// Check if there's a thread depth error
|
||||
const hasThreadDepthError = useMemo(() => {
|
||||
return status === 'error' && isThreadDepthError(askAiError);
|
||||
}, [status, askAiError]);
|
||||
const hasPromptBlockingError = useMemo(() => {
|
||||
return status === 'error' && isAskAiPromptBlockingError(askAiError, Boolean(agentStudio));
|
||||
}, [status, askAiError, agentStudio]);
|
||||
|
||||
const threadDepthApiMessage = useMemo(() => getThreadDepthErrorUserFacingMessage(askAiError), [askAiError]);
|
||||
const blockingApiMessage = useMemo(() => getAskAiBlockingBannerMessage(askAiError), [askAiError]);
|
||||
|
||||
const showBlockingBannerNewConversationLink = showAskAiBlockingBannerNewConversationLink(
|
||||
askAiError,
|
||||
Boolean(agentStudio),
|
||||
);
|
||||
|
||||
// Group messages into exchanges (user + assistant pairs)
|
||||
const exchanges: Exchange[] = useMemo(() => {
|
||||
|
|
@ -400,33 +401,35 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
|
|||
}
|
||||
}
|
||||
|
||||
return filterExchangesForThreadDepthError(grouped, hasThreadDepthError);
|
||||
}, [messages, hasThreadDepthError]);
|
||||
return filterExchangesForThreadDepthError(grouped, hasPromptBlockingError);
|
||||
}, [messages, hasPromptBlockingError]);
|
||||
|
||||
const handleSearchQueryClick = (query: string): void => {
|
||||
props.onAskAiToggle(false);
|
||||
props.setQuery(query);
|
||||
};
|
||||
|
||||
// Only show the thread depth error if we have assistant messages
|
||||
const showThreadDepthError = hasThreadDepthError && messages.some((m) => m.role === 'assistant');
|
||||
/** Thread depth only appears after at least one assistant reply;
|
||||
* other Agent Studio blocks can occur on the first turn.
|
||||
* */
|
||||
const showBlockingBanner =
|
||||
hasPromptBlockingError && (isThreadDepthError(askAiError) ? messages.some((m) => m.role === 'assistant') : true);
|
||||
|
||||
return (
|
||||
<div className="DocSearch-AskAiScreen DocSearch-AskAiScreen-Container">
|
||||
{/* Thread Depth Error */}
|
||||
{showThreadDepthError && (
|
||||
{/* Agent Studio cost-control errors */}
|
||||
{showBlockingBanner && (
|
||||
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth">
|
||||
<div className="DocSearch-AskAiScreen-Error-Content">
|
||||
{threadDepthApiMessage ? (
|
||||
<p className="DocSearch-AskAiScreen-Error-Title">{threadDepthApiMessage}</p>
|
||||
{blockingApiMessage ? <p className="DocSearch-AskAiScreen-Error-Title">{blockingApiMessage}</p> : null}
|
||||
{showBlockingBannerNewConversationLink ? (
|
||||
<p>
|
||||
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={props.onNewConversation}>
|
||||
{startNewConversationButtonText}
|
||||
</button>{' '}
|
||||
to continue.
|
||||
</p>
|
||||
) : null}
|
||||
<p>
|
||||
{threadDepthExceededMessage}{' '}
|
||||
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={props.onNewConversation}>
|
||||
{startNewConversationButtonText}
|
||||
</button>{' '}
|
||||
to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { useSuggestedQuestions } from './useSuggestedQuestions';
|
|||
import { useTouchEvents } from './useTouchEvents';
|
||||
import { useTrapFocus } from './useTrapFocus';
|
||||
import { groupBy, identity, noop, removeHighlightTags, isModifierEvent, scrollTo as scrollToUtils } from './utils';
|
||||
import { buildDummyAskAiHit, isThreadDepthError } from './utils/ai';
|
||||
import { buildDummyAskAiHit, isAgentStudioTokenOutputLimitError, isAskAiPromptBlockingError } from './utils/ai';
|
||||
import { manageLocalStorageQuota } from './utils/storage';
|
||||
|
||||
export type ModalTranslations = Partial<{
|
||||
|
|
@ -445,10 +445,16 @@ export function DocSearchModal({
|
|||
prevStatus.current = status;
|
||||
}, [status, messages, conversations, disableUserPersonalization, stoppedStream]);
|
||||
|
||||
// Check if there's a thread depth error (AI-217)
|
||||
const hasThreadDepthError = React.useMemo(() => {
|
||||
return status === 'error' && isThreadDepthError(askAiError as Error | undefined);
|
||||
}, [status, askAiError]);
|
||||
const hasAskAiPromptBlockingError = React.useMemo(() => {
|
||||
return status === 'error' && isAskAiPromptBlockingError(askAiError as Error | undefined, agentStudio);
|
||||
}, [status, askAiError, agentStudio]);
|
||||
|
||||
const askAiBlockingChrome = React.useMemo((): 'minimal' | 'thread-depth' | undefined => {
|
||||
const blocked = hasAskAiPromptBlockingError && askAiState !== 'new-conversation';
|
||||
if (!blocked) return undefined;
|
||||
// Match thread-depth modal chrome for all blocking errors except token output limit (minimal).
|
||||
return isAgentStudioTokenOutputLimitError(askAiError as Error | undefined) ? 'minimal' : 'thread-depth';
|
||||
}, [hasAskAiPromptBlockingError, askAiState, askAiError]);
|
||||
|
||||
const createSyntheticParent = React.useCallback(function createSyntheticParent(
|
||||
item: InternalDocSearchHit,
|
||||
|
|
@ -900,7 +906,8 @@ export function DocSearchModal({
|
|||
askAiError={askAiError}
|
||||
askAiState={askAiState}
|
||||
setAskAiState={setAskAiState}
|
||||
isThreadDepthError={hasThreadDepthError && askAiState !== 'new-conversation'}
|
||||
isThreadDepthError={hasAskAiPromptBlockingError && askAiState !== 'new-conversation'}
|
||||
askAiBlockingChrome={askAiBlockingChrome}
|
||||
onClose={onClose}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onAskAgain={(query) => {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ interface SearchBoxProps
|
|||
onNewConversation: () => void;
|
||||
onViewConversationHistory: () => void;
|
||||
isThreadDepthError?: boolean;
|
||||
askAiBlockingChrome?: 'minimal' | 'thread-depth';
|
||||
}
|
||||
|
||||
export function SearchBox({
|
||||
|
|
@ -129,8 +130,8 @@ export function SearchBox({
|
|||
searchPlaceholder = newConversationPlaceholder;
|
||||
}
|
||||
|
||||
// Override placeholder when thread depth error occurs (only in Ask AI mode)
|
||||
if (isThreadDepthError && props.isAskAiActive) {
|
||||
// Override placeholder when prompt is blocked (only in Ask AI mode); cost-control uses minimal chrome
|
||||
if (isThreadDepthError && props.isAskAiActive && props.askAiBlockingChrome !== 'minimal') {
|
||||
searchPlaceholder = threadDepthErrorPlaceholder;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { StoredSearchPlugin } from '../stored-searches';
|
|||
import { ToolCall, type ToolCallTranslations } from '../ToolCall';
|
||||
import type { StoredAskAiState } from '../types';
|
||||
import { isAIToolPart, type AIMessage } from '../types/AskiAi';
|
||||
import { extractLinksFromMessage, getMessageContent, isThreadDepthError } from '../utils/ai';
|
||||
import { extractLinksFromMessage, getMessageContent, isAskAiPromptBlockingError } from '../utils/ai';
|
||||
import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults';
|
||||
|
||||
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
|
||||
|
|
@ -62,11 +62,7 @@ export type ConversationScreenTranslations = Partial<
|
|||
*/
|
||||
errorTitleText: string;
|
||||
/**
|
||||
* Message shown when thread depth limit is exceeded (AI-217).
|
||||
*/
|
||||
threadDepthExceededMessage: string;
|
||||
/**
|
||||
* Button label to start a new conversation after a thread depth error.
|
||||
* Button label to start a new conversation after a blocking Ask AI error.
|
||||
*/
|
||||
startNewConversationButtonText: string;
|
||||
}
|
||||
|
|
@ -113,7 +109,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
|
|||
errorTitleText = 'Chat error',
|
||||
} = translations;
|
||||
|
||||
const isThreadDepth = isThreadDepthError(streamError);
|
||||
const isPromptBlockingError = isAskAiPromptBlockingError(streamError, Boolean(agentStudio));
|
||||
|
||||
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
|
||||
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
|
||||
|
|
@ -140,7 +136,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
|
|||
</div>
|
||||
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
|
||||
<div className="DocSearch-AskAiScreen-MessageContent">
|
||||
{status === 'error' && streamError && isLastExchange && !isThreadDepth && (
|
||||
{status === 'error' && streamError && isLastExchange && !isPromptBlockingError && (
|
||||
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
|
||||
<AlertIcon />
|
||||
<div className="DocSearch-AskAiScreen-Error-Content">
|
||||
|
|
|
|||
|
|
@ -35,11 +35,7 @@ export type PromptFormTranslations = Partial<{
|
|||
**/
|
||||
threadDepthErrorPlaceholder: string;
|
||||
/**
|
||||
* Message shown in the thread-depth banner above the prompt (AI-217).
|
||||
**/
|
||||
threadDepthExceededMessage: string;
|
||||
/**
|
||||
* Button label in the thread-depth banner to start a new conversation.
|
||||
* Button label in the blocking-error banner to start a new conversation.
|
||||
**/
|
||||
startNewConversationButtonText: string;
|
||||
/**
|
||||
|
|
@ -56,6 +52,8 @@ type Props = {
|
|||
onStopStreaming: () => void;
|
||||
showThreadDepthBanner: boolean;
|
||||
threadDepthApiMessage?: string;
|
||||
/** If false, the banner shows only the API message (for example, token output limit). */
|
||||
showBlockingBannerNewConversationLink?: boolean;
|
||||
onStartNewConversation: () => void;
|
||||
};
|
||||
|
||||
|
|
@ -71,6 +69,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
onStopStreaming,
|
||||
showThreadDepthBanner,
|
||||
threadDepthApiMessage,
|
||||
showBlockingBannerNewConversationLink = true,
|
||||
onStartNewConversation,
|
||||
},
|
||||
ref,
|
||||
|
|
@ -89,7 +88,6 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
promptLabelText = 'Press Enter to send, or Shift and Enter for new line.',
|
||||
promptAriaLabelText = 'Prompt input',
|
||||
threadDepthErrorPlaceholder = 'Conversation limit reached',
|
||||
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
|
||||
startNewConversationButtonText = 'Start a new conversation',
|
||||
threadDepthBannerContinueText = 'to continue.',
|
||||
} = translations;
|
||||
|
|
@ -164,67 +162,69 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
{threadDepthApiMessage ? (
|
||||
<p className="DocSearch-Sidepanel-ThreadDepthBanner-apiMessage">{threadDepthApiMessage}</p>
|
||||
) : null}
|
||||
<p>
|
||||
{threadDepthExceededMessage}{' '}
|
||||
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={onStartNewConversation}>
|
||||
{startNewConversationButtonText}
|
||||
</button>{' '}
|
||||
{threadDepthBannerContinueText}
|
||||
</p>
|
||||
{showBlockingBannerNewConversationLink ? (
|
||||
<p>
|
||||
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={onStartNewConversation}>
|
||||
{startNewConversationButtonText}
|
||||
</button>{' '}
|
||||
{threadDepthBannerContinueText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<form
|
||||
className="DocSearch-Sidepanel-Prompt--form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
{!showThreadDepthBanner ? (
|
||||
<form
|
||||
className="DocSearch-Sidepanel-Prompt--form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isStreaming || showThreadDepthBanner) return;
|
||||
if (isStreaming) return;
|
||||
|
||||
handleSend();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={promptRef}
|
||||
placeholder={promptPlaceholder}
|
||||
className="DocSearch-Sidepanel-Prompt--textarea"
|
||||
value={userPrompt}
|
||||
aria-label={promptAriaLabelText}
|
||||
aria-labelledby="prompt-label"
|
||||
autoComplete="off"
|
||||
translate="no"
|
||||
rows={isMobile ? 1 : 2}
|
||||
disabled={showThreadDepthBanner}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={managePromptHeight}
|
||||
onChange={(e) => setUserPrompt(e.target.value)}
|
||||
/>
|
||||
<span id="prompt-label" className="sr-only">
|
||||
{promptLabelText}
|
||||
</span>
|
||||
<div className="DocSearch-Sidepanel-Prompt--actions">
|
||||
{isStreaming && (
|
||||
<button
|
||||
type="button"
|
||||
title="Stop streaming"
|
||||
className="DocSearch-Sidepanel-Prompt--stop"
|
||||
onClick={onStopStreaming}
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
)}
|
||||
{!isStreaming && !showThreadDepthBanner && (
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Send question"
|
||||
title="Send question"
|
||||
className="DocSearch-Sidepanel-Prompt--submit"
|
||||
aria-disabled={userPrompt === ''}
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
handleSend();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={promptRef}
|
||||
placeholder={promptPlaceholder}
|
||||
className="DocSearch-Sidepanel-Prompt--textarea"
|
||||
value={userPrompt}
|
||||
aria-label={promptAriaLabelText}
|
||||
aria-labelledby="prompt-label"
|
||||
autoComplete="off"
|
||||
translate="no"
|
||||
rows={isMobile ? 1 : 2}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={managePromptHeight}
|
||||
onChange={(e) => setUserPrompt(e.target.value)}
|
||||
/>
|
||||
<span id="prompt-label" className="sr-only">
|
||||
{promptLabelText}
|
||||
</span>
|
||||
<div className="DocSearch-Sidepanel-Prompt--actions">
|
||||
{isStreaming && (
|
||||
<button
|
||||
type="button"
|
||||
title="Stop streaming"
|
||||
className="DocSearch-Sidepanel-Prompt--stop"
|
||||
onClick={onStopStreaming}
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
)}
|
||||
{!isStreaming && (
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Send question"
|
||||
title="Send question"
|
||||
className="DocSearch-Sidepanel-Prompt--submit"
|
||||
aria-disabled={userPrompt === ''}
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
<p className="DocSearch-Sidepanel-Prompt--disclaimer">{promptDisclaimerText}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import { useSuggestedQuestions } from '../useSuggestedQuestions';
|
|||
import {
|
||||
buildDummyAskAiHit,
|
||||
filterExchangesForThreadDepthError,
|
||||
getThreadDepthErrorUserFacingMessage,
|
||||
getAskAiBlockingBannerMessage,
|
||||
isAskAiPromptBlockingError,
|
||||
showAskAiBlockingBannerNewConversationLink,
|
||||
isThreadDepthError,
|
||||
} from '../utils/ai';
|
||||
|
||||
|
|
@ -209,27 +211,26 @@ function SidepanelInner(
|
|||
searchClient,
|
||||
});
|
||||
|
||||
const hasThreadDepthError = React.useMemo(
|
||||
() => status === 'error' && isThreadDepthError(askAiError),
|
||||
[status, askAiError],
|
||||
const hasAskAiPromptBlockingError = React.useMemo(
|
||||
() => status === 'error' && isAskAiPromptBlockingError(askAiError, agentStudio),
|
||||
[status, askAiError, agentStudio],
|
||||
);
|
||||
|
||||
const displayExchanges = React.useMemo(
|
||||
() => filterExchangesForThreadDepthError(exchanges, hasThreadDepthError),
|
||||
[exchanges, hasThreadDepthError],
|
||||
() => filterExchangesForThreadDepthError(exchanges, hasAskAiPromptBlockingError),
|
||||
[exchanges, hasAskAiPromptBlockingError],
|
||||
);
|
||||
|
||||
const showThreadDepthBanner =
|
||||
sidepanelState === 'conversation' && hasThreadDepthError && messages.some((m) => m.role === 'assistant');
|
||||
sidepanelState === 'conversation' &&
|
||||
hasAskAiPromptBlockingError &&
|
||||
(isThreadDepthError(askAiError) ? messages.some((m) => m.role === 'assistant') : true);
|
||||
|
||||
const threadDepthApiMessage = React.useMemo(() => getThreadDepthErrorUserFacingMessage(askAiError), [askAiError]);
|
||||
const threadDepthApiMessage = React.useMemo(() => getAskAiBlockingBannerMessage(askAiError), [askAiError]);
|
||||
|
||||
const promptFormTranslations = React.useMemo(
|
||||
() => ({
|
||||
...translations.promptForm,
|
||||
...(translations.conversationScreen?.threadDepthExceededMessage !== undefined
|
||||
? { threadDepthExceededMessage: translations.conversationScreen.threadDepthExceededMessage }
|
||||
: {}),
|
||||
...(translations.conversationScreen?.startNewConversationButtonText !== undefined
|
||||
? { startNewConversationButtonText: translations.conversationScreen.startNewConversationButtonText }
|
||||
: {}),
|
||||
|
|
@ -474,6 +475,10 @@ function SidepanelInner(
|
|||
isStreaming={isStreaming}
|
||||
showThreadDepthBanner={showThreadDepthBanner}
|
||||
threadDepthApiMessage={threadDepthApiMessage}
|
||||
showBlockingBannerNewConversationLink={showAskAiBlockingBannerNewConversationLink(
|
||||
askAiError,
|
||||
Boolean(agentStudio),
|
||||
)}
|
||||
translations={promptFormTranslations}
|
||||
onSend={handleSend}
|
||||
onStartNewConversation={handleStartNewConversation}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ describe('AskAiScreen', () => {
|
|||
|
||||
const onNewConversation = (): void => {};
|
||||
|
||||
const { container, getByText } = render(
|
||||
const { container } = render(
|
||||
<AskAiScreen
|
||||
{...baseProps}
|
||||
messages={messages}
|
||||
|
|
@ -74,12 +74,77 @@ describe('AskAiScreen', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByText('This conversation is now closed to keep responses accurate.', { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(getByText('Start a new conversation')).toBeInTheDocument();
|
||||
expect(within(container).getByText('AI-217 - Thread depth exceeded')).toBeInTheDocument();
|
||||
expect(within(container).getByText('Start a new conversation')).toBeInTheDocument();
|
||||
expect(within(container).getByText(/to continue\./i)).toBeInTheDocument();
|
||||
// Bound queries from `render()` use `baseElement` (often `document.body`), so a prior test can still
|
||||
// match. Restrict to this instance's root so we only assert on this tree.
|
||||
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('for Agent Studio cost-control errors, shows the blocking banner (message + start new conversation), not inline Chat error', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<AskAiScreen
|
||||
{...baseProps}
|
||||
agentStudio={true}
|
||||
messages={messages}
|
||||
status="error"
|
||||
askAiError={new Error('Too many requests (AI-205)')}
|
||||
onNewConversation={(): void => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(within(container).getByText('Too many requests')).toBeInTheDocument();
|
||||
expect(within(container).getByText('Start a new conversation')).toBeInTheDocument();
|
||||
expect(within(container).getByText(/to continue\./i)).toBeInTheDocument();
|
||||
expect(
|
||||
within(container).queryByText('This conversation is now closed to keep responses accurate.', { exact: false }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('for Agent Studio token output limit, banner shows only the human message without start-new-conversation', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'Hello' }],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'Hello! How can I assist' }],
|
||||
},
|
||||
];
|
||||
|
||||
const raw = JSON.stringify({
|
||||
error: 'Could not complete response due to token output limits',
|
||||
type: 'TokenOutputLimitError',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<AskAiScreen
|
||||
{...baseProps}
|
||||
agentStudio={true}
|
||||
messages={messages}
|
||||
status="error"
|
||||
askAiError={new Error(raw)}
|
||||
onNewConversation={(): void => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(within(container).getByText('Could not complete response due to token output limits')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Start a new conversation')).not.toBeInTheDocument();
|
||||
expect(within(container).queryByText(/to continue\./i)).not.toBeInTheDocument();
|
||||
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
|
||||
expect(within(container).queryByText(/\{"error":/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ASK_AI_API_URL, BETA_ASK_AI_API_URL } from './constants';
|
||||
import { extractAgentStudioErrorFieldMessage } from './utils/ai';
|
||||
|
||||
// ... existing imports ...
|
||||
const TOKEN_KEY = 'askai_token';
|
||||
|
||||
export const agentStudioBaseUrl = (appId: string): string => `https://${appId}.algolia.net/agent-studio/1`;
|
||||
|
|
@ -112,26 +112,66 @@ interface AgentStudioValidationError extends Error {
|
|||
|
||||
// Parse Agent Studio errors as they are returned as JSON rather than Markdown/text
|
||||
export const getAgentStudioErrorMessage = (error: Error): Error => {
|
||||
let errorMessage = error.message;
|
||||
const raw = error.message;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const parsedError = JSON.parse(error.message) as Error;
|
||||
|
||||
// Check for known errors that we know how to parse
|
||||
if (parsedError.name === 'ValidationError') {
|
||||
const validationError = parsedError as AgentStudioValidationError;
|
||||
|
||||
if (validationError.detail && validationError.detail.length > 0) {
|
||||
const { msg, loc } = validationError.detail[0];
|
||||
const field = loc.at(-1);
|
||||
|
||||
errorMessage = `${msg}: ${field}`;
|
||||
}
|
||||
} else {
|
||||
errorMessage = parsedError.message;
|
||||
}
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// We don't care about this catch, we default to the error.message above
|
||||
const extracted = extractAgentStudioErrorFieldMessage(raw);
|
||||
return new Error(extracted ?? raw);
|
||||
}
|
||||
|
||||
while (typeof parsed === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(parsed.trim());
|
||||
} catch {
|
||||
const extracted = extractAgentStudioErrorFieldMessage(raw);
|
||||
return new Error(extracted ?? (parsed as string));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
const extracted = extractAgentStudioErrorFieldMessage(raw);
|
||||
return new Error(extracted ?? raw);
|
||||
}
|
||||
|
||||
const parsedError = parsed as Error & {
|
||||
code?: string;
|
||||
errorCode?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (parsedError.name === 'ValidationError') {
|
||||
const validationError = parsedError as AgentStudioValidationError;
|
||||
let errorMessage = raw;
|
||||
if (validationError.detail && validationError.detail.length > 0) {
|
||||
const { msg, loc } = validationError.detail[0];
|
||||
const field = loc.at(-1);
|
||||
errorMessage = `${msg}: ${field}`;
|
||||
}
|
||||
return new Error(errorMessage);
|
||||
}
|
||||
|
||||
const extracted = extractAgentStudioErrorFieldMessage(raw);
|
||||
let errorMessage: string;
|
||||
if (extracted) {
|
||||
errorMessage = extracted;
|
||||
} else if (typeof parsedError.message === 'string' && parsedError.message.trim() !== '') {
|
||||
errorMessage = parsedError.message.trim();
|
||||
} else if (typeof parsedError.error === 'string' && parsedError.error.trim() !== '') {
|
||||
errorMessage = parsedError.error.trim();
|
||||
} else {
|
||||
errorMessage = raw;
|
||||
}
|
||||
|
||||
const code = parsedError.code ?? parsedError.errorCode;
|
||||
if (typeof code === 'string' && code.trim() !== '') {
|
||||
const c = code.trim();
|
||||
if (!errorMessage.toUpperCase().includes(c.toUpperCase())) {
|
||||
errorMessage = `${errorMessage} (${c})`;
|
||||
}
|
||||
}
|
||||
|
||||
return new Error(errorMessage);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,34 @@
|
|||
export {
|
||||
extractAgentStudioErrorFieldMessage,
|
||||
filterExchangesForThreadDepthError,
|
||||
getAskAiBlockingBannerMessage,
|
||||
getAskAiPromptBlockingUserFacingMessage,
|
||||
getThreadDepthErrorUserFacingMessage,
|
||||
isAgentStudioTokenOutputLimitError,
|
||||
isAskAiPromptBlockingError,
|
||||
isThreadDepthError,
|
||||
showAskAiBlockingBannerNewConversationLink,
|
||||
} from './utils/ai';
|
||||
|
||||
export {
|
||||
agentStudioPromptBlockingMatchers,
|
||||
AGENT_STUDIO_PROMPT_BLOCKING_CODES,
|
||||
extractAiErrorCodeFromMessage,
|
||||
matchesAgentStudioContextOrTokenLimitsPlainMessage,
|
||||
matchesAgentStudioMaxStepsMessage,
|
||||
matchesAgentStudioRateLimitMessage,
|
||||
matchesAgentStudioTokenOutputLimitPlainMessage,
|
||||
matchesAgentStudioWhitelistOrNotAllowedDomainPlainMessage,
|
||||
matchesRequestBlockedForThisDomainMessage,
|
||||
matchesThreadDepthLimitError,
|
||||
newConversationErrorMatchers,
|
||||
readAgentStudioJsonStringField,
|
||||
resolveAgentStudioPromptBlocking,
|
||||
type AgentStudioBlockingMatchContext,
|
||||
type AgentStudioPromptBlockingMatcher,
|
||||
type NewConversationErrorMatcher,
|
||||
} from './utils/askAiBlockingMatchers';
|
||||
|
||||
export * from './DocSearch';
|
||||
export * from './DocSearchButton';
|
||||
export * from './DocSearchModal';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AIMessage } from '../../types/AskiAi';
|
||||
import { filterExchangesForThreadDepthError, getThreadDepthErrorUserFacingMessage, isThreadDepthError } from '../ai';
|
||||
import {
|
||||
filterExchangesForThreadDepthError,
|
||||
extractAgentStudioErrorFieldMessage,
|
||||
getAskAiBlockingBannerMessage,
|
||||
getAskAiPromptBlockingUserFacingMessage,
|
||||
getThreadDepthErrorUserFacingMessage,
|
||||
isAgentStudioTokenOutputLimitError,
|
||||
isAskAiPromptBlockingError,
|
||||
isThreadDepthError,
|
||||
showAskAiBlockingBannerNewConversationLink,
|
||||
} from '../ai';
|
||||
|
||||
describe('isThreadDepthError', () => {
|
||||
it('detects AI-217 in message (any case)', () => {
|
||||
|
|
@ -69,3 +79,118 @@ describe('getThreadDepthErrorUserFacingMessage', () => {
|
|||
expect(getThreadDepthErrorUserFacingMessage(new Error('Network failed'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAskAiPromptBlockingError (Agent Studio cost controls)', () => {
|
||||
it('detects API codes when agentStudio is true', () => {
|
||||
expect(isAskAiPromptBlockingError(new Error('x (AI-203)'), true)).toBe(true);
|
||||
expect(isAskAiPromptBlockingError(new Error('x (AI-205)'), true)).toBe(true);
|
||||
expect(isAskAiPromptBlockingError(new Error('x (AI-224)'), true)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat Agent Studio codes as blocking when agentStudio is false', () => {
|
||||
expect(isAskAiPromptBlockingError(new Error('x (AI-205)'), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('still blocks thread depth when agentStudio is false', () => {
|
||||
expect(isAskAiPromptBlockingError(new Error('AI-217'), false)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches message heuristics when agentStudio is true', () => {
|
||||
expect(isAskAiPromptBlockingError(new Error('Rate limit exceeded'), true)).toBe(true);
|
||||
expect(isAskAiPromptBlockingError(new Error('Domain is not whitelisted'), true)).toBe(true);
|
||||
expect(isAskAiPromptBlockingError(new Error('Maximum token limit reached'), true)).toBe(true);
|
||||
expect(isAskAiPromptBlockingError(new Error('Maximum steps exceeded'), true)).toBe(true);
|
||||
});
|
||||
|
||||
it('omits “Start new conversation” for domain-block API copy but keeps it for other blocks', () => {
|
||||
expect(showAskAiBlockingBannerNewConversationLink(new Error('Request blocked for this domain'), true)).toBe(false);
|
||||
expect(
|
||||
showAskAiBlockingBannerNewConversationLink(
|
||||
new Error(JSON.stringify({ message: 'Request blocked for this domain' })),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(showAskAiBlockingBannerNewConversationLink(new Error('Domain is not whitelisted'), true)).toBe(true);
|
||||
expect(showAskAiBlockingBannerNewConversationLink(new Error('Rate limit exceeded'), true)).toBe(true);
|
||||
expect(showAskAiBlockingBannerNewConversationLink(new Error('AI-217'), false)).toBe(true);
|
||||
expect(showAskAiBlockingBannerNewConversationLink(new Error('AI-217'), true)).toBe(true);
|
||||
const tokenRaw = JSON.stringify({
|
||||
error: 'Could not complete response due to token output limits',
|
||||
type: 'TokenOutputLimitError',
|
||||
statusCode: 400,
|
||||
});
|
||||
expect(showAskAiBlockingBannerNewConversationLink(new Error(tokenRaw), true)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects Agent Studio JSON token output limit errors', () => {
|
||||
const raw = JSON.stringify({
|
||||
error: 'Could not complete response due to token output limits',
|
||||
type: 'TokenOutputLimitError',
|
||||
statusCode: 400,
|
||||
});
|
||||
expect(isAskAiPromptBlockingError(new Error(raw), true)).toBe(true);
|
||||
expect(getAskAiPromptBlockingUserFacingMessage(new Error(raw))).toBe(
|
||||
'Could not complete response due to token output limits',
|
||||
);
|
||||
expect(isAgentStudioTokenOutputLimitError(new Error(raw))).toBe(true);
|
||||
expect(isAgentStudioTokenOutputLimitError(new Error('Too many requests (AI-205)'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAskAiPromptBlockingUserFacingMessage', () => {
|
||||
it('strips a trailing (AI-xxx) suffix from transformed Agent Studio errors', () => {
|
||||
expect(getAskAiPromptBlockingUserFacingMessage(new Error('Too many requests (AI-205)'))).toBe('Too many requests');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAskAiBlockingBannerMessage', () => {
|
||||
it('returns the token-output fallback when the error is token limit but the message is empty', () => {
|
||||
expect(
|
||||
getAskAiBlockingBannerMessage(new Error(JSON.stringify({ type: 'TokenOutputLimitError', statusCode: 400 }))),
|
||||
).toBe('Could not complete response due to token output limits');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAgentStudioErrorFieldMessage', () => {
|
||||
it('reads error from normal JSON', () => {
|
||||
const raw = JSON.stringify({
|
||||
error: 'Could not complete response due to token output limits',
|
||||
type: 'TokenOutputLimitError',
|
||||
statusCode: 400,
|
||||
});
|
||||
expect(extractAgentStudioErrorFieldMessage(raw)).toBe('Could not complete response due to token output limits');
|
||||
});
|
||||
|
||||
it('reads error from JSON with escaped quotes (serialized twice)', () => {
|
||||
const inner = JSON.stringify({
|
||||
error: 'Could not complete response due to token output limits',
|
||||
type: 'TokenOutputLimitError',
|
||||
statusCode: 400,
|
||||
});
|
||||
const raw = JSON.stringify(inner);
|
||||
expect(extractAgentStudioErrorFieldMessage(raw)).toBe('Could not complete response due to token output limits');
|
||||
});
|
||||
|
||||
it('reads error from brace payload with backslash-escaped quotes', () => {
|
||||
const raw =
|
||||
'{\\"error\\": \\"Could not complete response due to token output limits\\", \\"type\\": \\"TokenOutputLimitError\\", \\"statusCode\\": 400}';
|
||||
expect(extractAgentStudioErrorFieldMessage(raw)).toBe('Could not complete response due to token output limits');
|
||||
});
|
||||
|
||||
it('treats domain-not-allowed style messages as Agent Studio prompt-blocking', () => {
|
||||
expect(isAskAiPromptBlockingError(new Error('Request blocked for this domain'), true)).toBe(true);
|
||||
expect(getAskAiBlockingBannerMessage(new Error('Request blocked for this domain'))).toBe(
|
||||
'Request blocked for this domain',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers message over machine error code (TOO_MANY_REQUESTS)', () => {
|
||||
const raw = JSON.stringify({
|
||||
error: 'TOO_MANY_REQUESTS',
|
||||
message: 'Rate limit exceeded. Retry after 60 seconds.',
|
||||
});
|
||||
expect(extractAgentStudioErrorFieldMessage(raw)).toBe('Rate limit exceeded. Retry after 60 seconds.');
|
||||
expect(isAskAiPromptBlockingError(new Error(raw), true)).toBe(true);
|
||||
expect(getAskAiBlockingBannerMessage(new Error(raw))).toBe('Rate limit exceeded. Retry after 60 seconds.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import type { TextUIPart } from 'ai';
|
|||
import type { StoredAskAiState } from '../types';
|
||||
import type { AIMessage } from '../types/AskiAi';
|
||||
|
||||
import {
|
||||
matchesThreadDepthLimitError,
|
||||
readAgentStudioJsonStringField,
|
||||
resolveAgentStudioPromptBlocking,
|
||||
} from './askAiBlockingMatchers';
|
||||
import { sanitizeUserInput } from './sanitize';
|
||||
|
||||
type ExtractedLink = {
|
||||
|
|
@ -116,8 +121,7 @@ export function filterExchangesForThreadDepthError<T extends ExchangeWithOptiona
|
|||
|
||||
function threadDepthFromPlainText(message: string): boolean {
|
||||
if (!message) return false;
|
||||
if (message.toUpperCase().includes('AI-217')) return true;
|
||||
return /thread\s+depth/i.test(message);
|
||||
return matchesThreadDepthLimitError(message.toLowerCase());
|
||||
}
|
||||
|
||||
function messageLooksLikeThreadDepth(message: string): boolean {
|
||||
|
|
@ -149,6 +153,151 @@ export function isThreadDepthError(error?: Error): boolean {
|
|||
return messageLooksLikeThreadDepth(error.message ?? '');
|
||||
}
|
||||
|
||||
function messageLooksLikeAgentStudioCostControl(error: Error): boolean {
|
||||
return resolveAgentStudioPromptBlocking(error).blocking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether further prompts should be blocked: thread depth (all backends) or Agent Studio cost controls.
|
||||
*/
|
||||
export function isAskAiPromptBlockingError(error: Error | undefined, agentStudio: boolean): boolean {
|
||||
if (!error) return false;
|
||||
if (isThreadDepthError(error)) return true;
|
||||
if (!agentStudio) return false;
|
||||
return messageLooksLikeAgentStudioCostControl(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Studio stream hit the completion token ceiling (`TokenOutputLimitError`).
|
||||
* This case uses a message-only banner (no “Start a new conversation” row).
|
||||
*/
|
||||
export function isAgentStudioTokenOutputLimitError(error?: Error): boolean {
|
||||
if (!error) return false;
|
||||
const msg = error.message ?? '';
|
||||
if (/TokenOutputLimitError/i.test(msg)) return true;
|
||||
if (/could not complete response due to token output limits/i.test(msg)) return true;
|
||||
try {
|
||||
const p = JSON.parse(msg) as { type?: string; error?: string };
|
||||
if (typeof p.type === 'string' && /^TokenOutputLimitError$/i.test(p.type.trim())) {
|
||||
return true;
|
||||
}
|
||||
if (typeof p.error === 'string' && /token output limits/i.test(p.error)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the blocking banner should include “Start a new conversation … to continue”.
|
||||
* Thread depth and most Agent Studio limits keep it; token output limit and “request blocked for this domain” omit it.
|
||||
*/
|
||||
export function showAskAiBlockingBannerNewConversationLink(error: Error | undefined, agentStudio: boolean): boolean {
|
||||
if (!error) return true;
|
||||
if (isAgentStudioTokenOutputLimitError(error)) return false;
|
||||
if (isThreadDepthError(error)) return true;
|
||||
if (!agentStudio) return true;
|
||||
return resolveAgentStudioPromptBlocking(error).showNewConversationLink;
|
||||
}
|
||||
|
||||
function stripTrailingAiCodeSuffix(message: string): string {
|
||||
return message.replace(/\s*\(AI-\d{3}\)\s*$/i, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls `message` or `error` from Agent Studio JSON payloads, including double-encoded JSON
|
||||
* and objects serialized with escaped quotes (`{\"error\": \"...\"}`).
|
||||
*/
|
||||
export function extractAgentStudioErrorFieldMessage(raw: string): string | undefined {
|
||||
let s = raw.trim();
|
||||
if (!s) return undefined;
|
||||
|
||||
let iterations = 0;
|
||||
while (iterations < 10) {
|
||||
iterations += 1;
|
||||
try {
|
||||
const v: unknown = JSON.parse(s);
|
||||
if (typeof v === 'string') {
|
||||
const next = v.trim();
|
||||
if (!next) return undefined;
|
||||
s = next;
|
||||
} else if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
const o = v as Record<string, unknown>;
|
||||
const msg = readAgentStudioJsonStringField(o, 'message');
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
const err = readAgentStudioJsonStringField(o, 'error');
|
||||
if (err) {
|
||||
return err;
|
||||
}
|
||||
return undefined;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch {
|
||||
if (/\\"/.test(s)) {
|
||||
s = s.replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
|
||||
} else {
|
||||
const mMsg = /"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(s);
|
||||
if (mMsg?.[1]) {
|
||||
return mMsg[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
|
||||
}
|
||||
const mErr = /"error"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(s);
|
||||
if (mErr?.[1]) {
|
||||
return mErr[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the API `message` when the body was JSON; otherwise the thrown message (without a trailing `(AI-xxx)` suffix).
|
||||
* Only meaningful when {@link isAskAiPromptBlockingError} would return true for this error.
|
||||
*/
|
||||
export function getAskAiPromptBlockingUserFacingMessage(error?: Error): string | undefined {
|
||||
if (!error) return undefined;
|
||||
|
||||
const raw = error.message ?? '';
|
||||
const extracted = extractAgentStudioErrorFieldMessage(raw);
|
||||
if (extracted) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
const stripped = stripTrailingAiCodeSuffix(raw.trim());
|
||||
return stripped !== '' ? stripped : undefined;
|
||||
}
|
||||
|
||||
const TOKEN_OUTPUT_LIMIT_FALLBACK = 'Could not complete response due to token output limits';
|
||||
|
||||
function looksLikeJsonObjectString(s: string): boolean {
|
||||
const t = s.trim();
|
||||
return t.startsWith('{') && t.endsWith('}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Message shown in the top blocking banner (parsed API text, never raw JSON when avoidable).
|
||||
*/
|
||||
export function getAskAiBlockingBannerMessage(error?: Error): string | undefined {
|
||||
if (!error) return undefined;
|
||||
|
||||
if (isAgentStudioTokenOutputLimitError(error)) {
|
||||
const m = getAskAiPromptBlockingUserFacingMessage(error);
|
||||
if (m && !looksLikeJsonObjectString(m)) {
|
||||
return m;
|
||||
}
|
||||
return TOKEN_OUTPUT_LIMIT_FALLBACK;
|
||||
}
|
||||
|
||||
return getAskAiPromptBlockingUserFacingMessage(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the API `message` field when the error body is JSON; otherwise the thrown message string.
|
||||
* Only meaningful when {@link isThreadDepthError} is true.
|
||||
|
|
@ -156,17 +305,5 @@ export function isThreadDepthError(error?: Error): boolean {
|
|||
export function getThreadDepthErrorUserFacingMessage(error?: Error): string | undefined {
|
||||
if (!error || !isThreadDepthError(error)) return undefined;
|
||||
|
||||
const raw = error.message ?? '';
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { message?: string };
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim() !== '') {
|
||||
return parsed.message.trim();
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
return trimmed !== '' ? trimmed : undefined;
|
||||
return getAskAiPromptBlockingUserFacingMessage(error);
|
||||
}
|
||||
|
|
|
|||
239
packages/docsearch-react/src/utils/askAiBlockingMatchers.ts
Normal file
239
packages/docsearch-react/src/utils/askAiBlockingMatchers.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Registry of Agent Studio prompt-blocking matchers (cost controls, access, limits).
|
||||
* Add cases here instead of scattering regex across call sites.
|
||||
* Set `showNewConversationLink` to false to omit the “Start a new conversation … to continue” row when starting over does not fix the issue (for example, domain allowlisting).
|
||||
*/
|
||||
|
||||
export type AgentStudioBlockingMatchContext = {
|
||||
message: string;
|
||||
messageLower: string;
|
||||
parsedJson: Record<string, unknown> | null;
|
||||
extractedCodeUpper: string | undefined;
|
||||
};
|
||||
|
||||
export type AgentStudioPromptBlockingMatcher = {
|
||||
matches: (ctx: AgentStudioBlockingMatchContext) => boolean;
|
||||
showNewConversationLink?: boolean;
|
||||
};
|
||||
|
||||
/** Agent Studio cost / access control codes that block further input. */
|
||||
export const AGENT_STUDIO_PROMPT_BLOCKING_CODES = new Set([
|
||||
'AI-203', // Forbidden (e.g. domain not whitelisted)
|
||||
'AI-205', // Rate limited
|
||||
'AI-224', // Context / max token length
|
||||
'AI-225', // Max agent steps (reserved; also matched by message heuristics)
|
||||
]);
|
||||
|
||||
export function readAgentStudioJsonStringField(o: Record<string, unknown>, key: string): string | undefined {
|
||||
for (const [k, v] of Object.entries(o)) {
|
||||
if (k.toLowerCase() === key.toLowerCase() && typeof v === 'string' && v.trim() !== '') {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractAiErrorCodeFromMessage(message: string): string | undefined {
|
||||
const direct = /\b(AI-\d{3})\b/i.exec(message);
|
||||
if (direct) return direct[1].toUpperCase();
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message) as { code?: string; errorCode?: string };
|
||||
const c = parsed.code ?? parsed.errorCode;
|
||||
if (typeof c === 'string' && /AI-\d{3}/i.test(c)) {
|
||||
return c.trim().toUpperCase();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Plain-text / stringified JSON heuristic; JSON-shaped payloads still use `isThreadDepthError` in `ai.ts`. */
|
||||
export function matchesThreadDepthLimitError(normalizedMessage: string): boolean {
|
||||
return normalizedMessage.includes('ai-217') || /thread\s+depth/.test(normalizedMessage);
|
||||
}
|
||||
|
||||
/** API copy for domain allowlisting — starting a new conversation does not resolve it. */
|
||||
export function matchesRequestBlockedForThisDomainMessage(normalizedMessage: string): boolean {
|
||||
return (
|
||||
/\brequest blocked for this domain\b/.test(normalizedMessage) ||
|
||||
/\bblocked for this domain\b/.test(normalizedMessage)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesAgentStudioMaxStepsMessage(normalizedMessage: string): boolean {
|
||||
const m = normalizedMessage;
|
||||
// Explicit phrases only (no nested optional quantifiers — avoids ReDoS / unsafe-regex tooling noise).
|
||||
return (
|
||||
/\bstep limit\b/.test(m) ||
|
||||
/\bmax steps\b/.test(m) ||
|
||||
/\bmax step\b/.test(m) ||
|
||||
/\bmaximum steps\b/.test(m) ||
|
||||
/\bmaximum step\b/.test(m) ||
|
||||
/\bmax agent steps\b/.test(m) ||
|
||||
/\bmax agent step\b/.test(m) ||
|
||||
/\bmaximum agent steps\b/.test(m) ||
|
||||
/\bmaximum agent step\b/.test(m)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesAgentStudioRateLimitMessage(normalizedMessage: string): boolean {
|
||||
return (
|
||||
/\b429\b/.test(normalizedMessage) ||
|
||||
/\brate\s*limit/i.test(normalizedMessage) ||
|
||||
/\btoo\s+many\s+attempts\b/.test(normalizedMessage) ||
|
||||
/\btoo_many_requests\b/.test(normalizedMessage)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesAgentStudioTokenOutputLimitPlainMessage(message: string): boolean {
|
||||
return /\bTokenOutputLimitError\b/i.test(message);
|
||||
}
|
||||
|
||||
export function matchesAgentStudioWhitelistOrNotAllowedDomainPlainMessage(normalizedMessage: string): boolean {
|
||||
return (
|
||||
/\bwhitelist(ed)?\b/.test(normalizedMessage) || /\bnot\s+allowed\s+for\s+this\s+domain\b/.test(normalizedMessage)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesAgentStudioContextOrTokenLimitsPlainMessage(normalizedMessage: string): boolean {
|
||||
const m = normalizedMessage;
|
||||
return (
|
||||
/\bcontext\s+length\b/.test(m) ||
|
||||
/\bmax tokens\b/.test(m) ||
|
||||
/\bmax token\b/.test(m) ||
|
||||
/\bmaximum tokens\b/.test(m) ||
|
||||
/\bmaximum token\b/.test(m) ||
|
||||
/\btoken\s+limit\b/.test(m) ||
|
||||
/\btoken\s+output\b/.test(m) ||
|
||||
/\boutput\s+limits?\b/.test(m)
|
||||
);
|
||||
}
|
||||
|
||||
function jsonMessageIsRequestBlockedForDomain(parsed: Record<string, unknown>): boolean {
|
||||
const msg = readAgentStudioJsonStringField(parsed, 'message') ?? '';
|
||||
return matchesRequestBlockedForThisDomainMessage(msg.toLowerCase());
|
||||
}
|
||||
|
||||
/** JSON-shaped errors other than the domain API string in `message` (that case is its own matcher). */
|
||||
function jsonPayloadImpliesCostControlExcludingRequestBlockedDomainMessage(parsed: Record<string, unknown>): boolean {
|
||||
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
||||
if (/tokenoutput|outputlimit|steplimit|maxstep|ratelimit|domainnotallowed/i.test(type)) {
|
||||
return true;
|
||||
}
|
||||
const errCode = readAgentStudioJsonStringField(parsed, 'error') ?? '';
|
||||
if (errCode.toUpperCase() === 'TOO_MANY_REQUESTS') {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/token output|output limits|token limits|rate limit|whitelist|step limit|max steps|could not complete response due to token/i.test(
|
||||
errCode,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const msg = readAgentStudioJsonStringField(parsed, 'message') ?? '';
|
||||
if (/rate limit exceeded|retry after \d+/i.test(msg)) {
|
||||
return true;
|
||||
}
|
||||
if (/whitelist/i.test(msg)) {
|
||||
return true;
|
||||
}
|
||||
const lower = msg.toLowerCase();
|
||||
const notAllowedAt = lower.indexOf('not allowed');
|
||||
if (notAllowedAt !== -1 && lower.indexOf('domain', notAllowedAt) !== -1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildAgentStudioBlockingContext(error: Error): AgentStudioBlockingMatchContext {
|
||||
const message = error.message ?? '';
|
||||
const messageLower = message.toLowerCase();
|
||||
let parsedJson: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const p = JSON.parse(message) as Record<string, unknown>;
|
||||
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
||||
parsedJson = p;
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return {
|
||||
message,
|
||||
messageLower,
|
||||
parsedJson,
|
||||
extractedCodeUpper: extractAiErrorCodeFromMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered registry: first match does not win alone — `resolveAgentStudioPromptBlocking`
|
||||
* OR-combines blocking and AND-combines “show new conversation” (any `false` hides the row).
|
||||
*/
|
||||
export const agentStudioPromptBlockingMatchers: AgentStudioPromptBlockingMatcher[] = [
|
||||
{
|
||||
matches: (c) =>
|
||||
typeof c.extractedCodeUpper === 'string' && AGENT_STUDIO_PROMPT_BLOCKING_CODES.has(c.extractedCodeUpper),
|
||||
},
|
||||
{
|
||||
matches: (c) => c.parsedJson !== null && jsonMessageIsRequestBlockedForDomain(c.parsedJson),
|
||||
showNewConversationLink: false,
|
||||
},
|
||||
{
|
||||
matches: (c) =>
|
||||
c.parsedJson !== null && jsonPayloadImpliesCostControlExcludingRequestBlockedDomainMessage(c.parsedJson),
|
||||
},
|
||||
{
|
||||
matches: (c) => matchesAgentStudioTokenOutputLimitPlainMessage(c.message),
|
||||
showNewConversationLink: false,
|
||||
},
|
||||
{
|
||||
matches: (c) => matchesAgentStudioRateLimitMessage(c.messageLower),
|
||||
},
|
||||
{
|
||||
matches: (c) => matchesRequestBlockedForThisDomainMessage(c.messageLower),
|
||||
showNewConversationLink: false,
|
||||
},
|
||||
{
|
||||
matches: (c) => matchesAgentStudioWhitelistOrNotAllowedDomainPlainMessage(c.messageLower),
|
||||
},
|
||||
{
|
||||
matches: (c) => matchesAgentStudioContextOrTokenLimitsPlainMessage(c.messageLower),
|
||||
},
|
||||
{
|
||||
matches: (c) => matchesAgentStudioMaxStepsMessage(c.messageLower),
|
||||
},
|
||||
];
|
||||
|
||||
export function resolveAgentStudioPromptBlocking(error: Error): {
|
||||
blocking: boolean;
|
||||
showNewConversationLink: boolean;
|
||||
} {
|
||||
const ctx = buildAgentStudioBlockingContext(error);
|
||||
const matched = agentStudioPromptBlockingMatchers.filter((m) => m.matches(ctx));
|
||||
if (matched.length === 0) {
|
||||
return { blocking: false, showNewConversationLink: true };
|
||||
}
|
||||
const showNewConversationLink = matched.every((m) => m.showNewConversationLink !== false);
|
||||
return { blocking: true, showNewConversationLink };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text matchers over `Error.message.toLowerCase()` (callers pass `error.message.toLowerCase()`).
|
||||
* Thread depth: first entry mirrors common plain cases; JSON-shaped payloads still need `isThreadDepthError` in `ai.ts`.
|
||||
*
|
||||
* Blocking UX and “Start new conversation” visibility are driven by `agentStudioPromptBlockingMatchers`.
|
||||
*/
|
||||
export type NewConversationErrorMatcher = (normalizedMessage: string) => boolean;
|
||||
|
||||
export const newConversationErrorMatchers: NewConversationErrorMatcher[] = [
|
||||
(m) => matchesThreadDepthLimitError(m),
|
||||
(m) => matchesAgentStudioRateLimitMessage(m),
|
||||
(m) => matchesRequestBlockedForThisDomainMessage(m),
|
||||
(m) => matchesAgentStudioWhitelistOrNotAllowedDomainPlainMessage(m),
|
||||
(m) => matchesAgentStudioContextOrTokenLimitsPlainMessage(m),
|
||||
(m) => matchesAgentStudioMaxStepsMessage(m),
|
||||
];
|
||||
Loading…
Reference in a new issue