Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/llm/anthropic/utils/message_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,12 @@ function _formatContent(message: BaseMessage) {
return contentBlocks.filter(
(block) =>
block !== null &&
!(block.type === 'text' && 'text' in block && block.text === '')
!(
block.type === 'text' &&
'text' in block &&
typeof block.text === 'string' &&
block.text.trim() === ''
)
);
}
}
Expand Down
77 changes: 77 additions & 0 deletions src/llm/anthropic/utils/server-tool-inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,4 +346,81 @@ describe('_convertMessagesToAnthropicPayload — server tool use (web search) mu
expect(textBlocks).toHaveLength(1);
expect(textBlocks[0].text).toBe('Here are the results.');
});

/**
* Regression for LibreChat discussion #12806.
*
* Anthropic web_search responses can include text blocks whose text is
* whitespace-only (e.g. ' ', '\n', '\t') alongside server_tool_use and
* web_search_tool_result blocks. On follow-up turns the API rejects these
* with: "messages: text content blocks must contain non-whitespace text".
*
* The empty-string check alone is insufficient — the filter must drop any
* text block whose trimmed content is empty.
*/
it.each([
['single space', ' '],
['newline', '\n'],
['tab', '\t'],
['multiple spaces', ' '],
['mixed whitespace', ' \n\t '],
])(
'filters whitespace-only text blocks from array content (%s)',
(_label, whitespace) => {
const messageHistory: BaseMessage[] = [
new HumanMessage('search for X'),
new AIMessage({
content: [
{ type: 'text', text: whitespace },
{
type: 'server_tool_use',
id: 'srvtoolu_1',
name: 'web_search',
input: { query: 'X' },
},
{
type: 'web_search_tool_result',
tool_use_id: 'srvtoolu_1',
content: [
{
type: 'web_search_result',
url: 'https://example.com',
title: 'Result',
encrypted_content: 'abc',
page_age: '1d',
},
],
},
{ type: 'text', text: 'Here are the results.' },
],
tool_calls: [
{
id: 'srvtoolu_1',
name: 'web_search',
args: { query: 'X' },
type: 'tool_call',
},
],
}),
new HumanMessage('follow up'),
];

const { messages } = _convertMessagesToAnthropicPayload(messageHistory);
const assistantContent = messages[1].content as any[];

const whitespaceTextBlocks = assistantContent.filter(
(b: any) =>
b.type === 'text' &&
typeof b.text === 'string' &&
b.text.trim() === ''
);
expect(whitespaceTextBlocks).toHaveLength(0);

const textBlocks = assistantContent.filter(
(b: any) => b.type === 'text'
);
expect(textBlocks).toHaveLength(1);
expect(textBlocks[0].text).toBe('Here are the results.');
}
);
});
61 changes: 61 additions & 0 deletions src/specs/anthropic.simple.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,67 @@ describe(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
);
});

test(`${capitalizeFirstLetter(provider)}: follow-up after assistant message with only whitespace text content`, async () => {
/**
* Regression for LibreChat discussion #12806.
*
* The Anthropic API has two distinct rejection rules (verified against
* the live API):
* 1. Strict empty `text: ''` → rejected anywhere
* "messages: text content blocks must be non-empty"
* 2. Whitespace-only `text: ' '` / '\n' / '\t' → rejected when the
* assistant message has no other accepted blocks (no tool blocks,
* no non-whitespace text)
* "messages: text content blocks must contain non-whitespace text"
*
* Anthropic responses for some prompts include a whitespace-only text
* block as the sole text content. Re-sending that history on a
* follow-up turn triggers rule 2.
*
* The wire-send filter in `_formatContent` must drop any text block
* whose trimmed content is empty. The previous filter used strict
* `text === ''` only, which caught rule 1 but not rule 2.
*/
const llmConfig = getLLMConfig(provider);
const customHandlers1 = setupCustomHandlers();

const followUpRun = await Run.create<t.IState>({
runId: 'repro-12806-followup',
graphConfig: {
type: 'standard',
llmConfig,
instructions: 'You are a friendly AI assistant.',
},
returnContent: true,
skipCleanup: true,
customHandlers: customHandlers1,
});

// Build history with an assistant message whose entire content array
// is a single whitespace-only text block. This is the precise shape
// the API rejects under rule 2 above.
conversationHistory = [
new HumanMessage('hi'),
new (require('@langchain/core/messages').AIMessage)({
content: [{ type: 'text', text: ' ' }],
}),
new HumanMessage('please respond with a short greeting'),
];

// With the fix: `_formatContent` drops the whitespace text block,
// the assistant content becomes an empty array, and the API accepts.
// Without the fix: the whitespace block is forwarded and the API
// rejects with "messages: text content blocks must contain non-whitespace text".
const finalContentParts = await followUpRun.processStream(
{ messages: conversationHistory },
config
);
expect(finalContentParts).toBeDefined();
const finalMessages = followUpRun.getRunMessages();
expect(finalMessages).toBeDefined();
expect(finalMessages?.length).toBeGreaterThan(0);
});

test('should handle errors appropriately', async () => {
// Test error scenarios
await expect(async () => {
Expand Down
Loading